From a0b80711384c2d579f2416d010aa53e26dc6ce94 Mon Sep 17 00:00:00 2001 From: fretchen Date: Mon, 20 Jul 2026 20:09:30 +0200 Subject: [PATCH 01/14] work on it --- assistent_plan.md | 313 +++++++----------- scw_js/scratchpad_base_bal.ts | 31 ++ .../x402_batch_settlement_buyer.ipynb | 309 +++++++++++++---- x402_facilitator/upstream/ISSUE_DRAFT.md | 123 +++++++ .../batch-settlement-chain-agnostic-repro.mjs | 53 +++ 5 files changed, 569 insertions(+), 260 deletions(-) create mode 100644 scw_js/scratchpad_base_bal.ts create mode 100644 x402_facilitator/upstream/ISSUE_DRAFT.md create mode 100644 x402_facilitator/upstream/batch-settlement-chain-agnostic-repro.mjs diff --git a/assistent_plan.md b/assistent_plan.md index 17fe81370..69c3811d3 100644 --- a/assistent_plan.md +++ b/assistent_plan.md @@ -1,255 +1,186 @@ # Assistant Modernization Plan -Scope: the website's LLM chat assistant (`website/pages/assistent/`, `scw_js/llm_service.ts`, `scw_js/sc_llm.ts`). Written after an investigation into provider support, skills/personas, x402, merkle-tree privacy, and EIP-3009. See decisions below for what's in scope now vs. deferred. +Migrating the LLM chat assistant from ETH-prepaid + Merkle settlement (`LLMv1`) to **x402 batch-settlement USDC payment channels**. -## Decisions from the investigation (don't re-derive these) - -- **x402 payment is now the FIRST renovation workstream** (see "Primary workstream" below). Investigation is complete and both former gates are cleared, so it's ready to build — it is no longer deferred/backlog. -- **The billing model is x402 batch-settlement payment _channels_** (not per-request x402). Correcting an earlier note: batch-settlement _does_ rely on a dedicated on-chain escrow contract (USDC alone can't escrow + claim-against-voucher), BUT that contract is **canonical x402 infrastructure you consume, not deploy** — `BATCH_SETTLEMENT_ADDRESS = 0x4020…0003`, verified deployed on Optimism/Base mainnet + Base Sepolia (not Optimism Sepolia). -- **This replaces `LLMv1` on-chain wholesale** (escrow, settlement, withdrawal) and changes the payment asset **ETH → USDC**. LLMv1 gets retired. Net threat-surface win: one fewer owned upgradeable contract under `CONTRACT_OWNER_PRIVATE_KEY`. -- **No real balance is deposited today** — greenfield, no user migration path needed. -- **Merkle-tree privacy is resolved as a consequence**, not a standalone fix — batch-settlement deletes the public per-request ledger entirely (`merkle/trees.json`, `leafhistory`, `LLMv1.processBatch` calldata all go away). -- **Storage decided: S3 compare-and-swap**, no new infra — Scaleway conditional-write support tested and confirmed (2026-07-14). -- **Mistral provider support + personas (PR 1–3) are independent smaller wins** — they don't block and aren't blocked by the x402 work, and can land in parallel or after. Kept in the plan but secondary to the payment workstream. -- **Tool/function calling stays backlog**, flagged "interesting, uncertain importance." - ---- - -## Renovation order (start here) - -**The first renovation work focuses on x402 payment.** That is the Primary workstream (below): migrate LLM billing from the ETH-prepaid + merkle-settlement model to x402 batch-settlement USDC payment channels, in the Phase 0–5 sequence (section F). **Phase 0 (spike/harness), Phase A (facilitator, PR #543), and Phase B (`scw_js/` server + `shared/s3-utils/`) are code-complete, and B0–B3 are now verified against the real, deployed facilitator** (2026-07-16, `sc_llm_x402_buyer.ipynb`: 3 real chat messages, all settled on Base Sepolia, `chargedCumulativeAmount` tracked correctly, one real on-chain transaction confirmed). The `x402_facilitator` bug-fix punch list is fully closed and deployed. **Remaining before Phase B can be called done: B4 (the 12h claim/settle cron, `llmx402cron`) has never been run — no manual verification, no confirmed on-chain `Claimed`/`receivers()` movement via that path.** Then **Phase C (`website/` client)**. - -The Mistral/persona PRs (PR 1–3) remain in this document as independent, lower-effort improvements. They touch different code (`llm_service.ts` provider config, website UI) and carry no billing/architecture risk, so they can proceed in parallel or slot in whenever convenient — but the payment workstream is the priority and the reason for this renovation. Read the **Primary workstream** section first; PR 1–3 are documented afterward. - ---- - -## PR 1 — Mistral provider support in `scw_js` _(secondary — independent quick win)_ - -**Why:** `scw_js/llm_service.ts` hardcodes a single IONOS endpoint/model. `growth-agent/agent/llm_client.py` already solved this with a `PROVIDERS` dict keyed by `LLM_PROVIDER`. Port the same shape to TypeScript. - -**Touches:** `scw_js/llm_service.ts`, `scw_js/serverless.yml`, `scw_js/test/llm_service.test.ts`, `scw_js/test/sc_llm.test.ts`, `scw_js/README.md`, `scw_js/.env` (local only, not committed). - -**Steps:** - -1. In `llm_service.ts`, replace the hardcoded `MODEL_NAME` / `ENDPOINT` constants with a `PROVIDERS` map mirroring `growth-agent/agent/llm_client.py:18-29` (`ionos` and `mistral`, each with `baseUrl`, `apiKeyEnv`, `defaultModel`). -2. Add provider selection via `LLM_PROVIDER` env var (default `"ionos"`, matching growth-agent's default), and an optional `LLM_MODEL` override — same two env vars growth-agent already uses, for consistency across the repo. -3. Update `callLLMAPI` to look up the active provider config instead of reading `IONOS_API_TOKEN` and the hardcoded endpoint directly. Keep the `dummy` short-circuit path unchanged. -4. Add `MISTRAL_API_KEY` as a `secret:` entry in `serverless.yml` (never `env:` — matches the existing `IONOS_API_TOKEN` handling) and add `LLM_PROVIDER` / `LLM_MODEL` as plain `env:` entries. -5. Update tests to cover both providers: token-missing error message, endpoint/model selection, and that `LLM_PROVIDER=mistral` reads `MISTRAL_API_KEY` not `IONOS_API_TOKEN`. -6. Update `scw_js/README.md`'s `sc_llm.js` section to mention the provider switch. - -**Acceptance:** `npm test` green in `scw_js/`; setting `LLM_PROVIDER=mistral` locally with `MISTRAL_API_KEY` set produces a real completion via `dev:bfl` or the local test server. - -**Out of scope:** no change to billing, auth, or the merkle-tree settlement path. - ---- - -## PR 2 — Selectable personas / system prompts in the website assistant - -**Why:** Today there is exactly one hardcoded system prompt (`assistent.systemPrompt` = `"You are a helpful assistant."` in `website/locales/en.ts:109`). No persona concept exists. Note: this is a pure UX feature, not a new trust boundary — `sc_llm.ts`'s `handle()` already forwards whatever `prompt` array the client sends without restricting `role: "system"` content, so persona selection doesn't change what was already possible. - -**Touches:** `website/pages/assistent/+Page.tsx`, `website/locales/en.ts` / `de.ts`, possibly a new `website/pages/assistent/personas.ts` (or similar) module. - -**Steps:** - -1. Define a small persona registry as a plain TS array/object: `{ id, label, systemPrompt }[]`. Start with 2-3 personas (e.g. "General assistant" = current default, "Blockchain helper", one reused from growth-agent per PR 3). -2. Decide localization approach: persona _labels_ should be localized (like other UI strings), but persona _prompt content_ likely stays in one language (English) regardless of UI locale — confirm this during the PR rather than assuming. -3. Add a persona selector (dropdown or button group) to the sidebar in `+Page.tsx`, next to the existing Balance/Actions/Agent sections. -4. Wire selected persona into `sendMessage()` — replace the fixed `systemPromptMessage` with the selected persona's prompt when building `promptArray` (`+Page.tsx:291-298`). -5. Persist the selected persona in local component state (reset behavior on `clearChat()` is a judgment call — decide whether switching persona should also clear history). -6. No backend changes needed — `sc_llm.ts` already accepts arbitrary system-role content. - -**Acceptance:** switching personas in the UI visibly changes assistant behavior/tone across a manual test conversation; existing default behavior unchanged when no persona is explicitly picked. +Structure: [Status](#status) → [Next: mainnet](#next-mainnet-transition) → [Then: Mistral](#then-mistral-provider) → [Then: retire legacy](#then-retire-legacy-phase-d) → [Reference](#reference--design-record) (settled decisions, don't re-derive) → [Backlog](#backlog). --- -## PR 3 — Reuse growth-agent's persona/voice content +## Status -**Why:** growth-agent already has a real, structured "voice" — not a static string, but a `Strategy` model (`growth-agent/agent/models.py:53-67`: `content_pillars`, `tone`, `target_audience`, `website_url`) used to build system prompts dynamically (`growth-agent/agent/nodes/drafts.py:53-61`, `_system_prompt()`). That's worth reusing for a "blog voice" persona in the website assistant rather than hand-writing a new one from scratch. +| Phase | What | State | +|---|---|---| +| 0 | Spike / harness | ✅ Done | +| A | `x402_facilitator` speaks batch-settlement | ✅ Done, deployed (`facilitator.fretchen.eu`) | +| B | `scw_js` server (`sc_llm_x402.ts` + `llmx402cron`) | ✅ Done, verified on Base Sepolia | +| C | `website` client (`/assistent-v2`) | ✅ Done, verified live in browser | +| **→ next** | **Base mainnet MVP** | **⬜ This document's focus** | +| — | Optimism mainnet | ⏸ Deferred — upstream [#2910](https://github.com/x402-foundation/x402/issues/2910) | +| D | Retire `LLMv1` / merkle / `sc_llm.ts` | ⬜ After mainnet is proven | -**Touches:** whichever persona module PR 2 introduced; possibly `scw_js/growth_api.ts` if you go with the live-fetch option below. +**Everything works end-to-end on Base Sepolia today.** A real browser user at `/assistent-v2` can connect a wallet, chat, and pay per message via USDC payment channels. -**Two implementation options — pick one at PR time:** +**B4 (the 12h claim/settle cron) is verified** — on-chain check on 2026-07-19 confirmed the deployed `llmx402cron` swept two channels from the previous evening: per-channel `totalClaimed` matches what the server recorded, and `receivers()` shows `totalClaimed == totalSettled == 5770` (the exact sum of every channel's owed amount), i.e. funds actually reached the receiver wallet, not just a pending bucket. -- **Option A: static copy (simpler, do this first).** Manually translate the current `Strategy` defaults into one more entry in the PR 2 persona registry (e.g. "Fred's blog voice" built from today's `content_pillars` / `tone` / `target_audience`). Cheap, but will drift out of sync if growth-agent's strategy changes. -- **Option B: live fetch (more correct, more work).** Add a public, read-only endpoint (new route or extend `growth_api.ts`) that exposes just the non-sensitive `Strategy` fields (tone, pillars, audience, website_url — none of this is secret) so the website assistant persona is generated from the same source of truth growth-agent uses. Requires deciding whether this needs auth at all (it's not owner-sensitive data) and where the route lives given `growth_api.ts` today requires `OWNER_ETH_ADDRESS` signature for everything. - -**Recommendation:** ship Option A in this PR, leave Option B as a note in the PR description for later if the strategy content turns out to change often enough to matter. - -**Acceptance:** the reused persona reads recognizably like growth-agent's Mastodon/Bluesky post voice when tested with a few chat prompts. +**Current live config:** Base Sepolia only (`eip155:84532`), flat price `$0.00142`/message, LLM responses **mocked** on testnet (no IONOS spend). --- -## Primary workstream — x402 batch-settlement payment (FIRST / active) - -This is the primary renovation focus and the reason for the whole effort. Investigation is complete and both former gates are cleared: - -- **Contract:** canonical, already deployed — you consume `BATCH_SETTLEMENT_ADDRESS = 0x4020…0003`, no deployment. (Testnet spike → Base Sepolia, since Optimism Sepolia lacks it.) -- **Storage:** S3 compare-and-swap, confirmed working on Scaleway — no new infra. +## Next: mainnet transition -Sub-sections **A/B/E** below are the design record; **F** is the concrete Phase 0–5 build breakdown — **start there**. (Sub-sections C/D are separate deferred backlog, further down.) +**Scope decision (2026-07-20): Base mainnet only for the MVP.** Optimism is deferred pending upstream [#2910](https://github.com/x402-foundation/x402/issues/2910) — see [OP status](#op-status-deferred) below. Base exercises 100% of the same code paths, so nothing about the MVP is weakened by waiting; adding OP later is a config change plus a re-run of Rung 1. -### A. x402 batch-settlement payment channels for LLM billing (the target design) +Approach: prove the payment rails with tiny real amounts at the **lowest layer first**, only moving up once each rung is solid. -This supersedes the earlier open question ("flat fee vs. token budget vs. prepaid credits"). After investigation, the answer is **x402 batch-settlement payment channels** ([docs.x402.org/schemes/batch-settlement](https://docs.x402.org/schemes/batch-settlement)). It solves all three problems at once: the per-request settlement economics, the "price known only after generation" mismatch, and the usage/spending-pattern privacy goal. +**The only code change this needs is one line.** Base mainnet (`eip155:8453`) is already configured everywhere else: -**Privacy goal (decided):** obfuscate usage and spending _pattern_ as much as possible. It is NOT necessary to hide that a wallet used the AI at all. This ranks the options and makes batch-settlement a strong fit. +| Package | Batch-settlement networks | Change needed | +|---|---|---| +| `x402_facilitator` (`chain_utils.ts:62`) | `eip155:10`, `eip155:8453`, `eip155:84532` | none | +| `scw_js` (`x402_server.ts:26`) | `eip155:8453`, `eip155:84532` | none | +| `website` (`AssistantChat.tsx:26`) | `eip155:84532` only | **add `eip155:8453`** | -**How the scheme works:** +### Pre-flight (before any real money moves) -1. **Deposit (on-chain, once):** client signs EIP-3009 auth; facilitator submits it, locking USDC into an escrow/channel contract. Default `depositMultiplier: 5` (client escrows 5× the max per-request price upfront). -2. **Per-request voucher (off-chain):** client signs a _cumulative_ voucher ("total owed on this channel so far", monotonically increasing, with a nonce). No transaction. Server verifies the signature and serves the response immediately. -3. **Claim (on-chain, periodic, batched):** the server's channel manager submits the latest voucher from many channels in one tx (`claimIntervalSecs`, `maxClaimsPerBatch`). Contract validates each signature, moves claimed USDC out of escrow. -4. **Settle:** claimed funds swept to receiver in a separate batched tx. -5. **Exit:** `withdrawDelay` (default 24h) lets the client unilaterally reclaim escrow if the server sits on vouchers; idle channels cooperatively refunded. +- [ ] **Facilitator recipient whitelist.** Batch-settlement is fee-free, so the facilitator gates *who* it will relay for via `x402_whitelist.ts` (`BATCH_SETTLEMENT_MANUAL_WHITELIST`). The receiver (`NFT_WALLET_PUBLIC_KEY`, `0xAAEB…239C`) **must be whitelisted for mainnet** or every payment is refused. Verify the env var on the deployed facilitator, not just locally. +- [ ] **Facilitator wallet gas** on Base mainnet (it pays for deposit/claim/settle txs). +- [ ] **Test wallet** funded with a small real USDC amount on Base (a few dollars covers thousands of messages at $0.00142 each). +- [ ] **Real LLM cost kicks in.** On testnet, `sc_llm_x402.ts` forces `useMock` (`isTestnet`). On mainnet it calls the real provider for real money — so mainnet cutover and the Mistral decision below are coupled. Confirm `IONOS_API_TOKEN`/`MISTRAL_API_KEY` is live and check the flat price still covers actual cost. +- [ ] **Sanity-check the price.** `$0.00142`/message assumes 2000 tokens at IONOS's 0.71 EUR/1M and 1 EUR ≈ 1 USDC. Re-derive if switching to Mistral (different per-token price). -**Why it fits (all three problems):** +### Rung 1 — Facilitator only (smallest surface, no LLM, no UI) -- **Economics:** one claim per channel per interval, many channels per tx — amortizes gas + the 0.01 USDC facilitator fee over hundreds of requests. Per-request settlement of a ~$0.001 LLM turn is otherwise 10–50× the service cost. -- **Post-generation pricing:** client authorizes an _upper bound_ (bounded by the 5× escrow); server claims the _actual_ amount via `setSettlementOverrides(res, { amount })` after it knows the real token count. Over-authorize / under-claim = clean fit for LLM. -- **Privacy:** the itemized public ledger (`wallet, tokenCount, cost, timestamp` per request, queryable via `leafhistory`) disappears. Only the deposit and a per-channel _cumulative_ claim total ever hit chain. +Prove the facilitator can verify + settle a real Base mainnet payment in isolation. -**Honest caveat — aggregation, not anonymization:** each channel's cumulative claim is individually listed on-chain (not blended across users), and the deposit links wallet→channel. So an observer can watch a channel's cumulative total tick up and read _per-interval_ spend from the deltas. Individual request size/timing within an interval is hidden; coarse spend-over-time is not. Granularity is tunable via the claim interval (longer = more aggregation = more privacy, at the cost of locked capital + server-side voucher risk). +- **Tool:** `x402_facilitator/notebooks/x402_batch_settlement_buyer.ipynb` — drives `/verify` + `/settle` directly with hand-built requirements, no resource server involved. +- **Change:** point it at Base mainnet (`eip155:8453`) + Base USDC `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913`. +- **Verify on-chain, not just `success: true`** (this repo has been burned by silent no-ops — see Gotchas): decoded `Claimed` event, `channels(channelId)` balance/`totalClaimed` movement, and `receivers()` bucket. +- **Stop here if anything is off.** No LLM tokens or UI are involved, so this is the cheapest place to find a mainnet-specific problem (wrong EIP-712 domain, whitelist rejection, gas). -**Trust comparison (why this beats plain prepaid credits):** +### Rung 2 — `scw_js` server (adds the real handler + real LLM) -- vs. today's merkle: gives up _public itemized_ auditability (the thing leaking privacy) but keeps client-side cryptographic safety. -- vs. trusted off-chain credits: strictly better — server can't claim more than the signed voucher or the escrow, client loss is bounded by the deposit, and `withdrawDelay` is a unilateral exit. Client never depends on server goodwill for custody. -- Conceptually close to LLMv1's existing "deposit a balance, spend it down" model (`depositForLLM`), so UX continuity for users is decent. +- **Tool:** `scw_js/notebooks/sc_llm_x402_buyer.ipynb` against a locally-run `npm run dev:llmx402`, pointed at the **deployed** facilitator and Base mainnet. +- **No network config change needed** — `eip155:8453` is already in `BATCH_SETTLEMENT_NETWORKS` (`x402_server.ts:26`). +- **This is where real LLM cost starts.** Consider a deliberately tiny `LLM_ESTIMATED_TOKENS_PER_MESSAGE` for the first run. +- Verify: deposit lands, 2–3 messages reuse the channel off-chain, `chargedCumulativeAmount` advances by exactly the per-message price, S3 `channels/.json` matches chain. +- Then deploy `llmx402` and re-run against the deployed function (not just local) — deployment surfaced real bugs before (`tsup` entry omissions). +- Let the **12h cron** fire once and confirm the sweep on-chain, the same way it was verified on Base Sepolia. -**Contract question — RESOLVED (no deployment needed).** Unlike the exact scheme (which calls USDC's own `transferWithAuthorization`, no custom contract), batch settlement needs on-chain _escrow_ logic USDC lacks (`deposit`, `claimWithSignature`, `refund`, `refundWithSignature`, `withdrawRequestedAt`, `settle`). BUT x402 ships this as a **canonical, pre-deployed contract at a deterministic CREATE2 address** baked into the SDK: `BATCH_SETTLEMENT_ADDRESS = 0x4020074e9dF2ce1deE5A9C1b5c3f541D02a10003` (same on every EVM chain; used as the EIP-712 voucher `verifyingContract` + the contract the facilitator calls; plus EIP3009/Permit2 token-collector addresses for the deposit paths). So operationally it's just like exact: **you deploy nothing, point at the built-in address, facilitator just needs a funded wallet.** Verified on-chain (`eth_getCode`, 2026-07-14): deployed on Optimism mainnet ✅, Base mainnet ✅, Base Sepolia ✅ — but **NOT on Optimism Sepolia ❌**, so run the Phase-0 testnet spike on **Base Sepolia** (`eip155:84532`, already supported in `x402_server.ts`). +### Rung 3 — UX (browser, real wallet, real money) -**Storage question — RESOLVED (Investigation E): S3 compare-and-swap, no new infra.** Scaleway conditional-write support tested and confirmed. +- **The one code change:** `CHAT_NETWORKS` in `website/components/AssistantChat.tsx:26` → `["eip155:8453"]`. `useAutoNetwork` picks the wallet's current chain if supported, else the **first** entry, so this makes Base mainnet the default. (Keeping `eip155:84532` alongside it would let a wallet already on Base Sepolia silently pay in testnet USDC — decide deliberately; dropping it is the safer default now that the flow is proven.) +- `wagmi.config.ts` already registers `base` — no config change needed. +- Verify in browser: first message opens the channel (one wallet signature), messages 2+ are silent (the `voucherSigner` delegate key), and the receipt link resolves to `basescan.org`. +- Watch for the chain-switch prompt flow (add-network + switch-network) if the wallet doesn't know Base — expected, already handled, surfaced via `switchError`. +- **Real LLM responses now.** Testnet mocking (`isTestnet`) no longer applies, so this is the first browser traffic that costs IONOS/Mistral money. -Both prior gates are now cleared; A is ready to be scoped into the Phase 1–5 PRs (section F). +### OP status (deferred) -### B. Merkle-tree / usage-ledger privacy — resolved as a consequence of A +**Filed upstream as [#2910](https://github.com/x402-foundation/x402/issues/2910)** (2026-07-20), framed as a question about batch-settlement on networks outside `DEFAULT_STABLECOINS` rather than "add Optimism". Repro + draft kept in [`x402_facilitator/upstream/`](x402_facilitator/upstream/). -Not a separate task. Moving LLM billing to batch-settlement channels (A) deletes the public per-request ledger entirely, which _is_ the privacy fix. A standalone patch to the current model would be cosmetic — `processBatch` calldata is public on Optimism forever regardless of S3 ACLs, and the contract needs the plaintext address to debit `llmBalance`. Retire `merkle/trees.json` + the `leafhistory` endpoint + LLMv1's merkle path when A ships. +What's blocked and why, in one line: `@x402/evm`'s `DEFAULT_STABLECOINS` has no `eip155:10` entry, and batch-settlement's `enhancePaymentRequirements` (`:1525`) and `createChannelManager` (`:1608`) call `getDefaultAsset()` unconditionally — so an explicit `asset` can't rescue it, unlike the `exact` scheme which passes requirements through untouched. Everything *else* about OP is ready: the batch-settlement contract is deployed there, and OP USDC (`0x0b2C…Ff85`) is EIP-712 `"USD Coin"` / `"2"` / 6 decimals (read from the contract 2026-07-19). -### E. State/infra investigation — FINDINGS (SDK already installed: `@x402/evm@2.17.0`) +**To add OP once #2910 resolves:** add `eip155:8453` → `eip155:10` to `CHAT_NETWORKS`, re-run Rung 1 on OP, then a browser smoke test. No architectural work. If upstream prefers a registry entry over honoring the caller's asset, a local `patch-package` is the interim option (not currently used in this repo — new tooling, and it would need applying in each package resolving `@x402/evm`). -**The SDK ships batch-settlement.** `@x402/evm` exports `BatchSettlementEvmScheme`, `SettlementEvmScheme`, and `BatchSettlementChannelManager` (with `claim()` / `settle()` / `claimAndSettle()` / `refundIdleChannels()` + an interval runner). The channel-manager complexity is done for you. +**Note:** the facilitator still advertises `eip155:10` for batch-settlement (`chain_utils.ts:62`) even though no resource server can currently use it. Harmless for the MVP since our client never offers OP, but worth tidying if #2910 stalls. -**What you must implement is small:** +### Known rough edges (not blockers, decide whether to fix first) -- Server `ChannelStorage` — 3 methods: `get(channelId)`, `list()`, and atomic `updateChannel(id, fn)`. -- Client `ClientChannelStorage` — trivial `get`/`set`/`delete`, lives in browser localStorage (no server infra). +- **Every message costs 2 HTTP round-trips.** `wrapFetchWithPayment` always sends a bare unpaid request, gets a 402, then retries with payment — on *every* call, with no caching (`@x402/fetch/index.mjs:5-13`). It's **cheap** (our handler short-circuits before any LLM call, so no wasted tokens — only latency). Fixable by caching the `PaymentRequired` from the first 402 and using `x402Client.createPaymentPayload()` directly, bypassing `wrapFetchWithPayment`. Worth doing, but not before mainnet. +- **Occasional double-402** on an already-open channel. Confirmed **normative protocol behavior**, not our bug: corrective-402 recovery is specified upstream (PR #2491) and every SDK implements a *bounded single retry*. [#2908](https://github.com/x402-foundation/x402/issues/2908) is an open proposal to expose typed recovery outcomes instead of today's bare boolean — worth tracking, since it would tell us *why* a recovery happened. It should still be occasional; if it fires on most messages, that's client/server drift (likely stale `localStorage`) — capture the second 402's `reason` to diagnose. +- **Voucher acceptance near a withdrawal deadline** ([#2901](https://github.com/x402-foundation/x402/issues/2901)): a server can accept vouchers it can't redeem if a payer has initiated a withdrawal. **The big version of this is already handled** — `WITHDRAW_DELAY_SECONDS = 86400` (24h) vs the 12h cron gives a 2× margin, with the rationale documented at `x402_server.ts:34-41` (it previously fell back to the SDK's 900s default, far below the cron interval). The narrower open question — when to *stop accepting* vouchers as a deadline approaches — is unresolved upstream and low-risk for us at this margin. -**Statefulness is intrinsic** (cumulative vouchers = the aggregation IS the state; can't be stateless), BUT: - -- SDK docstring names the acceptable atomic backends verbatim: "Redis/Valkey Lua scripts, SQL transactions, or Durable Objects." `InMemoryChannelStorage` only works inside one JS runtime. -- **Risk reframe:** a lost voucher update = you _under-claim_ (leak your own revenue), NOT user fund loss — the client is always protected by the on-chain escrow + `withdrawDelay`. So the correctness bar is forgiving; start simple. - -**DECISION: Option B (S3 compare-and-swap).** The blocking unknown is resolved — Scaleway Object Storage supports ETag conditional writes (tested against `my-imagestore`/`nl-ams` on 2026-07-14): `If-None-Match:*` → 412 on existing (create guard), `If-Match:` → 412 (CAS reject), `If-Match:` → 200 (CAS write). So we get atomic `updateChannel` with **zero new infrastructure**, a perfect stateless-serverless fit. Options A (single-instance) and C (Managed Redis) are set aside; C stays as the escape hatch only if channel counts ever grow enough that S3's `list()` cost (below) bites. - -**S3 `ChannelStorage` sketch:** - -- **Layout:** one object per channel, `channels/.json` (private ACL — this is server-internal state, NOT the public `merkle/` prefix). Store the SDK `Channel` record as JSON. -- **`get(id)`:** GET `channels/.json`, parse; null → `undefined`. -- **`updateChannel(id, fn)`:** GET (capture ETag) → run `fn(current)` → conditional PUT. New channel: `If-None-Match:*`. Existing: `If-Match:`. On **412** (or transient network error) → re-GET and retry with bounded backoff. Map result to `{channel, status: "updated"|"unchanged"|"deleted"}`; `fn` returning `undefined` → conditional DELETE. -- **`list()`:** `ListObjectsV2` on `channels/` prefix → GET each object. **This is the one wart** — N+1 requests per claim sweep. Acceptable at modest channel counts (tens–low hundreds); it's the only reason you'd ever switch to Option C. -- **s3-utils extension needed (first task):** `@fretchen/s3-utils` currently does plain GET/PUT with no conditional headers. Add: (1) return ETag from GET, (2) accept `If-Match`/`If-None-Match` on PUT + surface 412 (don't throw), (3) a `listObjects(prefix)` helper, (4) conditional DELETE. Keep it a thin, typed addition — the existing SigV4 signer already handles arbitrary signed headers (verified: the CAS test reused `sigv4.js` unchanged). -- **Retry semantics:** treat 412 AND network errors as retryable in the CAS loop (the existing s3-utils already retries 5xx/network for unconditional calls; mirror that). Cap retries; on exhaustion, fail the request (client just re-signs a fresh voucher next turn — no fund risk). - -**Architectural split (do this):** separate the concurrency-sensitive hot path (per-request `updateChannel`, in the `llm` serverless function) from the periodic claim/settle loop (`BatchSettlementChannelManager` interval runner → run in a scheduled container, growth-agent-style, where the on-chain-tx cron pattern already exists). Both share the same S3 `ChannelStorage`. +--- -**On-chain contract — RESOLVED (see section A):** no deployment needed. Canonical `BATCH_SETTLEMENT_ADDRESS = 0x4020…0003` is baked into the SDK and already deployed on Optimism/Base mainnet + Base Sepolia. Only caveat: NOT on Optimism Sepolia → do the testnet spike on Base Sepolia. +## Then: Mistral provider -### F. Batch-settlement transition — concrete implementation plan +Coupled to mainnet: once responses are no longer mocked, provider cost/quality is real. -**Phasing decision:** phase it — three phases matching the SDK's own `facilitator` / `server` / `client` split, plus a spike and a cleanup. Not just risk-reduction (facilitator + scw_js have historically been tricky) — the dependency order _forces_ sequencing: **facilitator must speak batch-settlement → before scw_js can settle → before the website can pay.** Each phase is independently testable via the Phase 0 Node harness (no browser UI needed to validate A/B). +`scw_js/llm_service.ts` hardcodes one IONOS endpoint/model. `growth-agent/agent/llm_client.py:18-29` already solved this with a `PROVIDERS` dict keyed by `LLM_PROVIDER` — port that shape to TypeScript. -**Locked decisions (from planning Q&A):** +- Replace hardcoded `MODEL_NAME`/`ENDPOINT` with a `PROVIDERS` map (`ionos`, `mistral`: `baseUrl`, `apiKeyEnv`, `defaultModel`). +- Select via `LLM_PROVIDER` (default `"ionos"`) + optional `LLM_MODEL` override — same two env vars growth-agent uses. +- `MISTRAL_API_KEY` as a `secret:` in `serverless.yml` (never `env:`). +- **Re-derive the price**: `convertTokensToUsdcCost()` hardcodes IONOS's 0.71 EUR/1M tokens. Mistral's rate differs — update it or the flat per-message price will be wrong in real money. +- Tests: both providers, token-missing error, correct key read per provider. -- **Auth:** drop the separate `sc-llm` EIP-191 Bearer token — the payment voucher proves wallet control. Remove `auth_utils` from the LLM path. -- **Fee:** LLM channels are **fee-free** — skip the facilitator fee hook + collection for batch-settlement (exact-scheme image fee untouched). -- **Settle trigger:** **periodic cron sweep only** (`claimAndSettle` across all claimable channels), in a new scheduled scw_js function. Interval « 24h `withdrawDelay`. Coarse many-channels-per-tx aggregation best serves the privacy goal. -- **Networks:** Base Sepolia for all pre-prod (OP Sepolia lacks the contract); Optimism mainnet for production cutover. -- **Storage:** S3 CAS via extended `@fretchen/s3-utils` (`file:` package at `shared/s3-utils/`). +--- -**Full detail:** `~/.claude/plans/now-propose-a-concrete-majestic-patterson.md` (file paths, SDK API names, per-phase verify steps). Progress tracker below. +## Then: retire legacy (Phase D) -**Phase 0 + Phase A: DONE — shipped in PR #543** (`x402-batch-settlement-facilitator` branch). Went beyond the original checklist below in two ways worth carrying into Phase B: +Only after mainnet is proven. **Order matters — recover funds before removing the code that can reach them.** -1. **Network gating fix (not in the original plan):** batch-settlement was initially registered on all 4 `getSupportedNetworks()`, including Optimism Sepolia — which has no deployed contract. Added `getBatchSettlementNetworks()` (`chain_utils.ts`) as a strict subset (OP mainnet, Base mainnet, Base Sepolia) and gated registration on it, with regression tests. **Phase B's scw_js server must apply the same gating** — don't assume all 4 networks are safe for batch-settlement. -2. **Claim-settlement bug fix + protocol discovery (not in the original plan):** `x402_settle.ts::settlePayment()` unconditionally called `verifyPayment()` before `settle()`, but the SDK's `scheme.verify()` has no branch for `"claim"`/`"settle"` payload types (only deposit/voucher/refund) — every claim failed before reaching real claim logic. Fixed by skipping verify for those two payload types. Along the way, confirmed from the **actual verified contract source** (Basescan) two facts Phase B needs: - - `VoucherClaim.totalClaimed` is the **new cumulative target being claimed to**, not "amount already claimed" — get this wrong and the contract silently no-ops (no revert, `success: true` from the facilitator, zero value moved). The SDK's `BatchSettlementChannelManager` is expected to compute this correctly from its own tracked state — **verify this in Phase B rather than assuming**, since our own hand-rolled construction got it wrong initially. - - **Claim and settle are a genuine two-step protocol**, not one: `claim`/`claimWithSignature` moves escrow into a per-`(receiver, token)` pending-payout bucket (no ERC-20 transfer, no visible balance change); a separate `settle(receiver, token)` call sweeps that bucket to the receiver's wallet. `claimAndSettle()` in the SDK does both as two transactions. - - Full walkthrough + real on-chain proof (decoded `Claimed` event, before/after `channels()` state) in `notebooks/x402_batch_settlement_buyer.ipynb`. +- [ ] **Pull any money out of `LLMv1` first.** Check the real on-chain balance on OP mainnet before assuming it's empty (plan notes say "no real balances", but verify — this is irreversible). Use `withdrawBalance`; leave it callable briefly for any user balances. +- [ ] Remove `llm_service.ts` merkle code (**keep `convertTokensToCost`**); remove `leaf_history.ts` + the `leafhistory` function; stop writing `merkle/trees.json`. +- [ ] Retire `sc_llm.ts` / the `llm` function and `useWalletAuth("sc-llm")`. +- [ ] Remove the old `/assistent` page + `BalanceDisplay` + `LeafHistorySidebar`; make `/assistent-v2` the canonical route. +- [ ] Retire the `LLMv1` contract (one fewer upgradeable contract under `CONTRACT_OWNER_PRIVATE_KEY` — a real threat-surface win). +- [ ] Update `scw_js/README.md` + `.github/THREAT_MODEL.md` (asset is now USDC; one fewer owned contract). -Checklist (for reference — all done except where noted): +--- -- [x] Interactive harness driving the client SDK (`@x402/evm/batch-settlement/client`) — shipped as `notebooks/x402_batch_settlement_buyer.ipynb` (Deno notebook, not a plain Node script, but the same purpose): deposit → `signVoucher` (accumulate) → claim, all proven on real Base Sepolia transactions. `wrapFetchWithPayment`, `claimAndSettle`'s settle-sweep step, and `refund` were **not** exercised — good candidates for a follow-up spike, not blockers for Phase B. -- [x] Confirmed: receiverAuthorizer self-managed by server (`/supported` shows no `receiverAuthorizer` for batch-settlement → facilitator needs no authorizer key). -- [ ] **Not yet confirmed** (defer to their natural phases): batch payment header name(s) for CORS (Phase C — needs a real browser fetch flow to observe) — `setSettlementOverrides` for post-generation actual-cost claims (Phase B — needs the real LLM handler). -- [x] Bump `@x402/core` + `@x402/evm` → `^2.17.0` (resolved 2.18.0). -- [x] `facilitator_instance.ts`: register `BatchSettlementEvmScheme` alongside `ExactEvmScheme`, gated by `getBatchSettlementNetworks()` (both `createFacilitator` and `createReadOnlyFacilitator`). -- [x] `onAfterVerify` fee hook made scheme-aware (skips for batch-settlement). -- [x] `x402_settle.ts`: `collectFee` guarded to exact scheme only; **plus** the unplanned claim-verify-skip fix above. -- [x] Tests: batch scheme registration (both facilitator variants) + no-fee assertions + claim/settle routing regression tests, in `test/facilitator_instance.test.ts` + `test/x402_settle.test.js`. Real-signature tests split into `test/integration/` (`npm run test:integration`, live RPC) to keep `npm test` hermetic. -- [x] Verified against a locally-run facilitator — and beyond the original scope, against **real Base Sepolia transactions** (deposit, accumulate, claim), with on-chain state independently confirmed via direct contract reads. +## Reference — design record -**`x402_facilitator` bug-fix punch list (found during Phase B verification, 2026-07-15 — shipped in PR #545, confirmed live on `facilitator.fretchen.eu` 2026-07-16):** +Settled decisions. **Don't re-derive these.** -- [x] **`getSupportedCapabilities()` reported a placeholder zero-address signer.** `facilitator_instance.ts`'s read-only facilitator (serves `/supported`) hardcoded `address: "0x0000…0000"` instead of the real signer, which newer `@x402/evm`/`@x402/core` clients reject outright (breaks `x402HTTPResourceServer.initialize()`'s strict validation). Fixed: `createReadOnlyFacilitator()` now reports `getFacilitatorAddress() ?? zeroAddress` — real address when a key is configured, zero address only as a genuine no-key fallback. **Confirmed deployed**: `curl https://facilitator.fretchen.eu/supported` now returns the real signer (`0x3F8d2Fb6…`), not the placeholder. -- [x] **Settle response dropped the SDK scheme's `extra` (incl. `channelState.channelId`).** `x402_settle.ts::settlePayment()` rebuilt its own `SettleResult` and silently dropped `facilitator.settle()`'s `extra` field, so the client crashed in `processSettleResponse` on `channelId.toLowerCase()` of `undefined` immediately after a real, correctly-settled payment. **This was misdiagnosed as an upstream `@x402/evm` bug; it was ours** — confirmed by the SDK maintainer on issue #2879. Fixed: `extra?: Record` added to `SettleResult`/`VerifyResult`, threaded through all three success return sites in `x402_settle.ts` *and* the HTTP handler's own response-body construction in `x402_facilitator.ts` (a second, separate drop found during the fix — the handler built its own response object and needed the same field added). **Confirmed live** via the same real Base Sepolia run (settlement receipts now include full `channelState.channelId`). -- [x] **Batch-settlement had no recipient gating at all (found in a follow-up review, not the original punch list).** Being fee-free, `batch-settlement` had no equivalent to `exact`'s allowance-based abuse gate — any caller could get the facilitator to relay claim/settle/deposit transactions at its own gas expense. Fixed: new `x402_whitelist.ts` (`BATCH_SETTLEMENT_MANUAL_WHITELIST`/`BATCH_SETTLEMENT_TEST_WALLETS` env-var allowlist, no on-chain lookups — an earlier draft reused the old `exact`-scheme whitelist's `isAuthorizedAgent()` on-chain check, but that registry (GenImNFTv4/LLMv1 agent authorization) has no real relationship to batch-settlement's receivers, so it was dropped in favor of a scoped, explicit allowlist), gated in both `facilitator_instance.ts`'s `onAfterVerify` (deposit/voucher/refund) and `x402_settle.ts`'s claim/settle branch. **A follow-up code review then found the initial gate checked the wrong field** — `paymentRequirements.payTo` (attacker-controlled, structurally unrelated to what `executeSettle()`/`executeClaimWithSignature()` actually act on) instead of the payload's own `payload.receiver`/`claims[].voucher.channel.receiver` — allowing the whitelist to be bypassed by any caller who knew one whitelisted address. Fixed: the gate now derives the receiver(s) directly from the payload (every claim in a batch, not just the first) and from `paymentRequirements.network` (not `accepted.network`, which also isn't cross-checked by `settle()` the way `verify()` does). Full regression test coverage for both bypasses added. -- [x] **`scw_js/sc_llm_x402.ts`'s verify-failure response was missing the SDK's response-time enrichment (found during B3 re-verification, not a facilitator bug).** When `verifyPayment()` returned `isValid: false`, the handler hand-built `{error, reason, payer}` instead of calling `resourceServer.createPaymentRequiredResponse(...)` — so a `cumulative_amount_mismatch` 402 never carried the `channelState`/`voucherState` the client SDK's built-in `processCorrectivePaymentRequired` self-heal needs, and the mismatch was a permanent dead end instead of an automatic resync-and-retry. Checked against the reference implementation (`x402-foundation/x402`'s own batch-settlement example server) — it uses `@x402/express`'s full HTTP middleware, which isn't a fit for a Scaleway `ScwEvent` handler, but its underlying `x402ResourceServer.createPaymentRequiredResponse()` is the same enrichment engine, callable directly without adopting that whole abstraction. Fixed; `create402Response()`'s type loosened to accept the SDK's real `PaymentRequired` shape. **Confirmed fixed** via the same real Base Sepolia run. +### Why batch-settlement -**Phase B — Server (`scw_js/` + `shared/s3-utils/`)** — B0 through B4 implemented (2026-07-15); B0–B3 verified against real Base Sepolia (2026-07-16); **B4 (the cron) still needs its own manual verification** before Phase B is fully done and Phase C starts. Full original plan also at `~/.claude/plans/now-propose-a-concrete-majestic-patterson.md`. +Solves three problems at once: per-request settlement economics, "price known only after generation", and usage-pattern privacy. -**Locked decisions for Phase B:** +1. **Deposit** (on-chain, once): client signs EIP-3009; USDC locked in escrow. `depositMultiplier: 5` by default. +2. **Voucher** (off-chain, per request): client signs a *cumulative* monotonically-increasing total. No tx. +3. **Claim** (on-chain, batched, periodic): many channels in one tx. +4. **Settle**: sweeps claimed funds to the receiver (a *separate* tx — see Gotchas). +5. **Exit**: `withdrawDelay` lets the client unilaterally reclaim escrow if the server sits on vouchers. -- **New parallel file, not a replacement.** The live website calls `sc_llm.ts`'s `llm` function via bearer auth today — replacing it in place would break the UI before Phase C (website) is ready to switch payment methods. Phase B ships `sc_llm_x402.ts` as a **new, additional** serverless function (`llmx402`) alongside the untouched `sc_llm.ts`/`llm`. Retiring `sc_llm.ts` is Phase D, after Phase C cuts the website over. -- **Second correction, from reading the actual SDK source (supersedes the `chargedCumulativeAmount`-override note below):** `@x402/evm/batch-settlement/server`'s `handleBeforeVerify`/`handleBeforeSettle` (see `index.mjs`) enforce that a voucher's `maxClaimableAmount` **exactly equals** `chargedCumulativeAmount + requirements.amount` — there is no room for the handler to charge a different, actual-token-cost-derived amount after the fact by directly mutating `chargedCumulativeAmount` (that would just make the next request's mismatch check fail). Real usage-based billing would require the SDK's corrective-402 mismatch flow (client re-signs after learning the true cumulative amount from a settlement response) — legitimate, but real client-side work belonging to Phase C at the earliest, and untested here. **B3 therefore charges a flat nominal price per message**, but that flat number is _derived_, not arbitrary: a new `convertTokensToUsdcCost()` in `llm_service.ts` reuses the same real IONOS per-token price as `convertTokensToCost()` (0.71 EUR / 1M tokens) but converts straight to USDC atomic units instead of ETH wei (the two 1e6 factors — USDC's 6 decimals and the per-million-tokens quote — cancel exactly, so it's just `tokens * 71n / 100n`). Treats 1 EUR = 1 USDC (documented simplification, same level of approximation as the existing static EUR/ETH rate — no live FX oracle). `sc_llm_x402.ts` applies it to `LLM_ESTIMATED_TOKENS_PER_MESSAGE` (env var, default `"2000"` → $0.00142/message) to get the fixed per-message price — still an estimate agreed before generation, not the request's real usage, for the SDK reason above, but now principled rather than guessed. `resourceServer.settlePayment()` commits `chargedCumulativeAmount += requirements.amount` itself via `handleBeforeSettle`, which also **confirmed a second useful fact**: for a voucher payload this hook returns `{ skip: true }`, so `settlePayment()` never calls the facilitator (no HTTP round-trip, no on-chain tx) — only a `deposit` payload actually reaches the facilitator/chain. -- **Stepwise, spike-first** (x402 has repeatedly hidden real bugs behind plausible-looking code all through Phase 0/A — network-gating, claim-verify-skip, `totalClaimed` semantics, EIP-7702 signature gotcha, Deno module resolution). Verify each risky assumption against real behavior before composing the full handler, same discipline as Phase A. -- **`scw_js` gets its own `scw_js/notebooks/` folder** (Deno kernel, own scoped `deno.json`, same `nodeModulesDir: "auto"` + `lock: false` fix already proven in `x402_facilitator/notebooks/deno.json`) — mirrors the existing per-package convention (`growth-agent/notebooks/` is already its own thing). B0's spike lives there, not in `x402_facilitator/notebooks/`, since it tests scw_js's own server behavior, not the facilitator. +**Economics:** amortizes gas + fees over hundreds of requests; per-request settlement of a ~$0.001 turn would otherwise cost 10–50× the service. -**Steps:** +**Privacy — aggregation, not anonymization (honest caveat):** the public per-request ledger disappears, but each channel's cumulative claim is individually on-chain and the deposit links wallet→channel. An observer reads *per-interval* spend from deltas; individual request size/timing is hidden. Tunable via claim interval. -- [x] **B0 — Spike:** `scw_js/notebooks/x402_batch_settlement_server_spike.ipynb` — registered a **server**-side `BatchSettlementEvmScheme` with `InMemoryChannelStorage`, drove it with a real buyer-side deposit + voucher flow on Base Sepolia, confirmed `verifyPayment()` auto-manages `ChannelStorage` and that `onchainStateTtlMs` defaults to a fixed 5 minutes (must override explicitly, e.g. 5000ms). The spike's step 7 ("manually bump `chargedCumulativeAmount`") turned out to be a red herring superseded by the SDK-source finding above — left in the notebook as a recorded (disproven) hypothesis, not deleted, since it explains why B3 doesn't do that. -- [x] **B1** Extended `@fretchen/s3-utils` (`shared/s3-utils/src/index.ts`, additive only): `getS3ObjectWithMeta` (ETag on GET); `putS3ObjectConditional` (`ifMatch`/`ifNoneMatch`, **412 surfaced as `{ok:false,status:412}`, not a throw**); `deleteS3Object` (conditional, 404 treated as success); `listObjects(prefix)` (`ListObjectsV2` + minimal XML parsing). 48 unit tests green, package rebuilt. -- [x] **B2** `scw_js/x402_channel_storage.ts` — `S3ChannelStorage` implementing `get`/`list`/`updateChannel`: `channels/.json`, private ACL; `updateChannel` = GET+ETag → callback → conditional PUT/DELETE → retry from a fresh read on 412 (bounded, 3 attempts) → throws past that. 12 unit tests green (create, update, CAS-conflict-then-retry, delete, delete-of-nonexistent, list-sorted). -- [x] **B3** `scw_js/sc_llm_x402.ts` (parallel to `sc_llm.ts`) + `createLLMResourceServer()`/`createBatchSettlementPaymentRequirements()`/`getBatchSettlementNetworks()` in `x402_server.ts`. One shared `BatchSettlementEvmScheme` instance (receiver-bound, not network-bound) registered across all 3 batch-settlement networks. Flow: `extractPaymentPayload` → `create402Response` if missing → validate network is a batch-settlement network → `verifyPayment` → `callLLMAPI` (unchanged) → `settlePayment` (commits the flat charge or, for a deposit, does the one real on-chain settle) → respond with settlement headers. Kept `body.data.prompt` request shape. Bearer auth (`auth_utils`), `checkWalletBalance`, and merkle calls are simply not imported into the new file. New `llmx402` function still needs adding to `serverless.yml` (deferred to alongside B4, so both new functions/secrets land together). 18 unit tests green (mocking `x402_server.js`/`llm_service.js`, not the real SDK — real verify/settle behavior is an integration concern, covered by the Base Sepolia verification step below, not unit tests). -- [x] **B4** `scw_js/llm_x402_cron.ts` — scheduled (`rate: "0 0/12 * * *"`, every 12h) `llmx402cron` function. Loops `getBatchSettlementNetworks()`, calling `scheme.createChannelManager(facilitatorClient, network).claimAndSettle()` per network via the same `createLLMResourceServer()` used by B3 (so it reads/writes the same S3 `ChannelStorage`); one network's failure is logged and doesn't stop the others (partial failure → 500 with per-network results in the body). New secret `RECEIVER_AUTHORIZER_PRIVATE_KEY` + `LLM_ESTIMATED_TOKENS_PER_MESSAGE` (default `"2000"`, feeding `convertTokensToUsdcCost()` — see above) added to `serverless.yml`'s `provider.secret` block (matching this file's actual existing convention — everything lives under `secret:`, not split into `env:`, regardless of sensitivity). Both `llmx402` (B3) and `llmx402cron` (B4) functions added together, plus both new entry points registered in `tsup.config.js` (they were silently excluded from `dist/` until this was done — caught by running `npm run build` and noticing only 4 of 6 expected bundles). 6 unit tests green (config errors, per-network dispatch, partial-failure handling), mocking `x402_server.js`. -- [x] **B3 verified against real Base Sepolia (2026-07-16) — four real bugs found and fixed along the way (see punch list above for the facilitator-side three; the fourth was ours in `sc_llm_x402.ts`):** - 1. **Fixed:** `BATCH_SETTLEMENT_NETWORKS` included `eip155:10` (Optimism mainnet), but `@x402/evm`'s own `DEFAULT_STABLECOINS` registry has no entry for it — `enhancePaymentRequirements()` threw, taking down the _entire_ 402 response (all networks), not just Optimism's. Fixed by dropping it from the offered networks (`x402_server.ts`). - 2. **Fixed:** the `paymentRequirements` object built for `verifyPayment`/`settlePayment` was a raw, un-enhanced object missing `extra.receiverAuthorizer`/`extra.withdrawDelay` — fields the client actually signed against (learned from the 402). The facilitator's `validateChannelConfig` treats a missing `receiverAuthorizer` as an automatic mismatch, so every real deposit was rejected with `receiver_authorizer_mismatch`. Fixed by running verify/settle requirements through `scheme.enhancePaymentRequirements()` too, not just the 402-building path. **Confirmed fixed** — a real deposit landed and settled on Base Sepolia. - 3. **OURS, not upstream (corrected).** `wrapFetchWithPayment` crashed client-side (`processSettleResponse`, `channelState.channelId.toLowerCase()` on `undefined`) immediately after a real, correctly-settled deposit — traced to `x402_facilitator` stripping `result.extra`; see the punch list above. **Fixed and confirmed deployed.** - 4. **OURS, found after the facilitator fix landed.** Verification then failed with `invalid_batch_settlement_evm_cumulative_amount_mismatch` on every retry — traced to `sc_llm_x402.ts`'s hand-rolled 402 failure body omitting the SDK's response-time enrichment, so the client's corrective-retry flow (`processCorrectivePaymentRequired`) had nothing to recover from; separately, the buyer notebook's `ClientEvmSigner` was also missing `readContract`, which that same recovery flow requires unconditionally. Both fixed — see the punch list above for the server-side fix; the notebook now builds its signer via `toClientEvmSigner(account, publicClient)`. - 5. **Confirmed via a full real run (2026-07-16, `scw_js/notebooks/sc_llm_x402_buyer.ipynb`):** 3 chat messages against the local `sc_llm_x402.ts` dev server + the real, deployed facilitator. All settled (`status: 200`), `chargedCumulativeAmount` progressed `2840 → 4260 → 5680` (exactly `+1420`/message, matching `convertTokensToUsdcCost(LLM_ESTIMATED_TOKENS_PER_MESSAGE)`), and one message's settlement receipt carried a real on-chain Base Sepolia transaction hash. Channel reuse across messages worked automatically via `fetchWithPayment` — no `useX402Chat.ts` workaround needed for that. Full findings recorded in the notebook itself. +**Trust:** strictly better than off-chain credits — the server can't claim more than the signed voucher or the escrow, and `withdrawDelay` is a unilateral exit. -- [ ] **B4 still needs its own manual verification — not done, and not covered by the B3 run above** (B3's on-chain activity came from `sc_llm_x402.ts`'s own synchronous settle path for a deposit, not the cron's `claimAndSettle()` sweep). `llm_x402_cron.ts` has never been run: no local dev script exists for it yet (`package.json` only has `dev:x402`/`dev:llmx402`/`dev:growth`), and there's no recorded decoded `Claimed` event or `receivers()` bucket movement via that path. `serverless.yml` does have the `llmx402cron` function registered with its schedule (`events: [{schedule: {rate: "0 0/12 * * *"}}]`, every 12h — note Scaleway's cron validator rejects the standard `*/12` step syntax, requiring `0/12` instead) — the *configuration* is in place, but it has neither been deployed nor exercised locally. **This is the one remaining item before Phase B can be called fully done.** +### Infrastructure -`sc_llm.ts`, `llm_service.ts`'s merkle functions, `leaf_history.ts`, and `LLMv1` retirement remain untouched until Phase D (after Phase C proves the website can pay via the new handler). +- **Contract: consume, don't deploy.** `BATCH_SETTLEMENT_ADDRESS = 0x4020074e9dF2ce1deE5A9C1b5c3f541D02a10003`, canonical CREATE2, same on every EVM chain. Deployed on OP mainnet ✅, Base mainnet ✅, Base Sepolia ✅ — **not** Optimism Sepolia ❌ (hence all testnet work on Base Sepolia). +- **Storage: S3 compare-and-swap**, no new infra. Scaleway supports ETag conditional writes (`If-Match`/`If-None-Match` → 412). `scw_js/x402_channel_storage.ts` implements `get`/`list`/`updateChannel` over `channels/.json`. `list()` is N+1 — fine at tens–hundreds of channels; Redis is the escape hatch if that ever bites. +- **Risk reframe:** a lost voucher update means *we under-claim* (our revenue), never user fund loss — the client is always protected by escrow + `withdrawDelay`. The correctness bar is forgiving. +- **Auth:** no separate bearer token — the payment voucher proves wallet control. +- **Fee:** LLM channels are fee-free (the exact-scheme image fee is untouched). -**Phase C — Client (`website/`)** — `@x402/evm`/`@x402/fetch` already deps; mirror `hooks/useX402ImageGeneration.ts` +### Pricing model (why it's flat) -- [ ] New `hooks/useX402Chat.ts`: manual `client.register(network, new BatchSettlementEvmScheme(signer, { storage }))` (no register helper) + `wrapFetchWithPayment`; extend signer with `readContract` via `toClientEvmSigner` + `usePublicClient`. -- [ ] Browser `ClientChannelStorage` backed by `localStorage`. -- [ ] `+Page.tsx sendMessage`: swap bearer-token fetch for `fetchWithPayment`; drop `useWalletAuth("sc-llm")`. -- [ ] `BalanceDisplay`: `readChannelBalanceAndTotalClaimed` + channel deposit instead of `checkBalance`/`depositForLLM`; USDC (6 decimals) not ETH. -- [ ] Verify in browser on Base Sepolia (deposit-on-first-chat, voucher per message, aggregated on-chain claim). +The SDK enforces `voucher.maxClaimableAmount === chargedCumulativeAmount + requirements.amount` **exactly** (`handleBeforeVerify`/`handleBeforeSettle`). So the handler *cannot* charge a different post-generation amount by mutating state — that just breaks the next request. True usage-based billing needs the corrective-402 flow (client re-signs after learning the real amount). -**Phase D — Retire merkle** (after C proven on mainnet) +So we charge a flat per-message price, but **derived, not arbitrary**: `convertTokensToUsdcCost()` reuses the real IONOS price (0.71 EUR/1M tokens) → USDC atomic units (the two 1e6 factors cancel: `tokens * 71n / 100n`), applied to `LLM_ESTIMATED_TOKENS_PER_MESSAGE` (default 2000 → $0.00142). Treats 1 EUR = 1 USDC (documented simplification, no FX oracle). -- [ ] Remove `llm_service.ts` merkle code (keep `convertTokensToCost`); remove `leaf_history.ts` + `leafhistory`; stop writing `trees.json`. -- [ ] Remove LLMv1 balance UI; retire `LLMv1` (leave `withdrawBalance` open briefly — no real balances). -- [ ] Update `scw_js/README.md` + `.github/THREAT_MODEL.md` (one fewer owned upgradeable contract; asset now USDC). +### Client-side session keys (`voucherSigner`) -## Backlog — deferred (not part of the first renovation) +Messages after the first don't prompt the wallet: an ephemeral local key (`privateKeyToAccount`, persisted in `localStorage` keyed by wallet address) is passed as `voucherSigner`, and its address becomes the channel's `payerAuthorizer` — baked into `channelId` at deposit time and verified by the facilitator against `payerAuthorizer`, not `payer`. -### C. Tool / function calling +- **Deposits/top-ups still prompt** (real ERC-3009 transfer, never delegated). With `depositMultiplier: 5` that's roughly one prompt per ~5 messages. +- **The key must not rotate independently of the channel** — a different key means a different `channelId`, silently orphaning the funded channel. Clear both together or neither. +- **Bounded risk:** a leaked key can only sign vouchers up to the *already-escrowed* balance, and refunds return to the real wallet. Worst case is griefing, not theft. -Flagged as "interesting, uncertain importance." Revisit after personas (PR 2/3) ship and if usage patterns show a real need (e.g. users asking things the assistant should be able to look up on-chain). +### Hard-won gotchas (each one cost real debugging) -### D. Other modernization items noticed during investigation, not requested yet +- **`VoucherClaim.totalClaimed` is the new cumulative target**, not "already claimed". Wrong value = contract silently no-ops: no revert, `success: true`, zero value moved. +- **Claim and settle are genuinely two steps.** `claim` moves escrow into a per-`(receiver, token)` bucket with *no* ERC-20 transfer and no visible balance change; `settle(receiver, token)` sweeps it. Always check `receivers()` and the actual wallet balance, not just a claim receipt. +- **Always verify on-chain, never trust `success: true`.** Multiple bugs here returned success while moving nothing. +- **Payment requirements must go through `scheme.enhancePaymentRequirements()`** for verify/settle too — not just the 402-building path. A raw object omits `extra.receiverAuthorizer`/`withdrawDelay`, which the client signed against → `receiver_authorizer_mismatch` on every deposit. +- **402 failure responses must use `resourceServer.createPaymentRequiredResponse()`**, not a hand-built body — otherwise the client's corrective-retry flow has no `channelState` to resync from and a mismatch becomes a permanent dead end. +- **The client signer needs `readContract`** (build via `toClientEvmSigner(account, publicClient)`) — the corrective-recovery path requires it unconditionally. +- **One unregistered network breaks *all* networks' 402** — `enhancePaymentRequirements` throws and takes down the whole response. (This is the OP blocker above.) +- **Don't pass through SDK results by cherry-picking fields.** Dropping `extra` from the facilitator's settle response crashed the client on `channelId.toLowerCase()` — misdiagnosed as an upstream bug for a while; it was ours. +- **Scaleway cron rejects `*/12`** step syntax — use `0/12`. +- **`tsup` entries are explicit**: new handlers are silently excluded from `dist/` until added to `tsup.config.js`. -- Streaming responses (currently full-response-only; both IONOS and Mistral endpoints are OpenAI-compatible and support streaming). -- Conversation persistence (chat history currently lives only in React state, lost on refresh). -- Structured output on the LLM chat path (growth-agent's `llm_client.py` has `structured_output()` via Pydantic; `sc_llm.ts` has nothing equivalent — only relevant if a future feature needs it, e.g. tool calling). +--- -### F. Facilitator-hosted `receiverAuthorizer` (simplify scw_js's key management) +## Backlog -`x402_facilitator`'s `BatchSettlementEvmScheme` constructor accepts an optional `authorizerSigner` — if configured, the facilitator advertises its own address as `receiverAuthorizer` in `/supported`, and `scw_js` could then omit `RECEIVER_AUTHORIZER_PRIVATE_KEY` entirely (one fewer secret to manage; `createLLMResourceServer()` would just not pass `receiverAuthorizerSigner`). Considered during Phase B's buyer-notebook verification work and deferred: the SDK's own doc comment requires that "a facilitator that advertises a `receiverAuthorizer` for servers to delegate to must authenticate refund requests" — i.e. the facilitator would need a way to verify an incoming refund request for receiver X is actually from/approved by receiver X, not an impersonator. That authentication mechanism doesn't exist in `x402_facilitator` today and is real security design work, not a config toggle. Revisit only with proper design attention to that auth mechanism — don't just flip the constructor argument. +- **Personas / selectable system prompts.** Today one hardcoded prompt (`assistent.systemPrompt`). A persona registry (`{id, label, systemPrompt}[]`) + sidebar selector; no backend change needed since `sc_llm*.ts` already forwards arbitrary system-role content. Optionally reuse growth-agent's `Strategy` voice (`models.py:53-67`) as a "blog voice" persona — static copy first, live fetch only if it drifts. +- **Skip the 402 probe round-trip** (see Known rough edges). +- **Facilitator-hosted `receiverAuthorizer`.** Would let `scw_js` drop `RECEIVER_AUTHORIZER_PRIVATE_KEY` entirely. Deferred: the SDK requires a facilitator advertising `receiverAuthorizer` to *authenticate refund requests*, which `x402_facilitator` has no mechanism for. Real security design work, not a config toggle. +- **Tool / function calling** — "interesting, uncertain importance." +- **Streaming responses** — both IONOS and Mistral are OpenAI-compatible and support it. +- **Conversation persistence** — chat history currently lives only in React state, lost on refresh. diff --git a/scw_js/scratchpad_base_bal.ts b/scw_js/scratchpad_base_bal.ts new file mode 100644 index 000000000..eb9f421fe --- /dev/null +++ b/scw_js/scratchpad_base_bal.ts @@ -0,0 +1,31 @@ +import { createPublicClient, http, formatUnits, formatEther } from "viem"; +import { base } from "viem/chains"; + +const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as const; +const PAYER = "0x553179556FC2A39e535D65b921e01fA995E79101" as const; +const FACILITATOR = "0x3F8d2Fb6fEA24E70155bC61471936F3c9C30c206" as const; + +const erc20 = [ + { + type: "function", + name: "balanceOf", + inputs: [{ name: "a", type: "address" }], + outputs: [{ type: "uint256" }], + stateMutability: "view", + }, +] as const; + +const c = createPublicClient({ chain: base, transport: http() }); + +const [payerUsdc, payerEth, facEth] = await Promise.all([ + c.readContract({ address: USDC_BASE, abi: erc20, functionName: "balanceOf", args: [PAYER] }), + c.getBalance({ address: PAYER }), + c.getBalance({ address: FACILITATOR }), +]); + +console.log("=== Base MAINNET balances ==="); +console.log(`payer ${PAYER}`); +console.log(` USDC: ${formatUnits(payerUsdc, 6)} ${payerUsdc === 0n ? " <-- EMPTY" : ""}`); +console.log(` ETH(gas): ${formatEther(payerEth)}`); +console.log(`facilitator ${FACILITATOR}`); +console.log(` ETH(gas): ${formatEther(facEth)} ${facEth === 0n ? " <-- EMPTY, cannot submit txs" : ""}`); diff --git a/x402_facilitator/notebooks/x402_batch_settlement_buyer.ipynb b/x402_facilitator/notebooks/x402_batch_settlement_buyer.ipynb index 3d06c69d7..823216f72 100644 --- a/x402_facilitator/notebooks/x402_batch_settlement_buyer.ipynb +++ b/x402_facilitator/notebooks/x402_batch_settlement_buyer.ipynb @@ -38,10 +38,21 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "id": "e5b90ed0", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "🚀 x402 Batch-Settlement Spike (Deno/TS)\n", + " Payer (buyer) : 0x553179556FC2A39e535D65b921e01fA995E79101\n", + " Recipient : 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\n", + " receiverAuthorizer: 0x09B1eC2c71b111Bc5560881FFd5E42d05E0AE65E\n" + ] + } + ], "source": [ "// Setup: imports + config\n", "import { load } from \"https://deno.land/std@0.224.0/dotenv/mod.ts\";\n", @@ -97,10 +108,25 @@ "execution_count": null, "id": "df636fad", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "🚨 REAL MONEY on Base Mainnet\n", + " eip155:8453 • USDC USD Coin @ 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\n", + " Facilitator: http://localhost:8080\n" + ] + } + ], "source": [ "// ── Network selection ──────────────────────────────────────────────\n", - "const USE_MAINNET = false; // ⚠️ true = REAL MONEY. Keep false for the spike.\n", + "const USE_MAINNET = true; // ⚠️ true = REAL MONEY. Keep false for the spike.\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 = true;\n", "const USE_BASE = true; // Base by default. (Optimism + testnet is unavailable.)\n", "\n", "// Guard: Optimism Sepolia has no batch-settlement contract.\n", @@ -138,7 +164,8 @@ "// Max per-request price (6-decimal USDC). 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 = \"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", @@ -161,10 +188,36 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "id": "2dbdd220", "metadata": {}, - "outputs": [], + "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:8453\n" + ] + } + ], "source": [ "const supported = await (await fetch(SUPPORTED_URL)).json();\n", "const kinds = supported.kinds ?? [];\n", @@ -201,10 +254,18 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 4, "id": "87f8ca73", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✅ buyer registered for eip155:8453 (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", @@ -237,10 +298,31 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 5, "id": "0a372849", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{\n", + " \"scheme\": \"batch-settlement\",\n", + " \"network\": \"eip155:8453\",\n", + " \"amount\": \"4000\",\n", + " \"asset\": \"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\",\n", + " \"payTo\": \"0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\",\n", + " \"maxTimeoutSeconds\": 3600,\n", + " \"extra\": {\n", + " \"name\": \"USD Coin\",\n", + " \"version\": \"2\",\n", + " \"receiverAuthorizer\": \"0x09B1eC2c71b111Bc5560881FFd5E42d05E0AE65E\",\n", + " \"withdrawDelay\": 86400\n", + " }\n", + "}\n" + ] + } + ], "source": [ "const paymentRequirements = {\n", " scheme: \"batch-settlement\",\n", @@ -282,10 +364,44 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 6, "id": "8ed125fd", "metadata": {}, - "outputs": [], + "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\": \"0x09B1eC2c71b111Bc5560881FFd5E42d05E0AE65E\",\n", + " \"token\": \"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\",\n", + " \"withdrawDelay\": 86400,\n", + " \"salt\": \"0x0000000000000000000000000000000000000000000000000000000000000000\"\n", + " },\n", + " \"voucher\": {\n", + " \"channelId\": \"0x39429abfb150f1ef3810d73248cbd9faafbdc62ff0d7f90b2053b9e965d3c52c\",\n", + " \"maxClaimableAmount\": \"4000\",\n", + " \"signature\": \"0x3feaf9716deaf9c4e31e73fd1f6e0e34ee190c6faf30302068d1815d47e5848847dfb4f00307ba54d08108f09d55b199522502340530907aa260843f552156b21b\"\n", + " },\n", + " \"deposit\": {\n", + " \"amount\": \"20000\",\n", + " \"authorization\": {\n", + " \"erc3009Authorization\": {\n", + " \"validAfter\": \"0\",\n", + " \"validBefore\": \"1784573070\",\n", + " \"salt\": \"0xef135d003b068189e60666b536141ff4bbe5d3c75ce329a33dc153f84086c4f6\",\n", + " \"signature\": \"0x48e6dd07482152b02cda59beba98aa95716df8db50abe063ab804b1f8c33868b7af17c0e75969917\n" + ] + } + ], "source": [ "const paymentPayload = await buyerClient.createPaymentPayload(paymentRequired as any);\n", "console.log(\"✅ payload created (deposit + voucher):\");\n", @@ -302,10 +418,21 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 7, "id": "65dc352b", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "verify (200): {\n", + " \"isValid\": true,\n", + " \"payer\": \"0x553179556FC2A39e535D65b921e01fA995E79101\"\n", + "}\n" + ] + } + ], "source": [ "const verifyRes = await fetch(VERIFY_URL, {\n", " method: \"POST\", headers: { \"Content-Type\": \"application/json\" },\n", @@ -328,10 +455,20 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 8, "id": "66524d3a", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "💵 Payer USDC: 1.34 (deposit needs ≈ 0.02)\n", + " ✅ enough USDC to open the channel\n", + "⛽ Facilitator ETH: 0.005981421456925335 (0x3F8d2Fb6fEA24E70155bC61471936F3c9C30c206)\n" + ] + } + ], "source": [ "const publicClient = createPublicClient({ chain: config.chain, transport: http(config.rpcUrl) });\n", "const erc20 = [{ inputs: [{ name: \"a\", type: \"address\" }], name: \"balanceOf\",\n", @@ -372,21 +509,41 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 11, "id": "5b9de201", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "settle (200): {\n", + " \"success\": false,\n", + " \"errorReason\": \"invalid_batch_settlement_evm_deposit_transaction_failed\",\n", + " \"payer\": \"0x553179556FC2A39e535D65b921e01fA995E79101\",\n", + " \"transaction\": \"\",\n", + " \"network\": \"eip155:8453\"\n", + "}\n" + ] + } + ], "source": [ - "if (USE_MAINNET) throw new Error(\"Refusing to auto-settle on mainnet. Set USE_MAINNET=false or run this cell deliberately.\");\n", + "const maySettle = !USE_MAINNET || CONFIRM_MAINNET_WRITES;\n", + "if (!maySettle) {\n", + " console.warn(\"⏭️ Skipped — this settles REAL USDC on mainnet.\");\n", + " console.warn(\" Set CONFIRM_MAINNET_WRITES = true in the network-selection cell to arm it.\");\n", + "}\n", "\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", - "// Fee-free: settleResult should carry NO facilitatorFees / fee.collected." + "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", + " // Fee-free: settleResult should carry NO facilitatorFees / fee.collected.\n", + "}" ] }, { @@ -473,47 +630,53 @@ "import { claimBatchTypes, BATCH_SETTLEMENT_DOMAIN, BATCH_SETTLEMENT_ADDRESS } from \"npm:@x402/evm@^2.17.0\";\n", "import { getAddress } from \"npm:viem@2\";\n", "\n", - "if (USE_MAINNET) throw new Error(\"Refusing to auto-claim on mainnet. Set USE_MAINNET=false or run this cell deliberately.\");\n", - "\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 we use 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", + "const mayClaim = !USE_MAINNET || CONFIRM_MAINNET_WRITES;\n", + "if (!mayClaim) {\n", + " console.warn(\"⏭️ Skipped — this claims REAL USDC on mainnet.\");\n", + " console.warn(\" Set CONFIRM_MAINNET_WRITES = true in the network-selection cell to arm it.\");\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", + "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 we use 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", "}" ] }, @@ -571,7 +734,15 @@ "name": "deno" }, "language_info": { - "name": "typescript" + "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, diff --git a/x402_facilitator/upstream/ISSUE_DRAFT.md b/x402_facilitator/upstream/ISSUE_DRAFT.md new file mode 100644 index 000000000..aa3fc8ea7 --- /dev/null +++ b/x402_facilitator/upstream/ISSUE_DRAFT.md @@ -0,0 +1,123 @@ +# Issue draft — batch-settlement on networks outside `DEFAULT_STABLECOINS` + +**Status:** ready to file at https://github.com/x402-foundation/x402/issues/new +**Repro:** `batch-settlement-chain-agnostic-repro.mjs` (same directory) — verified against `@x402/evm` 2.18.0 on 2026-07-20. +**Run it:** `npm i @x402/evm && node batch-settlement-chain-agnostic-repro.mjs` + +Everything below the line is the literal issue body. + +--- + +### Title + +`batch-settlement`: is there a supported way to use a network that isn't in `DEFAULT_STABLECOINS`? + +### What I'm trying to do + +Run the `batch-settlement` scheme on Optimism mainnet, paying in Circle USDC (`0x0b2C…Ff85`). The batch-settlement contract seems to be deployed there, and the token implements EIP-3009. + +Optimism isn't in `DEFAULT_STABLECOINS`, so I followed the guidance I was given in [#835](https://github.com/x402-foundation/x402/issues/835): + +> v2 supports any EVM-compatible chain as long as the payment asset implements EIP-3009 […] You can either implement a moneyParser […] or specify 'amount' (in atomic units) and 'asset' instead of 'price' + +That works for `exact`. With `batch-settlement` I can't get either approach to work, and I suspect I'm either holding it wrong or hitting something unintended — hence the question. + +### Reproduction + + +```js +// npm i @x402/evm && node repro.mjs +import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/server"; + +const scheme = new BatchSettlementEvmScheme("0x1111111111111111111111111111111111111111"); + +await scheme.enhancePaymentRequirements( + { + scheme: "batch-settlement", + network: "eip155:10", + amount: "1420", // explicit atomic amount + asset: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", // Circle USDC on OP + payTo: "0x1111111111111111111111111111111111111111", + extra: { name: "USD Coin", version: "2" }, + }, + {}, + [], +); +``` + +``` +Error: No default asset configured for network eip155:10 + at getDefaultAsset (chunk-DQI2DTA4.mjs:151:11) + at BatchSettlementEvmScheme.enhancePaymentRequirements (batch-settlement/server/index.mjs:1525:23) +``` + +### What I tried to narrow it down + +Varying one thing at a time (full script at the bottom): + +``` +ok batch-settlement eip155:8453 asset kept: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 +THROWS batch-settlement eip155:10 No default asset configured for network eip155:10 +ok exact eip155:10 asset kept: 0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85 +THROWS batch-settlement createChannelManager No default asset configured for network eip155:10 +THROWS batch-settlement + moneyParser No default asset configured for network eip155:10 +``` + +My reading of this, which I may well have wrong: + +- Rows 1–2: the same call succeeds on Base and fails on OP, so the requirements object itself seems to be accepted. +- Rows 2–3: `exact` accepts the same network and asset that `batch-settlement` rejects. +- Row 4: `createChannelManager` seems to depend on the registry as well, so this may not be limited to one call path. +- Row 5: a `registerMoneyParser` doesn't change the outcome — from the source it looks like it's consulted by `parseMoney`, which isn't on this path. + +### What I found in the source + +In `batch-settlement/server`, `enhancePaymentRequirements` looks up the asset before reading the caller's requirements: + +```js +const assetInfo = getDefaultAsset(paymentRequirements.network); // index.mjs:1525 +… +extra: { ...paymentRequirements.extra, name: assetInfo.name, version: assetInfo.version } +``` + +whereas `exact`'s implementation of the same method passes the requirements through: + +```js +enhancePaymentRequirements(paymentRequirements, supportedKind, extensionKeys) { + return Promise.resolve(paymentRequirements); +} +``` + +I couldn't find an option to supply the asset explicitly — as far as I can tell neither the client nor server `BatchSettlementEvmSchemeOptions` takes one — but I may have missed it. + +One thing I wasn't sure whether to flag: because a resource server builds `accepts[]` by calling this per network, a single unlisted network appears to throw for the whole 402 rather than just that entry. That could be intended. + +### Questions + +1. Is `batch-settlement` intended to work on networks outside `DEFAULT_STABLECOINS`, and if so, what's the supported way? Happy to just use it if I've missed the mechanism. +2. If the caller's `asset`/`extra` should be honoured here the way `exact` does, I'd be glad to attempt a PR. The part I'd need guidance on is whether the EIP-712 `name`/`version` are deliberately taken from the registry rather than the caller — that looked intentional, and I didn't want to assume. + +Separately, if it's simply that Optimism should be in the registry, these are the values I read from the contract on 2026-07-20: + +```js +"eip155:10": { + address: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", + name: "USD Coin", + version: "2", + decimals: 6, +}, +``` + +Glad to send that as a PR too if it's useful — though I assume the broader question above matters more than my particular chain. + +### Environment + +- `@x402/evm` 2.18.0, `@x402/core` 2.18.0 +- Node v24.16.0 +- Same behaviour on 2.17.0 + +### Full script + +```js + +``` diff --git a/x402_facilitator/upstream/batch-settlement-chain-agnostic-repro.mjs b/x402_facilitator/upstream/batch-settlement-chain-agnostic-repro.mjs new file mode 100644 index 000000000..407c417b3 --- /dev/null +++ b/x402_facilitator/upstream/batch-settlement-chain-agnostic-repro.mjs @@ -0,0 +1,53 @@ +// npm i @x402/evm && node repro.mjs +// No facilitator, no RPC, no wallet, no funds. +import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/server"; +import { ExactEvmScheme } from "@x402/evm/exact/server"; + +const PAY_TO = "0x1111111111111111111111111111111111111111"; +const AUTHORIZER = "0x2222222222222222222222222222222222222222"; +const USDC_OP = "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85"; // OP mainnet — not in DEFAULT_STABLECOINS +const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; // Base mainnet — in DEFAULT_STABLECOINS + +// Explicit atomic `amount` + `asset`: the documented way to use an unlisted chain (#835). +const reqs = (network, asset) => ({ + scheme: "batch-settlement", + network, + amount: "1420", + asset, + payTo: PAY_TO, + extra: { name: "USD Coin", version: "2" }, +}); + +const batch = new BatchSettlementEvmScheme(PAY_TO, { receiverAuthorizerSigner: { address: AUTHORIZER } }); + +// Calls enhancePaymentRequirements and reports whether the caller's `asset` survived. +const enhance = async (label, scheme, network, asset) => { + try { + const r = await scheme.enhancePaymentRequirements(reqs(network, asset), {}, []); + console.log(`ok ${label.padEnd(38)} asset kept: ${r.asset}`); + } catch (e) { + console.log(`THROWS ${label.padEnd(38)} ${e.message}`); + } +}; + +// Control: Base is in the registry. Shows the call is well-formed and the asset is honored. +await enhance("batch-settlement eip155:8453", batch, "eip155:8453", USDC_BASE); + +// The bug: identical call, unlisted network. Throws even though `asset` is supplied. +await enhance("batch-settlement eip155:10", batch, "eip155:10", USDC_OP); + +// The inconsistency: same network, same asset, same method — `exact` honors it. +await enhance("exact eip155:10", new ExactEvmScheme(), "eip155:10", USDC_OP); + +// A second, independent registry dependency: fixing enhancePaymentRequirements alone is not enough. +try { + batch.createChannelManager({}, "eip155:10"); + console.log("ok batch-settlement createChannelManager eip155:10"); +} catch (e) { + console.log(`THROWS ${"batch-settlement createChannelManager".padEnd(38)} ${e.message}`); +} + +// The other workaround from #835: registerMoneyParser only reaches parseMoney/defaultMoneyConversion. +const withParser = new BatchSettlementEvmScheme(PAY_TO, { receiverAuthorizerSigner: { address: AUTHORIZER } }); +withParser.registerMoneyParser(() => ({ amount: "1420", asset: USDC_OP })); +await enhance("batch-settlement + moneyParser", withParser, "eip155:10", USDC_OP); From 6583ecbc42ec31b3c4ee6375749f52a21057ddd4 Mon Sep 17 00:00:00 2001 From: fretchen Date: Mon, 20 Jul 2026 20:36:42 +0200 Subject: [PATCH 02/14] Clean the facilitator further --- scw_js/scratchpad_base_bal.ts | 31 -------------- .../x402_batch_settlement_buyer.ipynb | 40 +++++++++---------- x402_facilitator/serverless.yml | 9 ++++- 3 files changed, 27 insertions(+), 53 deletions(-) delete mode 100644 scw_js/scratchpad_base_bal.ts diff --git a/scw_js/scratchpad_base_bal.ts b/scw_js/scratchpad_base_bal.ts deleted file mode 100644 index eb9f421fe..000000000 --- a/scw_js/scratchpad_base_bal.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { createPublicClient, http, formatUnits, formatEther } from "viem"; -import { base } from "viem/chains"; - -const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as const; -const PAYER = "0x553179556FC2A39e535D65b921e01fA995E79101" as const; -const FACILITATOR = "0x3F8d2Fb6fEA24E70155bC61471936F3c9C30c206" as const; - -const erc20 = [ - { - type: "function", - name: "balanceOf", - inputs: [{ name: "a", type: "address" }], - outputs: [{ type: "uint256" }], - stateMutability: "view", - }, -] as const; - -const c = createPublicClient({ chain: base, transport: http() }); - -const [payerUsdc, payerEth, facEth] = await Promise.all([ - c.readContract({ address: USDC_BASE, abi: erc20, functionName: "balanceOf", args: [PAYER] }), - c.getBalance({ address: PAYER }), - c.getBalance({ address: FACILITATOR }), -]); - -console.log("=== Base MAINNET balances ==="); -console.log(`payer ${PAYER}`); -console.log(` USDC: ${formatUnits(payerUsdc, 6)} ${payerUsdc === 0n ? " <-- EMPTY" : ""}`); -console.log(` ETH(gas): ${formatEther(payerEth)}`); -console.log(`facilitator ${FACILITATOR}`); -console.log(` ETH(gas): ${formatEther(facEth)} ${facEth === 0n ? " <-- EMPTY, cannot submit txs" : ""}`); diff --git a/x402_facilitator/notebooks/x402_batch_settlement_buyer.ipynb b/x402_facilitator/notebooks/x402_batch_settlement_buyer.ipynb index 823216f72..8061df8b5 100644 --- a/x402_facilitator/notebooks/x402_batch_settlement_buyer.ipynb +++ b/x402_facilitator/notebooks/x402_batch_settlement_buyer.ipynb @@ -38,7 +38,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": 2, "id": "e5b90ed0", "metadata": {}, "outputs": [ @@ -49,7 +49,7 @@ "🚀 x402 Batch-Settlement Spike (Deno/TS)\n", " Payer (buyer) : 0x553179556FC2A39e535D65b921e01fA995E79101\n", " Recipient : 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\n", - " receiverAuthorizer: 0x09B1eC2c71b111Bc5560881FFd5E42d05E0AE65E\n" + " receiverAuthorizer: 0x8749fFc94bE415E75d0c9570A179EC68401AeC00\n" ] } ], @@ -105,7 +105,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "id": "df636fad", "metadata": {}, "outputs": [ @@ -164,8 +164,8 @@ "// Max per-request price (6-decimal USDC). 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 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", @@ -188,7 +188,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 5, "id": "2dbdd220", "metadata": {}, "outputs": [ @@ -254,7 +254,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 6, "id": "87f8ca73", "metadata": {}, "outputs": [ @@ -298,7 +298,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 7, "id": "0a372849", "metadata": {}, "outputs": [ @@ -316,7 +316,7 @@ " \"extra\": {\n", " \"name\": \"USD Coin\",\n", " \"version\": \"2\",\n", - " \"receiverAuthorizer\": \"0x09B1eC2c71b111Bc5560881FFd5E42d05E0AE65E\",\n", + " \"receiverAuthorizer\": \"0x8749fFc94bE415E75d0c9570A179EC68401AeC00\",\n", " \"withdrawDelay\": 86400\n", " }\n", "}\n" @@ -364,7 +364,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 8, "id": "8ed125fd", "metadata": {}, "outputs": [ @@ -381,24 +381,24 @@ " \"payer\": \"0x553179556FC2A39e535D65b921e01fA995E79101\",\n", " \"payerAuthorizer\": \"0x553179556FC2A39e535D65b921e01fA995E79101\",\n", " \"receiver\": \"0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\",\n", - " \"receiverAuthorizer\": \"0x09B1eC2c71b111Bc5560881FFd5E42d05E0AE65E\",\n", + " \"receiverAuthorizer\": \"0x8749fFc94bE415E75d0c9570A179EC68401AeC00\",\n", " \"token\": \"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\",\n", " \"withdrawDelay\": 86400,\n", " \"salt\": \"0x0000000000000000000000000000000000000000000000000000000000000000\"\n", " },\n", " \"voucher\": {\n", - " \"channelId\": \"0x39429abfb150f1ef3810d73248cbd9faafbdc62ff0d7f90b2053b9e965d3c52c\",\n", + " \"channelId\": \"0xa2b356e5f57d155ad404814afe16aa0d1b3593babde1b684b4f93d13f19d2ac5\",\n", " \"maxClaimableAmount\": \"4000\",\n", - " \"signature\": \"0x3feaf9716deaf9c4e31e73fd1f6e0e34ee190c6faf30302068d1815d47e5848847dfb4f00307ba54d08108f09d55b199522502340530907aa260843f552156b21b\"\n", + " \"signature\": \"0xeb9495694efee4d8daee7e8809f3069e7de51262e35c369165abdf1704282c6168b34e370fc14733e06eb87336c8985f0dcc00d104d264a822ee8bf5f60864fd1c\"\n", " },\n", " \"deposit\": {\n", " \"amount\": \"20000\",\n", " \"authorization\": {\n", " \"erc3009Authorization\": {\n", " \"validAfter\": \"0\",\n", - " \"validBefore\": \"1784573070\",\n", - " \"salt\": \"0xef135d003b068189e60666b536141ff4bbe5d3c75ce329a33dc153f84086c4f6\",\n", - " \"signature\": \"0x48e6dd07482152b02cda59beba98aa95716df8db50abe063ab804b1f8c33868b7af17c0e75969917\n" + " \"validBefore\": \"1784575872\",\n", + " \"salt\": \"0xf1c615baee0d3142f79c574253e26b196a24a199fb0715248d2c12d23e919e62\",\n", + " \"signature\": \"0xf741a61173ef7410e5aa0135ca520b664da8d7aaff14cc72402e3d6a4811a2693511f4626dc70722\n" ] } ], @@ -418,7 +418,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 9, "id": "65dc352b", "metadata": {}, "outputs": [ @@ -455,7 +455,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 10, "id": "66524d3a", "metadata": {}, "outputs": [ @@ -463,9 +463,9 @@ "name": "stdout", "output_type": "stream", "text": [ - "💵 Payer USDC: 1.34 (deposit needs ≈ 0.02)\n", + "💵 Payer USDC: 1.3 (deposit needs ≈ 0.02)\n", " ✅ enough USDC to open the channel\n", - "⛽ Facilitator ETH: 0.005981421456925335 (0x3F8d2Fb6fEA24E70155bC61471936F3c9C30c206)\n" + "⛽ Facilitator ETH: 0.005979725389478306 (0x3F8d2Fb6fEA24E70155bC61471936F3c9C30c206)\n" ] } ], diff --git a/x402_facilitator/serverless.yml b/x402_facilitator/serverless.yml index c096d4cf1..0cf2ce043 100644 --- a/x402_facilitator/serverless.yml +++ b/x402_facilitator/serverless.yml @@ -14,8 +14,13 @@ provider: # Fee amount in USDC smallest unit (6 decimals): 10000 = 0.01 USDC FACILITATOR_FEE_AMOUNT: "10000" # batch-settlement recipient whitelist (payTo, not the payer) — see x402_whitelist.ts. - # Testnet-only for now; add BATCH_SETTLEMENT_MANUAL_WHITELIST here too when cutting over to mainnet. - # Without this, every batch-settlement request is rejected with recipient_not_whitelisted. + # Without a match, every batch-settlement request is rejected with recipient_not_whitelisted. + # + # Two tiers, deliberately different trust levels: + # MANUAL_WHITELIST — production allowlist, applies on ANY network incl. mainnet. + # TEST_WALLETS — convenience tier, fenced behind isTestnet() so a testnet entry + # can never authorize real funds. Keep testnet-only wallets here. + BATCH_SETTLEMENT_MANUAL_WHITELIST: ${env:BATCH_SETTLEMENT_MANUAL_WHITELIST, ''} BATCH_SETTLEMENT_TEST_WALLETS: ${env:BATCH_SETTLEMENT_TEST_WALLETS, ''} # Secrets (set via Scaleway Console or CLI) secret: From 43d7de397f93515cfff290bccb03b9b77376e826 Mon Sep 17 00:00:00 2001 From: fretchen Date: Mon, 20 Jul 2026 20:43:02 +0200 Subject: [PATCH 03/14] Improve error handling. --- x402_facilitator/x402_facilitator.ts | 4 ++++ x402_facilitator/x402_settle.ts | 28 +++++++++++++++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/x402_facilitator/x402_facilitator.ts b/x402_facilitator/x402_facilitator.ts index cba6a1224..f22421865 100644 --- a/x402_facilitator/x402_facilitator.ts +++ b/x402_facilitator/x402_facilitator.ts @@ -218,6 +218,10 @@ async function handlePaymentRequest( 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", diff --git a/x402_facilitator/x402_settle.ts b/x402_facilitator/x402_settle.ts index 248eb0d20..6b6747b79 100644 --- a/x402_facilitator/x402_settle.ts +++ b/x402_facilitator/x402_settle.ts @@ -28,6 +28,14 @@ export interface SettleResult { transaction?: string; network?: string; errorReason?: string; + /** + * 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 this the real cause is + * silently discarded, which has previously turned a one-line revert into days of guessing. + */ + errorMessage?: string; /** Fee collection info (present when fee is configured) */ fee?: { collected: boolean; @@ -143,8 +151,18 @@ export async function settlePayment( const payer = claims?.[0]?.voucher?.channel?.payer; if (!result.success) { - logger.warn({ errorReason: result.errorReason }, "Batch-settlement claim/settle failed"); - return { success: false, errorReason: result.errorReason, payer, transaction: "", network }; + logger.warn( + { errorReason: result.errorReason, errorMessage: result.errorMessage }, + "Batch-settlement claim/settle failed", + ); + return { + success: false, + errorReason: result.errorReason, + errorMessage: result.errorMessage, + payer, + transaction: "", + network, + }; } logger.info( @@ -182,10 +200,14 @@ export async function settlePayment( const result = await facilitator.settle(paymentPayload as any, paymentRequirements as any); if (!result.success) { - logger.warn({ errorReason: result.errorReason }, "Settlement failed"); + logger.warn( + { errorReason: result.errorReason, errorMessage: result.errorMessage }, + "Settlement failed", + ); return { success: false, errorReason: result.errorReason, + errorMessage: result.errorMessage, payer: verifyResult.payer, transaction: "", network: accepted?.network as string, From 2e3e32c4907161f248d6265c3f74b2f07f25002f Mon Sep 17 00:00:00 2001 From: fretchen Date: Mon, 20 Jul 2026 20:54:54 +0200 Subject: [PATCH 04/14] Cleaning it further --- x402_facilitator/chain_utils.ts | 17 +++++++++++++++++ x402_facilitator/facilitator_instance.ts | 16 ++++++++++++---- x402_facilitator/serverless.yml | 9 +++++++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/x402_facilitator/chain_utils.ts b/x402_facilitator/chain_utils.ts index 16cae4cf1..46bbb19b1 100644 --- a/x402_facilitator/chain_utils.ts +++ b/x402_facilitator/chain_utils.ts @@ -20,6 +20,23 @@ export interface ChainConfig { USDC_NAME: string; } +/** + * Optional per-network RPC endpoint, read from `RPC_URL_` — e.g. + * `RPC_URL_EIP155_8453` for Base mainnet. Returns `undefined` when unset, which + * makes viem's `http()` fall back to the chain's default public endpoint. + * + * Configure this for any network carrying real traffic. The public defaults + * (e.g. `https://mainnet.base.org`) are aggressively rate-limited: a single + * batch-settlement deposit issues a Multicall3 batch of channel-state reads and + * comes back `over rate limit`, which surfaces as the generic + * `..._deposit_transaction_failed` even though nothing was ever submitted + * on-chain. It also explains multi-second latency on otherwise trivial reads. + */ +export function getRpcUrl(network: string): string | undefined { + const key = `RPC_URL_${network.replace(/[:-]/g, "_").toUpperCase()}`; + return process.env[key] || undefined; +} + /** * Get chain configuration including contract addresses * @param network - Network ID (e.g. "eip155:10" or "eip155:11155420") diff --git a/x402_facilitator/facilitator_instance.ts b/x402_facilitator/facilitator_instance.ts index e5f0b09d8..c99e84b75 100644 --- a/x402_facilitator/facilitator_instance.ts +++ b/x402_facilitator/facilitator_instance.ts @@ -19,7 +19,12 @@ import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/facilitator import pino from "pino"; import { loadPrivateKey } from "@fretchen/chain-utils"; import { checkMerchantAllowance, getFeeAmount, getFacilitatorAddress } from "./x402_fee"; -import { getChainConfig, getSupportedNetworks, getBatchSettlementNetworks } from "./chain_utils"; +import { + getChainConfig, + getSupportedNetworks, + getBatchSettlementNetworks, + getRpcUrl, +} from "./chain_utils"; import { isRecipientWhitelisted } from "./x402_whitelist"; const logger = pino({ level: process.env.LOG_LEVEL || "info" }); @@ -30,16 +35,19 @@ const logger = pino({ level: process.env.LOG_LEVEL || "info" }); */ 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(), + transport: http(rpcUrl), }); const walletClient = createWalletClient({ account, chain: config.chain, - transport: http(), + transport: http(rpcUrl), }); return toFacilitatorEvmSigner({ @@ -72,7 +80,7 @@ export function createReadOnlyFacilitator(): InstanceType viem falls + # back to the chain's public endpoint, which is aggressively rate-limited: a single + # batch-settlement deposit does a Multicall3 read batch and fails with "over rate + # limit", surfacing as the generic ..._deposit_transaction_failed. Set these for any + # network carrying real traffic. + RPC_URL_EIP155_8453: ${env:RPC_URL_EIP155_8453, ''} + RPC_URL_EIP155_10: ${env:RPC_URL_EIP155_10, ''} + RPC_URL_EIP155_84532: ${env:RPC_URL_EIP155_84532, ''} + RPC_URL_EIP155_11155420: ${env:RPC_URL_EIP155_11155420, ''} # Secrets (set via Scaleway Console or CLI) secret: FACILITATOR_WALLET_PRIVATE_KEY: ${env:FACILITATOR_WALLET_PRIVATE_KEY} From 305d6dde7232fc9576af8c4d70e0edc4875e445d Mon Sep 17 00:00:00 2001 From: fretchen Date: Mon, 20 Jul 2026 21:04:09 +0200 Subject: [PATCH 05/14] Clean up --- shared/chain-utils/src/env-utils.ts | 47 ++++++++++ shared/chain-utils/src/index.ts | 9 +- shared/chain-utils/src/key-utils.ts | 12 --- .../{key-utils.test.ts => env-utils.test.ts} | 48 +++++++++- x402_facilitator/chain_utils.ts | 20 +--- .../x402_batch_settlement_buyer.ipynb | 93 +++++++++++++------ 6 files changed, 170 insertions(+), 59 deletions(-) create mode 100644 shared/chain-utils/src/env-utils.ts delete mode 100644 shared/chain-utils/src/key-utils.ts rename shared/chain-utils/test/{key-utils.test.ts => env-utils.test.ts} (64%) diff --git a/shared/chain-utils/src/env-utils.ts b/shared/chain-utils/src/env-utils.ts new file mode 100644 index 000000000..1eb65fbf4 --- /dev/null +++ b/shared/chain-utils/src/env-utils.ts @@ -0,0 +1,47 @@ +// Node-only helpers that read `process.env`. Kept together (rather than one file per +// function) because the Node/browser split is the distinction that actually matters +// here: `website/` imports this package too, but only the isomorphic parts, and must +// use its own `import.meta.env.PUBLIC_ENV__*` convention instead of anything below. +// +// Minimal type shim — avoids taking @types/node as a library dependency. +declare const process: { env: Record } | undefined; + +/** + * Read and validate a 64-hex-character private key from an environment variable. + * Tolerates an optional `0x`/`0X` prefix and surrounding whitespace (Scaleway secrets + * frequently arrive with a trailing newline). + * + * @param envVarName - Name of the environment variable holding the key. + * @returns The key, normalized to a `0x`-prefixed lowercase-prefix hex string. + * @throws If the variable is unset/blank, or is not exactly 64 hex characters. + */ +export function loadPrivateKey(envVarName: string): `0x${string}` { + const raw = process?.env[envVarName]; + const trimmed = raw?.trim(); + if (!trimmed) throw new Error(`${envVarName} not configured`); + const hex = trimmed.replace(/^0x/i, ""); + if (!/^[0-9a-fA-F]{64}$/.test(hex)) + throw new Error(`${envVarName} invalid: must be 64 hex characters`); + return `0x${hex}`; +} + +/** + * Optional per-network RPC endpoint, read from `RPC_URL_` — e.g. + * `RPC_URL_EIP155_8453` for Base mainnet, `RPC_URL_EIP155_84532` for Base Sepolia. + * Returns `undefined` when unset or empty, which makes viem's `http()` fall back to + * the chain's default public endpoint. + * + * **Configure this for any network carrying real traffic.** The public defaults + * (e.g. `https://mainnet.base.org`) are aggressively rate-limited: a single + * batch-settlement deposit issues a Multicall3 batch of channel-state reads and comes + * back `over rate limit`, which the SDK surfaces as the generic + * `..._deposit_transaction_failed` even though nothing was ever submitted on-chain. + * The same throttling shows up as multi-second latency on otherwise trivial reads. + * + * @param network - CAIP-2 network identifier, e.g. `"eip155:8453"`. + * @returns The configured RPC URL, or `undefined` to accept viem's public default. + */ +export function getRpcUrl(network: string): string | undefined { + const key = `RPC_URL_${network.replace(/[:-]/g, "_").toUpperCase()}`; + return process?.env[key] || undefined; +} diff --git a/shared/chain-utils/src/index.ts b/shared/chain-utils/src/index.ts index 1ad7ed0a4..3442364d3 100644 --- a/shared/chain-utils/src/index.ts +++ b/shared/chain-utils/src/index.ts @@ -134,8 +134,13 @@ export type { USDCConfig }; export * from "./abi"; // ═══════════════════════════════════════════════════════════════ -// Key Utilities +// Environment Utilities (Node-only — read process.env, see env-utils.ts) +// ═══════════════════════════════════════════════════════════════ + +export { loadPrivateKey, getRpcUrl } from "./env-utils"; + +// ═══════════════════════════════════════════════════════════════ +// Auth Protocol (isomorphic — shared by scw_js and website) // ═══════════════════════════════════════════════════════════════ -export { loadPrivateKey } from "./key-utils"; export { AUTH_TOKEN_MAX_AGE_MS, buildAuthMessage } from "./auth-protocol"; diff --git a/shared/chain-utils/src/key-utils.ts b/shared/chain-utils/src/key-utils.ts deleted file mode 100644 index ad5d415b6..000000000 --- a/shared/chain-utils/src/key-utils.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Minimal type shim — avoids taking @types/node as a library dependency. -declare const process: { env: Record }; - -export function loadPrivateKey(envVarName: string): `0x${string}` { - const raw = process.env[envVarName]; - const trimmed = raw?.trim(); - if (!trimmed) throw new Error(`${envVarName} not configured`); - const hex = trimmed.replace(/^0x/i, ""); - if (!/^[0-9a-fA-F]{64}$/.test(hex)) - throw new Error(`${envVarName} invalid: must be 64 hex characters`); - return `0x${hex}`; -} diff --git a/shared/chain-utils/test/key-utils.test.ts b/shared/chain-utils/test/env-utils.test.ts similarity index 64% rename from shared/chain-utils/test/key-utils.test.ts rename to shared/chain-utils/test/env-utils.test.ts index a4d9bd261..7a81ebee0 100644 --- a/shared/chain-utils/test/key-utils.test.ts +++ b/shared/chain-utils/test/env-utils.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "vitest"; -import { loadPrivateKey } from "../src/key-utils"; +import { loadPrivateKey, getRpcUrl } from "../src/env-utils"; const VALID_HEX = "a".repeat(64); const KEY = "TEST_PRIVATE_KEY"; @@ -101,3 +101,49 @@ describe("loadPrivateKey", () => { } }); }); + +describe("getRpcUrl", () => { + function withRpcEnv(key: string, value: string | undefined, fn: () => void) { + const old = process.env[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + try { + fn(); + } finally { + if (old === undefined) delete process.env[key]; + else process.env[key] = old; + } + } + + test("maps a CAIP-2 network to RPC_URL_", () => { + withRpcEnv("RPC_URL_EIP155_8453", "https://base-mainnet.example/v2/key", () => { + expect(getRpcUrl("eip155:8453")).toBe("https://base-mainnet.example/v2/key"); + }); + }); + + test("returns undefined when unset, so viem falls back to the public endpoint", () => { + withRpcEnv("RPC_URL_EIP155_10", undefined, () => { + expect(getRpcUrl("eip155:10")).toBeUndefined(); + }); + }); + + // Must be undefined, not "" — viem treats an empty string as an invalid URL + // rather than as "use the default". + test("returns undefined for an empty env var", () => { + withRpcEnv("RPC_URL_EIP155_8453", "", () => { + expect(getRpcUrl("eip155:8453")).toBeUndefined(); + }); + }); + + test("uppercases and replaces separators (testnet ids keep their digits)", () => { + withRpcEnv("RPC_URL_EIP155_11155420", "https://op-sepolia.example", () => { + expect(getRpcUrl("eip155:11155420")).toBe("https://op-sepolia.example"); + }); + }); + + test("does not leak one network's endpoint to another", () => { + withRpcEnv("RPC_URL_EIP155_8453", "https://base-only.example", () => { + expect(getRpcUrl("eip155:84532")).toBeUndefined(); + }); + }); +}); diff --git a/x402_facilitator/chain_utils.ts b/x402_facilitator/chain_utils.ts index 46bbb19b1..4871db4c1 100644 --- a/x402_facilitator/chain_utils.ts +++ b/x402_facilitator/chain_utils.ts @@ -20,22 +20,10 @@ export interface ChainConfig { USDC_NAME: string; } -/** - * Optional per-network RPC endpoint, read from `RPC_URL_` — e.g. - * `RPC_URL_EIP155_8453` for Base mainnet. Returns `undefined` when unset, which - * makes viem's `http()` fall back to the chain's default public endpoint. - * - * Configure this for any network carrying real traffic. The public defaults - * (e.g. `https://mainnet.base.org`) are aggressively rate-limited: a single - * batch-settlement deposit issues a Multicall3 batch of channel-state reads and - * comes back `over rate limit`, which surfaces as the generic - * `..._deposit_transaction_failed` even though nothing was ever submitted - * on-chain. It also explains multi-second latency on otherwise trivial reads. - */ -export function getRpcUrl(network: string): string | undefined { - const key = `RPC_URL_${network.replace(/[:-]/g, "_").toUpperCase()}`; - return process.env[key] || undefined; -} +// Per-network RPC endpoint resolution lives in the shared package so `scw_js` (which +// has the same bare-`http()` exposure) uses one implementation and one env-var +// convention. Re-exported here so existing local imports keep working. +export { getRpcUrl } from "@fretchen/chain-utils"; /** * Get chain configuration including contract addresses diff --git a/x402_facilitator/notebooks/x402_batch_settlement_buyer.ipynb b/x402_facilitator/notebooks/x402_batch_settlement_buyer.ipynb index 8061df8b5..222962bd9 100644 --- a/x402_facilitator/notebooks/x402_batch_settlement_buyer.ipynb +++ b/x402_facilitator/notebooks/x402_batch_settlement_buyer.ipynb @@ -38,7 +38,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 11, "id": "e5b90ed0", "metadata": {}, "outputs": [ @@ -49,7 +49,7 @@ "🚀 x402 Batch-Settlement Spike (Deno/TS)\n", " Payer (buyer) : 0x553179556FC2A39e535D65b921e01fA995E79101\n", " Recipient : 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\n", - " receiverAuthorizer: 0x8749fFc94bE415E75d0c9570A179EC68401AeC00\n" + " receiverAuthorizer: 0x027C5aef4464108dfD129114BefC663abD5EBF91\n" ] } ], @@ -105,7 +105,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 12, "id": "df636fad", "metadata": {}, "outputs": [ @@ -188,7 +188,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 13, "id": "2dbdd220", "metadata": {}, "outputs": [ @@ -254,7 +254,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 14, "id": "87f8ca73", "metadata": {}, "outputs": [ @@ -298,7 +298,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 15, "id": "0a372849", "metadata": {}, "outputs": [ @@ -316,7 +316,7 @@ " \"extra\": {\n", " \"name\": \"USD Coin\",\n", " \"version\": \"2\",\n", - " \"receiverAuthorizer\": \"0x8749fFc94bE415E75d0c9570A179EC68401AeC00\",\n", + " \"receiverAuthorizer\": \"0x027C5aef4464108dfD129114BefC663abD5EBF91\",\n", " \"withdrawDelay\": 86400\n", " }\n", "}\n" @@ -364,7 +364,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 16, "id": "8ed125fd", "metadata": {}, "outputs": [ @@ -381,24 +381,24 @@ " \"payer\": \"0x553179556FC2A39e535D65b921e01fA995E79101\",\n", " \"payerAuthorizer\": \"0x553179556FC2A39e535D65b921e01fA995E79101\",\n", " \"receiver\": \"0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\",\n", - " \"receiverAuthorizer\": \"0x8749fFc94bE415E75d0c9570A179EC68401AeC00\",\n", + " \"receiverAuthorizer\": \"0x027C5aef4464108dfD129114BefC663abD5EBF91\",\n", " \"token\": \"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\",\n", " \"withdrawDelay\": 86400,\n", " \"salt\": \"0x0000000000000000000000000000000000000000000000000000000000000000\"\n", " },\n", " \"voucher\": {\n", - " \"channelId\": \"0xa2b356e5f57d155ad404814afe16aa0d1b3593babde1b684b4f93d13f19d2ac5\",\n", + " \"channelId\": \"0x50494ec721abdc0e2196b8f30d317051b9106dda4b0f42808bca236a8bd9a11d\",\n", " \"maxClaimableAmount\": \"4000\",\n", - " \"signature\": \"0xeb9495694efee4d8daee7e8809f3069e7de51262e35c369165abdf1704282c6168b34e370fc14733e06eb87336c8985f0dcc00d104d264a822ee8bf5f60864fd1c\"\n", + " \"signature\": \"0x116139ee3c23c81ecfd4fec50d4f98a05d7669c3279c4b938b822ee3d92564c700d6ba1f46928d426d77ed9ecfa49179530bb25120d919a0163408867658a2ac1b\"\n", " },\n", " \"deposit\": {\n", " \"amount\": \"20000\",\n", " \"authorization\": {\n", " \"erc3009Authorization\": {\n", " \"validAfter\": \"0\",\n", - " \"validBefore\": \"1784575872\",\n", - " \"salt\": \"0xf1c615baee0d3142f79c574253e26b196a24a199fb0715248d2c12d23e919e62\",\n", - " \"signature\": \"0xf741a61173ef7410e5aa0135ca520b664da8d7aaff14cc72402e3d6a4811a2693511f4626dc70722\n" + " \"validBefore\": \"1784577731\",\n", + " \"salt\": \"0xdbed9692e4ff836d0228160c3d200ae37b6e9fe541018e32f238bdec28f5a660\",\n", + " \"signature\": \"0xfc46d5a53d47ab7e584024fa588d7a1514db70c057c2f4a0ca56eb81681796fb5abb1c088249f3c5\n" ] } ], @@ -418,7 +418,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 17, "id": "65dc352b", "metadata": {}, "outputs": [ @@ -455,7 +455,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 18, "id": "66524d3a", "metadata": {}, "outputs": [ @@ -463,9 +463,9 @@ "name": "stdout", "output_type": "stream", "text": [ - "💵 Payer USDC: 1.3 (deposit needs ≈ 0.02)\n", + "💵 Payer USDC: 1.26 (deposit needs ≈ 0.02)\n", " ✅ enough USDC to open the channel\n", - "⛽ Facilitator ETH: 0.005979725389478306 (0x3F8d2Fb6fEA24E70155bC61471936F3c9C30c206)\n" + "⛽ Facilitator ETH: 0.005978029522798724 (0x3F8d2Fb6fEA24E70155bC61471936F3c9C30c206)\n" ] } ], @@ -509,7 +509,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 19, "id": "5b9de201", "metadata": {}, "outputs": [ @@ -518,12 +518,21 @@ "output_type": "stream", "text": [ "settle (200): {\n", - " \"success\": false,\n", - " \"errorReason\": \"invalid_batch_settlement_evm_deposit_transaction_failed\",\n", + " \"success\": true,\n", " \"payer\": \"0x553179556FC2A39e535D65b921e01fA995E79101\",\n", - " \"transaction\": \"\",\n", - " \"network\": \"eip155:8453\"\n", - "}\n" + " \"transaction\": \"0xf47d88934fab9f53fe2ce2b89e6e636007defddcb505ad20d6ca0661276984a0\",\n", + " \"network\": \"eip155:8453\",\n", + " \"extra\": {\n", + " \"channelState\": {\n", + " \"channelId\": \"0x50494ec721abdc0e2196b8f30d317051b9106dda4b0f42808bca236a8bd9a11d\",\n", + " \"balance\": \"20000\",\n", + " \"totalClaimed\": \"0\",\n", + " \"withdrawRequestedAt\": 0,\n", + " \"refundNonce\": \"0\"\n", + " }\n", + " }\n", + "}\n", + "🔗 https://basescan.org/tx/0xf47d88934fab9f53fe2ce2b89e6e636007defddcb505ad20d6ca0661276984a0\n" ] } ], @@ -562,10 +571,21 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 20, "id": "7c7c0198", "metadata": {}, - "outputs": [], + "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", @@ -622,10 +642,27 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 21, "id": "d74abe79", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "claim /settle (200): {\n", + " \"success\": true,\n", + " \"payer\": \"0x553179556FC2A39e535D65b921e01fA995E79101\",\n", + " \"transaction\": \"0x1a8fef6171689c5d839be1b6a8d7dcf69d85efc8dc210baed0a7c32dbf8645ce\",\n", + " \"network\": \"eip155:8453\"\n", + "}\n", + "🔗 https://basescan.org/tx/0x1a8fef6171689c5d839be1b6a8d7dcf69d85efc8dc210baed0a7c32dbf8645ce\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", From 1749f172328062874773513567a05f398515f887 Mon Sep 17 00:00:00 2001 From: fretchen Date: Mon, 20 Jul 2026 21:11:52 +0200 Subject: [PATCH 06/14] Cleaner typing --- x402_facilitator/package.json | 3 +- .../test/x402_facilitator.test.ts | 14 +- x402_facilitator/test/x402_fee.test.ts | 165 +++++++++++------- x402_facilitator/x402_facilitator.ts | 5 +- 4 files changed, 113 insertions(+), 74 deletions(-) diff --git a/x402_facilitator/package.json b/x402_facilitator/package.json index a4d5590cc..6f76f293b 100644 --- a/x402_facilitator/package.json +++ b/x402_facilitator/package.json @@ -15,7 +15,8 @@ "lint:fix": "eslint . --fix", "format": "prettier --write \"**/*.{js,json,md}\"", "format:check": "prettier --check \"**/*.{js,json,md}\"", - "check": "npm run lint && npm run format:check && npm run test:coverage", + "typecheck": "tsc --noEmit", + "check": "npm run lint && npm run format:check && npm run typecheck && npm run test:coverage", "predeploy": "npm run build", "deploy": "serverless deploy", "deploy:prod": "serverless deploy --stage production", diff --git a/x402_facilitator/test/x402_facilitator.test.ts b/x402_facilitator/test/x402_facilitator.test.ts index f48e03445..369676b79 100644 --- a/x402_facilitator/test/x402_facilitator.test.ts +++ b/x402_facilitator/test/x402_facilitator.test.ts @@ -1,5 +1,7 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { handleVerify, handleSettle, handleSupported, handle } from "../x402_facilitator.ts"; +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", () => ({ @@ -23,15 +25,15 @@ vi.mock("../x402_supported.js", () => ({ })); describe("x402_facilitator handlers", () => { - let verifyPayment; - let settlePayment; + 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; - settlePayment = settleModule.settlePayment; + verifyPayment = verifyModule.verifyPayment as Mock; + settlePayment = settleModule.settlePayment as Mock; }); afterEach(() => { diff --git a/x402_facilitator/test/x402_fee.test.ts b/x402_facilitator/test/x402_fee.test.ts index f581ddfba..b04604007 100644 --- a/x402_facilitator/test/x402_fee.test.ts +++ b/x402_facilitator/test/x402_fee.test.ts @@ -1,5 +1,3 @@ -// @ts-check - import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { getFeeAmount, @@ -7,6 +5,17 @@ import { checkMerchantAllowance, collectFee, } 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 () => { @@ -137,11 +146,13 @@ describe("x402_fee", () => { it("returns sufficient=true when allowance exceeds fee", async () => { const { getContract } = await import("viem"); - vi.mocked(getContract).mockReturnValue({ - read: { - allowance: vi.fn().mockResolvedValue(100000n), // 0.10 USDC - }, - }); + vi.mocked(getContract).mockReturnValue( + mockContract({ + read: { + allowance: vi.fn().mockResolvedValue(100000n), // 0.10 USDC + }, + }), + ); const result = await checkMerchantAllowance(merchant, "eip155:11155420"); @@ -152,11 +163,13 @@ describe("x402_fee", () => { it("returns sufficient=false when allowance is zero", async () => { const { getContract } = await import("viem"); - vi.mocked(getContract).mockReturnValue({ - read: { - allowance: vi.fn().mockResolvedValue(0n), - }, - }); + vi.mocked(getContract).mockReturnValue( + mockContract({ + read: { + allowance: vi.fn().mockResolvedValue(0n), + }, + }), + ); const result = await checkMerchantAllowance(merchant, "eip155:11155420"); @@ -167,11 +180,13 @@ describe("x402_fee", () => { it("returns sufficient=false when allowance is less than fee", async () => { const { getContract } = await import("viem"); - vi.mocked(getContract).mockReturnValue({ - read: { - allowance: vi.fn().mockResolvedValue(5000n), // Half the fee - }, - }); + vi.mocked(getContract).mockReturnValue( + mockContract({ + read: { + allowance: vi.fn().mockResolvedValue(5000n), // Half the fee + }, + }), + ); const result = await checkMerchantAllowance(merchant, "eip155:11155420"); @@ -199,11 +214,13 @@ describe("x402_fee", () => { it("handles RPC errors gracefully (fails open — does not block payment)", async () => { const { getContract } = await import("viem"); - vi.mocked(getContract).mockReturnValue({ - read: { - allowance: vi.fn().mockRejectedValue(new Error("RPC timeout")), - }, - }); + vi.mocked(getContract).mockReturnValue( + mockContract({ + read: { + allowance: vi.fn().mockRejectedValue(new Error("RPC timeout")), + }, + }), + ); const result = await checkMerchantAllowance(merchant, "eip155:11155420"); @@ -215,11 +232,13 @@ describe("x402_fee", () => { it("correctly calculates remaining settlements", async () => { const { getContract } = await import("viem"); - vi.mocked(getContract).mockReturnValue({ - read: { - allowance: vi.fn().mockResolvedValue(35000n), // 3.5 fees - }, - }); + vi.mocked(getContract).mockReturnValue( + mockContract({ + read: { + allowance: vi.fn().mockResolvedValue(35000n), // 3.5 fees + }, + }), + ); const result = await checkMerchantAllowance(merchant, "eip155:11155420"); @@ -257,18 +276,22 @@ describe("x402_fee", () => { const mockTxHash = "0xfee1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab"; const { getContract, createPublicClient } = await import("viem"); - vi.mocked(createPublicClient).mockReturnValue({ - waitForTransactionReceipt: vi.fn(async () => ({ - status: "success", - transactionHash: mockTxHash, - })), - }); - - vi.mocked(getContract).mockReturnValue({ - write: { - transferFrom: vi.fn().mockResolvedValue(mockTxHash), - }, - }); + 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"); @@ -280,18 +303,22 @@ describe("x402_fee", () => { const mockTxHash = "0xfail234567890abcdef1234567890abcdef1234567890abcdef1234567890ab"; const { getContract, createPublicClient } = await import("viem"); - vi.mocked(createPublicClient).mockReturnValue({ - waitForTransactionReceipt: vi.fn(async () => ({ - status: "reverted", - transactionHash: mockTxHash, - })), - }); - - vi.mocked(getContract).mockReturnValue({ - write: { - transferFrom: vi.fn().mockResolvedValue(mockTxHash), - }, - }); + 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"); @@ -302,11 +329,13 @@ describe("x402_fee", () => { it("returns insufficient_fee_allowance error", async () => { const { getContract } = await import("viem"); - vi.mocked(getContract).mockReturnValue({ - write: { - transferFrom: vi.fn().mockRejectedValue(new Error("ERC20InsufficientAllowance")), - }, - }); + vi.mocked(getContract).mockReturnValue( + mockContract({ + write: { + transferFrom: vi.fn().mockRejectedValue(new Error("ERC20InsufficientAllowance")), + }, + }), + ); const result = await collectFee(merchant, "eip155:11155420"); @@ -316,11 +345,13 @@ describe("x402_fee", () => { it("returns insufficient_merchant_balance error", async () => { const { getContract } = await import("viem"); - vi.mocked(getContract).mockReturnValue({ - write: { - transferFrom: vi.fn().mockRejectedValue(new Error("ERC20InsufficientBalance")), - }, - }); + vi.mocked(getContract).mockReturnValue( + mockContract({ + write: { + transferFrom: vi.fn().mockRejectedValue(new Error("ERC20InsufficientBalance")), + }, + }), + ); const result = await collectFee(merchant, "eip155:11155420"); @@ -330,11 +361,13 @@ describe("x402_fee", () => { it("returns generic error for unknown failures", async () => { const { getContract } = await import("viem"); - vi.mocked(getContract).mockReturnValue({ - write: { - transferFrom: vi.fn().mockRejectedValue(new Error("Network error")), - }, - }); + vi.mocked(getContract).mockReturnValue( + mockContract({ + write: { + transferFrom: vi.fn().mockRejectedValue(new Error("Network error")), + }, + }), + ); const result = await collectFee(merchant, "eip155:11155420"); diff --git a/x402_facilitator/x402_facilitator.ts b/x402_facilitator/x402_facilitator.ts index f22421865..3ea359aa4 100644 --- a/x402_facilitator/x402_facilitator.ts +++ b/x402_facilitator/x402_facilitator.ts @@ -326,7 +326,10 @@ if (process.env.NODE_ENV === "test") { dotenvModule.config(); const scw_fnc_node = await import("@scaleway/serverless-functions"); - scw_fnc_node.serveHandler(handle, 8080); + // 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"); From aeae23407a5c7bdeb5d57704ae55ee79e039f55b Mon Sep 17 00:00:00 2001 From: fretchen Date: Mon, 20 Jul 2026 21:56:21 +0200 Subject: [PATCH 07/14] Cleaner type handling --- website/components/FacilitatorApproval.tsx | 9 +- website/pages/x402/+Page.tsx | 10 +- website/test/FacilitatorApproval.test.tsx | 27 ++-- x402_facilitator/.prettierignore | 19 +++ x402_facilitator/README.md | 67 ++++++---- x402_facilitator/chain_utils.ts | 5 +- x402_facilitator/facilitator_instance.ts | 39 +++++- .../test/facilitator_instance.test.ts | 72 +++++++++- x402_facilitator/test/x402_supported.test.js | 53 ++++---- x402_facilitator/upstream/ISSUE_DRAFT.md | 123 ------------------ x402_facilitator/x402_settle.ts | 2 + x402_facilitator/x402_supported.ts | 90 +++++++------ 12 files changed, 268 insertions(+), 248 deletions(-) create mode 100644 x402_facilitator/.prettierignore delete mode 100644 x402_facilitator/upstream/ISSUE_DRAFT.md diff --git a/website/components/FacilitatorApproval.tsx b/website/components/FacilitatorApproval.tsx index 9c5969ac4..163f9f119 100644 --- a/website/components/FacilitatorApproval.tsx +++ b/website/components/FacilitatorApproval.tsx @@ -189,10 +189,11 @@ export function FacilitatorApproval({ if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }) - .then((json: { extensions?: Array<{ name: string; fee?: { recipient: string } }> }) => { - // Extract facilitator address from fee extension - const feeExt = json.extensions?.find((ext) => ext.name === "facilitator_fee"); - const recipient = feeExt?.fee?.recipient; + .then((json: { facilitatorFees?: { recipient?: string } }) => { + // The facilitator address (fee recipient) is disclosed in the top-level + // `facilitatorFees` object; `extensions` is now a spec-conformant string[] of + // extension keys. Absent when the facilitator runs without a fee configured. + const recipient = json.facilitatorFees?.recipient; if (recipient) { setFacilitatorAddress(recipient as Address); } else { diff --git a/website/pages/x402/+Page.tsx b/website/pages/x402/+Page.tsx index 583a8b4d7..99e4be9ac 100644 --- a/website/pages/x402/+Page.tsx +++ b/website/pages/x402/+Page.tsx @@ -449,8 +449,9 @@ return new Response(JSON.stringify(result), { status: 200 });`}

- The fee amount and facilitator address are advertised in the /supported endpoint under the{" "} - facilitator_fee extension. + The fee amount and facilitator address are advertised in the /supported endpoint in the{" "} + facilitatorFees object (with the facilitator_fee and{" "} + facilitatorFees keys listed under extensions).

{/* ── 4. How it works ──────────────────────────────────────────── */} @@ -556,8 +557,9 @@ return new Response(JSON.stringify(result), { status: 200 });`} {`curl https://facilitator.fretchen.eu/supported`}

- Returns a JSON object with kinds (supported network/scheme pairs), extensions (fee - configuration), and signers (facilitator addresses per network). + Returns a JSON object with kinds (supported network/scheme pairs), extensions{" "} + (advertised extension keys), signers (facilitator addresses per network), and{" "} + facilitatorFees (fee amount and recipient, when a fee is configured).

diff --git a/website/test/FacilitatorApproval.test.tsx b/website/test/FacilitatorApproval.test.tsx index ecf4e38e1..ec006791f 100644 --- a/website/test/FacilitatorApproval.test.tsx +++ b/website/test/FacilitatorApproval.test.tsx @@ -69,21 +69,24 @@ vi.mock("../styled-system/css", () => ({ css: vi.fn((..._args: unknown[]) => "mock-css-class"), })); -// Mock global fetch for /supported endpoint +// Mock global fetch for /supported endpoint. +// `extensions` is a spec-conformant string[] of extension keys; the facilitator address +// is disclosed in the top-level `facilitatorFees` object. const MOCK_SUPPORTED_RESPONSE = { kinds: [ { x402Version: 2, scheme: "exact", network: "eip155:10" }, { x402Version: 2, scheme: "exact", network: "eip155:8453" }, ], - extensions: [ - { - name: "facilitator_fee", - fee: { - amount: "10000", - recipient: "0xFacilitatorAddress1234567890123456789012", - }, - }, - ], + extensions: ["facilitator_fee", "facilitatorFees"], + facilitatorFees: { + version: "1", + model: "flat", + asset: "USDC", + flatFee: "10000", + decimals: 6, + recipient: "0xFacilitatorAddress1234567890123456789012", + networks: ["eip155:10", "eip155:8453"], + }, }; const FACILITATOR_ADDRESS = "0xFacilitatorAddress1234567890123456789012"; @@ -398,7 +401,9 @@ describe("FacilitatorApproval", () => { }); }); - it("shows error when /supported response has no fee extension", async () => { + it("shows error when /supported response has no facilitatorFees disclosure", async () => { + // Read-only facilitator (no fee configured): extension keys and the + // facilitatorFees object are both absent, so no address can be resolved. global.fetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ kinds: [], extensions: [] }), diff --git a/x402_facilitator/.prettierignore b/x402_facilitator/.prettierignore new file mode 100644 index 000000000..3b196990a --- /dev/null +++ b/x402_facilitator/.prettierignore @@ -0,0 +1,19 @@ +node_modules/ +coverage/ +dist/ +build/ +*.min.js +*.bundle.js +package-lock.json +yarn.lock +.git/ +.vscode/ +.env +*.log +# Python envs under notebooks/ (e.g. notebooks/.venv) — not JS, and huge. +.venv/ +.ruff_cache/ +__pycache__/ +*.ipynb +# Vendored / draft upstream material — not our source to format. +upstream/ diff --git a/x402_facilitator/README.md b/x402_facilitator/README.md index 8eec726b3..4cc7ea567 100644 --- a/x402_facilitator/README.md +++ b/x402_facilitator/README.md @@ -136,41 +136,46 @@ curl -X POST http://localhost:8080/verify -H "Content-Type: application/json" -d ### GET /supported -Returns supported networks, schemes, and assets. +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", - "assets": [ - { - "address": "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", - "name": "USDC", - "symbol": "USDC", - "decimals": 6 - } - ] - } - ], - "extensions": [ - { - "name": "recipient_whitelist", - "description": "Whitelisted recipients (NFT holders)", - "contracts": { - "mainnet": { - "genimg_v4": "0x80f95d330417a4acEfEA415FE9eE28db7A0A1Cdb", - "llmv1": "0x833F39D6e67390324796f861990ce9B7cf9F5dE1" - } - } - } + { "x402Version": 2, "scheme": "exact", "network": "eip155:10" }, + { "x402Version": 2, "scheme": "batch-settlement", "network": "eip155:10" } ], + "extensions": ["facilitator_fee", "facilitatorFees"], "signers": { - "eip155:*": ["0x..."] + "eip155:*": ["0xFacilitatorAddress..."] + }, + "facilitatorFees": { + "version": "1", + "model": "flat", + "asset": "USDC", + "flatFee": "10000", + "decimals": 6, + "recipient": "0xFacilitatorAddress...", + "networks": ["eip155:10", "eip155:8453", "eip155:11155420", "eip155:84532"], + "fee": { + "amount": "10000", + "description": "0.01 USDC per settlement", + "collection": "post_settlement_transferFrom" + }, + "setup": { + "description": "One-time USDC approval required. Call approve() on the USDC contract for the facilitator's address.", + "function": "approve(address spender, uint256 amount)", + "spender": "0xFacilitatorAddress...", + "recommended_amount": "100000000" + } } } ``` @@ -179,6 +184,14 @@ Returns supported networks, schemes, and assets. Validates payment authorization off-chain. +> **Scheme support:** the `exact` scheme is supported only via its **EIP-3009** +> payload variant (an `authorization` object, as shown below). **Permit2** payloads +> (a `permit2Authorization` object) are rejected with `invalidReason: +"permit2_not_supported"` — the fee model (post-settlement USDC `transferFrom`) is +> EIP-3009-specific, and the x402 Permit2 proxy has no per-network deployment registry +> here. The `batch-settlement` scheme is supported on the networks listed by +> `getBatchSettlementNetworks()`. + **Request:** ```json diff --git a/x402_facilitator/chain_utils.ts b/x402_facilitator/chain_utils.ts index 4871db4c1..e19bb3f9a 100644 --- a/x402_facilitator/chain_utils.ts +++ b/x402_facilitator/chain_utils.ts @@ -11,6 +11,7 @@ import { tryGetEIP3009SplitterAddress, getUSDCAddress, getUSDCName, + type Network, } from "@fretchen/chain-utils"; export interface ChainConfig { @@ -44,7 +45,7 @@ export function getChainConfig(network: string): ChainConfig { * Get all supported networks * @returns Array of supported CAIP-2 network identifiers */ -export function getSupportedNetworks(): string[] { +export function getSupportedNetworks(): Network[] { return ["eip155:10", "eip155:11155420", "eip155:8453", "eip155:84532"]; } @@ -63,6 +64,6 @@ export function getSupportedNetworks(): string[] { * against it would fail on-chain. * @returns Array of CAIP-2 network identifiers with a deployed batch-settlement contract */ -export function getBatchSettlementNetworks(): string[] { +export function getBatchSettlementNetworks(): Network[] { return ["eip155:10", "eip155:8453", "eip155:84532"]; } diff --git a/x402_facilitator/facilitator_instance.ts b/x402_facilitator/facilitator_instance.ts index c99e84b75..3c82e075f 100644 --- a/x402_facilitator/facilitator_instance.ts +++ b/x402_facilitator/facilitator_instance.ts @@ -57,7 +57,11 @@ function createSignerForNetwork(account: Account, network: string) { ...args, args: args.args || [], }), - verifyTypedData: (args) => publicClient.verifyTypedData(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, @@ -94,7 +98,11 @@ export function createReadOnlyFacilitator(): InstanceType publicClient.verifyTypedData(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"); }, @@ -191,8 +199,31 @@ export function createFacilitator(requirePrivateKey = true): InstanceType; @@ -132,13 +135,44 @@ describe("facilitator_instance onAfterVerify hook (fee model)", () => { process.env = { ...originalEnv }; }); - /** Helper: create mock hook arguments simulating a valid payment */ + /** + * 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): HookArgs { return { paymentPayload: { - accepted: { network }, + accepted: { network, scheme: "exact" }, payload: { authorization: { to: recipient } }, }, + requirements: { network, scheme: "exact", payTo: recipient }, + 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`. This facilitator does not + * support Permit2 — the hook must reject it with `permit2_not_supported`. + */ + function permit2HookArgs(recipient: string, network: string): HookArgs { + return { + paymentPayload: { + accepted: { network, scheme: "exact" }, + payload: { + permit2Authorization: { + witness: { to: recipient }, + from: "0xSomePayer000000000000000000000000000000", + }, + }, + }, + requirements: { network, scheme: "exact", payTo: recipient }, result: { isValid: true, payer: "0xSomePayer000000000000000000000000000000", @@ -413,14 +447,15 @@ describe("facilitator_instance onAfterVerify hook (fee model)", () => { expect(args.result.invalidReason).toBe("facilitator_not_configured"); }); - it("rejects when network is missing from payload", async () => { + it("rejects when network is missing from requirements", async () => { const args: HookArgs = { paymentPayload: { - accepted: {}, + accepted: { scheme: "exact" }, payload: { authorization: { to: "0x1111111111111111111111111111111111111111" }, }, }, + requirements: { scheme: "exact", payTo: "0x1111111111111111111111111111111111111111" }, result: { isValid: true }, }; @@ -430,12 +465,13 @@ describe("facilitator_instance onAfterVerify hook (fee model)", () => { expect(args.result.invalidReason).toBe("invalid_payload"); }); - it("rejects when recipient is missing from payload", async () => { + it("rejects when recipient is missing from requirements", async () => { const args: HookArgs = { paymentPayload: { - accepted: { network: "eip155:11155420" }, + accepted: { network: "eip155:11155420", scheme: "exact" }, payload: { authorization: {} }, }, + requirements: { network: "eip155:11155420", scheme: "exact" }, result: { isValid: true }, }; @@ -444,6 +480,28 @@ describe("facilitator_instance onAfterVerify hook (fee model)", () => { expect(args.result.isValid).toBe(false); expect(args.result.invalidReason).toBe("invalid_payload"); }); + + // ─────────────────────────────────────────────────────────── + // Permit2 rejection — only the EIP-3009 exact variant is supported + // ─────────────────────────────────────────────────────────── + + it("rejects Permit2 exact payloads with permit2_not_supported", async () => { + // Even a valid, well-formed Permit2 payment must be rejected: the fee model, the + // proxy deployment registry, and end-to-end coverage all assume EIP-3009. + vi.mocked(checkMerchantAllowance).mockResolvedValue({ + allowance: 100000n, + remainingSettlements: 10, + sufficient: true, + }); + + const args = permit2HookArgs("0x1111111111111111111111111111111111111111", "eip155:11155420"); + await hookHolder.current!(args); + + expect(args.result.isValid).toBe(false); + expect(args.result.invalidReason).toBe("permit2_not_supported"); + // Must reject before touching the fee-allowance path. + expect(checkMerchantAllowance).not.toHaveBeenCalled(); + }); }); // ═══════════════════════════════════════════════════════════════ diff --git a/x402_facilitator/test/x402_supported.test.js b/x402_facilitator/test/x402_supported.test.js index f04a150c8..c512aefb0 100644 --- a/x402_facilitator/test/x402_supported.test.js +++ b/x402_facilitator/test/x402_supported.test.js @@ -120,48 +120,51 @@ describe("x402 /supported endpoint", () => { ]); }); - test("includes facilitator_fee extension", () => { + 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(); - expect(capabilities.extensions).toBeDefined(); - expect(capabilities.extensions.length).toBeGreaterThan(0); - - const feeExtension = capabilities.extensions.find((e) => e.name === "facilitator_fee"); - expect(feeExtension).toBeDefined(); - expect(feeExtension.description).toBeDefined(); - expect(feeExtension.fee.recipient).toBeDefined(); - expect(feeExtension.fee.recipient).not.toBeNull(); - expect(feeExtension.setup.spender).not.toBeNull(); + // `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("includes facilitatorFees extension for fee-aware routing (#1016)", () => { + test("discloses fee detail in the top-level facilitatorFees object (#1016)", () => { process.env.FACILITATOR_WALLET_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; const capabilities = getSupportedCapabilities(); - 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.networks).toContain("eip155:10"); - expect(feesExtension.networks).toContain("eip155:8453"); - expect(feesExtension.networks).toContain("eip155:11155420"); - expect(feesExtension.networks).toContain("eip155:84532"); + const fees = capabilities.facilitatorFees; + expect(fees).toBeDefined(); + expect(fees.version).toBe("1"); + expect(fees.model).toBe("flat"); + expect(fees.asset).toBe("USDC"); + 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("omits facilitator_fee extension when private key is missing", () => { + test("omits fee extension keys and disclosure when private key is missing", () => { delete process.env.FACILITATOR_WALLET_PRIVATE_KEY; const capabilities = getSupportedCapabilities(); - const feeExtension = capabilities.extensions.find((e) => e.name === "facilitator_fee"); - expect(feeExtension).toBeUndefined(); + expect(capabilities.extensions).not.toContain("facilitator_fee"); + expect(capabilities.extensions).not.toContain("facilitatorFees"); + expect(capabilities.facilitatorFees).toBeUndefined(); }); }); diff --git a/x402_facilitator/upstream/ISSUE_DRAFT.md b/x402_facilitator/upstream/ISSUE_DRAFT.md deleted file mode 100644 index aa3fc8ea7..000000000 --- a/x402_facilitator/upstream/ISSUE_DRAFT.md +++ /dev/null @@ -1,123 +0,0 @@ -# Issue draft — batch-settlement on networks outside `DEFAULT_STABLECOINS` - -**Status:** ready to file at https://github.com/x402-foundation/x402/issues/new -**Repro:** `batch-settlement-chain-agnostic-repro.mjs` (same directory) — verified against `@x402/evm` 2.18.0 on 2026-07-20. -**Run it:** `npm i @x402/evm && node batch-settlement-chain-agnostic-repro.mjs` - -Everything below the line is the literal issue body. - ---- - -### Title - -`batch-settlement`: is there a supported way to use a network that isn't in `DEFAULT_STABLECOINS`? - -### What I'm trying to do - -Run the `batch-settlement` scheme on Optimism mainnet, paying in Circle USDC (`0x0b2C…Ff85`). The batch-settlement contract seems to be deployed there, and the token implements EIP-3009. - -Optimism isn't in `DEFAULT_STABLECOINS`, so I followed the guidance I was given in [#835](https://github.com/x402-foundation/x402/issues/835): - -> v2 supports any EVM-compatible chain as long as the payment asset implements EIP-3009 […] You can either implement a moneyParser […] or specify 'amount' (in atomic units) and 'asset' instead of 'price' - -That works for `exact`. With `batch-settlement` I can't get either approach to work, and I suspect I'm either holding it wrong or hitting something unintended — hence the question. - -### Reproduction - - -```js -// npm i @x402/evm && node repro.mjs -import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/server"; - -const scheme = new BatchSettlementEvmScheme("0x1111111111111111111111111111111111111111"); - -await scheme.enhancePaymentRequirements( - { - scheme: "batch-settlement", - network: "eip155:10", - amount: "1420", // explicit atomic amount - asset: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", // Circle USDC on OP - payTo: "0x1111111111111111111111111111111111111111", - extra: { name: "USD Coin", version: "2" }, - }, - {}, - [], -); -``` - -``` -Error: No default asset configured for network eip155:10 - at getDefaultAsset (chunk-DQI2DTA4.mjs:151:11) - at BatchSettlementEvmScheme.enhancePaymentRequirements (batch-settlement/server/index.mjs:1525:23) -``` - -### What I tried to narrow it down - -Varying one thing at a time (full script at the bottom): - -``` -ok batch-settlement eip155:8453 asset kept: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 -THROWS batch-settlement eip155:10 No default asset configured for network eip155:10 -ok exact eip155:10 asset kept: 0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85 -THROWS batch-settlement createChannelManager No default asset configured for network eip155:10 -THROWS batch-settlement + moneyParser No default asset configured for network eip155:10 -``` - -My reading of this, which I may well have wrong: - -- Rows 1–2: the same call succeeds on Base and fails on OP, so the requirements object itself seems to be accepted. -- Rows 2–3: `exact` accepts the same network and asset that `batch-settlement` rejects. -- Row 4: `createChannelManager` seems to depend on the registry as well, so this may not be limited to one call path. -- Row 5: a `registerMoneyParser` doesn't change the outcome — from the source it looks like it's consulted by `parseMoney`, which isn't on this path. - -### What I found in the source - -In `batch-settlement/server`, `enhancePaymentRequirements` looks up the asset before reading the caller's requirements: - -```js -const assetInfo = getDefaultAsset(paymentRequirements.network); // index.mjs:1525 -… -extra: { ...paymentRequirements.extra, name: assetInfo.name, version: assetInfo.version } -``` - -whereas `exact`'s implementation of the same method passes the requirements through: - -```js -enhancePaymentRequirements(paymentRequirements, supportedKind, extensionKeys) { - return Promise.resolve(paymentRequirements); -} -``` - -I couldn't find an option to supply the asset explicitly — as far as I can tell neither the client nor server `BatchSettlementEvmSchemeOptions` takes one — but I may have missed it. - -One thing I wasn't sure whether to flag: because a resource server builds `accepts[]` by calling this per network, a single unlisted network appears to throw for the whole 402 rather than just that entry. That could be intended. - -### Questions - -1. Is `batch-settlement` intended to work on networks outside `DEFAULT_STABLECOINS`, and if so, what's the supported way? Happy to just use it if I've missed the mechanism. -2. If the caller's `asset`/`extra` should be honoured here the way `exact` does, I'd be glad to attempt a PR. The part I'd need guidance on is whether the EIP-712 `name`/`version` are deliberately taken from the registry rather than the caller — that looked intentional, and I didn't want to assume. - -Separately, if it's simply that Optimism should be in the registry, these are the values I read from the contract on 2026-07-20: - -```js -"eip155:10": { - address: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", - name: "USD Coin", - version: "2", - decimals: 6, -}, -``` - -Glad to send that as a PR too if it's useful — though I assume the broader question above matters more than my particular chain. - -### Environment - -- `@x402/evm` 2.18.0, `@x402/core` 2.18.0 -- Node v24.16.0 -- Same behaviour on 2.17.0 - -### Full script - -```js - -``` diff --git a/x402_facilitator/x402_settle.ts b/x402_facilitator/x402_settle.ts index 6b6747b79..5762d70cf 100644 --- a/x402_facilitator/x402_settle.ts +++ b/x402_facilitator/x402_settle.ts @@ -299,6 +299,8 @@ export async function settlePayment( } const payload = paymentPayload.payload as Record | undefined; + // EIP-3009 shape only. Permit2 payloads (payer at permit2Authorization.from) are + // rejected at verify time (permit2_not_supported), so they never reach settle. const authorization = payload?.authorization as Record | undefined; const accepted = paymentPayload.accepted as Record | undefined; diff --git a/x402_facilitator/x402_supported.ts b/x402_facilitator/x402_supported.ts index 267e2454d..0e853f703 100644 --- a/x402_facilitator/x402_supported.ts +++ b/x402_facilitator/x402_supported.ts @@ -7,36 +7,50 @@ import { formatUnits } from "viem"; import { createReadOnlyFacilitator } from "./facilitator_instance"; import { getFeeAmount, getFacilitatorAddress } from "./x402_fee"; -/** Facilitator fee model disclosure per x402 Fee Disclosure proposal (coinbase/x402#1016) */ -interface FacilitatorFeesExtension { - name: string; +/** + * 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. + */ +interface FacilitatorFeesDisclosure { version: string; model: string; asset: string; flatFee: string; decimals: number; + /** Facilitator address that collects the fee (fee recipient / approval spender). */ + recipient: string; + /** CAIP-2 networks this fee model applies to. */ networks: string[]; -} - -interface FeeExtension { - name: string; - description: string; fee: { amount: string; - asset: string; - decimals: number; description: string; collection: string; - recipient: string | null; }; setup: { description: string; function: string; - spender: string | null; + spender: string; recommended_amount: string; }; } +/** Extension key advertised in `extensions` when a fee is configured. */ +const FACILITATOR_FEE_EXTENSION_KEY = "facilitator_fee"; +const FACILITATOR_FEES_EXTENSION_KEY = "facilitatorFees"; + +/** + * Shape of our `/supported` response. Matches `x402Facilitator.getSupported()` (whose + * `extensions` is `string[]`) plus our optional top-level `facilitatorFees` disclosure. + * `network` is `string` here to match the 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. + */ interface SupportedCapabilities { kinds: Array<{ x402Version: number; @@ -44,8 +58,10 @@ interface SupportedCapabilities { network: string; extra?: Record; }>; - extensions: Array>; + extensions: string[]; signers: Record; + /** Present only when a fee is configured (feeAmount > 0 and a facilitator key exists). */ + facilitatorFees?: FacilitatorFeesDisclosure; } /** @@ -55,28 +71,35 @@ interface SupportedCapabilities { export function getSupportedCapabilities(): SupportedCapabilities { const facilitator = createReadOnlyFacilitator(); - // Get base supported capabilities from facilitator - const supported = facilitator.getSupported() as SupportedCapabilities; - - // Ensure extensions array exists - supported.extensions = supported.extensions || []; + // Base response: { kinds, extensions: string[], signers } + const base = facilitator.getSupported(); + const supported: SupportedCapabilities = { + ...base, + extensions: [...(base.extensions ?? [])], + }; - // Add fee extension for public access 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) { - const feeExtension: FeeExtension = { - name: "facilitator_fee", - description: - "Per-transaction fee for facilitator operation. Merchants must approve USDC spending for the facilitator address. Fee is collected post-settlement via ERC-20 transferFrom.", + supported.extensions.push(FACILITATOR_FEE_EXTENSION_KEY, FACILITATOR_FEES_EXTENSION_KEY); + + // Derive networks from `kinds` to stay consistent with the advertised response. + supported.facilitatorFees = { + version: "1", + model: "flat", + asset: "USDC", + flatFee: feeAmount.toString(), + decimals: 6, + recipient: facilitatorAddress, + networks: [...new Set(supported.kinds.map((k) => k.network))], fee: { amount: feeAmount.toString(), - asset: "USDC", - decimals: 6, description: `${formatUnits(feeAmount, 6)} USDC per settlement`, collection: "post_settlement_transferFrom", - recipient: facilitatorAddress, }, setup: { description: @@ -86,21 +109,6 @@ export function getSupportedCapabilities(): SupportedCapabilities { recommended_amount: "100000000", // 100 USDC = 10,000 settlements }, }; - supported.extensions.push(feeExtension); - - // Add facilitatorFees extension per x402 Fee Disclosure proposal (#1016) - // Static fee model disclosure for fee-aware multi-facilitator routing - // Derive networks from supported.kinds to stay consistent with the response - const facilitatorFeesExtension: FacilitatorFeesExtension = { - name: "facilitatorFees", - version: "1", - model: "flat", - asset: "USDC", - flatFee: feeAmount.toString(), - decimals: 6, - networks: [...new Set(supported.kinds.map((k) => k.network))], - }; - supported.extensions.push(facilitatorFeesExtension); } return supported; From 6ea60583c34b04267e4831a95a3981866e21d676 Mon Sep 17 00:00:00 2001 From: fretchen Date: Mon, 20 Jul 2026 21:56:38 +0200 Subject: [PATCH 08/14] Update +Page.tsx --- website/pages/x402/+Page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/pages/x402/+Page.tsx b/website/pages/x402/+Page.tsx index 99e4be9ac..4f9cd028c 100644 --- a/website/pages/x402/+Page.tsx +++ b/website/pages/x402/+Page.tsx @@ -450,8 +450,8 @@ return new Response(JSON.stringify(result), { status: 200 });`}

The fee amount and facilitator address are advertised in the /supported endpoint in the{" "} - facilitatorFees object (with the facilitator_fee and{" "} - facilitatorFees keys listed under extensions). + facilitatorFees object (with the facilitator_fee and facilitatorFees{" "} + keys listed under extensions).

{/* ── 4. How it works ──────────────────────────────────────────── */} From cfba6a188ba6e90017e9f14a8741370dc536be62 Mon Sep 17 00:00:00 2001 From: fretchen Date: Tue, 21 Jul 2026 08:22:45 +0200 Subject: [PATCH 09/14] lint --- x402_facilitator/facilitator_instance.ts | 8 +-- .../x402_batch_settlement_buyer.ipynb | 72 ++----------------- x402_facilitator/package.json | 4 +- 3 files changed, 10 insertions(+), 74 deletions(-) diff --git a/x402_facilitator/facilitator_instance.ts b/x402_facilitator/facilitator_instance.ts index 3c82e075f..c034b9030 100644 --- a/x402_facilitator/facilitator_instance.ts +++ b/x402_facilitator/facilitator_instance.ts @@ -99,10 +99,10 @@ export function createReadOnlyFacilitator(): InstanceType; viem's VerifyTypedDataParameters wants the strict - // TypedDataParameter mapping. Runtime-correct pass-through — cast at the boundary. - verifyTypedData: (args) => - publicClient.verifyTypedData(args as Parameters[0]), + // 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"); }, diff --git a/x402_facilitator/notebooks/x402_batch_settlement_buyer.ipynb b/x402_facilitator/notebooks/x402_batch_settlement_buyer.ipynb index 222962bd9..8da48440c 100644 --- a/x402_facilitator/notebooks/x402_batch_settlement_buyer.ipynb +++ b/x402_facilitator/notebooks/x402_batch_settlement_buyer.ipynb @@ -105,75 +105,11 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": null, "id": "df636fad", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "🚨 REAL MONEY on Base Mainnet\n", - " eip155:8453 • USDC USD Coin @ 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\n", - " Facilitator: http://localhost:8080\n" - ] - } - ], - "source": [ - "// ── Network selection ──────────────────────────────────────────────\n", - "const USE_MAINNET = true; // ⚠️ true = REAL MONEY. Keep false for the spike.\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 = true;\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", - "// Max per-request price (6-decimal USDC). 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} • USDC ${config.usdcName} @ ${config.usdcAddress}`);\n", - "console.log(` Facilitator: ${FACILITATOR_URL}`);" - ] + "outputs": [], + "source": "// ── Network selection ──────────────────────────────────────────────\nconst USE_MAINNET = false; // ⚠️ true = REAL MONEY. Keep false for the spike.\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.\nconst CONFIRM_MAINNET_WRITES = false;\nconst USE_BASE = true; // Base by default. (Optimism + testnet is unavailable.)\n\n// Guard: Optimism Sepolia has no batch-settlement contract.\nif (!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\nconst 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};\nconst configKey = USE_MAINNET ? (USE_BASE ? \"base-mainnet\" : \"optimism-mainnet\") : \"base-testnet\";\nconst config = NETWORK_CONFIG[configKey];\n\n// Max per-request price (6-decimal USDC). Channel deposit ≈ depositMultiplier(5) × this.\nconst MAX_PRICE = \"4000\"; // $0.004 → deposit ≈ $0.02\n\nconst FACILITATOR_URL = \"http://localhost:8080\"; // or \"https://facilitator.fretchen.eu\"\n// const FACILITATOR_URL = \"https://facilitator.fretchen.eu\"\nconst VERIFY_URL = `${FACILITATOR_URL}/verify`;\nconst SETTLE_URL = `${FACILITATOR_URL}/settle`;\nconst SUPPORTED_URL = `${FACILITATOR_URL}/supported`;\n\nconsole.log(USE_MAINNET ? `🚨 REAL MONEY on ${config.networkName}` : `🧪 ${config.networkName}`);\nconsole.log(` ${config.caip2Network} • USDC ${config.usdcName} @ ${config.usdcAddress}`);\nconsole.log(` Facilitator: ${FACILITATOR_URL}`);" }, { "cell_type": "markdown", @@ -784,4 +720,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/x402_facilitator/package.json b/x402_facilitator/package.json index 6f76f293b..926bfa39f 100644 --- a/x402_facilitator/package.json +++ b/x402_facilitator/package.json @@ -13,8 +13,8 @@ "test:integration": "vitest run --config vitest.integration.config.js", "lint": "eslint .", "lint:fix": "eslint . --fix", - "format": "prettier --write \"**/*.{js,json,md}\"", - "format:check": "prettier --check \"**/*.{js,json,md}\"", + "format": "prettier --write \"**/*.{ts,js,json,md}\"", + "format:check": "prettier --check \"**/*.{ts,js,json,md}\"", "typecheck": "tsc --noEmit", "check": "npm run lint && npm run format:check && npm run typecheck && npm run test:coverage", "predeploy": "npm run build", From 0be208d779967dbdd87da8b9cdb035b4fa6549c0 Mon Sep 17 00:00:00 2001 From: fretchen Date: Tue, 21 Jul 2026 11:32:35 +0200 Subject: [PATCH 10/14] Enable Mistral --- scw_js/README.md | 12 ++ scw_js/genimg_x402_token.ts | 8 +- scw_js/llm_service.ts | 143 ++++++++++++++++----- scw_js/sc_llm_x402.ts | 40 ++++-- scw_js/serverless.yml | 9 ++ scw_js/test/genimg_x402_token.test.ts | 84 +++++++++++++ scw_js/test/getChain.test.ts | 11 +- scw_js/test/llm_service.test.ts | 175 +++++++++++++++++++++++--- scw_js/test/sc_llm_x402.test.ts | 117 +++++++++++++---- x402_facilitator/.env.example | 11 +- x402_facilitator/serverless.yml | 9 +- 11 files changed, 530 insertions(+), 89 deletions(-) diff --git a/scw_js/README.md b/scw_js/README.md index 6c528392d..0ecf8db51 100644 --- a/scw_js/README.md +++ b/scw_js/README.md @@ -118,6 +118,18 @@ API for reviewing, editing, and approving AI-generated social media drafts. Used | ---------- | -------- | -------------------------------------------- | | GenImNFTv4 | Optimism | `0x80f95d330417a4acEfEA415FE9eE28db7A0A1Cdb` | +### RPC configuration + +Direct on-chain calls (image mint, legacy LLMv1 merkle settlement) use `getRpcUrl` from +`@fretchen/chain-utils`, falling back to each chain's public endpoint when unset — fine +for local dev, but the public endpoints are aggressively rate-limited under real traffic. +Set a dedicated provider (e.g. Alchemy) as a Scaleway secret for production: + +- `RPC_URL_EIP155_10` — Optimism mainnet +- `RPC_URL_EIP155_8453` — Base mainnet +- `RPC_URL_EIP155_11155420` — Optimism Sepolia +- `RPC_URL_EIP155_84532` — Base Sepolia + ## 🗄️ S3 Storage Layout & Data Classification All functions share the `my-imagestore` bucket (region `nl-ams`). Access is controlled **per object** (object ACL), independent of the bucket ACL. When writing, only publish what is meant to be public — the table below is the source of truth for whether a prefix is public. diff --git a/scw_js/genimg_x402_token.ts b/scw_js/genimg_x402_token.ts index 5897cf3c7..794146302 100644 --- a/scw_js/genimg_x402_token.ts +++ b/scw_js/genimg_x402_token.ts @@ -5,6 +5,7 @@ import { getUSDCConfig, isTestnet, loadPrivateKey, + getRpcUrl, } from "@fretchen/chain-utils"; import { parseJsonBody } from "./utils.js"; import { @@ -492,8 +493,11 @@ async function handle( console.log(`🔗 Using chain: ${viemChain.name} (${clientNetwork})`); const chain = viemChain as unknown as Chain; - const publicClient = createPublicClient({ chain, transport: http() }); - const walletClient = createWalletClient({ account, chain, transport: http() }); + // 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(clientNetwork!); + const publicClient = createPublicClient({ chain, transport: http(rpcUrl) }); + const walletClient = createWalletClient({ account, chain, transport: http(rpcUrl) }); const contract = getContract({ address: contractAddress, diff --git a/scw_js/llm_service.ts b/scw_js/llm_service.ts index 0fa76bb3e..79b126695 100644 --- a/scw_js/llm_service.ts +++ b/scw_js/llm_service.ts @@ -1,16 +1,56 @@ import { getContract, createPublicClient, createWalletClient, http } from "viem"; import { getChain, getLLMv1ContractConfig } from "./getChain.js"; -import { loadPrivateKey } from "@fretchen/chain-utils"; +import { loadPrivateKey, getRpcUrl, toCAIP2 } from "@fretchen/chain-utils"; import { getS3Object, putS3Object } from "@fretchen/s3-utils"; import { StandardMerkleTree } from "@openzeppelin/merkle-tree"; import { privateKeyToAccount } from "viem/accounts"; import pino from "pino"; -const MODEL_NAME = "meta-llama/Llama-3.3-70B-Instruct"; -const ENDPOINT = "https://openai.inference.de-txl.ionos.com/v1/chat/completions"; const MERKLE_TREE_FILE = "merkle/trees.json"; const logger = pino({ level: process.env.LOG_LEVEL ?? "info" }); +interface LLMProviderConfig { + displayName: string; // for error messages/logs — e.g. "Could not reach IONOS: ..." + baseUrl: string; // no trailing "/chat/completions" — appended at call time + defaultModel: string; + apiKeyEnvVar: string; + // Price per 1,000,000 tokens, num/den to stay exact bigint math. USD for mistral; + // EUR for ionos (see convertTokensToUsdcCost's doc comment on the EUR/USDC simplification). + inputPricePerMillion: { num: bigint; den: bigint }; + outputPricePerMillion: { num: bigint; den: bigint }; +} + +const LLM_PROVIDERS: Record = { + ionos: { + displayName: "IONOS", + baseUrl: "https://openai.inference.de-txl.ionos.com/v1", + defaultModel: "meta-llama/Llama-3.3-70B-Instruct", + apiKeyEnvVar: "IONOS_API_TOKEN", + inputPricePerMillion: { num: 71n, den: 100n }, + outputPricePerMillion: { num: 71n, den: 100n }, // blended rate, unchanged — legacy sc_llm.ts path + }, + mistral: { + displayName: "Mistral", + baseUrl: "https://api.mistral.ai/v1", + defaultModel: "mistral-large-latest", + apiKeyEnvVar: "MISTRAL_API_KEY", + // Mistral Large 3, mistral.ai/pricing/api (fetched 2026-07-21) — re-verify before any + // mainnet cutover; Mistral has repriced materially before. + inputPricePerMillion: { num: 50n, den: 100n }, + outputPricePerMillion: { num: 150n, den: 100n }, + }, +}; + +function getLLMProviderConfig(provider: string): LLMProviderConfig { + const config = LLM_PROVIDERS[provider]; + if (!config) { + throw new Error( + `Unknown LLM provider: ${provider}. Valid providers: ${Object.keys(LLM_PROVIDERS).join(", ")}`, + ); + } + return config; +} + export interface LLMMessage { role: string; content: string; @@ -26,7 +66,11 @@ interface LLMResponse { model: string; } -export async function callLLMAPI(prompt: LLMMessage[], dummy = false): Promise { +export async function callLLMAPI( + prompt: LLMMessage[], + dummy = false, + provider = "ionos", +): Promise { if (dummy) { return { content: "I am a placeholder for the LLM response", @@ -34,11 +78,12 @@ export async function callLLMAPI(prompt: LLMMessage[], dummy = false): Promise { const activeChain = getChain(); - const publicClient = createPublicClient({ chain: activeChain, transport: http() }); + // 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(toCAIP2(activeChain.id)); + const publicClient = createPublicClient({ chain: activeChain, transport: http(rpcUrl) }); const { address: contractAddress, abi: llmAbi } = getLLMv1ContractConfig(); const contract = getContract({ @@ -321,8 +395,15 @@ export async function processMerkleTree( const account = privateKeyToAccount(loadPrivateKey("NFT_WALLET_PRIVATE_KEY")); const activeChain = getChain(); - const publicClient = createPublicClient({ chain: activeChain, transport: http() }); - const walletClient = createWalletClient({ account, chain: activeChain, transport: http() }); + // 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(toCAIP2(activeChain.id)); + const publicClient = createPublicClient({ chain: activeChain, transport: http(rpcUrl) }); + const walletClient = createWalletClient({ + account, + chain: activeChain, + transport: http(rpcUrl), + }); const { address: contractAddress, abi: llmAbi } = getLLMv1ContractConfig(); const llmContract = getContract({ diff --git a/scw_js/sc_llm_x402.ts b/scw_js/sc_llm_x402.ts index 244007ca4..9bf7480ee 100644 --- a/scw_js/sc_llm_x402.ts +++ b/scw_js/sc_llm_x402.ts @@ -34,9 +34,19 @@ const logger = pino({ level: process.env.LOG_LEVEL ?? "info" }); // the "authorize an upper bound, claim the real amount" pattern the SDK's // setSettlementOverrides() wraps for Express apps — we do it manually here since we call // settlePayment() directly. See getSettleAmount() below. +// This endpoint uses Mistral, not IONOS — see llm_service.ts's LLM_PROVIDERS. Legacy +// sc_llm.ts (merkle settlement) is untouched and stays on IONOS. +const LLM_PROVIDER = "mistral"; + const MAX_TOKENS_PER_MESSAGE = process.env.LLM_ESTIMATED_TOKENS_PER_MESSAGE ?? "2000"; +// No real prompt/completion split exists yet for the ceiling, so price the entire +// estimate as completion (output) tokens — the pricier of the two rates for a +// provider with an asymmetric split like Mistral's. This guarantees the ceiling is +// never an underestimate relative to whatever the real split turns out to be; +// getSettleAmount's cap below still protects the ceiling from ever being exceeded. const USDC_MAX_PRICE_PER_MESSAGE = convertTokensToUsdcCost( - BigInt(MAX_TOKENS_PER_MESSAGE), + { prompt_tokens: 0, completion_tokens: MAX_TOKENS_PER_MESSAGE }, + LLM_PROVIDER, ).toString(); /** @@ -46,8 +56,8 @@ const USDC_MAX_PRICE_PER_MESSAGE = convertTokensToUsdcCost( * as under-billing, not a fund-safety issue (the client is always protected by the * voucher's signed ceiling). */ -function getSettleAmount(totalTokens: number): string { - const actualCost = convertTokensToUsdcCost(totalTokens); +function getSettleAmount(usage: { prompt_tokens: number; completion_tokens: number }): string { + const actualCost = convertTokensToUsdcCost(usage, LLM_PROVIDER); const maxCost = BigInt(USDC_MAX_PRICE_PER_MESSAGE); return (actualCost > maxCost ? maxCost : actualCost).toString(); } @@ -90,7 +100,9 @@ export async function handle(event: ScwEvent, _context: unknown): Promise viem + # falls back to the public endpoint, which is aggressively rate-limited under real + # traffic. A real value here embeds an API key (e.g. an Alchemy URL) — secret, not env. + RPC_URL_EIP155_10: ${env:RPC_URL_EIP155_10, ''} + RPC_URL_EIP155_8453: ${env:RPC_URL_EIP155_8453, ''} + RPC_URL_EIP155_11155420: ${env:RPC_URL_EIP155_11155420, ''} + RPC_URL_EIP155_84532: ${env:RPC_URL_EIP155_84532, ''} plugins: - serverless-scaleway-functions diff --git a/scw_js/test/genimg_x402_token.test.ts b/scw_js/test/genimg_x402_token.test.ts index c20caaa64..7232b2168 100644 --- a/scw_js/test/genimg_x402_token.test.ts +++ b/scw_js/test/genimg_x402_token.test.ts @@ -797,6 +797,90 @@ describe("genimg_x402_token.js - x402 v2 Token Payment Tests", () => { ); }); + test("uses the configured RPC_URL_ endpoint for the mint client's network", async () => { + const rpcUrl = "https://opt-mainnet.g.alchemy.com/v2/test-key"; + setupTestEnvironment({ RPC_URL_EIP155_10: rpcUrl }); + setupSuccessfulMintingFlow(102); + + const mainnetPayment = { + x402Version: 2, + accepted: { + scheme: "exact", + network: "eip155:10", + amount: "1000", + asset: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", + payTo: "0xAAEBC1441323B8ad6Bdf6793A8428166b510239C", + }, + payload: { + authorization: { + from: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb", + to: "0xAAEBC1441323B8ad6Bdf6793A8428166b510239C", + value: "1000", + }, + }, + network: "eip155:10", + }; + + const event = { + httpMethod: "POST", + headers: { "x-payment": JSON.stringify(mainnetPayment) }, + body: JSON.stringify({ prompt: "Test RPC wiring" }), + path: "/genimg", + }; + + try { + const response = await handle(event, {}); + expect(response.statusCode).toBe(200); + + // Both the public and wallet clients must use the configured RPC endpoint, + // not viem's rate-limited public default. + expect(mockViemFunctions.http).toHaveBeenCalledWith(rpcUrl); + } finally { + // setupTestEnvironment's custom overrides aren't cleaned up by the shared + // afterEach (which only clears the base testEnvironment keys) — clear this + // one explicitly so it can't leak into the next test. + cleanupTestEnvironment(["RPC_URL_EIP155_10"]); + } + }); + + test("falls back to the public endpoint when no RPC_URL_ is configured", async () => { + // No RPC_URL_EIP155_10 set — default test env from setupTestEnvironment(). + setupSuccessfulMintingFlow(103); + + const mainnetPayment = { + x402Version: 2, + accepted: { + scheme: "exact", + network: "eip155:10", + amount: "1000", + asset: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", + payTo: "0xAAEBC1441323B8ad6Bdf6793A8428166b510239C", + }, + payload: { + authorization: { + from: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb", + to: "0xAAEBC1441323B8ad6Bdf6793A8428166b510239C", + value: "1000", + }, + }, + network: "eip155:10", + }; + + const event = { + httpMethod: "POST", + headers: { "x-payment": JSON.stringify(mainnetPayment) }, + body: JSON.stringify({ prompt: "Test RPC fallback" }), + path: "/genimg", + }; + + const response = await handle(event, {}); + expect(response.statusCode).toBe(200); + + // Unset RPC_URL_* => getRpcUrl returns undefined => viem's http() falls back + // to the chain's public default, same as before this change. + expect(mockViemFunctions.http).toHaveBeenCalledWith(undefined); + }); + test("should reject unsupported network (production only accepts Optimism/Base)", async () => { const unsupportedPayment = { x402Version: 2, diff --git a/scw_js/test/getChain.test.ts b/scw_js/test/getChain.test.ts index a0b2e4c9a..fd727dedc 100644 --- a/scw_js/test/getChain.test.ts +++ b/scw_js/test/getChain.test.ts @@ -27,6 +27,8 @@ import { getGenAiNFTTestnetNetworks, getViemChain, getUSDCConfig, + getRpcUrl, + toCAIP2, } from "@fretchen/chain-utils"; // USDC contracts expose name() and version() functions for EIP-712 domain @@ -233,9 +235,16 @@ describe("EIP-712 Domain Validation (On-Chain)", () => { * @returns {Promise<{name: string, version: string}>} */ async function readOnChainDomain(chain, contractAddress) { + // Uses RPC_URL_ when configured (see getRpcUrl in @fretchen/chain-utils) + // instead of viem's public default. This is the actual, concrete verification + // that a dedicated RPC provider (e.g. Alchemy) works end-to-end: set + // RPC_URL_EIP155_10 to a real Alchemy URL locally, leave SKIP_RPC_TESTS unset, + // and re-run this suite — the withRetry/backoff helper above exists precisely + // because the public endpoints rate-limit under repeated calls like these; a + // dedicated provider sidesteps that instead of just retrying through it. const client = createPublicClient({ chain, - transport: http(), + transport: http(getRpcUrl(toCAIP2(chain.id))), }); const name = await withRetry(() => diff --git a/scw_js/test/llm_service.test.ts b/scw_js/test/llm_service.test.ts index 94007036c..81bee8f37 100644 --- a/scw_js/test/llm_service.test.ts +++ b/scw_js/test/llm_service.test.ts @@ -127,6 +127,48 @@ describe("llm_service.js", () => { }), ); }); + + test("uses the Mistral endpoint/model/auth when provider is 'mistral'", async () => { + setupTestEnvironment({ MISTRAL_API_KEY: "test-mistral-key" }); + const prompt = [{ role: "user", content: "Test" }]; + + try { + await callLLMAPI(prompt, false, "mistral"); + + expect(global.fetch).toHaveBeenCalledWith( + "https://api.mistral.ai/v1/chat/completions", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: expect.stringContaining("test-mistral-key"), + }), + body: JSON.stringify({ + model: "mistral-large-latest", + messages: [{ role: "user", content: "Test" }], + }), + }), + ); + } finally { + // setupTestEnvironment's custom overrides aren't cleared by the shared afterEach + // (base testEnvironment keys only) — clear this one explicitly. + cleanupTestEnvironment(["MISTRAL_API_KEY"]); + } + }); + + test("throws when MISTRAL_API_KEY is not set", async () => { + delete process.env.MISTRAL_API_KEY; + const prompt = [{ role: "user", content: "Test" }]; + await expect(callLLMAPI(prompt, false, "mistral")).rejects.toThrow( + "API token not found. Please configure the MISTRAL_API_KEY environment variable.", + ); + }); + + test("throws a friendly error for an unknown provider", async () => { + const prompt = [{ role: "user", content: "Test" }]; + await expect(callLLMAPI(prompt, false, "openai")).rejects.toThrow( + /Unknown LLM provider: openai/, + ); + }); }); const sampleLeaf: Leaf = { @@ -338,6 +380,35 @@ describe("checkWalletBalance — ETH deposit gate", () => { `Insufficient balance. Required: ${REQUIRED}, Current: 5000000000000`, ); }); + + // ===== RPC endpoint wiring (getChain().id -> CAIP-2 -> getRpcUrl -> http()) ===== + // getChain() is mocked above to return { id: 10, ... } (OP Mainnet), so the + // relevant env var is RPC_URL_EIP155_10. + + test("uses the configured RPC_URL_EIP155_10 endpoint when set", async () => { + const rpcUrl = "https://opt-mainnet.g.alchemy.com/v2/test-key"; + setupTestEnvironment({ RPC_URL_EIP155_10: rpcUrl }); + mockCheckBalance.mockResolvedValue(REQUIRED); + + try { + await checkWalletBalance(USER_ADDRESS, REQUIRED); + // Not viem's rate-limited public default. + expect(mockViemFunctions.http).toHaveBeenCalledWith(rpcUrl); + } finally { + // setupTestEnvironment's custom overrides aren't cleared by the shared + // afterEach (base testEnvironment keys only) — clear this one explicitly. + cleanupTestEnvironment(["RPC_URL_EIP155_10"]); + } + }); + + test("falls back to the public endpoint when RPC_URL_EIP155_10 is unset", async () => { + // No RPC_URL_EIP155_10 set — default test env from setupTestEnvironment(). + mockCheckBalance.mockResolvedValue(REQUIRED); + + await checkWalletBalance(USER_ADDRESS, REQUIRED); + + expect(mockViemFunctions.http).toHaveBeenCalledWith(undefined); + }); }); describe("convertTokensToCost — ETH wei conversion (regression check after parseTokenCount refactor)", () => { @@ -361,36 +432,106 @@ describe("convertTokensToCost — ETH wei conversion (regression check after par }); }); -describe("convertTokensToUsdcCost — direct EUR-to-USDC conversion (no ETH hop)", () => { - test("converts a known token count to the expected USDC atomic units", () => { - // 1,000,000 tokens * 0.71 EUR/USDC per 1M tokens = 710,000 atomic units ($0.71) - expect(convertTokensToUsdcCost(1_000_000n)).toBe(710_000n); - }); +describe("convertTokensToUsdcCost — per-provider, input/output-split USDC conversion", () => { + describe("ionos — blended rate (input === output), unchanged math", () => { + test("converts a known token split to the expected USDC atomic units", () => { + // 500,000 prompt + 500,000 completion = 1,000,000 tokens total, both priced at + // ionos's blended 0.71 EUR/USDC per 1M tokens = 710,000 atomic units ($0.71) — + // same total as the old single-rate formula, since input === output for ionos. + expect( + convertTokensToUsdcCost({ prompt_tokens: 500_000n, completion_tokens: 500_000n }, "ionos"), + ).toBe(710_000n); + }); - test("matches the estimated-tokens-per-message default used by sc_llm_x402.ts", () => { - // 2000 tokens * 71 / 100 = 1420 atomic units ($0.00142) - expect(convertTokensToUsdcCost(2000n)).toBe(1420n); + test("blended rate is split-independent — same total regardless of prompt/completion mix", () => { + const allPrompt = convertTokensToUsdcCost( + { prompt_tokens: 1_000_000n, completion_tokens: 0n }, + "ionos", + ); + const allCompletion = convertTokensToUsdcCost( + { prompt_tokens: 0n, completion_tokens: 1_000_000n }, + "ionos", + ); + expect(allPrompt).toBe(710_000n); + expect(allCompletion).toBe(710_000n); + }); + + test("returns zero for zero tokens", () => { + expect(convertTokensToUsdcCost({ prompt_tokens: 0n, completion_tokens: 0n }, "ionos")).toBe( + 0n, + ); + }); }); - test("accepts number and numeric-string inputs equivalently to bigint", () => { - const viaBigint = convertTokensToUsdcCost(1500n); - expect(convertTokensToUsdcCost(1500)).toBe(viaBigint); - expect(convertTokensToUsdcCost("1500")).toBe(viaBigint); + describe("mistral — asymmetric input/output rates ($0.50/M in, $1.50/M out)", () => { + test("matches the estimated-tokens-per-message ceiling convention used by sc_llm_x402.ts", () => { + // sc_llm_x402.ts prices the whole pre-auth estimate as completion (output) + // tokens (the pricier rate) since no real split exists yet for the ceiling. + // 2000 tokens * $1.50/M = 3000 atomic units ($0.003). + expect( + convertTokensToUsdcCost({ prompt_tokens: 0n, completion_tokens: 2000n }, "mistral"), + ).toBe(3000n); + }); + + test("prices input tokens at the input rate only", () => { + // 1,000,000 prompt tokens * $0.50/M = 500,000 atomic units. + expect( + convertTokensToUsdcCost({ prompt_tokens: 1_000_000n, completion_tokens: 0n }, "mistral"), + ).toBe(500_000n); + }); + + test("prices completion tokens at the (higher) output rate only", () => { + // 1,000,000 completion tokens * $1.50/M = 1,500,000 atomic units. + expect( + convertTokensToUsdcCost({ prompt_tokens: 0n, completion_tokens: 1_000_000n }, "mistral"), + ).toBe(1_500_000n); + }); + + test("sums input and output cost for a mixed split", () => { + // 500,000 * $0.50/M + 500,000 * $1.50/M = 250,000 + 750,000 = 1,000,000 atomic units. + expect( + convertTokensToUsdcCost( + { prompt_tokens: 500_000n, completion_tokens: 500_000n }, + "mistral", + ), + ).toBe(1_000_000n); + }); }); - test("returns zero for zero tokens", () => { - expect(convertTokensToUsdcCost(0n)).toBe(0n); + test("accepts number and numeric-string inputs equivalently to bigint", () => { + const viaBigint = convertTokensToUsdcCost( + { prompt_tokens: 1000n, completion_tokens: 500n }, + "mistral", + ); + expect( + convertTokensToUsdcCost({ prompt_tokens: 1000, completion_tokens: 500 }, "mistral"), + ).toBe(viaBigint); + expect( + convertTokensToUsdcCost({ prompt_tokens: "1000", completion_tokens: "500" }, "mistral"), + ).toBe(viaBigint); }); test("rejects a negative number", () => { - expect(() => convertTokensToUsdcCost(-5)).toThrow(TypeError); + expect(() => + convertTokensToUsdcCost({ prompt_tokens: -5, completion_tokens: 0 }, "mistral"), + ).toThrow(TypeError); }); test("rejects a non-finite number", () => { - expect(() => convertTokensToUsdcCost(Infinity)).toThrow(TypeError); + expect(() => + convertTokensToUsdcCost({ prompt_tokens: Infinity, completion_tokens: 0 }, "mistral"), + ).toThrow(TypeError); }); test("rejects a non-numeric string", () => { - expect(() => convertTokensToUsdcCost("abc")).toThrow(TypeError); + expect(() => + convertTokensToUsdcCost({ prompt_tokens: "abc", completion_tokens: 0 }, "mistral"), + ).toThrow(TypeError); + }); + + test("rejects an unknown provider", () => { + expect(() => + convertTokensToUsdcCost({ prompt_tokens: 100n, completion_tokens: 100n }, "openai"), + ).toThrow(/Unknown LLM provider: openai/); }); }); diff --git a/scw_js/test/sc_llm_x402.test.ts b/scw_js/test/sc_llm_x402.test.ts index 83d48a908..648183d07 100644 --- a/scw_js/test/sc_llm_x402.test.ts +++ b/scw_js/test/sc_llm_x402.test.ts @@ -25,16 +25,32 @@ const { const mockEnhancePaymentRequirements = vi.fn().mockImplementation(async (base: unknown) => base); return { mockCallLLMAPI: vi.fn(), - // Real formula (matches llm_service.ts's actual convertTokensToUsdcCost: tokens * 71n / 100n), - // not a fixed stub — so tests can verify the settlement amount actually tracks whatever - // usage.total_tokens callLLMAPI returns, not just the flat ceiling. Called once at module - // load (for the ceiling, USDC_MAX_PRICE_PER_MESSAGE) — must work before beforeEach runs. - mockConvertTokensToUsdcCost: vi - .fn() - .mockImplementation((tokenCount: bigint | number | string) => { - const tc = typeof tokenCount === "bigint" ? tokenCount : BigInt(tokenCount); - return (tc * 71n) / 100n; - }), + // Real formula (matches llm_service.ts's actual convertTokensToUsdcCost: separate + // input/output rates per provider — see LLM_PROVIDERS there), not a fixed stub — so + // tests can verify the settlement amount actually tracks whatever usage callLLMAPI + // returns, not just the flat ceiling. Called once at module load (for the ceiling, + // USDC_MAX_PRICE_PER_MESSAGE) — must work before beforeEach runs. Simplified to a + // single shared denominator (valid since both providers below have inDen === outDen + // === 100n today; the real implementation cross-multiplies to not assume that). + mockConvertTokensToUsdcCost: vi.fn().mockImplementation( + ( + usage: { + prompt_tokens: bigint | number | string; + completion_tokens: bigint | number | string; + }, + provider: string, + ) => { + const RATES: Record = { + ionos: { in: 71n, out: 71n, den: 100n }, + mistral: { in: 50n, out: 150n, den: 100n }, + }; + const rate = RATES[provider]; + if (!rate) throw new Error(`Unknown LLM provider: ${provider}`); + const p = BigInt(usage.prompt_tokens); + const c = BigInt(usage.completion_tokens); + return (p * rate.in + c * rate.out) / rate.den; + }, + ), mockCreateLLMResourceServer: vi.fn(), mockCreateBatchSettlementPaymentRequirements: vi.fn(), mockCreate402Response: vi.fn(), @@ -208,8 +224,10 @@ describe("sc_llm_x402", () => { it("returns a 402 built from createBatchSettlementPaymentRequirements", async () => { const res = await handle(makeEvent() as never, {}); expect(res.statusCode).toBe(402); + // Ceiling: the whole 2000-token estimate priced as completion (output) tokens + // at Mistral's $1.50/M rate — 2000 * 150 / 100 = 3000. expect(mockCreateBatchSettlementPaymentRequirements).toHaveBeenCalledWith( - expect.objectContaining({ payTo: VALID_ADDRESS, scheme: mockScheme, amount: "1420" }), + expect.objectContaining({ payTo: VALID_ADDRESS, scheme: mockScheme, amount: "3000" }), ); expect(mockCreate402Response).toHaveBeenCalled(); }); @@ -311,7 +329,8 @@ describe("sc_llm_x402", () => { mockExtractPaymentPayload.mockReturnValue(samplePaymentPayload); // eip155:84532 const res = await handle(makeEvent() as never, {}); expect(res.statusCode).toBe(200); - expect(mockCallLLMAPI).toHaveBeenCalledWith(expect.anything(), true); + // Third arg is the fixed provider — this endpoint always uses Mistral, live or mock. + expect(mockCallLLMAPI).toHaveBeenCalledWith(expect.anything(), true, "mistral"); }); it("uses the real LLM path on a mainnet payment", async () => { @@ -321,7 +340,59 @@ describe("sc_llm_x402", () => { }); const res = await handle(makeEvent() as never, {}); expect(res.statusCode).toBe(200); - expect(mockCallLLMAPI).toHaveBeenCalledWith(expect.anything(), false); + expect(mockCallLLMAPI).toHaveBeenCalledWith(expect.anything(), false, "mistral"); + }); + + // ═══════════════════════════════════════════════════════════ + // SECURITY: testnet must never reach the real Mistral API — see the guard + // in sc_llm_x402.ts. Absent/true stay mocked (unaffected, both real callers + // — website + notebook — never send useDummyData at all); an explicit + // false is a caller error, rejected outright rather than silently downgraded. + // ═══════════════════════════════════════════════════════════ + + it("rejects an explicit useDummyData=false on a testnet network", async () => { + const res = await handle( + makeEvent({ + body: JSON.stringify({ + data: { prompt: [{ role: "user", content: "hi" }], useDummyData: false }, + }), + }) as never, + {}, + ); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error).toMatch(/Real inference is not available on testnet/); + expect(mockVerifyPayment).not.toHaveBeenCalled(); + expect(mockCallLLMAPI).not.toHaveBeenCalled(); + }); + + it("allows an explicit useDummyData=true on a testnet network (still mocks)", async () => { + const res = await handle( + makeEvent({ + body: JSON.stringify({ + data: { prompt: [{ role: "user", content: "hi" }], useDummyData: true }, + }), + }) as never, + {}, + ); + expect(res.statusCode).toBe(200); + expect(mockCallLLMAPI).toHaveBeenCalledWith(expect.anything(), true, "mistral"); + }); + + it("allows an explicit useDummyData=false on a mainnet network (real path proceeds)", async () => { + mockExtractPaymentPayload.mockReturnValue({ + accepted: { network: "eip155:8453", scheme: "batch-settlement" }, + payload: { type: "voucher" }, + }); + const res = await handle( + makeEvent({ + body: JSON.stringify({ + data: { prompt: [{ role: "user", content: "hi" }], useDummyData: false }, + }), + }) as never, + {}, + ); + expect(res.statusCode).toBe(200); + expect(mockCallLLMAPI).toHaveBeenCalledWith(expect.anything(), false, "mistral"); }); }); @@ -370,7 +441,8 @@ describe("sc_llm_x402", () => { // ═══════════════════════════════════════════════════════════ it("settles for the LLM's actual token usage, not the ceiling", async () => { - // 1000 tokens -> 1000 * 71 / 100 = 710, well under the 1420 ceiling (2000 tokens). + // 200 prompt + 800 completion -> 0.5*200 + 1.5*800 = 100 + 1200 = 1300 (Mistral + // rates), well under the 3000 ceiling (2000 tokens, all priced as completion). mockCallLLMAPI.mockResolvedValue({ content: "answer", usage: { prompt_tokens: 200, completion_tokens: 800, total_tokens: 1000 }, @@ -380,24 +452,25 @@ describe("sc_llm_x402", () => { const res = await handle(makeEvent() as never, {}); expect(res.statusCode).toBe(200); - // verifyPayment must still see the pre-authorized ceiling (1420) — the client signed + // verifyPayment must still see the pre-authorized ceiling (3000) — the client signed // its voucher against that, and handleBeforeVerify requires an exact match. const enhancedRequirements = await mockEnhancePaymentRequirements.mock.results[0]?.value; expect(mockVerifyPayment).toHaveBeenCalledWith( samplePaymentPayload, - expect.objectContaining({ amount: "1420" }), + expect.objectContaining({ amount: "3000" }), ); // settlePayment must see the real, usage-derived amount instead. expect(mockSettlePayment).toHaveBeenCalledWith( samplePaymentPayload, - expect.objectContaining({ ...enhancedRequirements, amount: "710" }), + expect.objectContaining({ ...enhancedRequirements, amount: "1300" }), ); }); it("caps the settlement amount at the ceiling when usage runs over the estimate", async () => { - // 3000 tokens -> 3000 * 71 / 100 = 2130, which exceeds the 1420 ceiling — must be - // capped there rather than settling for more than the client authorized (or aborting). + // 500 prompt + 2500 completion -> 0.5*500 + 1.5*2500 = 250 + 3750 = 4000, which + // exceeds the 3000 ceiling — must be capped there rather than settling for more + // than the client authorized (or aborting). mockCallLLMAPI.mockResolvedValue({ content: "answer", usage: { prompt_tokens: 500, completion_tokens: 2500, total_tokens: 3000 }, @@ -408,7 +481,7 @@ describe("sc_llm_x402", () => { expect(res.statusCode).toBe(200); expect(mockSettlePayment).toHaveBeenCalledWith( samplePaymentPayload, - expect.objectContaining({ amount: "1420" }), + expect.objectContaining({ amount: "3000" }), ); }); @@ -423,10 +496,10 @@ describe("sc_llm_x402", () => { const res = await handle(makeEvent() as never, {}); expect(res.statusCode).toBe(200); - // 15 * 71 / 100 = 10.65 -> 10 (integer division). + // 5 prompt + 15 completion -> 0.5*5 + 1.5*15 = 2.5 + 22.5 = 25 exactly. expect(mockSettlePayment).toHaveBeenCalledWith( samplePaymentPayload, - expect.objectContaining({ amount: "10" }), + expect.objectContaining({ amount: "25" }), ); }); }); diff --git a/x402_facilitator/.env.example b/x402_facilitator/.env.example index e9e488342..a68b04c5e 100644 --- a/x402_facilitator/.env.example +++ b/x402_facilitator/.env.example @@ -9,10 +9,13 @@ SCW_DEFAULT_PROJECT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx FACILITATOR_WALLET_PRIVATE_KEY=0x... FACILITATOR_WALLET_PUBLIC_KEY=0x... -# Optional: Custom RPC endpoints (defaults provided in chain_utils.js) -# Set these for production to use dedicated RPC providers (Infura, Alchemy, Ankr, etc.) -# OPTIMISM_RPC_URL=https://opt-mainnet.g.alchemy.com/v2/YOUR_API_KEY -# OPTIMISM_SEPOLIA_RPC_URL=https://opt-sepolia.g.alchemy.com/v2/YOUR_API_KEY +# Optional: Custom RPC endpoints (see getRpcUrl in chain_utils.ts). Unset falls back to +# each chain's public endpoint. Set these for production to use a dedicated RPC provider +# (Infura, Alchemy, Ankr, etc.) — the public endpoints are aggressively rate-limited. +# RPC_URL_EIP155_10=https://opt-mainnet.g.alchemy.com/v2/YOUR_API_KEY +# RPC_URL_EIP155_8453=https://base-mainnet.g.alchemy.com/v2/YOUR_API_KEY +# RPC_URL_EIP155_84532=https://base-sepolia.g.alchemy.com/v2/YOUR_API_KEY +# RPC_URL_EIP155_11155420=https://opt-sepolia.g.alchemy.com/v2/YOUR_API_KEY # Local development NODE_ENV=test diff --git a/x402_facilitator/serverless.yml b/x402_facilitator/serverless.yml index 0b5829894..5dfead5a0 100644 --- a/x402_facilitator/serverless.yml +++ b/x402_facilitator/serverless.yml @@ -22,18 +22,19 @@ provider: # can never authorize real funds. Keep testnet-only wallets here. BATCH_SETTLEMENT_MANUAL_WHITELIST: ${env:BATCH_SETTLEMENT_MANUAL_WHITELIST, ''} BATCH_SETTLEMENT_TEST_WALLETS: ${env:BATCH_SETTLEMENT_TEST_WALLETS, ''} + # Secrets (set via Scaleway Console or CLI) + secret: + FACILITATOR_WALLET_PRIVATE_KEY: ${env:FACILITATOR_WALLET_PRIVATE_KEY} # Per-network RPC endpoints (see getRpcUrl in chain_utils.ts). Unset => viem falls # back to the chain's public endpoint, which is aggressively rate-limited: a single # batch-settlement deposit does a Multicall3 read batch and fails with "over rate # limit", surfacing as the generic ..._deposit_transaction_failed. Set these for any - # network carrying real traffic. + # network carrying real traffic. A real value embeds an API key (e.g. an Alchemy + # URL) — secret, not env. RPC_URL_EIP155_8453: ${env:RPC_URL_EIP155_8453, ''} RPC_URL_EIP155_10: ${env:RPC_URL_EIP155_10, ''} RPC_URL_EIP155_84532: ${env:RPC_URL_EIP155_84532, ''} RPC_URL_EIP155_11155420: ${env:RPC_URL_EIP155_11155420, ''} - # Secrets (set via Scaleway Console or CLI) - secret: - FACILITATOR_WALLET_PRIVATE_KEY: ${env:FACILITATOR_WALLET_PRIVATE_KEY} plugins: - serverless-scaleway-functions From e31d998c76a16f5d481c1fdb485c0fe04c4bd85c Mon Sep 17 00:00:00 2001 From: fretchen Date: Tue, 21 Jul 2026 16:12:47 +0200 Subject: [PATCH 11/14] Add proper channel healing --- scw_js/notebooks/sc_llm_x402_buyer.ipynb | 312 ++++++++++++++++++----- scw_js/sc_llm_x402.ts | 22 +- scw_js/test/sc_llm_x402.test.ts | 3 + scw_js/x402_server.ts | 17 +- website/hooks/useX402Chat.test.ts | 30 +++ website/hooks/useX402Chat.ts | 22 +- 6 files changed, 340 insertions(+), 66 deletions(-) diff --git a/scw_js/notebooks/sc_llm_x402_buyer.ipynb b/scw_js/notebooks/sc_llm_x402_buyer.ipynb index 4cdc833ad..652ba11f8 100644 --- a/scw_js/notebooks/sc_llm_x402_buyer.ipynb +++ b/scw_js/notebooks/sc_llm_x402_buyer.ipynb @@ -3,7 +3,50 @@ { "cell_type": "markdown", "metadata": {}, - "source": "# `sc_llm_x402` Buyer Notebook — real end-to-end chat payment (Deno/TypeScript)\n\nDrives the **real, locally-running** `sc_llm_x402.ts` batch-settlement chat handler as a buyer would —\nthe missing real-server verification for Phase B, and a literal, runnable blueprint for\n`website/hooks/useX402Chat.ts` (Phase C, not yet built). Unlike\n`x402_facilitator/notebooks/x402_batch_settlement_buyer.ipynb` (which hits the facilitator's raw\n`/verify`+`/settle` endpoints directly with hand-built payment requirements), this notebook only ever\ntalks to the **resource server** over plain HTTP via `wrapFetchWithPayment` — exactly what a real client\n(browser or otherwise) does. The server decides and advertises everything else.\n\n**Terminology — x402 SDK role → this repo's package:**\n\n| SDK role | Repo package | Role in this repo |\n|---|---|---|\n| **Client** (buyer / payer) | `website/` *(this notebook stands in for it)* | Browser wallet: signs the deposit + per-message vouchers |\n| **Server** (seller / merchant) | `scw_js/` (`sc_llm_x402.ts`) | Verifies vouchers, serves the LLM response, commits the charge |\n| **Facilitator** | `x402_facilitator/` | Neither buyer nor seller — executes on-chain deposit/claim/settle |\n\n## ⚠️ Prerequisites\n\n1. **Deno Jupyter kernel** — `deno jupyter --install` (same global kernel as the sibling facilitator notebooks).\n2. **`scw_js/.env`** needs:\n - `TEST_WALLET_PRIVATE_KEY` — the buyer wallet, funded with Base Sepolia USDC (get some from\n https://faucet.circle.com/). Purely a notebook-testing convenience — no scw_js production code\n reads this key.\n - `NFT_WALLET_PUBLIC_KEY` — the receiver address (the server reads the same file).\n - `RECEIVER_AUTHORIZER_PRIVATE_KEY` — required for `createLLMResourceServer()` to construct at all.\n A pure off-chain signer, no funding needed. See `assistent_plan.md` §Backlog F for why this isn't\n delegated to the facilitator (yet).\n - `SCW_ACCESS_KEY` / `SCW_SECRET_KEY` — the server's `S3ChannelStorage` writes real objects under\n `channels/` in the production bucket when this runs (private ACL, harmless, but real).\n3. **Start the local server** (separate terminal): `cd scw_js && npm run dev:llmx402` — listens on `:8085`,\n and internally talks to the **real deployed facilitator** (`https://facilitator.fretchen.eu` by default)\n unless `FACILITATOR_URL` is overridden. This means the first cell that opens a channel submits a\n **real on-chain transaction on Base Sepolia**.\n4. **Or skip step 3 entirely** and set `USE_DEPLOYED = true` in the network-selection cell below to hit\n the real deployed `llmx402` Scaleway function instead of a local server — same S3 channel storage\n bucket and same facilitator either way, so results are directly comparable. The deployed function\n scales to zero when idle, so the very first request after a while may take several extra seconds\n (cold start) before the usual settlement timing shown in this notebook's past runs.\n" + "source": [ + "# `sc_llm_x402` Buyer Notebook — real end-to-end chat payment (Deno/TypeScript)\n", + "\n", + "Drives the **real, locally-running** `sc_llm_x402.ts` batch-settlement chat handler as a buyer would —\n", + "the missing real-server verification for Phase B, and the runnable blueprint\n", + "`website/hooks/useX402Chat.ts` was built from. Unlike\n", + "`x402_facilitator/notebooks/x402_batch_settlement_buyer.ipynb` (which hits the facilitator's raw\n", + "`/verify`+`/settle` endpoints directly with hand-built payment requirements), this notebook only ever\n", + "talks to the **resource server** over plain HTTP via `wrapFetchWithPayment` — exactly what a real client\n", + "(browser or otherwise) does. The server decides and advertises everything else.\n", + "\n", + "**Terminology — x402 SDK role → this repo's package:**\n", + "\n", + "| SDK role | Repo package | Role in this repo |\n", + "|---|---|---|\n", + "| **Client** (buyer / payer) | `website/` *(this notebook stands in for it)* | Browser wallet: signs the deposit + per-message vouchers |\n", + "| **Server** (seller / merchant) | `scw_js/` (`sc_llm_x402.ts`) | Verifies vouchers, serves the LLM response, commits the charge |\n", + "| **Facilitator** | `x402_facilitator/` | Neither buyer nor seller — executes on-chain deposit/claim/settle |\n", + "\n", + "## ⚠️ Prerequisites\n", + "\n", + "1. **Deno Jupyter kernel** — `deno jupyter --install` (same global kernel as the sibling facilitator notebooks).\n", + "2. **`scw_js/.env`** needs:\n", + " - `TEST_WALLET_PRIVATE_KEY` — the buyer wallet, funded with USDC on whichever network you select\n", + " below (Base Sepolia: https://faucet.circle.com/; Base Mainnet: a real, funded wallet). Purely a\n", + " notebook-testing convenience — no scw_js production code reads this key.\n", + " - `NFT_WALLET_PUBLIC_KEY` — the receiver address (the server reads the same file).\n", + " - `RECEIVER_AUTHORIZER_PRIVATE_KEY` — required for `createLLMResourceServer()` to construct at all.\n", + " A pure off-chain signer, no funding needed. See `assistent_plan.md` §Backlog F for why this isn't\n", + " delegated to the facilitator (yet).\n", + " - `MISTRAL_API_KEY` — required once `USE_MAINNET` is armed below (the **server**, not this notebook,\n", + " reads it). Real Mistral completions are billed to this key — see `llm_service.ts`'s `LLM_PROVIDERS`.\n", + " - `SCW_ACCESS_KEY` / `SCW_SECRET_KEY` — the server's `S3ChannelStorage` writes real objects under\n", + " `channels/` in the production bucket when this runs (private ACL, harmless, but real).\n", + "3. **Start the local server** (separate terminal): `cd scw_js && npm run dev:llmx402` — listens on `:8085`,\n", + " and internally talks to the **real deployed facilitator** (`https://facilitator.fretchen.eu` by default)\n", + " unless `FACILITATOR_URL` is overridden. This means the first cell that opens a channel submits a\n", + " **real on-chain transaction on the selected network**.\n", + "4. **Or skip step 3 entirely** and set `USE_DEPLOYED = true` in the network-selection cell below to hit\n", + " the real deployed `llmx402` Scaleway function instead of a local server — same S3 channel storage\n", + " bucket and same facilitator either way, so results are directly comparable. The deployed function\n", + " scales to zero when idle, so the very first request after a while may take several extra seconds\n", + " (cold start) before the usual settlement timing shown in this notebook's past runs." + ] }, { "cell_type": "code", @@ -46,18 +89,71 @@ "source": [ "## Network selection\n", "\n", - "Base Sepolia only — the canonical `BATCH_SETTLEMENT_ADDRESS` has no deployment on Optimism Sepolia\n", - "(confirmed in the facilitator's own buyer spike). `getBatchSettlementNetworks()` on the server side\n", - "already restricts to Base Sepolia / Base mainnet / Optimism mainnet; this notebook only ever offers\n", - "the testnet.\n" + "`getBatchSettlementNetworks()` on the server side (`x402_server.ts`) only allows **Base** —\n", + "`[\"eip155:8453\", \"eip155:84532\"]`. Optimism is excluded entirely (a separate, pre-existing\n", + "`@x402/evm` `DEFAULT_STABLECOINS` gap, not a spike limitation — filed upstream, see\n", + "`x402_facilitator/upstream/`).\n", + "\n", + "| Network | CAIP-2 | Real Mistral call? |\n", + "|---|---|---|\n", + "| Base Sepolia (testnet) | `eip155:84532` | No — the server always mocks (`isTestnet`), regardless of what the client sends |\n", + "| Base Mainnet | `eip155:8453` | **Yes** — real USDC settlement + a real, billed Mistral completion |\n", + "\n", + "**One flag decides everything**: `USE_MAINNET` below selects the network — and since the server mocks\n", + "every testnet request unconditionally, choosing Mistral *is* choosing mainnet. There's no separate\n", + "confirmation step; flipping this one flag is the deliberate action.\n" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "metadata": {}, - "outputs": [], - "source": "const NETWORK = \"eip155:84532\"; // Base Sepolia\n\n// Toggle between the local dev server and the real deployed Scaleway function — same code path\n// either way, since the channel is on-chain state keyed by (buyer, receiver, network, asset), not\n// by which server queried it. Local: `cd scw_js && npm run dev:llmx402` (see prerequisites above).\nconst USE_DEPLOYED = false;\nconst SERVICE_URL = USE_DEPLOYED\n ? \"https://mypersonaljscloudivnad9dy-llmx402.functions.fnc.fr-par.scw.cloud\"\n : \"http://localhost:8085\";\n\nconsole.log(`🧪 Base Sepolia (${NETWORK}) — service: ${SERVICE_URL}`);" + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "🚨 REAL MONEY on Base Mainnet\n", + " eip155:8453 • USDC USD Coin @ 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\n", + " Service: http://localhost:8085\n" + ] + } + ], + "source": [ + "import { base, baseSepolia } from \"npm:viem@2/chains\";\n", + "\n", + "const USE_MAINNET = true; // ⚠️ true = REAL MONEY: mainnet USDC settlement + a real, billed Mistral call.\n", + "\n", + "const NETWORK_CONFIG = {\n", + " \"base-testnet\": {\n", + " caip2Network: \"eip155:84532\" as const, networkName: \"Base Sepolia (Testnet)\",\n", + " chain: baseSepolia, usdcName: \"USDC\",\n", + " usdcAddress: \"0x036CbD53842c5426634e7929541eC2318f3dCF7e\" as `0x${string}`,\n", + " explorer: \"https://sepolia.basescan.org\", faucet: \"https://faucet.circle.com/\",\n", + " },\n", + " \"base-mainnet\": {\n", + " caip2Network: \"eip155:8453\" as const, networkName: \"Base Mainnet\",\n", + " chain: base, usdcName: \"USD Coin\",\n", + " usdcAddress: \"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\" as `0x${string}`,\n", + " explorer: \"https://basescan.org\", faucet: \"Bridge: https://bridge.base.org\",\n", + " },\n", + "};\n", + "const config = NETWORK_CONFIG[USE_MAINNET ? \"base-mainnet\" : \"base-testnet\"];\n", + "const NETWORK = config.caip2Network;\n", + "\n", + "// Toggle between the local dev server and the real deployed Scaleway function — orthogonal to\n", + "// USE_MAINNET, same code path either way, since the channel is on-chain state keyed by\n", + "// (buyer, receiver, network, asset), not by which server queried it.\n", + "// Local: `cd scw_js && npm run dev:llmx402` (see prerequisites above).\n", + "const USE_DEPLOYED = false;\n", + "const SERVICE_URL = USE_DEPLOYED\n", + " ? \"https://mypersonaljscloudivnad9dy-llmx402.functions.fnc.fr-par.scw.cloud\"\n", + " : \"http://localhost:8085\";\n", + "\n", + "console.log(USE_MAINNET ? `🚨 REAL MONEY on ${config.networkName}` : `🧪 ${config.networkName}`);\n", + "console.log(` ${NETWORK} • USDC ${config.usdcName} @ ${config.usdcAddress}`);\n", + "console.log(` Service: ${SERVICE_URL}`);" + ] }, { "cell_type": "markdown", @@ -85,7 +181,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "✅ buyer client ready, registered for eip155:84532\n" + "✅ buyer client ready, registered for eip155:8453\n" ] } ], @@ -98,7 +194,6 @@ " type BatchSettlementClientContext,\n", "} from \"npm:@x402/evm@^2.17.0/batch-settlement/client\";\n", "import { createPublicClient, http } from \"npm:viem@2\";\n", - "import { baseSepolia } from \"npm:viem@2/chains\";\n", "\n", "// readContract is documented as \"optional\" on ClientEvmSigner (only \"required for extension\n", "// enrichment\" per the SDK's own JSDoc), but batch-settlement's corrective-402 recovery\n", @@ -110,7 +205,9 @@ "// toClientEvmSigner() is the SDK's own helper for composing a full signer from a plain\n", "// account + a public client. In useX402Chat.ts: swap `account`/`publicClient` here for a wagmi\n", "// WalletClient adapter + `usePublicClient()` (see useX402ImageGeneration.ts's signer shape).\n", - "const publicClient = createPublicClient({ chain: baseSepolia, transport: http() });\n", + "// chain comes from `config` (network-selection cell) — mainnet/testnet selects both the\n", + "// network AND which chain object the public client reads against.\n", + "const publicClient = createPublicClient({ chain: config.chain, transport: http() });\n", "const buyerSigner = toClientEvmSigner(\n", " { address: account.address, signTypedData: (a: any) => account.signTypedData(a) },\n", " publicClient,\n", @@ -142,6 +239,65 @@ "console.log(\"✅ buyer client ready, registered for\", NETWORK);" ] }, + { + "cell_type": "markdown", + "id": "22d28231", + "metadata": {}, + "source": [ + "## Pre-flight — buyer USDC balance vs. estimated cost\n", + "\n", + "Neither the network flag above nor the warnings below change the fact that arming `USE_MAINNET`\n", + "spends real money. Check the buyer has enough USDC *before* message #1 tries to open a channel.\n", + "The estimate isn't a guess — it's derived the same way `sc_llm_x402.ts` derives its ceiling:\n", + "`LLM_ESTIMATED_TOKENS_PER_MESSAGE` (default 2000) priced entirely as completion tokens at Mistral's\n", + "$1.50/M rate ⇒ 3000 atomic units (`$0.003`) per message; the channel deposit is ≈5× that\n", + "(batch-settlement's default `depositMultiplier`) ⇒ ≈`$0.015`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "aae724d6", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "💵 Buyer USDC: 1.24 (estimated deposit ≈ 0.015)\n", + " ✅ enough USDC to open the channel\n" + ] + } + ], + "source": [ + "import { formatUnits } from \"npm:viem@2\";\n", + "\n", + "const erc20BalanceOfAbi = [{\n", + " inputs: [{ name: \"account\", type: \"address\" }],\n", + " name: \"balanceOf\",\n", + " outputs: [{ name: \"\", type: \"uint256\" }],\n", + " stateMutability: \"view\",\n", + " type: \"function\",\n", + "}] as const;\n", + "\n", + "const CEILING_ATOMIC = 3000n; // 2000 tokens * $1.50/M (Mistral output rate) — see markdown above\n", + "const ESTIMATED_DEPOSIT_ATOMIC = CEILING_ATOMIC * 5n;\n", + "\n", + "const buyerUsdc = await publicClient.readContract({\n", + " address: config.usdcAddress,\n", + " abi: erc20BalanceOfAbi,\n", + " functionName: \"balanceOf\",\n", + " args: [account.address],\n", + "});\n", + "\n", + "console.log(`💵 Buyer USDC: ${formatUnits(buyerUsdc, 6)} (estimated deposit ≈ ${formatUnits(ESTIMATED_DEPOSIT_ATOMIC, 6)})`);\n", + "if (buyerUsdc < ESTIMATED_DEPOSIT_ATOMIC) {\n", + " console.log(` ⚠️ insufficient USDC on ${config.networkName} — ${config.faucet}`);\n", + "} else {\n", + " console.log(` ✅ enough USDC to open the channel`);\n", + "}" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -152,44 +308,58 @@ "gets `402`, the SDK builds a **deposit** payload (open the channel + first voucher) and signs it, the\n", "retry carries the payment header, and the server verifies + settles (the deposit — the one real on-chain\n", "tx per channel lifetime, confirmed in the B0 spike). Extracting the settlement receipt mirrors\n", - "`useX402ImageGeneration.ts`'s `x402HTTPClient.getPaymentSettleResponse(...)` call exactly.\n" + "`useX402ImageGeneration.ts`'s `x402HTTPClient.getPaymentSettleResponse(...)` call exactly.\n", + "\n", + "> ⚠️ Sending a message performs a **real on-chain USDC settlement** on the selected network, and —\n", + "> when `USE_MAINNET` is armed — a **real, billed Mistral API call**. Both cost real money the moment\n", + "> `USE_MAINNET = true`.\n", + "\n", + "> ℹ️ If a send is **interrupted** between verify and settle (kernel interrupt, network drop), the\n", + "> server leaves a short-lived per-channel lock, and the next send on that channel returns\n", + "> `invalid_batch_settlement_evm_channel_busy`. This is **intentional and self-healing** — the lock\n", + "> serializes requests on one channel and expires on its own (≤ `LLM_MAX_TIMEOUT_SECONDS`, currently\n", + "> 120s; the client SDK does not auto-recover from it). Just wait a couple of minutes and re-run. The\n", + "> website surfaces this as a friendly \"wait a few seconds and try again\" message\n", + "> (`useX402Chat.ts::describePaymentError`)." ] }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 5, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "📡 status=200 elapsed=11569ms\n", + "📡 status=200 elapsed=12901ms\n", "📨 body: {\n", - " \"content\": \"I am a placeholder for the LLM response\",\n", + " \"content\": \"The capital of France is **Paris**.\",\n", " \"usage\": {\n", - " \"prompt_tokens\": 5,\n", - " \"completion_tokens\": 15,\n", - " \"total_tokens\": 15\n", + " \"prompt_tokens\": 10,\n", + " \"total_tokens\": 19,\n", + " \"completion_tokens\": 9,\n", + " \"prompt_tokens_details\": {\n", + " \"cached_tokens\": 0\n", + " }\n", " },\n", - " \"model\": \"placeholder model\"\n", + " \"model\": \"mistral-large-latest\"\n", "}\n", "🧾 settlement receipt: {\n", " \"success\": true,\n", - " \"payer\": \"0x553179556fc2a39e535d65b921e01fa995e79101\",\n", - " \"transaction\": \"\",\n", - " \"network\": \"eip155:84532\",\n", - " \"amount\": \"\",\n", + " \"payer\": \"0x553179556FC2A39e535D65b921e01fA995E79101\",\n", + " \"transaction\": \"0xcf5f89bae9626c199793dd75fef91afc7b097050d674347bc1b7b851c19c10ff\",\n", + " \"network\": \"eip155:8453\",\n", " \"extra\": {\n", " \"channelState\": {\n", - " \"channelId\": \"0xdd9e576d5d30096bce8ed29916ee2d3faaf3a34269011b881eccfb0e082719d7\",\n", - " \"balance\": \"0\",\n", + " \"channelId\": \"0xbd47699c24d5fbd16eb316de548ab39e36931a953ee33aa30dc9b28ff5507446\",\n", + " \"balance\": \"15000\",\n", " \"totalClaimed\": \"0\",\n", " \"withdrawRequestedAt\": 0,\n", " \"refundNonce\": \"0\",\n", - " \"chargedCumulativeAmount\": \"2840\"\n", + " \"chargedCumulativeAmount\": \"18\"\n", " },\n", - " \"chargedAmount\": \"1420\"\n", + " \"chargedAmount\": \"18\"\n", " }\n", "}\n" ] @@ -209,12 +379,16 @@ " // `toClientEvmSigner` fix in the previous cell for why that no longer surfaces as a hard\n", " // failure either way). Keeping this try/catch as defense-in-depth, not because the crash is\n", " // expected anymore.\n", + " //\n", + " // No `useDummyData` field — matches the real client (useX402Chat.ts) exactly. Mock-vs-real\n", + " // is decided entirely server-side by NETWORK (see the network-selection cell): testnet always\n", + " // mocks, mainnet always calls the real Mistral API. There is nothing else to set here.\n", " let response: Response;\n", " try {\n", " response = await fetchWithPayment(SERVICE_URL, {\n", " method: \"POST\",\n", " headers: { \"Content-Type\": \"application/json\" },\n", - " body: JSON.stringify({ data: { prompt: [{ role: \"user\", content }], useDummyData: true } }),\n", + " body: JSON.stringify({ data: { prompt: [{ role: \"user\", content }] } }),\n", " });\n", " } catch (err) {\n", " const elapsedMs = Math.round(performance.now() - started);\n", @@ -263,66 +437,62 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 6, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "📡 status=200 elapsed=9565ms\n", + "📡 status=200 elapsed=5345ms\n", "📨 body: {\n", - " \"content\": \"I am a placeholder for the LLM response\",\n", + " \"content\": \"The capital of Germany is **Berlin**. It has been the capital since the reunification of Germany in 1990, following the fall of the Berlin Wall in 1989. Before that, Bonn served as the capital of West Germany (Federal Republic of Germany) during the Cold War.\",\n", " \"usage\": {\n", - " \"prompt_tokens\": 5,\n", - " \"completion_tokens\": 15,\n", - " \"total_tokens\": 15\n", + " \"prompt_tokens\": 11,\n", + " \"total_tokens\": 74,\n", + " \"completion_tokens\": 63,\n", + " \"prompt_tokens_details\": {\n", + " \"cached_tokens\": 0\n", + " }\n", " },\n", - " \"model\": \"placeholder model\"\n", + " \"model\": \"mistral-large-latest\"\n", "}\n", "🧾 settlement receipt: {\n", " \"success\": true,\n", - " \"payer\": \"0x553179556FC2A39e535D65b921e01fA995E79101\",\n", - " \"transaction\": \"0x0b2e109339ae6f7995c086d53248697df90a24099683e20cf5d02866cfdec791\",\n", - " \"network\": \"eip155:84532\",\n", + " \"payer\": \"0x553179556fc2a39e535d65b921e01fa995e79101\",\n", + " \"transaction\": \"\",\n", + " \"network\": \"eip155:8453\",\n", + " \"amount\": \"\",\n", " \"extra\": {\n", " \"channelState\": {\n", - " \"channelId\": \"0xdd9e576d5d30096bce8ed29916ee2d3faaf3a34269011b881eccfb0e082719d7\",\n", - " \"balance\": \"21300\",\n", + " \"channelId\": \"0xbd47699c24d5fbd16eb316de548ab39e36931a953ee33aa30dc9b28ff5507446\",\n", + " \"balance\": \"0\",\n", " \"totalClaimed\": \"0\",\n", " \"withdrawRequestedAt\": 0,\n", " \"refundNonce\": \"0\",\n", - " \"chargedCumulativeAmount\": \"4260\"\n", + " \"chargedCumulativeAmount\": \"118\"\n", " },\n", - " \"chargedAmount\": \"1420\"\n", + " \"chargedAmount\": \"100\"\n", " }\n", "}\n", - "📡 status=200 elapsed=1403ms\n", + "📡 status=200 elapsed=23975ms\n", "📨 body: {\n", - " \"content\": \"I am a placeholder for the LLM response\",\n", - " \"usage\": {\n", - " \"prompt_tokens\": 5,\n", - " \"completion_tokens\": 15,\n", - " \"total_tokens\": 15\n", - " },\n", - " \"model\": \"placeholder model\"\n", - "}\n", + " \"content\": \"Italy is a fascinating country with a rich history, vibrant culture, and significant global influence. Here’s a quick overview of key aspects:\\n\\n### **1. Geography & Regions**\\n- **Location**: Southern Europe, shaped like a boot, surrounded by the Mediterranean Sea (Adriatic, Ionian, Tyrrhenian, and Ligurian Seas).\\n- **Regions**: 20 regions, including iconic ones like **Tuscany** (Florence, Siena), **Lombardy** (Milan), **Veneto** (Venice), **Lazio** (Rome), **Campania** (Naples, Pompeii), and **Sicily** (Palermo).\\n- **Landmarks**: The Alps (north), Apennine Mountains (spine\n", "🧾 settlement receipt: {\n", " \"success\": true,\n", - " \"payer\": \"0x553179556fc2a39e535d65b921e01fa995e79101\",\n", - " \"transaction\": \"\",\n", - " \"network\": \"eip155:84532\",\n", - " \"amount\": \"\",\n", + " \"payer\": \"0x553179556FC2A39e535D65b921e01fA995E79101\",\n", + " \"transaction\": \"0xebadfc6c97ee7988d69cb1c696649d96053f9a63f8da4ae7058ddf8ca798b673\",\n", + " \"network\": \"eip155:8453\",\n", " \"extra\": {\n", " \"channelState\": {\n", - " \"channelId\": \"0xdd9e576d5d30096bce8ed29916ee2d3faaf3a34269011b881eccfb0e082719d7\",\n", - " \"balance\": \"21300\",\n", + " \"channelId\": \"0xbd47699c24d5fbd16eb316de548ab39e36931a953ee33aa30dc9b28ff5507446\",\n", + " \"balance\": \"30000\",\n", " \"totalClaimed\": \"0\",\n", " \"withdrawRequestedAt\": 0,\n", " \"refundNonce\": \"0\",\n", - " \"chargedCumulativeAmount\": \"5680\"\n", + " \"chargedCumulativeAmount\": \"1871\"\n", " },\n", - " \"chargedAmount\": \"1420\"\n", + " \"chargedAmount\": \"1753\"\n", " }\n", "}\n" ] @@ -336,7 +506,27 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## Findings (observed 2026-07-16, real run against local server + real Base Sepolia facilitator)\n\n- [x] Message #1: no fresh deposit tx this run (`transaction: \"\"`) — this run reused a channel already\n open from earlier testing (persisted in Deno's `localStorage`), not a brand-new one.\n `chargedCumulativeAmount` started at `\"2840\"` (= 2× the per-message price) rather than `\"1420\"`,\n confirming the channel already carried state from a prior session.\n- [x] `chargedCumulativeAmount` progressed correctly across all three messages: `2840 → 4260 → 5680`\n — a consistent `+1420` per message, matching `USDC_PRICE_PER_MESSAGE`\n (`convertTokensToUsdcCost(LLM_ESTIMATED_TOKENS_PER_MESSAGE)`). No `invalid_batch_settlement_evm_cumulative_amount_mismatch`\n anywhere in the run — both the client-side `readContract` fix and the server-side\n `createPaymentRequiredResponse` enrichment fix hold up end-to-end.\n- [x] Messages #2/#3: `fetchWithPayment` **did** reuse the channel automatically — no second deposit,\n no extra 402 round-trip visible in the logs beyond the corrective flow. Message #2's settlement\n receipt included a real on-chain transaction hash (`0x0b2e1093…`), confirming an actual claim/settle\n landed on Base Sepolia (`balance` jumped from `\"0\"` to `\"21300\"`); message #3 was a pure off-chain\n voucher (`transaction: \"\"`, `balance` unchanged).\n- [x] No `useX402Chat.ts` workaround needed for channel reuse — the default `fetchWithPayment` +\n `WebStorageClientChannelStorage` behavior already does the right thing automatically.\n\n" + "source": [ + "## Findings (observed 2026-07-16, real run against local server + real Base Sepolia facilitator)\n", + "\n", + "- [x] Message #1: no fresh deposit tx this run (`transaction: \"\"`) — this run reused a channel already\n", + " open from earlier testing (persisted in Deno's `localStorage`), not a brand-new one.\n", + " `chargedCumulativeAmount` started at `\"2840\"` (= 2× the per-message price) rather than `\"1420\"`,\n", + " confirming the channel already carried state from a prior session.\n", + "- [x] `chargedCumulativeAmount` progressed correctly across all three messages: `2840 → 4260 → 5680`\n", + " — a consistent `+1420` per message, matching `USDC_PRICE_PER_MESSAGE`\n", + " (`convertTokensToUsdcCost(LLM_ESTIMATED_TOKENS_PER_MESSAGE)`). No `invalid_batch_settlement_evm_cumulative_amount_mismatch`\n", + " anywhere in the run — both the client-side `readContract` fix and the server-side\n", + " `createPaymentRequiredResponse` enrichment fix hold up end-to-end.\n", + "- [x] Messages #2/#3: `fetchWithPayment` **did** reuse the channel automatically — no second deposit,\n", + " no extra 402 round-trip visible in the logs beyond the corrective flow. Message #2's settlement\n", + " receipt included a real on-chain transaction hash (`0x0b2e1093…`), confirming an actual claim/settle\n", + " landed on Base Sepolia (`balance` jumped from `\"0\"` to `\"21300\"`); message #3 was a pure off-chain\n", + " voucher (`transaction: \"\"`, `balance` unchanged).\n", + "- [x] No `useX402Chat.ts` workaround needed for channel reuse — the default `fetchWithPayment` +\n", + " `WebStorageClientChannelStorage` behavior already does the right thing automatically.\n", + "\n" + ] } ], "metadata": { @@ -359,4 +549,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/scw_js/sc_llm_x402.ts b/scw_js/sc_llm_x402.ts index 9bf7480ee..9a94990d9 100644 --- a/scw_js/sc_llm_x402.ts +++ b/scw_js/sc_llm_x402.ts @@ -9,6 +9,7 @@ import { extractPaymentPayload, createSettlementHeaders, getBatchSettlementNetworks, + LLM_MAX_TIMEOUT_SECONDS, } from "./x402_server.js"; import type { ScwEvent } from "./types.js"; @@ -176,7 +177,9 @@ export async function handle(event: ScwEvent, _context: unknown): Promise ({ extractPaymentPayload: mockExtractPaymentPayload, createSettlementHeaders: mockCreateSettlementHeaders, getBatchSettlementNetworks: mockGetBatchSettlementNetworks, + // Real constant (not a mock fn) — imported by sc_llm_x402.ts for the verify-time + // maxTimeoutSeconds; keep in sync with x402_server.ts's exported value. + LLM_MAX_TIMEOUT_SECONDS: 120, })); vi.mock("@fretchen/chain-utils", () => ({ diff --git a/scw_js/x402_server.ts b/scw_js/x402_server.ts index 1d2525e3f..efe5692f4 100644 --- a/scw_js/x402_server.ts +++ b/scw_js/x402_server.ts @@ -40,6 +40,21 @@ const ONCHAIN_STATE_TTL_MS = 5_000; // hourly interval at the time. 24h leaves the (now 12h) cron a 2x safety margin. const WITHDRAW_DELAY_SECONDS = Number(process.env.LLM_WITHDRAW_DELAY_SECONDS ?? "86400"); +// TTL for the batch-settlement per-channel "pendingRequest" lock. @x402/evm sets this lock +// in onBeforeVerify and clears it in onAfterSettle to serialize concurrent requests on one +// channel; a second request while the lock is live is rejected with `channel_busy`. +// maxTimeoutSeconds is the ONLY thing that drives this TTL in the batch-settlement scheme — +// it does not gate voucher freshness or payment expiry (verified against the SDK). The SDK +// clamps the derived TTL to [5s, 10min] (pendingExpiresAt), so a large value like the +// previous 3600 pinned it to the 10-minute ceiling: if a request is abandoned between verify +// and settle (tab close, network drop, notebook interrupt) the lock is orphaned and the +// channel stays busy for the full 10 minutes — and the client SDK does NOT auto-recover from +// `channel_busy`. 120s keeps ample headroom over the real verify + LLM + settle wall-clock +// (seconds, even for a slow mainnet completion + on-chain claim) while cutting the worst-case +// orphan lockout to 2 minutes. Must be used identically at 402-advertise time (below) AND at +// verify time (sc_llm_x402.ts) — the SDK treats maxTimeoutSeconds as immutable across the two. +export const LLM_MAX_TIMEOUT_SECONDS = 120; + export function getSupportedNetworks(): string[] { return SUPPORTED_NETWORKS; } @@ -131,7 +146,7 @@ export async function createBatchSettlementPaymentRequirements({ amount, asset: config.address, payTo, - maxTimeoutSeconds: 3600, + maxTimeoutSeconds: LLM_MAX_TIMEOUT_SECONDS, extra: { name: config.usdcName, version: config.usdcVersion }, }; return scheme.enhancePaymentRequirements( diff --git a/website/hooks/useX402Chat.test.ts b/website/hooks/useX402Chat.test.ts index 2a83df0a2..39574d444 100644 --- a/website/hooks/useX402Chat.test.ts +++ b/website/hooks/useX402Chat.test.ts @@ -240,6 +240,36 @@ describe("useX402Chat", () => { expect(result.current.status).toBe("error"); expect(result.current.error).toContain("402"); }); + + it("surfaces a friendly, actionable message for a channel_busy 402", async () => { + // The transient per-channel lock the server holds across verify→settle. The raw code + // is opaque and the client SDK does not auto-recover from it, so the hook maps it to + // a "wait and retry" line instead of dumping the reason code. + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "invalid_batch_settlement_evm_channel_busy" }), { + status: 402, + }), + ), + ); + + const { result } = renderHook(() => useX402Chat(NETWORK)); + + let thrown: Error | undefined; + await act(async () => { + try { + await result.current.sendMessage([{ role: "user", content: "Hi" }]); + } catch (err) { + thrown = err as Error; + } + }); + + expect(thrown?.message).toMatch(/still being settled/i); + expect(thrown?.message).not.toContain("channel_busy"); + expect(result.current.status).toBe("error"); + expect(result.current.error).toMatch(/wait a few seconds/i); + }); }); describe("Reset Functionality", () => { diff --git a/website/hooks/useX402Chat.ts b/website/hooks/useX402Chat.ts index a9e74753c..bb4cbe160 100644 --- a/website/hooks/useX402Chat.ts +++ b/website/hooks/useX402Chat.ts @@ -78,6 +78,26 @@ function getOrCreateVoucherSigner(walletAddress: string) { return privateKeyToAccount(privateKey); } +/** + * Turn a non-OK payment response into a user-facing message. Batch-settlement's + * `channel_busy` is a transient, self-healing per-channel lock — the server holds it across + * a single message's verify→settle to serialize requests on one channel, and the x402 client + * SDK does NOT auto-recover from it — so it warrants an actionable "wait and retry" line + * rather than dumping the raw reason code. Any other reason keeps the informative default. + */ +function describePaymentError(status: number, body: string): string { + let errorCode: string | undefined; + try { + errorCode = (JSON.parse(body) as { error?: string }).error; + } catch { + // Non-JSON body — fall through to the generic message. + } + if (errorCode?.includes("channel_busy")) { + return "Your previous message is still being settled on-chain. Please wait a few seconds and send it again."; + } + return `Request failed: ${status} - ${body}`; +} + export interface UseX402ChatResult { sendMessage: (prompt: X402ChatMessage[]) => Promise; status: X402GenerationStatus; @@ -192,7 +212,7 @@ export function useX402Chat(network: string): UseX402ChatResult { if (!response.ok) { const errorText = await response.text(); - throw new Error(`Request failed: ${response.status} - ${errorText}`); + throw new Error(describePaymentError(response.status, errorText)); } const result = (await response.json()) as X402ChatResponse; From 187e6da4e001a58dae2bd398cd8ad3b0c9d8f2fb Mon Sep 17 00:00:00 2001 From: fretchen Date: Tue, 21 Jul 2026 16:17:10 +0200 Subject: [PATCH 12/14] Update package.json --- scw_js/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/scw_js/package.json b/scw_js/package.json index 4774a017e..d721feff1 100644 --- a/scw_js/package.json +++ b/scw_js/package.json @@ -16,6 +16,7 @@ "check": "tsc --noEmit && npm run lint && npm run format:check && npm run test:coverage", "dev:x402": "NODE_ENV=test npx tsx genimg_x402_token.ts", "dev:llmx402": "NODE_ENV=test npx tsx sc_llm_x402.ts", + "dev:llmx402cron": "NODE_ENV=test npx tsx llm_x402_cron.ts", "dev:growth": "NODE_ENV=test npx tsx growth_api.ts" }, "keywords": [], From 0fc629e9e516d5f7b1111178055528dfc0115d70 Mon Sep 17 00:00:00 2001 From: fretchen Date: Tue, 21 Jul 2026 16:50:07 +0200 Subject: [PATCH 13/14] proper escrow --- scw_js/notebooks/sc_llm_x402_buyer.ipynb | 58 ++++++++++++------------ website/hooks/useX402Chat.test.ts | 25 ++++++++++ website/hooks/useX402Chat.ts | 34 +++++++++++++- 3 files changed, 85 insertions(+), 32 deletions(-) diff --git a/scw_js/notebooks/sc_llm_x402_buyer.ipynb b/scw_js/notebooks/sc_llm_x402_buyer.ipynb index 652ba11f8..a4a02a6f0 100644 --- a/scw_js/notebooks/sc_llm_x402_buyer.ipynb +++ b/scw_js/notebooks/sc_llm_x402_buyer.ipynb @@ -106,7 +106,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "metadata": {}, "outputs": [ { @@ -122,7 +122,7 @@ "source": [ "import { base, baseSepolia } from \"npm:viem@2/chains\";\n", "\n", - "const USE_MAINNET = true; // ⚠️ true = REAL MONEY: mainnet USDC settlement + a real, billed Mistral call.\n", + "const USE_MAINNET = false; // ⚠️ true = REAL MONEY: mainnet USDC settlement + a real, billed Mistral call.\n", "\n", "const NETWORK_CONFIG = {\n", " \"base-testnet\": {\n", @@ -174,17 +174,9 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "✅ buyer client ready, registered for eip155:8453\n" - ] - } - ], + "outputs": [], "source": [ "import { x402Client, wrapFetchWithPayment, x402HTTPClient } from \"npm:@x402/fetch@^2.17.0\";\n", "import { toClientEvmSigner } from \"npm:@x402/evm@^2.17.0\";\n", @@ -192,6 +184,7 @@ " BatchSettlementEvmScheme,\n", " type ClientChannelStorage,\n", " type BatchSettlementClientContext,\n", + " type BatchSettlementDepositStrategyContext,\n", "} from \"npm:@x402/evm@^2.17.0/batch-settlement/client\";\n", "import { createPublicClient, http } from \"npm:viem@2\";\n", "\n", @@ -230,7 +223,22 @@ "\n", "// Deno exposes the global `localStorage` (persists across kernel restarts). Browser: window.localStorage.\n", "const buyerStorage = new WebStorageClientChannelStorage(localStorage);\n", - "const buyerScheme = new BatchSettlementEvmScheme(buyerSigner, { storage: buyerStorage });\n", + "\n", + "// Floor for channel deposits/top-ups, in USDC atomic units (6 decimals) — $0.50. Same value\n", + "// and same reasoning as useX402Chat.ts's MINIMUM_DEPOSIT_ATOMIC: the SDK's own default\n", + "// (depositMultiplier x per-message ceiling) tracks whatever the ceiling happens to be\n", + "// (currently ~$0.003/message), sizing deposits at only ~1-3 cents — enough for ~5 messages\n", + "// worst-case before another on-chain top-up (a real tx + wait) is needed. $0.50 comfortably\n", + "// covers a full session while keeping the number small on the axis that actually matters for\n", + "// this app: it's the blast radius if a delegate voucher-signer key ever leaks.\n", + "const MINIMUM_DEPOSIT_ATOMIC = 500_000n;\n", + "\n", + "function depositStrategy(context: BatchSettlementDepositStrategyContext): string {\n", + " const required = BigInt(context.minimumDepositAmount);\n", + " return (required > MINIMUM_DEPOSIT_ATOMIC ? required : MINIMUM_DEPOSIT_ATOMIC).toString();\n", + "}\n", + "\n", + "const buyerScheme = new BatchSettlementEvmScheme(buyerSigner, { storage: buyerStorage, depositStrategy });\n", "\n", "const client = new x402Client();\n", "client.register(NETWORK, buyerScheme);\n", @@ -248,27 +256,17 @@ "\n", "Neither the network flag above nor the warnings below change the fact that arming `USE_MAINNET`\n", "spends real money. Check the buyer has enough USDC *before* message #1 tries to open a channel.\n", - "The estimate isn't a guess — it's derived the same way `sc_llm_x402.ts` derives its ceiling:\n", - "`LLM_ESTIMATED_TOKENS_PER_MESSAGE` (default 2000) priced entirely as completion tokens at Mistral's\n", - "$1.50/M rate ⇒ 3000 atomic units (`$0.003`) per message; the channel deposit is ≈5× that\n", - "(batch-settlement's default `depositMultiplier`) ⇒ ≈`$0.015`.\n" + "The deposit is governed by the `depositStrategy` set up in the previous cell — a fixed $0.50 floor\n", + "(see `MINIMUM_DEPOSIT_ATOMIC`), not the SDK's own smaller multiplier-of-ceiling default — chosen so\n", + "a full multi-message session doesn't need a mid-conversation on-chain top-up.\n" ] }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "id": "aae724d6", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "💵 Buyer USDC: 1.24 (estimated deposit ≈ 0.015)\n", - " ✅ enough USDC to open the channel\n" - ] - } - ], + "outputs": [], "source": [ "import { formatUnits } from \"npm:viem@2\";\n", "\n", @@ -280,8 +278,8 @@ " type: \"function\",\n", "}] as const;\n", "\n", - "const CEILING_ATOMIC = 3000n; // 2000 tokens * $1.50/M (Mistral output rate) — see markdown above\n", - "const ESTIMATED_DEPOSIT_ATOMIC = CEILING_ATOMIC * 5n;\n", + "// Matches the depositStrategy cell above — the actual deposit floor, not the SDK's default.\n", + "const ESTIMATED_DEPOSIT_ATOMIC = MINIMUM_DEPOSIT_ATOMIC;\n", "\n", "const buyerUsdc = await publicClient.readContract({\n", " address: config.usdcAddress,\n", diff --git a/website/hooks/useX402Chat.test.ts b/website/hooks/useX402Chat.test.ts index 39574d444..0920a2631 100644 --- a/website/hooks/useX402Chat.test.ts +++ b/website/hooks/useX402Chat.test.ts @@ -117,11 +117,36 @@ describe("useX402Chat", () => { expect.objectContaining({ storage: expect.any(WebStorageClientChannelStorage), voucherSigner: expect.objectContaining({ address: expect.stringMatching(/^0x[a-fA-F0-9]{40}$/) }), + depositStrategy: expect.any(Function), }), ); expect(mockRegister).toHaveBeenCalledWith(NETWORK, expect.anything()); }); + it("deposit strategy floors deposits/top-ups at $0.50, ignoring the SDK's smaller default", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(JSON.stringify({ content: "hi" }), { status: 200 })), + ); + + const { result } = renderHook(() => useX402Chat(NETWORK)); + await act(async () => { + await result.current.sendMessage([{ role: "user", content: "Hi" }]); + }); + + const { depositStrategy } = mockBatchSettlementEvmScheme.mock.calls[0][1] as { + depositStrategy: (ctx: { minimumDepositAmount: string }) => string; + }; + + // Below the floor (e.g. the SDK's own ~1-3 cent default): clamp up to $0.50. + expect(depositStrategy({ minimumDepositAmount: "15000" })).toBe("500000"); + // Above the floor (an unusually expensive top-up): the SDK requires >= this amount, + // so it must be respected, not clamped down. + expect(depositStrategy({ minimumDepositAmount: "600000" })).toBe("600000"); + // Exactly at the floor: either value is correct; assert it's still >= minimum. + expect(BigInt(depositStrategy({ minimumDepositAmount: "500000" }))).toBeGreaterThanOrEqual(500_000n); + }); + it("reuses the same delegated voucher signer across multiple messages", async () => { vi.stubGlobal( "fetch", diff --git a/website/hooks/useX402Chat.ts b/website/hooks/useX402Chat.ts index bb4cbe160..1b9f813c1 100644 --- a/website/hooks/useX402Chat.ts +++ b/website/hooks/useX402Chat.ts @@ -18,7 +18,11 @@ import { privateKeyToAccount, generatePrivateKey } from "viem/accounts"; import { useConfiguredPublicClient } from "./useConfiguredPublicClient"; import type { X402ChatMessage, X402ChatResponse, X402PaymentReceipt, X402GenerationStatus } from "../types/x402"; // Type-only import — erased at compile time, so no @x402 runtime is pulled into SSR. -import type { ClientChannelStorage, BatchSettlementClientContext } from "@x402/evm/batch-settlement/client"; +import type { + ClientChannelStorage, + BatchSettlementClientContext, + BatchSettlementDepositStrategyContext, +} from "@x402/evm/batch-settlement/client"; // Endpoint of the batch-settlement chat function (override for local dev with // PUBLIC_ENV__LLM_X402_ENDPOINT=http://localhost:8085). @@ -78,6 +82,32 @@ function getOrCreateVoucherSigner(walletAddress: string) { return privateKeyToAccount(privateKey); } +// Floor for channel deposits/top-ups, in USDC atomic units (6 decimals) — $0.50. +// The SDK's own default (depositMultiplier x per-message ceiling) tracks whatever the +// ceiling happens to be, currently ~$0.003/message, so it sizes deposits at ~1-3 cents: +// enough for only ~5 messages worst-case before another on-chain top-up (a real tx, a +// real wallet-adjacent wait) is needed. $0.50 comfortably covers a full multi-message +// session (100s of messages even at worst-case per-message pricing) while keeping the +// number small on the two axes that actually matter for this app: it's the blast radius +// of the localStorage voucher-signer above if it ever leaks (bounded to this amount, +// never more), and the capital a user has locked up if the server stops cooperating and +// they have to wait out withdrawDelay to exit unilaterally. Both are trivial at $0.50; +// neither improves by going lower, so lower just buys more top-up friction for no benefit. +const MINIMUM_DEPOSIT_ATOMIC = 500_000n; + +/** + * Custom deposit sizing: always deposit/top-up to at least `MINIMUM_DEPOSIT_ATOMIC`, + * regardless of the SDK's default multiplier-of-ceiling formula — see the constant's + * comment for why a fixed floor is the right lever here, not `depositPolicy.depositMultiplier` + * (which would still scale with the ceiling rather than decoupling from it). + * `minimumDepositAmount` is the true minimum the SDK needs for the top-up in progress; the + * SDK requires the returned amount be >= it, so it's respected as a floor of its own. + */ +function depositStrategy(context: BatchSettlementDepositStrategyContext): string { + const required = BigInt(context.minimumDepositAmount); + return (required > MINIMUM_DEPOSIT_ATOMIC ? required : MINIMUM_DEPOSIT_ATOMIC).toString(); +} + /** * Turn a non-OK payment response into a user-facing message. Batch-settlement's * `channel_busy` is a transient, self-healing per-channel lock — the server holds it across @@ -164,7 +194,7 @@ export function useX402Chat(network: string): UseX402ChatResult { // Delegate voucher signing to a persisted local key so only the deposit/top-up // prompts the real wallet — see getOrCreateVoucherSigner's doc comment. const voucherSigner = getOrCreateVoucherSigner(walletClient.account.address); - const scheme = new BatchSettlementEvmScheme(signer, { storage, voucherSigner }); + const scheme = new BatchSettlementEvmScheme(signer, { storage, voucherSigner, depositStrategy }); const client = new x402Client(); client.register(network, scheme); From a49c7cc575408b2a47e915c7ebf0e9bed884227d Mon Sep 17 00:00:00 2001 From: fretchen Date: Tue, 21 Jul 2026 20:12:28 +0200 Subject: [PATCH 14/14] Update serverless.yml --- x402_facilitator/serverless.yml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/x402_facilitator/serverless.yml b/x402_facilitator/serverless.yml index 4f257b2d0..5dfead5a0 100644 --- a/x402_facilitator/serverless.yml +++ b/x402_facilitator/serverless.yml @@ -22,15 +22,6 @@ provider: # can never authorize real funds. Keep testnet-only wallets here. BATCH_SETTLEMENT_MANUAL_WHITELIST: ${env:BATCH_SETTLEMENT_MANUAL_WHITELIST, ''} BATCH_SETTLEMENT_TEST_WALLETS: ${env:BATCH_SETTLEMENT_TEST_WALLETS, ''} - # Per-network RPC endpoints (see getRpcUrl in chain_utils.ts). Unset => viem falls - # back to the chain's public endpoint, which is aggressively rate-limited: a single - # batch-settlement deposit does a Multicall3 read batch and fails with "over rate - # limit", surfacing as the generic ..._deposit_transaction_failed. Set these for any - # network carrying real traffic. - RPC_URL_EIP155_8453: ${env:RPC_URL_EIP155_8453, ''} - RPC_URL_EIP155_10: ${env:RPC_URL_EIP155_10, ''} - RPC_URL_EIP155_84532: ${env:RPC_URL_EIP155_84532, ''} - RPC_URL_EIP155_11155420: ${env:RPC_URL_EIP155_11155420, ''} # Secrets (set via Scaleway Console or CLI) secret: FACILITATOR_WALLET_PRIVATE_KEY: ${env:FACILITATOR_WALLET_PRIVATE_KEY}