From 394be318421768c451f6578a55dc52ab59018edd Mon Sep 17 00:00:00 2001 From: fretchen Date: Sat, 10 Oct 2026 23:04:07 +0200 Subject: [PATCH 01/13] Sellers accept EURe on Base (Permit2), listed EURe > EURC > USDC EURe moves into @fretchen/chain-utils (getSettlementTokens: 18 decimals, Monerium EURe v1, transferMethod permit2); the website-facing getStablecoins stays USDC+EURC so the buyer side is untouched until its own PR. All three scw_js sellers price EURe (image env EURE_PAYMENT_AMOUNT, search/fetch literals, chat on Mistral's EUR card via tokensToCost's decimals rescale), their 402 entries carry extra.assetTransferMethod: "permit2" and the challenge advertises eip2612GasSponsoring, and the claim cron enumerates getSettlementTokens so EURe channels are claimed. The facilitator reads the shared registry, dropping its local EURE_ADDRESSES (behavior-identical). --- .agents/skills/x402/SKILL.md | 23 +-- scw_js/README.md | 67 ++++--- scw_js/genimg_x402_token.ts | 5 +- scw_js/llm_service.ts | 31 ++-- scw_js/llm_x402_cron.ts | 8 +- scw_js/openapi.genimg.json | 8 +- scw_js/openapi.llm.json | 6 +- scw_js/openapi.search.json | 6 +- scw_js/sc_llm_x402.ts | 27 ++- scw_js/scripts/fee_allowances.ts | 8 +- scw_js/scripts/generate-openapi-genimg.ts | 13 +- scw_js/scripts/generate-openapi-llm.ts | 7 +- scw_js/scripts/generate-openapi-search.ts | 8 +- scw_js/search_schemas.ts | 5 +- scw_js/serverless.yml | 3 + scw_js/stablecoin_pricing.ts | 45 +++-- scw_js/test/fee_allowances.test.ts | 19 +- scw_js/test/genimg_x402_token.test.ts | 87 +++++++-- scw_js/test/llm_service.test.ts | 63 +++++-- scw_js/test/llm_x402_cron.test.ts | 8 +- scw_js/test/sc_llm_x402.test.ts | 15 +- scw_js/test/stablecoin_pricing.test.ts | 24 ++- scw_js/test/x402_offer_eure.test.ts | 165 ++++++++++++++++++ scw_js/x402_server.ts | 111 ++++++++---- shared/chain-utils/src/addresses.ts | 71 ++++++++ shared/chain-utils/test/index.test.ts | 66 +++++++ x402_facilitator/chain_utils.ts | 50 ++---- x402_facilitator/test/chain_utils.test.ts | 3 +- x402_facilitator/test/helpers/signPayment.ts | 3 +- .../test/x402_permit2_real_sdk.test.ts | 2 +- 30 files changed, 725 insertions(+), 232 deletions(-) create mode 100644 scw_js/test/x402_offer_eure.test.ts diff --git a/.agents/skills/x402/SKILL.md b/.agents/skills/x402/SKILL.md index cf50bddcb..f017d5aa5 100644 --- a/.agents/skills/x402/SKILL.md +++ b/.agents/skills/x402/SKILL.md @@ -86,17 +86,18 @@ fallback (see `genimg_x402_token.ts`). - **The facilitator collects a fee** via `transferFrom(merchant, facilitator, fee)`, in the token the payment settled in — so the merchant needs one `approve()` **per token** it takes (USDC, and EURC on Base). Amounts live only in `website/pages/x402/sellers/`. -- **Two stablecoins: USDC everywhere, EURC on Base and Base Sepolia only.** They are two parallel - price lists — every price is set in each token, never converted (see `scw_js/README.md` → - _Stablecoins and pricing_). On Base the 402 lists EURC first. That order is the seller's - preference; the buyer's choice is made by its own selector and spend controls. -- **The facilitator also settles EURe (Monerium) on Base — sellers do not offer it yet.** EURe has - no EIP-3009, so it is paid via **Permit2** (`exact`'s `permit2Authorization` payload, usually - with an `eip2612GasSponsoring` permit), and it has **18 decimals**: the 0.01 fee is `10¹⁶` - atomic units and a seller's fee approval is `10¹⁸` (1 EURe), not `1000000`. Domain - `"Monerium EURe"`, version `"1"`. Not on Optimism. EURe is facilitator-local - (`x402_facilitator/chain_utils.ts` → `getFeeTokens()`), not in `@fretchen/chain-utils`, until - the seller PR. See `x402_facilitator/README.md` → _EURe via Permit2_. +- **Three settlement tokens: USDC everywhere, EURC and EURe on Base and Base Sepolia.** They are + three parallel price lists — every price is set in each token, never converted (see + `scw_js/README.md` → _Stablecoins and pricing_). On Base the 402 lists EURe first + (`STABLECOIN_PREFERENCE`); that order is the seller's preference, and the buyer's choice is + made by its own selector and spend controls. EURe (Monerium, 18 decimals) has no EIP-3009, + so it is paid via **Permit2** (`exact`'s and batch-settlement's `permit2Authorization`, with + the 402 advertising `eip2612GasSponsoring`), and the 0.01 facilitator fee is `10¹⁶` atomic + units, a seller's fee approval `10¹⁸` (1 EURe). Domain `"Monerium EURe"`, version `"1"`. + Registered in `@fretchen/chain-utils` → `getSettlementTokens()` (the facilitator's fee tokens + and the sellers' offers both read it); the website still pays only USDC/EURC via + `getStablecoins()` until its buyer-side PR. See `scw_js/README.md` → _Stablecoins and pricing_ + and _Known EURe Domain Names_, and `x402_facilitator/README.md` → _EURe via Permit2_. ## Writing about x402 on the site diff --git a/scw_js/README.md b/scw_js/README.md index c7fe70c37..e7b7b51a5 100644 --- a/scw_js/README.md +++ b/scw_js/README.md @@ -231,26 +231,34 @@ done Later, if wanted: tell paying clients they can leave feedback for the `agentId` (Reputation Registry); reference the ids from `x-discovery` in the OpenAPI specs; move the `agentURI` to IPFS or a `data:` URI if the domain dependency becomes a concern. The Validation Registry waits on its spec section. -### Stablecoins and pricing (USDC, EURC) - -All three sellers (image, chat, search) take **USDC on every network and EURC on Base / Base -Sepolia**. Circle has no EURC on Optimism. USDC and EURC are **two parallel price lists**: every -price is set explicitly in each token, and nothing is converted between them. - -| What | USDC | EURC | Where it is set | -| -------------- | ------------------ | ------------------ | -------------------------------------------------------------- | -| Image | 0.07 | 0.06 | `USDC_PAYMENT_AMOUNT` / `EURC_PAYMENT_AMOUNT` (serverless.yml) | -| Search / fetch | 0.01 / 0.001 | 0.01 / 0.001 | `PRICE_ATOMIC` in `search_schemas.ts` | -| Chat | Mistral's USD card | Mistral's EUR card | `pricePerMillion` in `llm_service.ts` | +### Stablecoins and pricing (USDC, EURC, EURe) + +All three sellers (image, chat, search) take **USDC on every network and EURC and EURe on +Base / Base Sepolia**. Circle has no EURC and Monerium no EURe on Optimism. USDC, EURC and EURe +are **three parallel price lists**: every price is set explicitly in each token, and nothing is +converted between them. EURe has **18 decimals** (USDC and EURC have 6), so its atomic prices +are twelve orders of magnitude larger than the same nominal price in EURC — `tokensToCost` +rescales by the token's decimals, and a price literal that assumes 6 decimals is wrong by 10¹². +EURe has no EIP-3009: its 402 entries carry `assetTransferMethod: "permit2"` and its challenges +advertise `eip2612GasSponsoring`, so a buyer needs no `approve()` — not to Permit2, not to the +token contract. + +| What | USDC | EURC | EURe | Where it is set | +| -------------- | ------------------ | ------------------ | ------------------ | -------------------------------------------------------------------------------------- | +| Image | 0.07 | 0.06 | 0.06 | `USDC_PAYMENT_AMOUNT` / `EURC_PAYMENT_AMOUNT` / `EURE_PAYMENT_AMOUNT` (serverless.yml) | +| Search / fetch | 0.01 / 0.001 | 0.01 / 0.001 | 0.01 / 0.001 | `PRICE_ATOMIC` in `search_schemas.ts` | +| Chat | Mistral's USD card | Mistral's EUR card | Mistral's EUR card | `pricePerMillion` in `llm_service.ts` | The chat price is computed per message from token usage, on the rate card of the currency the -channel is in: $0.50 / $1.50 per million input/output tokens, or €0.44 / €1.50. Each card is the -provider's own published price in that currency. OpenAPI `x-payment-info.price` must be quoted in -USD, so it carries the USDC price; the EURC price is stated in the description. - -- **Preference:** on Base the 402 lists EURC first (`STABLECOIN_PREFERENCE`). A stock x402 client - pays with the first entry its spend controls allow, and the SDK's built-in asset registry only - knows USDC, so a default client keeps paying USDC until it allowlists EURC. +channel is in: $0.50 / $1.50 per million input/output tokens, or €0.44 / €1.50 (EURC and EURe +share the EUR card — a euro is a euro). Each card is the provider's own published price in that +currency. OpenAPI `x-payment-info.price` must be quoted in USD, so it carries the USDC price; +the EURC and EURe prices are stated in the description. + +- **Preference:** on Base the 402 lists EURe, then EURC, then USDC (`STABLECOIN_PREFERENCE`). A + stock x402 client pays with the first entry its spend controls allow, and the SDK's built-in + asset registry only knows USDC, so a default client keeps paying USDC until it allowlists + EURe or EURC. - **Facilitator fee:** charged in the token the payment settled in, so the seller wallet (`NFT_WALLET_PUBLIC_KEY`) needs an `approve()` for the facilitator for **every (network, token) pair the sellers offer**. Without it every payment or claim in that pair fails with @@ -258,11 +266,12 @@ USD, so it carries the USDC price; the EURC price is stated in the description. any is below one fee; `npm run fee-allowances -- --approve` fixes the short ones (needs `NFT_WALLET_PRIVATE_KEY` and a little gas). It runs as `predeploy`, so `npm run deploy` refuses to ship an offer that cannot be paid. The claim cron still warns per token when an approval runs - low between deploys. -- **Channels:** USDC and EURC channels share each network's `channels//` prefix (the - token is part of the channel id). The cron claims one token at a time, because a claim batch - must not mix tokens. `scripts/recover_channels.ts` takes the token as an argument for the same - reason. + low between deploys. EURe's fee approval is **1 EURe (10¹⁸)** — a `1000000` allowance, right + for the 6-decimal tokens, covers no EURe fee at all. +- **Channels:** USDC, EURC and EURe channels share each network's `channels//` prefix + (the token is part of the channel id). The cron claims one token at a time, because a claim + batch must not mix tokens. `scripts/recover_channels.ts` takes the token as an argument for + the same reason. ## 🗄️ S3 Storage Layout & Data Classification @@ -394,6 +403,18 @@ EURC exists on Base only (Circle has no Optimism deployment). Unlike USDC, mainn | Base Mainnet | `eip155:8453` | `0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42` | `EURC` | `2` | ✅ 2026-09-25 | | Base Sepolia | `eip155:84532` | `0x808456652fdb597867f38412077A9182bf77359F` | `EURC` | `2` | ✅ 2026-09-25 | +### Known EURe Domain Names + +EURe (Monerium) exists on Base only. It has **no EIP-3009** — it settles via Permit2 with an +EIP-2612 permit (`assetTransferMethod: "permit2"`), so the domain below signs the permit, not a +`transferWithAuthorization`. Mainnet and sandbox share one name and version; the sandbox address +is the testnet token. Domain values recomputed from `DOMAIN_SEPARATOR()` on-chain, 2026-10-04. + +| Network | CAIP-2 ID | Address | Domain Name | Version | Verified | +| ------------ | -------------- | -------------------------------------------- | --------------- | ------- | ------------- | +| Base Mainnet | `eip155:8453` | `0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C` | `Monerium EURe` | `1` | ✅ 2026-10-04 | +| Base Sepolia | `eip155:84532` | `0x29F37F6adCa168B79B8d9567eab9BE3fBF21db85` | `Monerium EURe` | `1` | ✅ 2026-10-04 | + > ⚠️ **Warning:** Mainnet and Testnet often have DIFFERENT domain names! Always verify. --- diff --git a/scw_js/genimg_x402_token.ts b/scw_js/genimg_x402_token.ts index e61e47d6d..ea4733ade 100644 --- a/scw_js/genimg_x402_token.ts +++ b/scw_js/genimg_x402_token.ts @@ -50,11 +50,12 @@ import { offeredStablecoins, resolvePaidStablecoin, type PriceList } from "./sta // Re-export for backward compatibility with tests export { handle, create402Response }; -// The image price, quoted separately in each token's atomic units (6 decimals): 0.07 USDC or -// 0.06 EURC. Two parallel prices, not one converted into the other. +// The image price, quoted separately in each token's atomic units: 0.07 USDC, 0.06 EURC or +// 0.06 EURe (18 decimals). Parallel prices, not one converted into the other. const PRICE_ATOMIC: PriceList = { USDC: process.env.USDC_PAYMENT_AMOUNT ?? "70000", EURC: process.env.EURC_PAYMENT_AMOUNT ?? "60000", + EURe: process.env.EURE_PAYMENT_AMOUNT ?? "60000000000000000", }; const GAS_BUFFER = parseEther("0.00001"); diff --git a/scw_js/llm_service.ts b/scw_js/llm_service.ts index 16aabf3ff..f21d79c33 100644 --- a/scw_js/llm_service.ts +++ b/scw_js/llm_service.ts @@ -1,4 +1,4 @@ -import type { StablecoinSymbol } from "@fretchen/chain-utils"; +import type { SettlementTokenSymbol } from "@fretchen/chain-utils"; import { logger } from "./logger.js"; import { UpstreamChatCompletionSchema, flattenUpstreamContent } from "./upstream_schemas.js"; @@ -8,9 +8,9 @@ interface LLMProviderConfig { defaultModel: string; apiKeyEnvVar: string; // Price per 1,000,000 tokens, num/den to stay exact bigint math — one rate card per - // settlement token, each as the provider publishes it in that currency. Two parallel price - // systems: a EURC cost is computed from the EUR card, never converted from the USD one. - pricePerMillion: Record; + // settlement token, each as the provider publishes it in that currency. Parallel price + // systems: a EURC or EURe cost is computed from the EUR card, never converted from the USD one. + pricePerMillion: Record; } interface RatePerMillion { @@ -30,6 +30,9 @@ const LLM_PROVIDERS: Record = { pricePerMillion: { USDC: { input: { num: 50n, den: 100n }, output: { num: 150n, den: 100n } }, // $0.50 / $1.50 (2026-07-21) EURC: { input: { num: 44n, den: 100n }, output: { num: 150n, den: 100n } }, // €0.44 / €1.50 (2026-09-26) + // Same euro card as EURC: EURe is a euro like EURC, so the provider's EUR prices apply + // unchanged — only the atomic scale differs (18 decimals, passed by the caller). + EURe: { input: { num: 44n, den: 100n }, output: { num: 150n, den: 100n } }, // €0.44 / €1.50 }, }, }; @@ -305,15 +308,17 @@ function parseTokenCount(tokenCount: bigint | number | string): bigint { } /** - * Cost of `usage` in `symbol`'s atomic units (6 decimals), from the provider's rate card in that - * currency, pricing prompt and completion tokens separately — providers typically charge more + * Cost of `usage` in `symbol`'s atomic units, from the provider's rate card in that currency, + * pricing prompt and completion tokens separately — providers typically charge more * for completion (output) tokens than prompt (input) tokens, so a single blended rate would * systematically mis-price a provider with an asymmetric split (e.g. Mistral: $0.50/M input vs * $1.50/M output — a 3x gap. See LLM_PROVIDERS above). * - * Both tokens have 6 decimals and prices are quoted per 1,000,000 tokens, so the 1e6 factors - * cancel exactly — no separate decimals conversion needed. A card is read as 1 USD = 1 USDC and - * 1 EUR = 1 EURC. + * Prices are quoted per 1,000,000 tokens and USDC/EURC have 6 decimals, so for those two the + * 1e6 factors cancel exactly and the raw cross-product is already atomic. `decimals` rescales + * that for anything else: EURe's 18 decimals multiply the result by 10¹² — forgetting this + * would undercharge EURe messages by twelve orders of magnitude, silently. A card is read as + * 1 USD = 1 USDC and 1 EUR = 1 EURC = 1 EURe. */ export function tokensToCost( usage: { @@ -321,7 +326,8 @@ export function tokensToCost( completion_tokens: bigint | number | string; }, provider: string, - symbol: StablecoinSymbol, + symbol: SettlementTokenSymbol, + decimals: number, ): bigint { const config = getLLMProviderConfig(provider); const p = parseTokenCount(usage.prompt_tokens); @@ -331,6 +337,7 @@ export function tokensToCost( const { num: outNum, den: outDen } = output; // Cross-multiply to keep one shared denominator instead of assuming inDen === outDen. // No explicit 1e6 factor here — the "per 1,000,000 tokens" divisor and the tokens' 6 - // decimals cancel exactly. - return (p * inNum * outDen + c * outNum * inDen) / (inDen * outDen); + // decimals cancel exactly; `decimals` then rescales from that 6-decimal base. + const base = (p * inNum * outDen + c * outNum * inDen) / (inDen * outDen); + return decimals === 6 ? base : base * 10n ** BigInt(decimals - 6); } diff --git a/scw_js/llm_x402_cron.ts b/scw_js/llm_x402_cron.ts index 1646320d1..70182d94f 100644 --- a/scw_js/llm_x402_cron.ts +++ b/scw_js/llm_x402_cron.ts @@ -1,6 +1,6 @@ import { logger } from "./logger.js"; import { createPublicClient, http } from "viem"; -import { getStablecoins, getViemChain, getRpcUrl } from "@fretchen/chain-utils"; +import { getSettlementTokens, getViemChain, getRpcUrl } from "@fretchen/chain-utils"; import { createLLMResourceServer, createFacilitatorClient, @@ -173,9 +173,11 @@ export async function handle( // allowance to run out and the check below is skipped entirely. const feeConfig = await getFacilitatorFeeConfig(); - // Every stablecoin deployed on the network — each token's channels are claimed separately. + // Every token the sellers offer on the network (`getSettlementTokens`: USDC, EURC and EURe + // on Base) — each token's channels are claimed separately. A token missing here would leave + // its channels' escrow unclaimed forever, so this must read the same list the 402s offer. for (const network of getBatchSettlementNetworks()) { - for (const coin of getStablecoins(network)) { + for (const coin of getSettlementTokens(network)) { // Pass the token explicitly on EVERY network, not just Optimism. Omitting it makes // the SDK fall back to its `DEFAULT_STABLECOINS` registry, which still has no // "eip155:10" entry (see BATCH_SETTLEMENT_NETWORKS in x402_server.ts) and throws diff --git a/scw_js/openapi.genimg.json b/scw_js/openapi.genimg.json index 5c6b710b0..a4c497276 100644 --- a/scw_js/openapi.genimg.json +++ b/scw_js/openapi.genimg.json @@ -2,9 +2,9 @@ "openapi": "3.1.0", "info": { "title": "Fretchen AI Image Generation Service", - "description": "AI-powered image generation with NFT minting on Optimism/Base, paid via x402 in USDC (or EURC on Base).", + "description": "AI-powered image generation with NFT minting on Optimism/Base, paid via x402 in USDC, EURC or EURe on Base (USDC everywhere; the euro tokens on Base).", "version": "1.0.0", - "x-guidance": "OpenAI images-generation body. POST / with { prompt, size } and no payment header to receive a 402 with x402 v2 payment requirements (accepts[]). Pay with one of the offered entries — USDC on every network, EURC on Base where the 402 lists it — retry with the payment header, and the service returns { created, data: [{ url }], model } — data[0].url is the image. The mint recipient is derived from the payment payload, so there is no recipient field. Unknown request fields are rejected, not ignored. This agent also mints the image as an NFT and reports it under the x_nft response extension; that is a declared capability, not part of the images/v1 contract, and a client that only wants an image can ignore it. A 200 does not by itself mean the NFT was minted — check x_nft.status. Note: payment uses x402, so a stock OpenAI SDK cannot pay this endpoint — the OpenAI shape is for body legibility, not drop-in SDK use. Testnet networks return a placeholder image rather than a generated one.", + "x-guidance": "OpenAI images-generation body. POST / with { prompt, size } and no payment header to receive a 402 with x402 v2 payment requirements (accepts[]). Pay with one of the offered entries — USDC on every network, EURC and EURe on Base where the 402 lists them — retry with the payment header, and the service returns { created, data: [{ url }], model } — data[0].url is the image. The mint recipient is derived from the payment payload, so there is no recipient field. Unknown request fields are rejected, not ignored. This agent also mints the image as an NFT and reports it under the x_nft response extension; that is a declared capability, not part of the images/v1 contract, and a client that only wants an image can ignore it. A 200 does not by itself mean the NFT was minted — check x_nft.status. Note: payment uses x402, so a stock OpenAI SDK cannot pay this endpoint — the OpenAI shape is for body legibility, not drop-in SDK use. Testnet networks return a placeholder image rather than a generated one.", "contact": { "name": "fretchen", "url": "https://www.fretchen.eu", @@ -35,7 +35,7 @@ }, { "name": "x402", - "description": "Paid via x402 stablecoin payments (USDC, or EURC on Base)" + "description": "Paid via x402 stablecoin payments (USDC, EURC or EURe on Base)" } ], "paths": { @@ -43,7 +43,7 @@ "post": { "operationId": "genimgX402Token", "summary": "Generate AI Image and Mint NFT (x402 stablecoin payment)", - "description": "Generates an AI image using Black Forest Labs (BFL), uploads it to S3, mints an NFT on Optimism/Base, and transfers it to the payer. Requires an x402 payment: 0.07 USDC, or 0.06 EURC on Base — two separately set prices, not a conversion. An unpaid request is answered with the 402 challenge whatever its body says, so a client can always discover the payment terms; a paid request is validated before anything is verified or settled.", + "description": "Generates an AI image using Black Forest Labs (BFL), uploads it to S3, mints an NFT on Optimism/Base, and transfers it to the payer. Requires an x402 payment: 0.07 USDC, 0.06 EURC or 0.06 EURe on Base — three separately set prices, not conversions. EURe is paid via Permit2, so a buyer without a Permit2 allowance still needs no approve() transaction. An unpaid request is answered with the 402 challenge whatever its body says, so a client can always discover the payment terms; a paid request is validated before anything is verified or settled.", "tags": ["Image Generation", "NFT", "x402"], "security": [], "x-payment-info": { diff --git a/scw_js/openapi.llm.json b/scw_js/openapi.llm.json index 08fff7fa7..0ef4f1480 100644 --- a/scw_js/openapi.llm.json +++ b/scw_js/openapi.llm.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Fretchen AI Assistant (LLM) Service", - "description": "AI chat assistant, paid via x402 batch-settlement payment channels in USDC (or EURC on Base).", + "description": "AI chat assistant, paid via x402 batch-settlement payment channels in USDC, EURC or EURe on Base (USDC everywhere; the euro tokens on Base).", "version": "1.0.0", "x-guidance": "OpenAI chat-completions body. POST / with { model, messages: [{ role, content }, ...] } and no payment header to receive a 402 with x402 batch-settlement payment requirements (accepts[]). Open/top up a payment channel per the requirements, retry with the payment header, and the service returns a standard OpenAI chat.completion object. Streaming (stream: true) is not supported. Each message is metered and settled up to a per-message price ceiling; the real cost is usage-derived and typically lower. Other standard OpenAI chat params (temperature, top_p, stop, seed, the penalties, response_format) are forwarded to the upstream model and work as normal; they are not enumerated here because the upstream owns that contract. The exceptions are stream, n and max_tokens, which are rejected with a 400 rather than ignored, because each would move cost past the fixed per-message ceiling this endpoint meters against. Tool calling is supported (see x-capabilities): send OpenAI-shaped tools, and a tool call comes back as choices[].message.tool_calls with finish_reason: \"tool_calls\" and content: null. Execute it yourself and send the result back as a role:\"tool\" message with the matching tool_call_id — this endpoint never calls a tool on your behalf, and accepts no callback or MCP server URL. Note: payment uses x402 batch-settlement (a stateful channel), so a stock OpenAI SDK cannot pay this endpoint without batch-settlement client wiring — the OpenAI shape is for body legibility, not drop-in SDK use.", "contact": { @@ -35,7 +35,7 @@ }, { "name": "x402", - "description": "Paid via x402 batch-settlement payment channels in USDC, or EURC on Base" + "description": "Paid via x402 batch-settlement payment channels in USDC, EURC or EURe on Base" } ], "paths": { @@ -43,7 +43,7 @@ "post": { "operationId": "llmX402", "summary": "Chat with the AI assistant (x402 batch-settlement payment)", - "description": "Sends a prompt to the LLM and returns its response. Payment is settled per message via an x402 batch-settlement payment channel (USDC, or EURC on Base), capped at a per-message price ceiling.", + "description": "Sends a prompt to the LLM and returns its response. Payment is settled per message via an x402 batch-settlement payment channel (USDC, EURC or EURe on Base), capped at a per-message price ceiling.", "tags": ["LLM", "Chat", "x402"], "security": [], "x-payment-info": { diff --git a/scw_js/openapi.search.json b/scw_js/openapi.search.json index 5499e2b9f..5c32745ad 100644 --- a/scw_js/openapi.search.json +++ b/scw_js/openapi.search.json @@ -30,14 +30,14 @@ }, { "name": "x402", - "description": "Paid via x402 batch-settlement payment channels in USDC, or EURC on Base" + "description": "Paid via x402 batch-settlement payment channels in USDC, EURC or EURe on Base" } ], "paths": { "/search": { "get": { "operationId": "searchWeb", - "summary": "Web search with extracted page content (Brave LLM Context API) (x402: 0.01 USDC, or 0.01 EURC on Base)", + "summary": "Web search with extracted page content (Brave LLM Context API) (x402: 0.01 USDC, or 0.01 EURC, or 0.01 EURe on Base)", "description": "Proxies Brave's LLM Context API and returns search results with extracted page content. The cost knobs (result count, tokens per page, number of pages fetched) are pinned server-side and ignored if passed — the budget is the proxy's, not the caller's.", "tags": ["Web", "x402"], "security": [], @@ -91,7 +91,7 @@ "/fetch": { "get": { "operationId": "fetchUrl", - "summary": "The readable text of one public web page (x402: 0.001 USDC, or 0.001 EURC on Base)", + "summary": "The readable text of one public web page (x402: 0.001 USDC, or 0.001 EURC, or 0.001 EURe on Base)", "description": "Retrieves one public web page and returns its readable text. Only https is accepted, and every resolved address is checked: a payment authorises a fetch, not a fetch of an internal address. Redirects are followed with per-hop re-validation, and size and content-type are capped.", "tags": ["Web", "x402"], "security": [], diff --git a/scw_js/sc_llm_x402.ts b/scw_js/sc_llm_x402.ts index e077a86be..c37050a0b 100644 --- a/scw_js/sc_llm_x402.ts +++ b/scw_js/sc_llm_x402.ts @@ -15,9 +15,14 @@ import { FORWARDED_OWN_KEYS, MAX_MESSAGES_BYTES, } from "./llm_schemas.js"; -import { isTestnet, type StablecoinSymbol } from "@fretchen/chain-utils"; +import { isTestnet, type SettlementTokenSymbol } from "@fretchen/chain-utils"; import { logger } from "./logger.js"; -import { offeredStablecoins, resolvePaidStablecoin, type PriceList } from "./stablecoin_pricing.js"; +import { + offeredStablecoins, + resolvePaidStablecoin, + TOKEN_DECIMALS, + type PriceList, +} from "./stablecoin_pricing.js"; import { createLLMResourceServer, createBatchSettlementPaymentRequirements, @@ -92,15 +97,21 @@ const MAX_TOKENS_PER_MESSAGE = process.env.LLM_ESTIMATED_TOKENS_PER_MESSAGE ?? " // 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. // -// One ceiling per token, each priced on the provider's own rate card in that currency — two -// parallel price systems, no conversion (see llm_service.ts). -const maxPriceFor = (symbol: StablecoinSymbol): string => +// One ceiling per token, each priced on the provider's own rate card in that currency — three +// parallel price systems, no conversion (see llm_service.ts). EURe's ceiling is the same EUR +// arithmetic rescaled to 18 decimals (tokensToCost's `decimals`). +const maxPriceFor = (symbol: SettlementTokenSymbol): string => tokensToCost( { prompt_tokens: 0, completion_tokens: MAX_TOKENS_PER_MESSAGE }, LLM_PROVIDER, symbol, + TOKEN_DECIMALS[symbol], ).toString(); -const MAX_PRICE_ATOMIC: PriceList = { USDC: maxPriceFor("USDC"), EURC: maxPriceFor("EURC") }; +const MAX_PRICE_ATOMIC: PriceList = { + USDC: maxPriceFor("USDC"), + EURC: maxPriceFor("EURC"), + EURe: maxPriceFor("EURe"), +}; /** * Real, usage-derived charge for this message in `symbol`'s atomic units, from that currency's @@ -111,9 +122,9 @@ const MAX_PRICE_ATOMIC: PriceList = { USDC: maxPriceFor("USDC"), EURC: maxPriceF */ function getSettleAmount( usage: { prompt_tokens: number; completion_tokens: number }, - symbol: StablecoinSymbol, + symbol: SettlementTokenSymbol, ): string { - const actualCost = tokensToCost(usage, LLM_PROVIDER, symbol); + const actualCost = tokensToCost(usage, LLM_PROVIDER, symbol, TOKEN_DECIMALS[symbol]); const maxCost = BigInt(MAX_PRICE_ATOMIC[symbol]); return (actualCost > maxCost ? maxCost : actualCost).toString(); } diff --git a/scw_js/scripts/fee_allowances.ts b/scw_js/scripts/fee_allowances.ts index 1bcaf9d6d..82312b37a 100644 --- a/scw_js/scripts/fee_allowances.ts +++ b/scw_js/scripts/fee_allowances.ts @@ -31,7 +31,7 @@ import { getRpcUrl, getViemChain, loadPrivateKey, - type StablecoinInfo, + type SettlementTokenInfo, } from "@fretchen/chain-utils"; import { getFacilitatorFeeConfig, getSupportedNetworks } from "../x402_server.js"; import { offeredStablecoins } from "../stablecoin_pricing.js"; @@ -43,13 +43,13 @@ export const SETTLEMENTS_PER_APPROVAL = 100n; export interface FeePair { network: string; - coin: StablecoinInfo; + coin: SettlementTokenInfo; } -/** Every (network, stablecoin) pair the sellers put in a 402, and so every pair that needs an approval. */ +/** Every (network, token) pair the sellers put in a 402, and so every pair that needs an approval. */ export function listFeePairs( networks: readonly string[], - coinsFor: (network: string) => StablecoinInfo[] = offeredStablecoins, + coinsFor: (network: string) => SettlementTokenInfo[] = offeredStablecoins, ): FeePair[] { return networks.flatMap((network) => coinsFor(network).map((coin) => ({ network, coin }))); } diff --git a/scw_js/scripts/generate-openapi-genimg.ts b/scw_js/scripts/generate-openapi-genimg.ts index 242ffe46a..d088375c0 100644 --- a/scw_js/scripts/generate-openapi-genimg.ts +++ b/scw_js/scripts/generate-openapi-genimg.ts @@ -46,10 +46,10 @@ export function generateOpenApiSpec(): GenimgSpec { info: { title: "Fretchen AI Image Generation Service", description: - "AI-powered image generation with NFT minting on Optimism/Base, paid via x402 in USDC (or EURC on Base).", + "AI-powered image generation with NFT minting on Optimism/Base, paid via x402 in USDC, EURC or EURe on Base (USDC everywhere; the euro tokens on Base).", version: "1.0.0", "x-guidance": - "OpenAI images-generation body. POST / with { prompt, size } and no payment header to receive a 402 with x402 v2 payment requirements (accepts[]). Pay with one of the offered entries — USDC on every network, EURC on Base where the 402 lists it — retry with the payment header, and the service returns { created, data: [{ url }], model } — data[0].url is the image. The mint recipient is derived from the payment payload, so there is no recipient field. Unknown request fields are rejected, not ignored. This agent also mints the image as an NFT and reports it under the x_nft response extension; that is a declared capability, not part of the images/v1 contract, and a client that only wants an image can ignore it. A 200 does not by itself mean the NFT was minted — check x_nft.status. Note: payment uses x402, so a stock OpenAI SDK cannot pay this endpoint — the OpenAI shape is for body legibility, not drop-in SDK use. Testnet networks return a placeholder image rather than a generated one.", + "OpenAI images-generation body. POST / with { prompt, size } and no payment header to receive a 402 with x402 v2 payment requirements (accepts[]). Pay with one of the offered entries — USDC on every network, EURC and EURe on Base where the 402 lists them — retry with the payment header, and the service returns { created, data: [{ url }], model } — data[0].url is the image. The mint recipient is derived from the payment payload, so there is no recipient field. Unknown request fields are rejected, not ignored. This agent also mints the image as an NFT and reports it under the x_nft response extension; that is a declared capability, not part of the images/v1 contract, and a client that only wants an image can ignore it. A 200 does not by itself mean the NFT was minted — check x_nft.status. Note: payment uses x402, so a stock OpenAI SDK cannot pay this endpoint — the OpenAI shape is for body legibility, not drop-in SDK use. Testnet networks return a placeholder image rather than a generated one.", contact: CONTACT, }, "x-discovery": { @@ -68,7 +68,10 @@ export function generateOpenApiSpec(): GenimgSpec { tags: [ { name: "Image Generation", description: "AI text-to-image and image editing" }, { name: "NFT", description: "Mints the result as an NFT on Optimism/Base" }, - { name: "x402", description: "Paid via x402 stablecoin payments (USDC, or EURC on Base)" }, + { + name: "x402", + description: "Paid via x402 stablecoin payments (USDC, EURC or EURe on Base)", + }, ], paths: { "/": { @@ -76,12 +79,12 @@ export function generateOpenApiSpec(): GenimgSpec { operationId: "genimgX402Token", summary: "Generate AI Image and Mint NFT (x402 stablecoin payment)", description: - "Generates an AI image using Black Forest Labs (BFL), uploads it to S3, mints an NFT on Optimism/Base, and transfers it to the payer. Requires an x402 payment: 0.07 USDC, or 0.06 EURC on Base — two separately set prices, not a conversion. An unpaid request is answered with the 402 challenge whatever its body says, so a client can always discover the payment terms; a paid request is validated before anything is verified or settled.", + "Generates an AI image using Black Forest Labs (BFL), uploads it to S3, mints an NFT on Optimism/Base, and transfers it to the payer. Requires an x402 payment: 0.07 USDC, 0.06 EURC or 0.06 EURe on Base — three separately set prices, not conversions. EURe is paid via Permit2, so a buyer without a Permit2 allowance still needs no approve() transaction. An unpaid request is answered with the 402 challenge whatever its body says, so a client can always discover the payment terms; a paid request is validated before anything is verified or settled.", tags: ["Image Generation", "NFT", "x402"], security: [], "x-payment-info": { protocols: ["x402"], - // The discovery spec prices in USD, so this is the USDC price; EURC is in the description. + // The discovery spec prices in USD, so this is the USDC price; EURC and EURe are in the description. price: { mode: "fixed", currency: "USD", amount: "0.07" }, }, requestBody: { diff --git a/scw_js/scripts/generate-openapi-llm.ts b/scw_js/scripts/generate-openapi-llm.ts index ce209954b..614135fa9 100644 --- a/scw_js/scripts/generate-openapi-llm.ts +++ b/scw_js/scripts/generate-openapi-llm.ts @@ -72,7 +72,7 @@ export function generateOpenApiSpec(): LlmSpec { info: { title: "Fretchen AI Assistant (LLM) Service", description: - "AI chat assistant, paid via x402 batch-settlement payment channels in USDC (or EURC on Base).", + "AI chat assistant, paid via x402 batch-settlement payment channels in USDC, EURC or EURe on Base (USDC everywhere; the euro tokens on Base).", version: "1.0.0", "x-guidance": "OpenAI chat-completions body. POST / with { model, messages: [{ role, content }, ...] } and no payment header to receive a 402 with x402 batch-settlement payment requirements (accepts[]). Open/top up a payment channel per the requirements, retry with the payment header, and the service returns a standard OpenAI chat.completion object. Streaming (stream: true) is not supported. Each message is metered and settled up to a per-message price ceiling; the real cost is usage-derived and typically lower. Other standard OpenAI chat params (temperature, top_p, stop, seed, the penalties, response_format) are forwarded to the upstream model and work as normal; they are not enumerated here because the upstream owns that contract. " + @@ -101,7 +101,8 @@ export function generateOpenApiSpec(): LlmSpec { { name: "Chat", description: "Multi-turn conversational messages" }, { name: "x402", - description: "Paid via x402 batch-settlement payment channels in USDC, or EURC on Base", + description: + "Paid via x402 batch-settlement payment channels in USDC, EURC or EURe on Base", }, ], paths: { @@ -110,7 +111,7 @@ export function generateOpenApiSpec(): LlmSpec { operationId: "llmX402", summary: "Chat with the AI assistant (x402 batch-settlement payment)", description: - "Sends a prompt to the LLM and returns its response. Payment is settled per message via an x402 batch-settlement payment channel (USDC, or EURC on Base), capped at a per-message price ceiling.", + "Sends a prompt to the LLM and returns its response. Payment is settled per message via an x402 batch-settlement payment channel (USDC, EURC or EURe on Base), capped at a per-message price ceiling.", tags: ["LLM", "Chat", "x402"], security: [], "x-payment-info": { diff --git a/scw_js/scripts/generate-openapi-search.ts b/scw_js/scripts/generate-openapi-search.ts index bdd136158..608f977c9 100644 --- a/scw_js/scripts/generate-openapi-search.ts +++ b/scw_js/scripts/generate-openapi-search.ts @@ -104,7 +104,8 @@ function operationFor(route: Route) { operationId: route === "search" ? "searchWeb" : "fetchUrl", summary: `${DESCRIPTION[route]} (x402: ${formatUsdcAtomicAsDecimalUsd(PRICE_ATOMIC[route].USDC)} USDC, ` + - `or ${formatUsdcAtomicAsDecimalUsd(PRICE_ATOMIC[route].EURC)} EURC on Base)`, + `or ${formatUsdcAtomicAsDecimalUsd(PRICE_ATOMIC[route].EURC)} EURC, ` + + `or ${formatUsdcAtomicAsDecimalUsd(PRICE_ATOMIC[route].EURe, 18n)} EURe on Base)`, description: ROUTE_NOTES[route], tags: ["Web", "x402"], security: [] as const, @@ -113,7 +114,7 @@ function operationFor(route: Route) { price: { mode: "fixed" as const, currency: "USD" as const, - // The discovery spec prices in USD, so this is the USDC list; EURC is in the summary. + // The discovery spec prices in USD, so this is the USDC list; EURC and EURe are in the summary. amount: formatUsdcAtomicAsDecimalUsd(PRICE_ATOMIC[route].USDC), }, }, @@ -174,7 +175,8 @@ export function generateOpenApiSpec() { { name: "Web", description: "Search and single-page retrieval for agents" }, { name: "x402", - description: "Paid via x402 batch-settlement payment channels in USDC, or EURC on Base", + description: + "Paid via x402 batch-settlement payment channels in USDC, EURC or EURe on Base", }, ], paths: { diff --git a/scw_js/search_schemas.ts b/scw_js/search_schemas.ts index e4acfcbf3..49b108caf 100644 --- a/scw_js/search_schemas.ts +++ b/scw_js/search_schemas.ts @@ -55,8 +55,9 @@ export type Route = (typeof ROUTES)[number]; * effect of generating a document. This module has no side effects. */ export const PRICE_ATOMIC: Record = { - search: { USDC: "10000", EURC: "10000" }, - fetch: { USDC: "1000", EURC: "1000" }, + // USDC/EURC in 6 decimals, EURe in 18 — same nominal prices, each in its own atomic units. + search: { USDC: "10000", EURC: "10000", EURe: "10000000000000000" }, + fetch: { USDC: "1000", EURC: "1000", EURe: "1000000000000000" }, }; /** What each route sells, used both in the 402 challenge and in the published spec. */ diff --git a/scw_js/serverless.yml b/scw_js/serverless.yml index 13e4eec40..f5baa79de 100644 --- a/scw_js/serverless.yml +++ b/scw_js/serverless.yml @@ -21,6 +21,9 @@ provider: USDC_PAYMENT_AMOUNT: ${env:USDC_PAYMENT_AMOUNT, "70000"} # The image's EURC price — its own euro price, not a conversion of USDC_PAYMENT_AMOUNT. EURC_PAYMENT_AMOUNT: ${env:EURC_PAYMENT_AMOUNT, "60000"} + # The image's EURe price — the same euro price in EURe's 18 decimals (0.06 EURe), not a + # conversion. EURe is paid via Permit2, so an EURe buyer needs no ETH for an approval. + EURE_PAYMENT_AMOUNT: ${env:EURE_PAYMENT_AMOUNT, "60000000000000000"} # One address, or several comma-separated — see parseOwnerAddresses in chain-utils. OWNER_ETH_ADDRESS: ${env:OWNER_ETH_ADDRESS} LLM_ESTIMATED_TOKENS_PER_MESSAGE: ${env:LLM_ESTIMATED_TOKENS_PER_MESSAGE, "20000"} diff --git a/scw_js/stablecoin_pricing.ts b/scw_js/stablecoin_pricing.ts index cde5b4736..4e7b27f5f 100644 --- a/scw_js/stablecoin_pricing.ts +++ b/scw_js/stablecoin_pricing.ts @@ -1,34 +1,49 @@ /** - * Which stablecoins the sellers offer, in which order, and how their prices are expressed. + * Which settlement tokens the sellers offer, in which order, and how their prices are expressed. * - * USDC and EURC are two parallel price systems. Every price is quoted explicitly in each token - * (`PriceList`), and anything derived — the LLM's per-message cost — is computed inside that - * token's own system from its own rate card. There is no exchange rate anywhere: a EURC price is - * a euro price someone chose, not a converted dollar price. + * USDC, EURC and EURe are three parallel price systems. Every price is quoted explicitly in each + * token (`PriceList`), and anything derived — the LLM's per-message cost — is computed inside that + * token's own system from its own rate card. There is no exchange rate anywhere: a EURC or EURe + * price is a euro price someone chose, not a converted dollar price. EURe has 18 decimals + * (USDC and EURC have 6), so a PriceList's atomic values differ per token — never assume 6. */ -import { getStablecoins, type StablecoinInfo, type StablecoinSymbol } from "@fretchen/chain-utils"; +import { + getSettlementTokens, + type SettlementTokenInfo, + type SettlementTokenSymbol, +} from "@fretchen/chain-utils"; -/** A price in each token's atomic units (both have 6 decimals). */ -export type PriceList = Record; +/** A price in each token's atomic units — 6 decimals for USDC/EURC, 18 for EURe. */ +export type PriceList = Record; /** * The seller's preference, most preferred first. Its only effect is the order of the 402 * `accepts` array: a stock x402 client pays with the first entry its spend controls allow. * The SDK's built-in asset registry knows only USDC, so a default-configured client drops the - * EURC entry and keeps paying USDC; a client that allowlists EURC pays EURC. + * EURe and EURC entries and keeps paying USDC; a client that allowlists EURe (or EURC) pays it. */ -export const STABLECOIN_PREFERENCE: readonly StablecoinSymbol[] = ["EURC", "USDC"]; +export const STABLECOIN_PREFERENCE: readonly SettlementTokenSymbol[] = ["EURe", "EURC", "USDC"]; -/** The stablecoins to offer on `network`, most preferred first. */ -export function offeredStablecoins(network: string): StablecoinInfo[] { - return getStablecoins(network).sort( +/** The settlement tokens to offer on `network`, most preferred first. */ +export function offeredStablecoins(network: string): SettlementTokenInfo[] { + return getSettlementTokens(network).sort( (a, b) => STABLECOIN_PREFERENCE.indexOf(a.symbol) - STABLECOIN_PREFERENCE.indexOf(b.symbol), ); } -/** The stablecoin a buyer paid with, if this seller offers it on `network`; null otherwise. */ -export function resolvePaidStablecoin(network: string, asset: unknown): StablecoinInfo | null { +/** + * Decimals per settlement token, derived from the shared registry (USDC/EURC 6, EURe 18) — a + * symbol's decimals never vary by network, so this is a lookup table built from the registry, + * not a second source of truth. Base carries all three tokens, so it is the one network the + * table is read from. Pricing code that only holds a symbol uses it to rescale. + */ +export const TOKEN_DECIMALS = Object.fromEntries( + offeredStablecoins("eip155:8453").map((token) => [token.symbol, token.decimals]), +) as Record; + +/** The token a buyer paid with, if this seller offers it on `network`; null otherwise. */ +export function resolvePaidStablecoin(network: string, asset: unknown): SettlementTokenInfo | null { if (typeof asset !== "string") { return null; } diff --git a/scw_js/test/fee_allowances.test.ts b/scw_js/test/fee_allowances.test.ts index 78d53793f..c70b0a919 100644 --- a/scw_js/test/fee_allowances.test.ts +++ b/scw_js/test/fee_allowances.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { getStablecoins, type StablecoinInfo } from "@fretchen/chain-utils"; +import { getSettlementTokens, type SettlementTokenInfo } from "@fretchen/chain-utils"; // Importing the script pulls in x402_server, which reaches S3 only through these; stub them so // importing the module for its pure helpers never touches the network (as migrate_channel_storage's test does). @@ -14,18 +14,25 @@ import { assessAllowance, listFeePairs } from "../scripts/fee_allowances.js"; const FEE = 10_000n; // 0.01 of a 6-decimal token describe("listFeePairs", () => { - it("pairs every offered network with every stablecoin offered there", () => { + it("pairs every offered network with every token offered there", () => { const pairs = listFeePairs(["eip155:10", "eip155:8453"]).map( (p) => `${p.network} ${p.coin.symbol}`, ); - // Optimism has no EURC; Base has both. The check must cover exactly what the 402s offer. - expect(pairs.sort()).toEqual(["eip155:10 USDC", "eip155:8453 EURC", "eip155:8453 USDC"]); + // Optimism has no EURC or EURe; Base has all three. The check must cover exactly what the + // 402s offer — EURe included, or its approval is never checked and every EURe payment is + // refused with insufficient_fee_allowance (the EURC launch bug). + expect(pairs.sort()).toEqual([ + "eip155:10 USDC", + "eip155:8453 EURC", + "eip155:8453 EURe", + "eip155:8453 USDC", + ]); }); it("follows the offer list rather than a list of its own", () => { - const onlyUsdc = (network: string): StablecoinInfo[] => - getStablecoins(network).filter((coin) => coin.symbol === "USDC"); + const onlyUsdc = (network: string): SettlementTokenInfo[] => + getSettlementTokens(network).filter((coin) => coin.symbol === "USDC"); expect(listFeePairs(["eip155:8453"], onlyUsdc).map((p) => p.coin.symbol)).toEqual(["USDC"]); }); diff --git a/scw_js/test/genimg_x402_token.test.ts b/scw_js/test/genimg_x402_token.test.ts index 52dd63692..3c17c5f0f 100644 --- a/scw_js/test/genimg_x402_token.test.ts +++ b/scw_js/test/genimg_x402_token.test.ts @@ -145,7 +145,7 @@ describe("genimg_x402_token.js - x402 v2 Token Payment Tests", () => { resourceUrl: "https://api.example.com/genimg", description: "AI Image Generation", mimeType: "application/json", - price: { USDC: "1000", EURC: "900" }, + price: { USDC: "1000", EURC: "900", EURe: "900000000000000" }, payTo: "0xAAEBC1441323B8ad6Bdf6793A8428166b510239C", }); const response = create402Response(paymentRequirements); @@ -177,7 +177,7 @@ describe("genimg_x402_token.js - x402 v2 Token Payment Tests", () => { resourceUrl: "/genimg", description: "AI Image Generation", mimeType: "application/json", - price: { USDC: "1000", EURC: "900" }, + price: { USDC: "1000", EURC: "900", EURe: "900000000000000" }, payTo: "0xAAEBC1441323B8ad6Bdf6793A8428166b510239C", }); const response = create402Response(paymentRequirements); @@ -207,7 +207,7 @@ describe("genimg_x402_token.js - x402 v2 Token Payment Tests", () => { resourceUrl: "/genimg", description: "AI Image Generation", mimeType: "application/json", - price: { USDC: "1000", EURC: "900" }, + price: { USDC: "1000", EURC: "900", EURe: "900000000000000" }, payTo: "0xAAEBC1441323B8ad6Bdf6793A8428166b510239C", }); const response = create402Response(paymentRequirements); @@ -223,7 +223,7 @@ describe("genimg_x402_token.js - x402 v2 Token Payment Tests", () => { resourceUrl: "/genimg", description: "AI Image Generation", mimeType: "application/json", - price: { USDC: "1000", EURC: "900" }, + price: { USDC: "1000", EURC: "900", EURe: "900000000000000" }, payTo: "0xAAEBC1441323B8ad6Bdf6793A8428166b510239C", }); const response = create402Response(paymentRequirements); @@ -241,7 +241,7 @@ describe("genimg_x402_token.js - x402 v2 Token Payment Tests", () => { resourceUrl: "/genimg", description: "AI Image Generation", mimeType: "application/json", - price: { USDC: "1000", EURC: "900" }, + price: { USDC: "1000", EURC: "900", EURe: "900000000000000" }, payTo: "0xAAEBC1441323B8ad6Bdf6793A8428166b510239C", networks: ["eip155:11155420"], // Only Sepolia }); @@ -261,7 +261,7 @@ describe("genimg_x402_token.js - x402 v2 Token Payment Tests", () => { resourceUrl: "/genimg", description: "AI Image Generation", mimeType: "application/json", - price: { USDC: "1000", EURC: "900" }, + price: { USDC: "1000", EURC: "900", EURe: "900000000000000" }, payTo: "0xAAEBC1441323B8ad6Bdf6793A8428166b510239C", networks: undefined, // Default: all networks }); @@ -358,7 +358,7 @@ describe("genimg_x402_token.js - x402 v2 Token Payment Tests", () => { // Default is production mode: Optimism + Base expect(body.accepts).toBeInstanceOf(Array); - // Optimism: USDC. Base: EURC first (the preference), then USDC. + // Optimism: USDC. Base: EURe first (the preference), then EURC, then USDC. expect( body.accepts.map((a: { network: string; extra: { name: string } }) => [ a.network, @@ -366,6 +366,7 @@ describe("genimg_x402_token.js - x402 v2 Token Payment Tests", () => { ]), ).toEqual([ ["eip155:10", "USD Coin"], + ["eip155:8453", "Monerium EURe"], ["eip155:8453", "EURC"], ["eip155:8453", "USD Coin"], ]); @@ -1776,13 +1777,13 @@ describe("genimg_x402_token.js - x402 v2 Token Payment Tests", () => { resourceUrl: "/test", description: "Test Resource", mimeType: "application/json", - price: { USDC: "1000", EURC: "900" }, + price: { USDC: "1000", EURC: "900", EURe: "900000000000000" }, payTo: "0xAAEBC1441323B8ad6Bdf6793A8428166b510239C", }); expect(requirements.x402Version).toBe(2); - // USDC on all four networks, plus EURC on Base and Base Sepolia. - expect(requirements.accepts).toHaveLength(6); + // USDC on all four networks, plus EURC and EURe on Base and Base Sepolia. + expect(requirements.accepts).toHaveLength(8); const networks = requirements.accepts.map((a) => a.network); expect(networks).toContain("eip155:10"); @@ -1851,7 +1852,7 @@ describe("genimg_x402_token.js - x402 v2 Token Payment Tests", () => { resourceUrl: "/test", description: "Test", mimeType: "application/json", - price: { USDC: "1000", EURC: "900" }, + price: { USDC: "1000", EURC: "900", EURe: "900000000000000" }, payTo: "0xAAEBC1441323B8ad6Bdf6793A8428166b510239C", }); @@ -1874,36 +1875,88 @@ describe("genimg_x402_token.js - x402 v2 Token Payment Tests", () => { }); }); - describe("USDC and EURC as two parallel price lists", () => { - test("lists EURC before USDC on Base, and only USDC on Optimism", async () => { + describe("USDC, EURC and EURe as three parallel price lists", () => { + test("lists EURe, then EURC, then USDC on Base, and only USDC on Optimism", async () => { const { createPaymentRequirements } = await import("../x402_server.js"); const { accepts } = createPaymentRequirements({ resourceUrl: "/test", description: "Test", mimeType: "application/json", - price: { USDC: "1001", EURC: "850" }, + price: { USDC: "1001", EURC: "850", EURe: "850000000000000" }, payTo: "0xAAEBC1441323B8ad6Bdf6793A8428166b510239C", }); const byNetwork = (network: string) => accepts.filter((a) => a.network === network).map((a) => a.extra.name); // The order is the preference: a stock client pays with the first entry it allows. - expect(byNetwork("eip155:8453")).toEqual(["EURC", "USD Coin"]); - expect(byNetwork("eip155:84532")).toEqual(["EURC", "USDC"]); + expect(byNetwork("eip155:8453")).toEqual(["Monerium EURe", "EURC", "USD Coin"]); + expect(byNetwork("eip155:84532")).toEqual(["Monerium EURe", "EURC", "USDC"]); expect(byNetwork("eip155:10")).toEqual(["USD Coin"]); expect(byNetwork("eip155:11155420")).toEqual(["USDC"]); + const baseEure = accepts.find( + (a) => a.network === "eip155:8453" && a.extra.name === "Monerium EURe", + ); const baseEurc = accepts.find( (a) => a.network === "eip155:8453" && a.extra.name === "EURC", ); const baseUsdc = accepts.find( (a) => a.network === "eip155:8453" && a.extra.name === "USD Coin", ); - // Each entry carries its own token's price — no conversion between the two. + // Each entry carries its own token's price — no conversion between the three. expect(baseEurc?.amount).toBe("850"); expect(baseEurc?.asset).toBe("0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42"); expect(baseUsdc?.amount).toBe("1001"); + // EURe: the same euro price in its own 18 decimals, on its own address. + expect(baseEure?.amount).toBe("850000000000000"); + expect(baseEure?.asset).toBe("0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C"); + }); + + test("marks the EURe entry as Permit2 — and only that entry", async () => { + const { createPaymentRequirements } = await import("../x402_server.js"); + + const { accepts, extensions } = createPaymentRequirements({ + resourceUrl: "/test", + description: "Test", + mimeType: "application/json", + price: { USDC: "1001", EURC: "850", EURe: "850000000000000" }, + payTo: "0xAAEBC1441323B8ad6Bdf6793A8428166b510239C", + }); + + const baseEure = accepts.find( + (a) => a.network === "eip155:8453" && a.extra.name === "Monerium EURe", + ); + // EURe has no EIP-3009; without this field the SDK client defaults to it and fails. + expect(baseEure?.extra).toEqual({ + name: "Monerium EURe", + version: "1", + assetTransferMethod: "permit2", + }); + // The EIP-3009 entries carry the field never — its absence IS the "eip3009" signal. + for (const accept of accepts.filter((a) => a.extra.name !== "Monerium EURe")) { + expect(accept.extra).not.toHaveProperty("assetTransferMethod"); + } + // The challenge advertises gas sponsoring so a fresh wallet can pay EURe with no + // approve() to Permit2: the client only signs the EIP-2612 permit when this is present. + expect(extensions).toEqual({ eip2612GasSponsoring: {} }); + }); + + test("advertises no gas-sponsoring extension when only EIP-3009 tokens are offered", async () => { + const { createPaymentRequirements } = await import("../x402_server.js"); + + // Optimism only: USDC — no EURe, so no extension. + const { accepts, extensions } = createPaymentRequirements({ + resourceUrl: "/test", + description: "Test", + mimeType: "application/json", + price: { USDC: "1001", EURC: "850", EURe: "850000000000000" }, + payTo: "0xAAEBC1441323B8ad6Bdf6793A8428166b510239C", + networks: ["eip155:10"], + }); + + expect(accepts.every((a) => a.extra.name === "USD Coin")).toBe(true); + expect(extensions).toBeUndefined(); }); }); }); diff --git a/scw_js/test/llm_service.test.ts b/scw_js/test/llm_service.test.ts index e302093a7..738c20327 100644 --- a/scw_js/test/llm_service.test.ts +++ b/scw_js/test/llm_service.test.ts @@ -382,29 +382,34 @@ describe("tokensToCost — per-provider, per-currency, input/output-split pricin // 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(tokensToCost({ prompt_tokens: 0n, completion_tokens: 2000n }, "mistral", "USDC")).toBe( - 3000n, - ); + expect( + tokensToCost({ prompt_tokens: 0n, completion_tokens: 2000n }, "mistral", "USDC", 6), + ).toBe(3000n); }); test("prices input tokens at the input rate only", () => { // 1,000,000 prompt tokens * $0.50/M = 500,000 atomic units. expect( - tokensToCost({ prompt_tokens: 1_000_000n, completion_tokens: 0n }, "mistral", "USDC"), + tokensToCost({ prompt_tokens: 1_000_000n, completion_tokens: 0n }, "mistral", "USDC", 6), ).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( - tokensToCost({ prompt_tokens: 0n, completion_tokens: 1_000_000n }, "mistral", "USDC"), + tokensToCost({ prompt_tokens: 0n, completion_tokens: 1_000_000n }, "mistral", "USDC", 6), ).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( - tokensToCost({ prompt_tokens: 500_000n, completion_tokens: 500_000n }, "mistral", "USDC"), + tokensToCost( + { prompt_tokens: 500_000n, completion_tokens: 500_000n }, + "mistral", + "USDC", + 6, + ), ).toBe(1_000_000n); }); }); @@ -413,15 +418,38 @@ describe("tokensToCost — per-provider, per-currency, input/output-split pricin test("prices input tokens on the EUR input rate", () => { // 1,000,000 prompt tokens * €0.44/M = 440,000 EURC atomic units — not the USD card's 500,000. expect( - tokensToCost({ prompt_tokens: 1_000_000n, completion_tokens: 0n }, "mistral", "EURC"), + tokensToCost({ prompt_tokens: 1_000_000n, completion_tokens: 0n }, "mistral", "EURC", 6), ).toBe(440_000n); }); test("prices the same mixed usage differently on each card", () => { const usage = { prompt_tokens: 500_000n, completion_tokens: 500_000n }; // USD: 250,000 + 750,000. EUR: 220,000 + 750,000. - expect(tokensToCost(usage, "mistral", "USDC")).toBe(1_000_000n); - expect(tokensToCost(usage, "mistral", "EURC")).toBe(970_000n); + expect(tokensToCost(usage, "mistral", "USDC", 6)).toBe(1_000_000n); + expect(tokensToCost(usage, "mistral", "EURC", 6)).toBe(970_000n); + }); + }); + + describe("mistral, EURe card — the EUR card rescaled to 18 decimals", () => { + test("prices input tokens on the EUR rate, in EURe atomic units", () => { + // 1,000,000 prompt tokens * €0.44/M = 440,000 in 6-decimal units, ×10¹² for EURe's 18. + expect( + tokensToCost({ prompt_tokens: 1_000_000n, completion_tokens: 0n }, "mistral", "EURe", 18), + ).toBe(440_000n * 10n ** 12n); + }); + + test("matches the EURC cost for the same usage, twelve orders of magnitude up", () => { + const usage = { prompt_tokens: 500_000n, completion_tokens: 500_000n }; + expect(tokensToCost(usage, "mistral", "EURe", 18)).toBe( + tokensToCost(usage, "mistral", "EURC", 6) * 10n ** 12n, + ); + }); + + test("rescales from the 6-decimal base, not from the per-million divisor", () => { + // 2,000 completion tokens * €1.50/M = 3,000 in 6-decimal units → 3,000 ×10¹² in EURe. + expect( + tokensToCost({ prompt_tokens: 0n, completion_tokens: 2000n }, "mistral", "EURe", 18), + ).toBe(3000n * 10n ** 12n); }); }); @@ -430,36 +458,37 @@ describe("tokensToCost — per-provider, per-currency, input/output-split pricin { prompt_tokens: 1000n, completion_tokens: 500n }, "mistral", "USDC", + 6, ); - expect(tokensToCost({ prompt_tokens: 1000, completion_tokens: 500 }, "mistral", "USDC")).toBe( - viaBigint, - ); expect( - tokensToCost({ prompt_tokens: "1000", completion_tokens: "500" }, "mistral", "USDC"), + tokensToCost({ prompt_tokens: 1000, completion_tokens: 500 }, "mistral", "USDC", 6), + ).toBe(viaBigint); + expect( + tokensToCost({ prompt_tokens: "1000", completion_tokens: "500" }, "mistral", "USDC", 6), ).toBe(viaBigint); }); test("rejects a negative number", () => { expect(() => - tokensToCost({ prompt_tokens: -5, completion_tokens: 0 }, "mistral", "USDC"), + tokensToCost({ prompt_tokens: -5, completion_tokens: 0 }, "mistral", "USDC", 6), ).toThrow(TypeError); }); test("rejects a non-finite number", () => { expect(() => - tokensToCost({ prompt_tokens: Infinity, completion_tokens: 0 }, "mistral", "USDC"), + tokensToCost({ prompt_tokens: Infinity, completion_tokens: 0 }, "mistral", "USDC", 6), ).toThrow(TypeError); }); test("rejects a non-numeric string", () => { expect(() => - tokensToCost({ prompt_tokens: "abc", completion_tokens: 0 }, "mistral", "USDC"), + tokensToCost({ prompt_tokens: "abc", completion_tokens: 0 }, "mistral", "USDC", 6), ).toThrow(TypeError); }); test("rejects an unknown provider", () => { expect(() => - tokensToCost({ prompt_tokens: 100n, completion_tokens: 100n }, "openai", "USDC"), + tokensToCost({ prompt_tokens: 100n, completion_tokens: 100n }, "openai", "USDC", 6), ).toThrow(/Unknown LLM provider: openai/); }); }); diff --git a/scw_js/test/llm_x402_cron.test.ts b/scw_js/test/llm_x402_cron.test.ts index 48e55110b..fcbfec0f8 100644 --- a/scw_js/test/llm_x402_cron.test.ts +++ b/scw_js/test/llm_x402_cron.test.ts @@ -56,7 +56,7 @@ vi.mock("viem", async () => { // ===== Import after mocks ===== import { handle } from "../llm_x402_cron.js"; -import { USDC_ADDRESSES, EURC_ADDRESSES } from "@fretchen/chain-utils"; +import { USDC_ADDRESSES, EURC_ADDRESSES, EURE_ADDRESSES } from "@fretchen/chain-utils"; // ===== Helpers ===== @@ -66,13 +66,15 @@ function makeEvent() { return { httpMethod: "GET", headers: {}, body: null }; } -/** One claim run per (network, token): Optimism has USDC only, Base and Base Sepolia both. */ +/** One claim run per (network, token): Optimism has USDC only, Base and Base Sepolia all three. */ const RUNS: Array<[string, string]> = [ ["eip155:10", USDC_ADDRESSES["eip155:10"]], ["eip155:8453", USDC_ADDRESSES["eip155:8453"]], ["eip155:8453", EURC_ADDRESSES["eip155:8453"]], + ["eip155:8453", EURE_ADDRESSES["eip155:8453"]], ["eip155:84532", USDC_ADDRESSES["eip155:84532"]], ["eip155:84532", EURC_ADDRESSES["eip155:84532"]], + ["eip155:84532", EURE_ADDRESSES["eip155:84532"]], ]; // ===== Tests ===== @@ -158,8 +160,10 @@ describe("llm_x402_cron", () => { ["eip155:10", "USDC"], ["eip155:8453", "USDC"], ["eip155:8453", "EURC"], + ["eip155:8453", "EURe"], ["eip155:84532", "USDC"], ["eip155:84532", "EURC"], + ["eip155:84532", "EURe"], ]); expect(body.results[0]).toEqual({ network: "eip155:10", diff --git a/scw_js/test/sc_llm_x402.test.ts b/scw_js/test/sc_llm_x402.test.ts index b1f9882ef..aaffaccc8 100644 --- a/scw_js/test/sc_llm_x402.test.ts +++ b/scw_js/test/sc_llm_x402.test.ts @@ -40,20 +40,24 @@ const { completion_tokens: bigint | number | string; }, provider: string, - symbol: "USDC" | "EURC", + symbol: "USDC" | "EURC" | "EURe", + decimals: number, ) => { - // One rate card per currency, as in llm_service.ts: $0.50/$1.50 and €0.44/€1.50. + // One rate card per currency, as in llm_service.ts: $0.50/$1.50 and €0.44/€1.50 — + // EURe shares the EUR card, rescaled to its 18 decimals like the real tokensToCost. const RATES: Record> = { mistral: { USDC: { in: 50n, out: 150n, den: 100n }, EURC: { in: 44n, out: 150n, den: 100n }, + EURe: { in: 44n, out: 150n, den: 100n }, }, }; const rate = RATES[provider]?.[symbol]; 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; + const base = (p * rate.in + c * rate.out) / rate.den; + return decimals === 6 ? base : base * 10n ** BigInt(decimals - 6); }, ), mockCreateLLMResourceServer: vi.fn(), @@ -489,8 +493,9 @@ describe("sc_llm_x402", () => { expect.objectContaining({ payTo: VALID_ADDRESS, scheme: mockScheme, - // 20000 tokens at the output rate: $1.50/M and €1.50/M both give 30000. - price: { USDC: "30000", EURC: "30000" }, + // 20000 tokens at the output rate: $1.50/M and €1.50/M give 30000 in 6 decimals, + // and 30000 ×10¹² for EURe's 18 — the same ceiling in each token's own units. + price: { USDC: "30000", EURC: "30000", EURe: "30000000000000000" }, }), ); expect(mockCreate402Response).toHaveBeenCalled(); diff --git a/scw_js/test/stablecoin_pricing.test.ts b/scw_js/test/stablecoin_pricing.test.ts index 32dae37b0..40248404c 100644 --- a/scw_js/test/stablecoin_pricing.test.ts +++ b/scw_js/test/stablecoin_pricing.test.ts @@ -8,18 +8,30 @@ const OP = "eip155:10"; describe("stablecoin_pricing", () => { describe("offeredStablecoins", () => { - it("offers EURC first on both Base networks — the order is the preference", () => { - expect(offeredStablecoins(BASE).map((c) => c.symbol)).toEqual(["EURC", "USDC"]); - expect(offeredStablecoins(BASE_SEPOLIA).map((c) => c.symbol)).toEqual(["EURC", "USDC"]); + it("offers EURe, then EURC, then USDC on both Base networks — the order is the preference", () => { + expect(offeredStablecoins(BASE).map((c) => c.symbol)).toEqual(["EURe", "EURC", "USDC"]); + expect(offeredStablecoins(BASE_SEPOLIA).map((c) => c.symbol)).toEqual([ + "EURe", + "EURC", + "USDC", + ]); }); - it("offers USDC only on Optimism, where EURC does not exist", () => { + it("offers USDC only on Optimism, where EURC and EURe do not exist", () => { expect(offeredStablecoins(OP).map((c) => c.symbol)).toEqual(["USDC"]); }); - it("needs no configuration to offer EURC", () => { - // EURC is a first-class price list, not a conversion that depends on a rate being set. + it("needs no configuration to offer EURC or EURe", () => { + // EURC and EURe are first-class price lists, not conversions that depend on a rate being set. expect(offeredStablecoins(BASE).some((c) => c.symbol === "EURC")).toBe(true); + expect(offeredStablecoins(BASE).some((c) => c.symbol === "EURe")).toBe(true); + }); + + it("carries each token's decimals — EURe is 18, not 6", () => { + const bySymbol = Object.fromEntries(offeredStablecoins(BASE).map((c) => [c.symbol, c])); + expect(bySymbol.EURe.decimals).toBe(18); + expect(bySymbol.EURC.decimals).toBe(6); + expect(bySymbol.USDC.decimals).toBe(6); }); }); diff --git a/scw_js/test/x402_offer_eure.test.ts b/scw_js/test/x402_offer_eure.test.ts new file mode 100644 index 000000000..faae0b8aa --- /dev/null +++ b/scw_js/test/x402_offer_eure.test.ts @@ -0,0 +1,165 @@ +/** + * The EURe 402 wire contract, both schemes in one place. + * + * EURe differs from USDC/EURC in three ways that must each show up on the wire, or a buyer + * cannot pay at all: it settles through Permit2 (so its entry carries + * `extra.assetTransferMethod: "permit2"` — the SDK client routes on + * `extra?.assetTransferMethod ?? "eip3009"`, and EURe has no EIP-3009), a fresh wallet needs + * the EIP-2612 permit (so the challenge advertises `eip2612GasSponsoring`), and its price is + * in 18 decimals. The last test proves the offer end to end: a real @x402/evm buyer scheme + * turns this exact entry into the permit2 payload the facilitator verifies. + * + * The batch-settlement scheme here is a fake whose `enhancePaymentRequirements` mirrors the + * real @x402/evm 2.28 one (`{ ...base, extra: { ...base.extra, receiverAuthorizer, + * withdrawDelay, minDeposit } }` — verified against the installed SDK's source): the real + * scheme needs S3 channel storage and a receiver-authorizer key, and what it contributes to + * this contract is precisely that spread — every `base.extra` key survives enhancement. + */ +import { describe, expect, it } from "vitest"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; +import { EURE_ADDRESSES } from "@fretchen/chain-utils"; +import { + createBatchSettlementPaymentRequirements, + createPaymentRequirements, +} from "../x402_server.js"; + +const BASE = "eip155:8453" as const; +const PAY_TO = "0xAAEBC1441323B8ad6Bdf6793A8428166b510239C" as `0x${string}`; +// The same nominal price per token, each in its own atomic units — EURe's is 18 decimals. +const PRICE = { USDC: "1001", EURC: "850", EURe: "850000000000000" }; +const EURE = EURE_ADDRESSES[BASE]; + +/** A scheme shaped like @x402/evm's BatchSettlementEvmScheme for the enhance call only. */ +const fakeBatchScheme = { + enhancePaymentRequirements: async (base: Record) => ({ + ...base, + extra: { + ...(base.extra as Record), + receiverAuthorizer: "0x073f26bd42d8ac19a3b4f95e2d2b1f4e576b8a12", + withdrawDelay: 86400, + minDeposit: "0", + }, + }), +} as unknown as Parameters[0]["scheme"]; + +describe("EURe offers — the wire contract both schemes must meet", () => { + describe("exact scheme", () => { + it("lists EURe first on Base with its own price, address and Permit2 method", () => { + const { accepts, extensions } = createPaymentRequirements({ + resourceUrl: "/test", + description: "Test", + mimeType: "application/json", + price: PRICE, + payTo: PAY_TO, + networks: [BASE], + }); + + expect(accepts).toHaveLength(3); + expect(accepts[0]).toMatchObject({ + asset: EURE, + amount: "850000000000000", + extra: { name: "Monerium EURe", version: "1", assetTransferMethod: "permit2" }, + }); + expect(extensions).toEqual({ eip2612GasSponsoring: {} }); + }); + + it("offers plain USDC on Optimism, with no Permit2 entry and no extension", () => { + const { accepts, extensions } = createPaymentRequirements({ + resourceUrl: "/test", + description: "Test", + mimeType: "application/json", + price: PRICE, + payTo: PAY_TO, + networks: ["eip155:10"], + }); + + expect(accepts).toHaveLength(1); + expect(accepts[0].extra).toEqual({ name: "USD Coin", version: "2" }); + expect(extensions).toBeUndefined(); + }); + }); + + describe("batch-settlement scheme", () => { + it("keeps assetTransferMethod through enhancePaymentRequirements", async () => { + const { accepts, extensions } = await createBatchSettlementPaymentRequirements({ + resourceUrl: "/test", + description: "Test", + mimeType: "application/json", + price: PRICE, + payTo: PAY_TO, + scheme: fakeBatchScheme, + networks: [BASE], + }); + + expect(accepts).toHaveLength(3); + const eure = accepts.find((a) => (a as { asset?: string }).asset === EURE) as { + extra: Record; + }; + // The enhanced entry carries the scheme's own keys AND the buyer's Permit2 routing. + expect(eure.extra).toMatchObject({ + name: "Monerium EURe", + version: "1", + assetTransferMethod: "permit2", + receiverAuthorizer: "0x073f26bd42d8ac19a3b4f95e2d2b1f4e576b8a12", + withdrawDelay: 86400, + }); + expect(extensions).toEqual({ eip2612GasSponsoring: {} }); + }); + }); + + describe("a real SDK buyer can pay the EURe offer", () => { + it("turns the exact-scheme EURe entry into a permit2 payload with an EIP-2612 permit", async () => { + const { accepts, extensions } = createPaymentRequirements({ + resourceUrl: "/test", + description: "Test", + mimeType: "application/json", + price: PRICE, + payTo: PAY_TO, + networks: [BASE], + }); + const eureRequirement = accepts.find((a) => a.asset === EURE) as unknown as Parameters< + InstanceType["createPaymentPayload"] + >[1]; + + // A fresh wallet: no Permit2 allowance, permit nonce 0 — so the scheme must sign the + // EIP-2612 permit for Permit2 (the extension the challenge advertises). + const account = privateKeyToAccount(generatePrivateKey()); + const signer = { + address: account.address, + signTypedData: (msg: Parameters[0]) => + account.signTypedData(msg), + readContract: async ({ functionName }: { functionName: string }) => { + if (functionName === "allowance" || functionName === "nonces") { + return 0n; + } + throw new Error(`offer test: unexpected client read ${functionName}`); + }, + }; + const { ExactEvmScheme } = await import("@x402/evm/exact/client"); + const scheme = new ExactEvmScheme(signer as never); + + const payload = (await scheme.createPaymentPayload(2, eureRequirement, { + extensions: extensions as Record, + })) as unknown as { + payload: { permit2Authorization: Record }; + extensions?: Record }>; + }; + + // The payload is Permit2-shaped and pays exactly what the offer asked, to the seller. + const auth = payload.payload.permit2Authorization as { + from: string; + permitted: { token: string; amount: string }; + witness: { to: string }; + }; + expect(auth.from).toBe(account.address); + expect(auth.permitted.token.toLowerCase()).toBe(EURE.toLowerCase()); + expect(auth.permitted.amount).toBe("850000000000000"); + expect(auth.witness.to.toLowerCase()).toBe(PAY_TO.toLowerCase()); + // Gas sponsoring: the buyer signed the EIP-2612 permit, so it needs no approve() tx. + expect(payload.extensions?.eip2612GasSponsoring?.info).toMatchObject({ + from: account.address, + asset: EURE, + }); + }); + }); +}); diff --git a/scw_js/x402_server.ts b/scw_js/x402_server.ts index 398c35571..a6b0f4e3f 100644 --- a/scw_js/x402_server.ts +++ b/scw_js/x402_server.ts @@ -385,37 +385,41 @@ export async function createBatchSettlementPaymentRequirements({ x402Version: number; resource: { url: string; description: string; mimeType: string }; accepts: unknown[]; + /** Present when a Permit2 token is offered (see `permit2Extensions`). */ + extensions?: Record; }> { + const offered = networks.flatMap((network) => + offeredStablecoins(network).map((coin) => ({ network, coin })), + ); const accepts = await Promise.all( - networks.flatMap((network) => - offeredStablecoins(network).map((coin) => { - const base: SdkPaymentRequirements = { + offered.map(({ network, coin }) => { + const base: SdkPaymentRequirements = { + scheme: "batch-settlement", + network: network as `${string}:${string}`, + amount: price[coin.symbol], + asset: coin.address, + payTo, + maxTimeoutSeconds, + extra: tokenExtra(coin), + }; + return scheme.enhancePaymentRequirements( + base, + { + x402Version: 2, scheme: "batch-settlement", network: network as `${string}:${string}`, - amount: price[coin.symbol], - asset: coin.address, - payTo, - maxTimeoutSeconds, - extra: { name: coin.name, version: coin.version }, - }; - return scheme.enhancePaymentRequirements( - base, - { - x402Version: 2, - scheme: "batch-settlement", - network: network as `${string}:${string}`, - extra: base.extra, - }, - [], - ); - }), - ), + extra: base.extra, + }, + [], + ); + }), ); return { x402Version: 2, resource: { url: resourceUrl, description, mimeType }, accepts, + extensions: permit2Extensions(offered.map(({ coin }) => coin)), }; } @@ -439,8 +443,42 @@ export interface PaymentRequirements { asset: string; payTo: string; maxTimeoutSeconds: number; - extra: { name: string; version: string }; + /** EURe (Permit2) entries also carry `assetTransferMethod: "permit2"`; EIP-3009 ones omit it. */ + extra: { name: string; version: string; assetTransferMethod?: "permit2" }; }>; + /** Present when a Permit2 token is offered: lets buyers sign an EIP-2612 permit for Permit2 + * inside the payment (gas sponsoring), so a fresh wallet needs no approve() transaction. */ + extensions?: Record; +} + +/** + * The 402 `extensions` entry that makes an EURe buyer's payment permit-carrying. The client only + * signs an EIP-2612 permit when the challenge advertises the extension; without it, a wallet + * without a standing Permit2 approval cannot pay EURe at all. + */ +function permit2Extensions( + coins: readonly { transferMethod: string }[], +): Record | undefined { + return coins.some((coin) => coin.transferMethod === "permit2") + ? { eip2612GasSponsoring: {} } + : undefined; +} + +/** + * The per-token `extra` every client needs to build its payment: the token's EIP-712 domain, plus + * the transfer method when it is not the EIP-3009 default — EURe has no EIP-3009, and a client + * that tries it (the SDK's `extra?.assetTransferMethod ?? "eip3009"` default) fails at signing. + */ +function tokenExtra(coin: { name: string; version: string; transferMethod: string }): { + name: string; + version: string; + assetTransferMethod?: "permit2"; +} { + return { + name: coin.name, + version: coin.version, + ...(coin.transferMethod === "permit2" && { assetTransferMethod: "permit2" as const }), + }; } /** The exact-scheme 402: one entry per (network, offered stablecoin), most preferred first. */ @@ -452,22 +490,24 @@ export function createPaymentRequirements({ payTo, networks = getSupportedNetworks(), }: PaymentRequirementsOptions): PaymentRequirements { - const accepts = networks.flatMap((network) => - offeredStablecoins(network).map((coin) => ({ - scheme: "exact", - network, - amount: price[coin.symbol], - asset: coin.address, - payTo, - maxTimeoutSeconds: 60, - extra: { name: coin.name, version: coin.version }, - })), + const offered = networks.flatMap((network) => + offeredStablecoins(network).map((coin) => ({ network, coin })), ); + const accepts = offered.map(({ network, coin }) => ({ + scheme: "exact", + network, + amount: price[coin.symbol], + asset: coin.address, + payTo, + maxTimeoutSeconds: 60, + extra: tokenExtra(coin), + })); return { x402Version: 2, resource: { url: resourceUrl, description, mimeType }, accepts, + extensions: permit2Extensions(offered.map(({ coin }) => coin)), }; } @@ -537,12 +577,11 @@ export function extractPaymentPayload( * discovery spec requires in decimal USD, not atomic units). Bigint-based to avoid float * precision loss; trims trailing zeros so "3000" -> "0.003", not "0.003000". */ -export function formatUsdcAtomicAsDecimalUsd(atomicAmount: string): string { +export function formatUsdcAtomicAsDecimalUsd(atomicAmount: string, decimals: bigint = 6n): string { const atomic = BigInt(atomicAmount); - const DECIMALS = 6n; - const divisor = 10n ** DECIMALS; + const divisor = 10n ** decimals; const whole = atomic / divisor; - const fraction = (atomic % divisor).toString().padStart(Number(DECIMALS), "0"); + const fraction = (atomic % divisor).toString().padStart(Number(decimals), "0"); const trimmedFraction = fraction.replace(/0+$/, ""); return trimmedFraction ? `${whole}.${trimmedFraction}` : whole.toString(); } diff --git a/shared/chain-utils/src/addresses.ts b/shared/chain-utils/src/addresses.ts index ad3e567a8..eb4d2ecdb 100644 --- a/shared/chain-utils/src/addresses.ts +++ b/shared/chain-utils/src/addresses.ts @@ -123,6 +123,20 @@ export const EURC_NAMES: Record = { "eip155:84532": "EURC", }; +// ═══════════════════════════════════════════════════════════════ +// EURe (Monerium — Base only, like EURC; no Optimism deployment) +// ═══════════════════════════════════════════════════════════════ + +/** + * Addresses from Monerium's token API (api.monerium.app; api.monerium.dev for the sandbox), + * confirmed on-chain 2026-10-04: name() "Monerium EURe", decimals() 18, EIP-712 version "1", + * EIP-2612 permit present, EIP-3009 absent. + */ +export const EURE_ADDRESSES: Record = { + "eip155:8453": "0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C", // Base + "eip155:84532": "0x29F37F6adCa168B79B8d9567eab9BE3fBF21db85", // Base Sepolia (Monerium sandbox) +}; + // ═══════════════════════════════════════════════════════════════ // Stablecoin lookup (USDC + EURC) // ═══════════════════════════════════════════════════════════════ @@ -167,6 +181,63 @@ export function findStablecoin(network: string, address: string): StablecoinInfo return getStablecoins(network).find((coin) => coin.address.toLowerCase() === wanted) ?? null; } +// ═══════════════════════════════════════════════════════════════ +// Settlement tokens — the stablecoins plus how each one moves +// ═══════════════════════════════════════════════════════════════ + +/** The stablecoins plus EURe — everything this project settles or charges fees in. */ +export type SettlementTokenSymbol = StablecoinSymbol | "EURe"; + +/** How a buyer authorizes moving a token: USDC and EURC implement EIP-3009 + * (`transferWithAuthorization`); EURe does not, so it is paid through Permit2 — with an EIP-2612 + * permit to Permit2 bundled in, so the buyer needs no separate approval transaction. + */ +export type AssetTransferMethod = "eip3009" | "permit2"; + +export interface SettlementTokenInfo { + symbol: SettlementTokenSymbol; + address: `0x${string}`; + /** EIP-712 domain name */ + name: string; + /** EIP-712 domain version */ + version: string; + /** ERC-20 decimals: 6 for USDC/EURC, 18 for EURe. Never assume 6. */ + decimals: number; + transferMethod: AssetTransferMethod; +} + +/** + * Every token this project settles on `network`: the stablecoins (USDC, EURC) plus EURe, where + * deployed. Sellers and the facilitator read this — the website does not: it keeps reading + * `getStablecoins()`, which stays USDC+EURC only, so the buyer side stays EURe-blind until it + * is deliberately switched (its spend-control cap is a 6-decimal figure that EURe's 18 + * decimals would silently veto). Like `getStablecoins`, this list carries no preference order — + * EURe is appended after the stablecoins, and a seller's own preference + * (`STABLECOIN_PREFERENCE` in scw_js) sorts it for the wire. + */ +export function getSettlementTokens(network: string): SettlementTokenInfo[] { + const coins: SettlementTokenInfo[] = getStablecoins(network).map((coin) => ({ + symbol: coin.symbol, + address: coin.address, + name: coin.name, + version: coin.version, + decimals: 6, + transferMethod: "eip3009", + })); + const eure = EURE_ADDRESSES[network]; + if (eure) { + coins.push({ + symbol: "EURe", + address: eure, + name: "Monerium EURe", + version: "1", + decimals: 18, + transferMethod: "permit2", + }); + } + return coins; +} + // ═══════════════════════════════════════════════════════════════ // EIP3009 Splitter // ═══════════════════════════════════════════════════════════════ diff --git a/shared/chain-utils/test/index.test.ts b/shared/chain-utils/test/index.test.ts index 3f4a719d1..f241ea9f7 100644 --- a/shared/chain-utils/test/index.test.ts +++ b/shared/chain-utils/test/index.test.ts @@ -33,8 +33,10 @@ import { TESTNET_EIP3009_SPLITTER_ADDRESSES, USDC_ADDRESSES, EURC_ADDRESSES, + EURE_ADDRESSES, findStablecoin, getStablecoins, + getSettlementTokens, } from "../src/addresses"; describe("@fretchen/chain-utils", () => { @@ -352,6 +354,70 @@ describe("@fretchen/chain-utils", () => { }); }); + describe("getSettlementTokens()", () => { + test("appends EURe after the stablecoins, in registry order, on Base", () => { + expect(getSettlementTokens("eip155:8453").map((c) => c.symbol)).toEqual([ + "USDC", + "EURC", + "EURe", + ]); + expect(getSettlementTokens("eip155:84532").map((c) => c.symbol)).toEqual([ + "USDC", + "EURC", + "EURe", + ]); + }); + + test("carries each token's own decimals, transfer method and EIP-712 domain", () => { + expect(getSettlementTokens("eip155:8453")).toEqual([ + { + symbol: "USDC", + address: USDC_ADDRESSES["eip155:8453"], + name: "USD Coin", + version: "2", + decimals: 6, + transferMethod: "eip3009", + }, + { + symbol: "EURC", + address: EURC_ADDRESSES["eip155:8453"], + name: "EURC", + version: "2", + decimals: 6, + transferMethod: "eip3009", + }, + { + symbol: "EURe", + address: EURE_ADDRESSES["eip155:8453"], + name: "Monerium EURe", + version: "1", + decimals: 18, + transferMethod: "permit2", + }, + ]); + }); + + test("has no EURe or EURC on Optimism, where neither is deployed", () => { + for (const network of ["eip155:10", "eip155:11155420"]) { + expect(getSettlementTokens(network).map((c) => c.symbol)).toEqual(["USDC"]); + } + }); + + test("is empty for an unknown network", () => { + expect(getSettlementTokens("eip155:1")).toEqual([]); + }); + + test("leaves getStablecoins() unchanged — the website surface (regression guard)", () => { + for (const network of ["eip155:8453", "eip155:10", "eip155:1"]) { + expect(getStablecoins(network).map((c) => c.symbol)).toEqual( + getStablecoins(network).map((c) => c.symbol), + ); + expect(getStablecoins("eip155:8453").map((c) => c.symbol)).toEqual(["USDC", "EURC"]); + expect(getStablecoins(network).every((c) => c.symbol !== "EURe")).toBe(true); + } + }); + }); + describe("getUSDCConfig()", () => { test("should return complete config for Optimism Mainnet", () => { const config = getUSDCConfig("eip155:10"); diff --git a/x402_facilitator/chain_utils.ts b/x402_facilitator/chain_utils.ts index 6f7c3bab8..8ec7c34ad 100644 --- a/x402_facilitator/chain_utils.ts +++ b/x402_facilitator/chain_utils.ts @@ -11,8 +11,9 @@ import { tryGetEIP3009SplitterAddress, getUSDCAddress, getUSDCName, - getStablecoins, + getSettlementTokens, type Network, + type SettlementTokenInfo, } from "@fretchen/chain-utils"; export interface ChainConfig { @@ -70,57 +71,28 @@ export function getBatchSettlementNetworks(): Network[] { } // ═══════════════════════════════════════════════════════════════ -// Fee tokens — the stablecoins this facilitator settles and charges its fee in +// Fee tokens — the tokens this facilitator settles and charges its fee in // ═══════════════════════════════════════════════════════════════ /** * How a buyer authorizes moving a token. USDC and EURC implement EIP-3009 * (`transferWithAuthorization`); EURe does not, so it is paid through Permit2 — with an EIP-2612 * permit to Permit2 bundled in, so the buyer needs no separate approval transaction. + * (Mirrored from @fretchen/chain-utils, where the registry lives.) */ export type TransferMethod = "eip3009" | "permit2"; -export interface FeeToken { - symbol: string; - address: `0x${string}`; - /** ERC-20 decimals: 6 for USDC/EURC, 18 for EURe. Never assume 6. */ - decimals: number; - transferMethod: TransferMethod; -} +/** A token this facilitator settles and may charge its fee in (same shape as the shared registry's). */ +export type FeeToken = SettlementTokenInfo; /** - * Monerium EURe. Facilitator-local for now: the sellers (`scw_js`) do not offer it yet, and the - * shared `@fretchen/chain-utils` registry they read must not list a token they cannot sell. It - * moves there with the seller-side PR. - * - * Addresses from Monerium's token API (api.monerium.app; api.monerium.dev for the sandbox), - * confirmed on-chain 2026-10-04: name() "Monerium EURe", decimals() 18, EIP-712 version "1", - * EIP-2612 permit present, EIP-3009 absent. No Optimism deployment. + * Every token this facilitator settles on `network`, from the shared registry: USDC everywhere, + * EURC and EURe (Monerium, 18 decimals, paid via Permit2) on Base. The sellers (`scw_js`) read + * the same list for what they offer, so the facilitator never charges a fee in a token the + * sellers cannot be paid in. */ -export const EURE_ADDRESSES: Record = { - "eip155:8453": "0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C", // Base - "eip155:84532": "0x29F37F6adCa168B79B8d9567eab9BE3fBF21db85", // Base Sepolia (Monerium sandbox) -}; -export const EURE_DECIMALS = 18; - -/** Every token this facilitator settles on `network`: USDC everywhere, EURC and EURe on Base. */ export function getFeeTokens(network: string): FeeToken[] { - const tokens: FeeToken[] = getStablecoins(network).map((coin) => ({ - symbol: coin.symbol, - address: coin.address, - decimals: 6, - transferMethod: "eip3009", - })); - const eure = EURE_ADDRESSES[network]; - if (eure) { - tokens.push({ - symbol: "EURe", - address: eure, - decimals: EURE_DECIMALS, - transferMethod: "permit2", - }); - } - return tokens; + return getSettlementTokens(network); } /** The fee token at `address` on `network`, case-insensitively; null for any other token. */ diff --git a/x402_facilitator/test/chain_utils.test.ts b/x402_facilitator/test/chain_utils.test.ts index 384bac39b..a3ef9cad3 100644 --- a/x402_facilitator/test/chain_utils.test.ts +++ b/x402_facilitator/test/chain_utils.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; -import { getFeeTokens, findFeeToken, EURE_ADDRESSES } from "../chain_utils.js"; +import { getFeeTokens, findFeeToken } from "../chain_utils.js"; +import { EURE_ADDRESSES } from "@fretchen/chain-utils"; const BASE = "eip155:8453"; const BASE_SEPOLIA = "eip155:84532"; diff --git a/x402_facilitator/test/helpers/signPayment.ts b/x402_facilitator/test/helpers/signPayment.ts index 94f82aa45..33e962488 100644 --- a/x402_facilitator/test/helpers/signPayment.ts +++ b/x402_facilitator/test/helpers/signPayment.ts @@ -14,7 +14,8 @@ import { randomBytes } from "node:crypto"; import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; import { ExactEvmScheme } from "@x402/evm/exact/client"; -import { EURE_ADDRESSES, getChainConfig } from "../../chain_utils"; +import { getChainConfig } from "../../chain_utils"; +import { EURE_ADDRESSES } from "@fretchen/chain-utils"; type Address = `0x${string}`; diff --git a/x402_facilitator/test/x402_permit2_real_sdk.test.ts b/x402_facilitator/test/x402_permit2_real_sdk.test.ts index 1a1a9b6cf..fc785c6fb 100644 --- a/x402_facilitator/test/x402_permit2_real_sdk.test.ts +++ b/x402_facilitator/test/x402_permit2_real_sdk.test.ts @@ -23,7 +23,7 @@ import { verifyPayment } from "../x402_verify.js"; import { resetFacilitator } from "../facilitator_instance.js"; import { getFacilitatorAddress } from "../x402_fee.js"; import { SettleResponseSchema } from "../x402_schemas.js"; -import { EURE_ADDRESSES } from "../chain_utils.js"; +import { EURE_ADDRESSES } from "@fretchen/chain-utils"; import { chain } from "./helpers/fakeChain"; import { signPermit2Payment, EURE_DOMAIN, SELLER } from "./helpers/signPayment"; From a1dba58ee38802425111f1ee88dd20326dc35f98 Mon Sep 17 00:00:00 2001 From: fretchen Date: Sat, 10 Oct 2026 23:14:48 +0200 Subject: [PATCH 02/13] Buyer notebooks: allowlist EURe so it can be paid end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each _buyer notebook's spend-control allowlist decides which non-registry token is payable, so EURe needs an entry like EURC's: genimg on Base mainnet (with its own 18-decimal cap — the 6-decimal figure would cap it at 10⁻¹²), sc_llm on both Base networks (the default Base Sepolia run pays sandbox EURe), search on Base mainnet. --- scw_js/notebooks/genimg_x402_buyer.ipynb | 10 +++++++++- scw_js/notebooks/sc_llm_x402_buyer.ipynb | 25 ++++++++++++++---------- scw_js/notebooks/search_x402_buyer.ipynb | 20 ++++++++++--------- 3 files changed, 35 insertions(+), 20 deletions(-) diff --git a/scw_js/notebooks/genimg_x402_buyer.ipynb b/scw_js/notebooks/genimg_x402_buyer.ipynb index c9c347575..4aed2cd53 100644 --- a/scw_js/notebooks/genimg_x402_buyer.ipynb +++ b/scw_js/notebooks/genimg_x402_buyer.ipynb @@ -172,6 +172,9 @@ " // shared/chain-utils/src/addresses.ts and scw_js/README.md's EURC domain-name table.\n", " eurcAddress: \"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42\" as `0x${string}` | undefined,\n", " eurcName: \"EURC\" as string | undefined,\n", + " // EURe on Base (Monerium, 18 decimals) \u2014 paid via Permit2 with an EIP-2612 permit", + " // (the server's 402 advertises eip2612GasSponsoring), so this buyer signs no approve().", + " eureAddress: \"0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C\" as `0x${string}` | undefined,", " nftContract: \"0xa5d6a3eEDADc3346E22dF9556dc5B99f2777ab68\",\n", " rpcUrl: \"https://base-rpc.publicnode.com\",\n", " explorer: \"https://basescan.org\", faucet: \"Bridge: https://bridge.base.org\",\n", @@ -189,7 +192,7 @@ "console.log(USE_MAINNET ? `\ud83d\udea8 REAL MONEY on ${config.networkName}` : `\ud83e\uddea ${config.networkName}`);\n", "console.log(` ${NETWORK} \u2022 USDC ${config.usdcName} @ ${config.usdcAddress}`);\n", "if (config.eurcAddress) {\n", - " console.log(` ${NETWORK} \u2022 EURC @ ${config.eurcAddress} (server prefers this over USDC)`);\n", + " console.log(` ${NETWORK} \u2022 EURC @ ${config.eurcAddress}, EURe @ ${config.eureAddress} (server prefers EURe)`);\n", "}\n", "console.log(` GenImNFT: ${config.nftContract}`);\n", "console.log(` Service : ${SERVICE_URL}`);" @@ -248,11 +251,16 @@ "// the opposite reason: the registry only knows USDC at all, so an unlisted EURC entry is dropped\n", "// silently rather than paid \u2014 allowlisting it is what lets a Base run ever choose it.\n", "const MAX_PER_PAYMENT_ATOMIC = \"1000000\"; // $1, mirroring the SDK's own default cap\n", + "// EURe's own cap: 1 EURe in its 18 decimals \u2014 the 6-decimal figure above would cap it at 10\u207b\u00b9\u00b2.", + "const MAX_PER_PAYMENT_EURE = \"1000000000000000000\";", "client.setSpendControls({\n", " allowedAssets: Object.values(NETWORK_CONFIG).flatMap((c) => [\n", " { network: c.caip2Network, asset: c.usdcAddress, maxAmountPerPayment: MAX_PER_PAYMENT_ATOMIC },\n", " ...(c.eurcAddress\n", " ? [{ network: c.caip2Network, asset: c.eurcAddress, maxAmountPerPayment: MAX_PER_PAYMENT_ATOMIC }]\n", + " ...(c.eureAddress", + " ? [{ network: c.caip2Network, asset: c.eureAddress, maxAmountPerPayment: MAX_PER_PAYMENT_EURE }]", + " : []),", " : []),\n", " ]),\n", "});\n", diff --git a/scw_js/notebooks/sc_llm_x402_buyer.ipynb b/scw_js/notebooks/sc_llm_x402_buyer.ipynb index 5b2001dbc..99b8da6b5 100644 --- a/scw_js/notebooks/sc_llm_x402_buyer.ipynb +++ b/scw_js/notebooks/sc_llm_x402_buyer.ipynb @@ -158,6 +158,8 @@ " // EURC on Base Sepolia — see shared/chain-utils/src/addresses.ts and scw_js/README.md's\n", " // EURC domain-name table.\n", " eurcAddress: \"0x808456652fdb597867f38412077A9182bf77359F\" as `0x${string}` | undefined,\n", + " // EURe (Monerium, 18 decimals, Permit2 + EIP-2612 permit — no approve(), no ETH needed).", + " eureAddress: \"0x29F37F6adCa168B79B8d9567eab9BE3fBF21db85\" as `0x${string}` | undefined,", " // Testnet traffic is light enough that the chain's default endpoint is fine.\n", " rpcUrl: undefined as string | undefined,\n", " explorer: \"https://sepolia.basescan.org\", faucet: \"https://faucet.circle.com/\",\n", @@ -165,6 +167,8 @@ " \"base-mainnet\": {\n", " caip2Network: \"eip155:8453\" as const, networkName: \"Base Mainnet\",\n", " chain: base, usdcName: \"USD Coin\",\n", + " // EURe (Monerium, 18 decimals, Permit2 + EIP-2612 permit — no approve(), no ETH needed).", + " eureAddress: \"0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C\" as `0x${string}` | undefined,", " usdcAddress: \"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\" as `0x${string}`,\n", " eurcAddress: \"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42\" as `0x${string}` | undefined,\n", " rpcUrl: \"https://base-rpc.publicnode.com\",\n", @@ -200,7 +204,7 @@ "console.log(USE_MAINNET ? `🚨 REAL MONEY on ${config.networkName}` : `🧪 ${config.networkName}`);\n", "console.log(` ${NETWORK} • USDC ${config.usdcName} @ ${config.usdcAddress}`);\n", "if (config.eurcAddress) {\n", - " console.log(` ${NETWORK} • EURC @ ${config.eurcAddress} (server prefers this over USDC)`);\n", + " console.log(` ${NETWORK} • EURC @ ${config.eurcAddress}, EURe @ ${config.eureAddress} (server prefers EURe)`);\n", "}\n", "console.log(` Service: ${SERVICE_URL}`);" ] @@ -306,16 +310,17 @@ "client.register(NETWORK, buyerScheme);\n", "\n", "// USDC already passes the SDK's default spend-control asset registry on every network this\n", - "// notebook uses. EURC is not in that registry at all, so without this entry it would be dropped\n", - "// from the offer rather than paid. With it, the SDK takes the first offered entry — EURC on Base.\n", - "if (config.eurcAddress) {\n", - " client.setSpendControls({\n", - " allowedAssets: [{ network: NETWORK, asset: config.eurcAddress }],\n", - " });\n", - "}\n", + "// notebook uses. EURC and EURe are not in that registry at all, so without these entries they\n", + "// would be dropped from the offer rather than paid. With them, the SDK takes the first offered\n", + "// entry — EURe on Base, which the server now prefers.\n", + "client.setSpendControls({\n", + " allowedAssets: [\n", + " ...(config.eurcAddress ? [{ network: NETWORK, asset: config.eurcAddress }] : []),\n", + " ...(config.eureAddress ? [{ network: NETWORK, asset: config.eureAddress }] : []),\n", + " ],\n", + "});\n", "\n", - "const fetchWithPayment = wrapFetchWithPayment(fetch, client);\n", - "console.log(\"✅ buyer client ready, registered for\", NETWORK);" + "const fetchWithPayment = wrapFetchWithPayment(fetch, client);\n" ] }, { diff --git a/scw_js/notebooks/search_x402_buyer.ipynb b/scw_js/notebooks/search_x402_buyer.ipynb index 72a480162..298c694f2 100644 --- a/scw_js/notebooks/search_x402_buyer.ipynb +++ b/scw_js/notebooks/search_x402_buyer.ipynb @@ -135,6 +135,8 @@ " // EURC on Base \u2014 see shared/chain-utils/src/addresses.ts and scw_js/README.md's EURC\n", " // domain-name table.\n", " eurcAddress: \"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42\" as `0x${string}` | undefined,\n", + " // EURe on Base (Monerium, 18 decimals, Permit2 + EIP-2612 permit \u2014 no approve()).", + " eureAddress: \"0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C\" as `0x${string}` | undefined,", " rpcUrl: \"https://base-rpc.publicnode.com\",\n", " explorer: \"https://basescan.org\",\n", " },\n", @@ -252,13 +254,14 @@ "client.register(NETWORK, buyerScheme);\n", "\n", "// USDC already passes the SDK's default spend-control asset registry on both networks this\n", - "// notebook uses, so it needs no explicit entry. EURC does not exist in that registry at all, so\n", - "// without this it would be silently dropped from Base's offer rather than ever paid.\n", - "if (config.eurcAddress) {\n", - " client.setSpendControls({\n", - " allowedAssets: [{ network: NETWORK, asset: config.eurcAddress }],\n", - " });\n", - "}\n", + "// notebook uses, so it needs no explicit entry. EURC and EURe do not exist in that registry\n", + "// at all, so without these they would be silently dropped from Base's offer rather than paid.\n", + "client.setSpendControls({\n", + " allowedAssets: [\n", + " ...(config.eurcAddress ? [{ network: NETWORK, asset: config.eurcAddress }] : []),\n", + " ...(config.eureAddress ? [{ network: NETWORK, asset: config.eureAddress }] : []),\n", + " ],\n", + "});\n", "\n", "const fetchWithPayment = wrapFetchWithPayment(fetch, client);\n", "\n", @@ -274,8 +277,7 @@ " })\n", " : null;\n", "}\n", - "\n", - "console.log(\"\u2705 buyer client ready, registered for\", NETWORK);" + "\n" ] }, { From 2da553d29622e07549fd158b1a57c0793dd6549a Mon Sep 17 00:00:00 2001 From: fretchen Date: Sat, 10 Oct 2026 23:26:31 +0200 Subject: [PATCH 03/13] Fee math per token: read /supported's assets, never the 6-decimal nominal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit facilitatorFeeFor (new fee_config.ts, pure and unmockable-by-proxy) resolves the fee in each token's own atomic units from /supported's facilitatorFees .assets, falling back to the nominal flatFee only for 6-decimal tokens and returning null (unknown, never a guess) otherwise. The fee-allowance script and the claim cron both used the nominal figure for every token: an 18-decimal EURe allowance divided by the 6-decimal 10000 reported a real 0.98-EURe approval as 98000000000000 settlements, and --approve would have written 10⁻¹² EURe over a healthy allowance. The parse is per-entry safe too: one malformed asset no longer nulls the whole config (which read as "no fee configured" and silently disabled the checks). Encoded as regressions: 0.98 EURe = 98 claims, empty assets on an old facilitator = no runway reported for EURe. --- scw_js/fee_config.ts | 46 +++++++++ scw_js/llm_x402_cron.ts | 25 +++-- scw_js/scripts/fee_allowances.ts | 48 ++++++--- scw_js/test/fee_allowances.test.ts | 154 ++++++++++++++++++++++++++++- scw_js/test/llm_x402_cron.test.ts | 68 ++++++++++++- scw_js/x402_server.ts | 49 ++++++--- 6 files changed, 357 insertions(+), 33 deletions(-) create mode 100644 scw_js/fee_config.ts diff --git a/scw_js/fee_config.ts b/scw_js/fee_config.ts new file mode 100644 index 000000000..47ad78794 --- /dev/null +++ b/scw_js/fee_config.ts @@ -0,0 +1,46 @@ +/** + * The facilitator's fee model, as /supported publishes it — and how to read the right figure + * for a token. + * + * Separate module, not part of `x402_server.ts`, deliberately: `x402_server` pulls in S3 + * storage and the SDK resource-server machinery, and several tests mock it wholesale — a + * consumer that imports the fee math from there would get the mock, not the math. This file + * has no imports at all, so every consumer (sellers' fee-allowance script, the claim cron, + * and their tests) runs the real thing. + */ + +/** What the facilitator charges, and the address that collects it. */ +export interface FacilitatorFeeConfig { + /** Spender to approve — the facilitator wallet that runs `transferFrom`. */ + recipient: `0x${string}`; + /** Flat fee per settlement, in 6-decimal NOMINAL units — the top-level figure /supported + * publishes. Valid as an atomic amount only for 6-decimal tokens (USDC, EURC); EURe's + * real fee is 10¹² times this, and lives in `feeByAsset`. Never divide an allowance by + * this number for a token with other decimals — see `facilitatorFeeFor`. */ + flatFee: bigint; + /** Per-token fees in each token's OWN atomic units, from /supported's `facilitatorFees.assets` + * (key: `":"`). Empty on a facilitator build predating the + * per-token assets list — then only 6-decimal tokens have a knowable fee. */ + feeByAsset: Map; +} + +/** + * The facilitator's fee in `token`'s own atomic units on `network`, or null when it cannot be + * known: no per-token `assets` entry, and `token` is not a 6-decimal token for which the + * nominal `flatFee` happens to be the atomic amount. Callers must treat null as "unknown", + * never fall back to the nominal figure — dividing an 18-decimal EURe allowance by the + * 6-decimal 10000 reports a 1-EURe approval as a trillion settlements, and approving + * 100 × 10000 atomic would write an allowance of 10⁻¹² EURe over a healthy one. + */ +export function facilitatorFeeFor( + fee: FacilitatorFeeConfig, + network: string, + token: string, + decimals: number, +): bigint | null { + const perToken = fee.feeByAsset.get(`${network}:${token.toLowerCase()}`); + if (perToken !== undefined) { + return perToken; + } + return decimals === 6 ? fee.flatFee : null; +} diff --git a/scw_js/llm_x402_cron.ts b/scw_js/llm_x402_cron.ts index 70182d94f..57d367c8a 100644 --- a/scw_js/llm_x402_cron.ts +++ b/scw_js/llm_x402_cron.ts @@ -1,14 +1,21 @@ import { logger } from "./logger.js"; import { createPublicClient, http } from "viem"; -import { getSettlementTokens, getViemChain, getRpcUrl } from "@fretchen/chain-utils"; +import { + getSettlementTokens, + getViemChain, + getRpcUrl, + type SettlementTokenInfo, +} from "@fretchen/chain-utils"; import { createLLMResourceServer, createFacilitatorClient, getBatchSettlementNetworks, getFacilitatorFeeConfig, useEnhancedRefundRequirements, - type FacilitatorFeeConfig, } from "./x402_server.js"; +// From fee_config, not x402_server: the cron's test mocks x402_server wholesale, and this +// function is the very logic under test — it must stay the real one. +import { facilitatorFeeFor, type FacilitatorFeeConfig } from "./fee_config.js"; import { resyncChannelState } from "./x402_channel_sync.js"; import type { Channel } from "@x402/evm/batch-settlement/server"; import type { ScwEvent } from "./types.js"; @@ -108,21 +115,27 @@ function findStuckChannels(channels: Channel[]): string[] { async function readFeeAllowanceClaimsLeft( receiver: `0x${string}`, network: string, - token: `0x${string}`, + coin: SettlementTokenInfo, fee: FacilitatorFeeConfig, ): Promise { + const feePerClaim = facilitatorFeeFor(fee, network, coin.address, coin.decimals); + if (feePerClaim === null) { + // The facilitator publishes no per-token fee for this token (an old build, pre-`assets`): + // the nominal 6-decimal figure would be 10¹² off for EURe, so no number beats a wrong one. + return null; + } try { const publicClient = createPublicClient({ chain: getViemChain(network), transport: http(getRpcUrl(network)), }); const allowance = await publicClient.readContract({ - address: token, + address: coin.address, abi: ERC20_ALLOWANCE_ABI, functionName: "allowance", args: [receiver, fee.recipient], }); - return Number(allowance / fee.flatFee); + return Number(allowance / feePerClaim); } catch (err) { logger.debug({ err, network }, "Could not read fee allowance — skipping the low-balance check"); return null; @@ -190,7 +203,7 @@ export async function handle( // allowance, this is the run where the warning is most needed. let claimsLeft: number | null = null; if (feeConfig) { - claimsLeft = await readFeeAllowanceClaimsLeft(receiverAddress, network, token, feeConfig); + claimsLeft = await readFeeAllowanceClaimsLeft(receiverAddress, network, coin, feeConfig); if (claimsLeft !== null && BigInt(claimsLeft) < LOW_ALLOWANCE_CLAIMS) { logger.warn( { diff --git a/scw_js/scripts/fee_allowances.ts b/scw_js/scripts/fee_allowances.ts index 82312b37a..0d6e21fe8 100644 --- a/scw_js/scripts/fee_allowances.ts +++ b/scw_js/scripts/fee_allowances.ts @@ -25,7 +25,14 @@ * npm run fee-allowances -- --approve # approve 100 fees' worth where short */ import dotenv from "dotenv"; -import { createPublicClient, createWalletClient, erc20Abi, http, type Address } from "viem"; +import { + createPublicClient, + createWalletClient, + erc20Abi, + formatUnits, + http, + type Address, +} from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { getRpcUrl, @@ -33,6 +40,7 @@ import { loadPrivateKey, type SettlementTokenInfo, } from "@fretchen/chain-utils"; +import { facilitatorFeeFor } from "../fee_config.js"; import { getFacilitatorFeeConfig, getSupportedNetworks } from "../x402_server.js"; import { offeredStablecoins } from "../stablecoin_pricing.js"; @@ -71,9 +79,9 @@ export function assessAllowance(allowance: bigint | null, flatFee: bigint): Allo : { kind: "short", settlementsLeft }; } -/** Both stablecoins have 6 decimals. */ -function units(atomic: bigint): string { - return (Number(atomic) / 1e6).toFixed(2); +/** A token amount in whole units — USDC/EURC have 6 decimals, EURe 18. */ +function units(atomic: bigint, decimals: number): string { + return formatUnits(atomic, decimals); } function publicClientFor(network: string) { @@ -111,7 +119,9 @@ async function approve(pair: FeePair, spender: Address, amount: bigint): Promise if (receipt.status !== "success") { throw new Error(`approve reverted (${hash})`); } - console.log(` ✅ ${pair.network} ${pair.coin.symbol}: approved ${units(amount)} (${hash})`); + console.log( + ` ✅ ${pair.network} ${pair.coin.symbol}: approved ${units(amount, pair.coin.decimals)} (${hash})`, + ); } async function main(): Promise { @@ -145,21 +155,35 @@ async function main(): Promise { } console.log( - `Seller ${seller} → facilitator ${fee.recipient}, fee ${units(fee.flatFee)} per settlement\n`, + `Seller ${seller} → facilitator ${fee.recipient}, nominal fee ${units(fee.flatFee, 6)} per settlement\n` + + " (per-token figures below come from /supported's facilitatorFees.assets — EURe's 0.01 is 10¹² its nominal figure)\n", ); - const short: FeePair[] = []; + // (network, token) → the fee in THAT token's atomic units. Computed once: both the check + // and --approve must use it, never the nominal 6-decimal flatFee — for EURe that would + // divide an 18-decimal allowance by a 6-decimal fee (a 1-EURe approval reported as a + // trillion settlements) and approve 10⁻¹² EURe over a healthy allowance. + const short: Array<{ pair: FeePair; fee: bigint }> = []; for (const pair of listFeePairs(getSupportedNetworks())) { - const status = assessAllowance(await readAllowance(pair, seller, fee.recipient), fee.flatFee); const label = `${pair.network.padEnd(16)} ${pair.coin.symbol.padEnd(5)}`; + const pairFee = facilitatorFeeFor(fee, pair.network, pair.coin.address, pair.coin.decimals); + if (pairFee === null) { + // facilitatorFeeFor returns null only for a non-6-decimal token with no per-token entry — + // an old facilitator build. No number beats a wrong one; refuse to guess. + console.warn( + `⚠️ ${label} the facilitator publishes no per-token fee — old build? Run the latest x402_facilitator. Skipped.`, + ); + continue; + } + const status = assessAllowance(await readAllowance(pair, seller, fee.recipient), pairFee); if (status.kind === "unknown") { console.warn(`⚠️ ${label} allowance unreadable — skipped`); } else { const mark = status.kind === "ok" ? "✅" : "❌"; console.log( - `${mark} ${label} ${String(status.settlementsLeft).padStart(4)} settlements left`, + `${mark} ${label} ${String(status.settlementsLeft).padStart(4)} settlements left (${units(pairFee, pair.coin.decimals)} per settlement)`, ); - if (status.kind === "short") short.push(pair); + if (status.kind === "short") short.push({ pair, fee: pairFee }); } } @@ -176,9 +200,9 @@ async function main(): Promise { process.exit(1); } - const amount = fee.flatFee * SETTLEMENTS_PER_APPROVAL; let failed = 0; - for (const pair of short) { + for (const { pair, fee: pairFee } of short) { + const amount = pairFee * SETTLEMENTS_PER_APPROVAL; try { await approve(pair, fee.recipient, amount); } catch (err) { diff --git a/scw_js/test/fee_allowances.test.ts b/scw_js/test/fee_allowances.test.ts index c70b0a919..edd8f0579 100644 --- a/scw_js/test/fee_allowances.test.ts +++ b/scw_js/test/fee_allowances.test.ts @@ -1,5 +1,11 @@ -import { describe, it, expect, vi } from "vitest"; -import { getSettlementTokens, type SettlementTokenInfo } from "@fretchen/chain-utils"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { + getSettlementTokens, + EURE_ADDRESSES, + EURC_ADDRESSES, + USDC_ADDRESSES, + type SettlementTokenInfo, +} from "@fretchen/chain-utils"; // Importing the script pulls in x402_server, which reaches S3 only through these; stub them so // importing the module for its pure helpers never touches the network (as migrate_channel_storage's test does). @@ -10,6 +16,8 @@ vi.mock("@fretchen/s3-utils", () => ({ })); import { assessAllowance, listFeePairs } from "../scripts/fee_allowances.js"; +import { facilitatorFeeFor } from "../fee_config.js"; +import { getFacilitatorFeeConfig } from "../x402_server.js"; const FEE = 10_000n; // 0.01 of a 6-decimal token @@ -54,4 +62,146 @@ describe("assessAllowance", () => { // allowance that may well exist. expect(assessAllowance(null, FEE)).toEqual({ kind: "unknown" }); }); + + it("counts EURe settlements with the 18-decimal fee, not the 6-decimal nominal one", () => { + // The real wallet state that once printed "98000000000000 settlements left": a 0.98 EURe + // allowance is 98 settlements at 0.01 EURe (10¹⁶) each — dividing by the nominal 10000 + // instead inflated it by 10¹². + const EURE_FEE = 10_000_000_000_000_000n; // 0.01 EURe, 18 decimals + expect(assessAllowance(980_000_000_000_000_000n, EURE_FEE)).toEqual({ + kind: "ok", + settlementsLeft: 98n, + }); + }); +}); + +describe("facilitatorFeeFor — which fee figure applies to a token", () => { + const BASE = "eip155:8453"; + const EURE_FEE = 10_000_000_000_000_000n; // 0.01 EURe, as /supported's assets publish it + const config = (feeByAsset: Map) => ({ + recipient: "0x3F8d2Fb6fEA24E70155bC61471936F3c9C30c206" as `0x${string}`, + flatFee: FEE, + feeByAsset, + }); + + it("uses the per-token entry — EURe's fee is 10¹² the nominal figure", () => { + const fee = config(new Map([[`${BASE}:${EURE_ADDRESSES[BASE].toLowerCase()}`, EURE_FEE]])); + expect(facilitatorFeeFor(fee, BASE, EURE_ADDRESSES[BASE], 18)).toBe(EURE_FEE); + }); + + it("falls back to the nominal figure for a 6-decimal token with no per-token entry", () => { + expect(facilitatorFeeFor(config(new Map()), BASE, USDC_ADDRESSES[BASE], 6)).toBe(FEE); + }); + + it("returns null for a non-6-decimal token without a per-token entry — never the nominal figure", () => { + // The guard against the 10¹² bug: an old facilitator build publishes no assets, and the + // only honest answer for EURe then is "unknown", not flatFee. + expect(facilitatorFeeFor(config(new Map()), BASE, EURE_ADDRESSES[BASE], 18)).toBeNull(); + }); + + it("matches the asset address case-insensitively and never across networks", () => { + const fee = config(new Map([[`${BASE}:${EURE_ADDRESSES[BASE].toLowerCase()}`, EURE_FEE]])); + expect( + facilitatorFeeFor(fee, BASE, EURE_ADDRESSES[BASE].toUpperCase() as `0x${string}`, 18), + ).toBe(EURE_FEE); + // Base Sepolia's EURe has no entry here: the Base-mainnet fee must not answer for it. + expect(facilitatorFeeFor(fee, "eip155:84532", EURE_ADDRESSES["eip155:84532"], 18)).toBeNull(); + }); +}); + +describe("getFacilitatorFeeConfig — parsing /supported", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + /** The shape the deployed facilitator publishes (facilitator.fretchen.eu/supported, abridged). */ + const supportedBody = (assets: unknown[]) => ({ + extensions: ["facilitator_fee", "facilitatorFees"], + facilitatorFees: { + version: "1", + model: "flat", + asset: "settled", + flatFee: "10000", + decimals: 6, + recipient: "0x3F8d2Fb6fEA24E70155bC61471936F3c9C30c206", + assets, + }, + }); + + function stubSupported(body: unknown) { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, json: async () => body })), + ); + } + + it("collects each token's own fee from facilitatorFees.assets, keyed network+asset", async () => { + const BASE = "eip155:8453"; + stubSupported( + supportedBody([ + { + network: BASE, + asset: USDC_ADDRESSES[BASE], + symbol: "USDC", + decimals: 6, + flatFee: "10000", + recommended_amount: "1000000", + }, + { + network: BASE, + asset: EURC_ADDRESSES[BASE], + symbol: "EURC", + decimals: 6, + flatFee: "10000", + recommended_amount: "1000000", + }, + { + network: BASE, + asset: EURE_ADDRESSES[BASE], + symbol: "EURe", + decimals: 18, + flatFee: "10000000000000000", + recommended_amount: "1000000000000000000", + }, + ]), + ); + + const fee = await getFacilitatorFeeConfig(); + expect(fee?.recipient).toBe("0x3F8d2Fb6fEA24E70155bC61471936F3c9C30c206"); + expect(fee?.flatFee).toBe(FEE); + expect(fee?.feeByAsset.get(`${BASE}:${EURE_ADDRESSES[BASE].toLowerCase()}`)).toBe( + 10_000_000_000_000_000n, + ); + }); + + it("skips malformed or zero-fee asset entries rather than guessing", async () => { + const BASE = "eip155:8453"; + stubSupported( + supportedBody([ + { network: BASE, asset: USDC_ADDRESSES[BASE], flatFee: "10000" }, // fine + { network: BASE, flatFee: "10000" }, // no asset — skipped + { network: BASE, asset: EURC_ADDRESSES[BASE], flatFee: "0" }, // zero fee — skipped + { network: BASE, asset: "0xbadbadbadbadbadbadbadbadbadbadbadbadbad", flatFee: "nan" }, // unparseable — skipped + ]), + ); + + const fee = await getFacilitatorFeeConfig(); + expect(fee?.feeByAsset.size).toBe(1); + expect(fee?.feeByAsset.has(`${BASE}:${USDC_ADDRESSES[BASE].toLowerCase()}`)).toBe(true); + }); + + it("yields an empty per-token map on an old facilitator build, where only 6-decimal fees are knowable", async () => { + stubSupported(supportedBody([])); + + const fee = await getFacilitatorFeeConfig(); + expect(fee?.feeByAsset.size).toBe(0); + // The nominal fallback still answers for USDC/EURC; EURe is honestly unknown. + expect(facilitatorFeeFor(fee!, "eip155:8453", USDC_ADDRESSES["eip155:8453"], 6)).toBe(FEE); + expect(facilitatorFeeFor(fee!, "eip155:8453", EURE_ADDRESSES["eip155:8453"], 18)).toBeNull(); + }); + + it("returns null when the facilitator advertises no fee at all", async () => { + stubSupported({ extensions: [] }); + expect(await getFacilitatorFeeConfig()).toBeNull(); + }); }); diff --git a/scw_js/test/llm_x402_cron.test.ts b/scw_js/test/llm_x402_cron.test.ts index fcbfec0f8..5e1c81954 100644 --- a/scw_js/test/llm_x402_cron.test.ts +++ b/scw_js/test/llm_x402_cron.test.ts @@ -118,12 +118,30 @@ describe("llm_x402_cron", () => { }); mockCreateFacilitatorClient.mockReturnValue({}); mockGetBatchSettlementNetworks.mockReturnValue(["eip155:10", "eip155:8453", "eip155:84532"]); - // Default: the facilitator charges 0.01 USDC and the approval is healthy (100 claims). + // Default: the facilitator charges 0.01 per settlement, published per token — the nominal + // 10000 for the 6-decimal tokens, 10¹⁶ for EURe, exactly as the deployed /supported's + // facilitatorFees.assets does. Keyed per RUNS: every (network, token) the cron claims. + const isEure = (token: string) => + Object.values(EURE_ADDRESSES).some((a) => a.toLowerCase() === token.toLowerCase()); mockGetFacilitatorFeeConfig.mockResolvedValue({ recipient: "0x3F8d2Fb6fEA24E70155bC61471936F3c9C30c206", flatFee: 10000n, + feeByAsset: new Map( + RUNS.map( + ([network, token]) => + [ + `${network}:${token.toLowerCase()}`, + isEure(token) ? 10_000_000_000_000_000n : 10000n, + ] as const, + ), + ), }); - mockReadContract.mockResolvedValue(1_000_000n); + // Healthy by default on every token: 1 USDC/EURC-worth (100 claims at 0.01) and 1 EURe + // (100 claims at the per-token fee). Per-token because the fee's atomic size differs. + mockReadContract.mockImplementation( + ({ address }: { address: `0x${string}` }) => + isEure(address) ? 1_000_000_000_000_000_000n : 1_000_000n, // 1 EURe : 1 USDC/EURC + ); }); it("returns 500 when NFT_WALLET_PUBLIC_KEY is missing", async () => { @@ -498,6 +516,52 @@ describe("llm_x402_cron", () => { expect(mockLoggerWarn).not.toHaveBeenCalled(); }); + it("computes EURe claims left with the per-token fee — 0.98 EURe is 98 claims, not 9.8×10¹⁴", async () => { + // The regression: dividing an 18-decimal allowance by the nominal 6-decimal 10000 once + // reported a real 0.98-EURe approval as 98000000000000 claims. The fee must come from + // /supported's per-token assets (10¹⁶ for EURe), so the same wallet reads as 98. + mockReadContract.mockImplementation(({ address }: { address: `0x${string}` }) => + Object.values(EURE_ADDRESSES).some((a) => a.toLowerCase() === address.toLowerCase()) + ? 980_000_000_000_000_000n // 0.98 EURe — the wallet state that printed the wrong figure + : 1_000_000n, + ); + + const res = await handle(makeEvent() as never, {}); + const body = JSON.parse(res.body) as { + results: Array<{ network: string; asset: string; feeAllowanceClaimsLeft?: number }>; + }; + + for (const network of ["eip155:8453", "eip155:84532"]) { + const eure = body.results.find((r) => r.network === network && r.asset === "EURe"); + expect(eure?.feeAllowanceClaimsLeft).toBe(98); + } + // The 6-decimal tokens keep their own fee: 1 USDC-worth is still 100 claims. + expect(body.results.find((r) => r.network === "eip155:10")?.feeAllowanceClaimsLeft).toBe(100); + }); + + it("reports no runway for EURe against a facilitator that publishes no per-token fees", async () => { + // An old facilitator build has no facilitatorFees.assets. The nominal 10000 would be 10¹² + // off for EURe, so the honest answer is "unknown" — no number, and no warning. + mockGetFacilitatorFeeConfig.mockResolvedValue({ + recipient: "0x3F8d2Fb6fEA24E70155bC61471936F3c9C30c206", + flatFee: 10000n, + feeByAsset: new Map(), + }); + + const res = await handle(makeEvent() as never, {}); + const body = JSON.parse(res.body) as { + results: Array<{ network: string; asset: string; feeAllowanceClaimsLeft?: number }>; + }; + + const eure = body.results.find((r) => r.asset === "EURe"); + expect(eure?.feeAllowanceClaimsLeft).toBeUndefined(); + expect( + mockLoggerWarn.mock.calls.some((call) => String(call[0]?.asset ?? "").includes("EURe")), + ).toBe(false); + // The 6-decimal tokens fall back to the nominal fee and still report a runway. + expect(body.results.find((r) => r.network === "eip155:10")?.feeAllowanceClaimsLeft).toBe(100); + }); + it("still claims when the allowance read fails — the check must never cost a claim", async () => { mockReadContract.mockRejectedValue(new Error("RPC down")); diff --git a/scw_js/x402_server.ts b/scw_js/x402_server.ts index a6b0f4e3f..367cea4d2 100644 --- a/scw_js/x402_server.ts +++ b/scw_js/x402_server.ts @@ -77,14 +77,13 @@ export function createFacilitatorClient(): HTTPFacilitatorClient { return new HTTPFacilitatorClient({ url: FACILITATOR_URL }); } -/** What the facilitator charges, and the address that collects it. */ -export interface FacilitatorFeeConfig { - /** Spender to approve — the facilitator wallet that runs `transferFrom`. */ - recipient: `0x${string}`; - /** Flat fee per settlement, in the settled token's atomic units (the same nominal amount in - * USDC and EURC — the facilitator charges the fee in whichever token the payment settles in). */ - flatFee: bigint; -} +/** + * The facilitator's fee model lives in `fee_config.ts` (pure, import-light, unmocked by tests + * that mock this module wholesale) — re-exported here so existing consumers keep their imports. + */ +export type { FacilitatorFeeConfig } from "./fee_config.js"; +export { facilitatorFeeFor } from "./fee_config.js"; +import type { FacilitatorFeeConfig } from "./fee_config.js"; /** * Read the fee model the facilitator currently advertises, or null when it charges none. @@ -115,8 +114,15 @@ export async function getFacilitatorFeeConfig(): Promise; + }; + } + )?.facilitatorFees; if (typeof fees?.recipient !== "string" || typeof fees?.flatFee !== "string") { // No fee configured on this facilitator — nothing to check an allowance against. return null; @@ -125,7 +131,28 @@ export async function getFacilitatorFeeConfig(): Promise(); + for (const asset of fees.assets ?? []) { + if ( + typeof asset?.network === "string" && + typeof asset.asset === "string" && + typeof asset.flatFee === "string" + ) { + try { + const perToken = BigInt(asset.flatFee); + if (perToken > 0n) { + feeByAsset.set(`${asset.network}:${asset.asset.toLowerCase()}`, perToken); + } + } catch { + // Unparseable figure — leave the token unknown rather than poison the config. + } + } + } + return { recipient: fees.recipient as `0x${string}`, flatFee, feeByAsset }; } catch (err) { logger.warn({ err }, "Could not read facilitator fee config"); return null; From 38358e25e2c9848b9b63269965ba42ad13398ff4 Mon Sep 17 00:00:00 2001 From: fretchen Date: Sat, 10 Oct 2026 23:35:33 +0200 Subject: [PATCH 04/13] search buyer notebook: show EURe offers correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quote cell divided every accepts[].amount by 1e6 — the same 6-decimal assumption the fee script had — so an EURe offer would have printed its price twelve orders of magnitude too large, and the startup log still claimed the server prefers EURC. Per-token decimals now, and the log names EURe as the preference. --- scw_js/notebooks/search_x402_buyer.ipynb | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/scw_js/notebooks/search_x402_buyer.ipynb b/scw_js/notebooks/search_x402_buyer.ipynb index 298c694f2..69a046fe5 100644 --- a/scw_js/notebooks/search_x402_buyer.ipynb +++ b/scw_js/notebooks/search_x402_buyer.ipynb @@ -118,6 +118,7 @@ "text": [ "\ud83d\udea8 REAL MONEY on Base Mainnet\n", " eip155:8453 \u2022 USDC USD Coin @ 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\n", + " eip155:8453 \u2022 EURC @ 0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42 (server prefers this over USDC)\n", " Service: http://localhost:8084\n" ] } @@ -135,8 +136,8 @@ " // EURC on Base \u2014 see shared/chain-utils/src/addresses.ts and scw_js/README.md's EURC\n", " // domain-name table.\n", " eurcAddress: \"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42\" as `0x${string}` | undefined,\n", - " // EURe on Base (Monerium, 18 decimals, Permit2 + EIP-2612 permit \u2014 no approve()).", - " eureAddress: \"0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C\" as `0x${string}` | undefined,", + " // EURe on Base (Monerium, 18 decimals, Permit2 + EIP-2612 permit \u2014 no approve()).\n", + " eureAddress: \"0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C\" as `0x${string}` | undefined,\n", " rpcUrl: \"https://base-rpc.publicnode.com\",\n", " explorer: \"https://basescan.org\",\n", " },\n", @@ -166,7 +167,7 @@ "console.log(`\ud83d\udea8 REAL MONEY on ${config.networkName}`);\n", "console.log(` ${NETWORK} \u2022 USDC ${config.usdcName} @ ${config.usdcAddress}`);\n", "if (config.eurcAddress) {\n", - " console.log(` ${NETWORK} \u2022 EURC @ ${config.eurcAddress} (server prefers this over USDC)`);\n", + " console.log(` ${NETWORK} \u2022 EURC @ ${config.eurcAddress}`);\n}\nif (config.eureAddress) {\n console.log(` ${NETWORK} \u2022 EURe @ ${config.eureAddress} (server prefers this)`);\n", "}\n", "console.log(` Service: ${SERVICE_URL}`);" ] @@ -332,10 +333,10 @@ " console.log(`\\nGET /${route} \u2192 ${status} \"${terms.resource.description}\"`);\n", " console.log(` resource: ${terms.resource.url}`);\n", " for (const accept of terms.accepts) {\n", - " const usd = (Number(accept.amount) / 1e6).toFixed(4);\n", + " // Per-token decimals: USDC/EURC are 6, EURe 18 \u2014 one 1e6 divisor would print\n // EURe's price twelve orders of magnitude too large.\n const decimals = accept.asset === config.eureAddress ? 18 : 6;\n const price = (Number(accept.amount) / 10 ** decimals).toFixed(4);\n", " const asset = accept.extra?.name ?? accept.asset;\n", " console.log(\n", - " ` ${accept.network.padEnd(13)} ${accept.scheme} ${accept.amount} ${asset} atomic ($${usd})` +\n", + " ` ${accept.network.padEnd(13)} ${accept.scheme} ${accept.amount} ${asset} atomic (${price})` +\n", " ` \u2192 ${accept.payTo} lock ${accept.maxTimeoutSeconds}s`,\n", " );\n", " }\n", @@ -533,9 +534,7 @@ "name": "deno" }, "language_info": { - "codemirror_mode": { - "name": "typescript" - }, + "codemirror_mode": "typescript", "file_extension": ".ts", "mimetype": "text/x.typescript", "name": "typescript", From 578cd0481a77b939493c4a8fa9ad99ce690ced69 Mon Sep 17 00:00:00 2001 From: fretchen Date: Sat, 10 Oct 2026 23:44:52 +0200 Subject: [PATCH 05/13] Threat model: list batch-settlement channel escrow as an asset, and the claim path in the blast radius MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The security review of the EURe seller-side branch found the gap: buyer deposits escrowed in batch-settlement channels (USDC, EURC and now EURe on Base) had no asset row, and a stalled claim sweep had no blast-radius entry. Both name the property that matters — a stalled claim delays the buyer's escrow, the withdraw path is the buyer's own, so nothing is lost. --- .github/THREAT_MODEL.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/THREAT_MODEL.md b/.github/THREAT_MODEL.md index d1a7bba69..90093b6d4 100644 --- a/.github/THREAT_MODEL.md +++ b/.github/THREAT_MODEL.md @@ -23,6 +23,7 @@ What has monetary value, irreversibility, or trust significance in this system: | ETH in LLMv1 (deprecated) | On-chain | User prepaid balances; withdrawable by users or claimable by providers. **No serverless code path calls this contract anymore** — `sc_llm.ts`/`leaf_history.ts` were retired in favor of `/assistent`'s x402 batch-settlement USDC channels. Final decommission (contract-level retirement) is pending confirmation that all balances have been withdrawn. | | Stablecoin settlements | x402_facilitator | Per-request payments routed through the facilitator: USDC (any supported network), EURC and EURe on Base. EURe (Monerium, no EIP-3009, 18 decimals) settles via Permit2 through the x402 exact Permit2 proxy | | x402 exact Permit2 proxy + Uniswap Permit2 (external) | On-chain, Base and Base Sepolia (SDK-pinned addresses, not upgradeable by this project) | Third-party contracts the facilitator settles EURe through. A defect affects EURe settlement availability, not fund safety: `/verify` simulates `settleWithPermit` against the live proxy before anything is broadcast, and Permit2 enforces the buyer's signature over the witness on-chain | +| Batch-settlement channel escrow | On-chain, canonical x402 batch-settlement contract (Optimism + Base mainnet) | Buyer deposits held in the channel's own token — USDC, EURC or EURe on Base — until claimed against signed vouchers. Escrow is the buyer's: refundable via the buyer-owned `withdraw` path after the lock, and a stalled claim delays it rather than losing it. Every token's channels are separate (the token is part of `channelId`), so each needs its own claim sweep and its own fee allowance | | EIP3009 Splitter contract (testnet) | On-chain, Optimism Sepolia only (`eip155:11155420`) | Routes USDC settlements via EIP-3009; owner-upgradeable. Not yet on mainnet — promotes to a live asset on mainnet deployment | | NFT metadata integrity | On-chain (token URIs) | Authoritative record of what image each token represents | | Owner EOA private key | Dedicated keystore account (`0x1af51D…fBB20`), separated from daily wallet since 2026-06 | Controls every upgradeable contract and owns the three ERC-8004 agent identities — the highest-value key in the system | @@ -46,6 +47,7 @@ If a component is fully compromised, what else falls with it: | **Owner EOA** | Malicious upgrade to every upgradeable contract | Complete ETH drain from GenImNFTv4 + LLMv1; all NFT URIs replaceable; USDC fee wallet redirectable — total system compromise. The EIP3009 Splitter joins this blast radius once deployed to mainnet. Key is now a dedicated EOA (not daily wallet); full mitigation requires Gnosis Safe. The same key owns the three ERC-8004 agent identities (Base), so a compromise also lets an attacker repoint their `agentURI` to impersonate the services; a Safe migration must transfer them too, which clears each `agentWallet` and needs re-binding. | | **Agent wallet** (scw_js) | `requestImageUpdate()` callable arbitrarily; drains GenImNFTv4 at `mintPrice` per call | NFT metadata corruption for all tokens; contract ETH drained | | **Facilitator wallet** | Settlement fees redirected (USDC, EURC, EURe) | Financial only; no access to user funds or upgrade paths | +| **Claim path** (the cron's per-token sweep + the seller's per-token fee allowance) | Claims for one token stall — a token missing from the sweep's enumeration leaves that token's escrow unclaimed indefinitely, and an exhausted fee allowance stalls claims with `insufficient_fee_allowance` | Buyer impact is delay, not loss — the `withdraw` path after the lock is the buyer's own. Revenue delayed; no fund redirection | | **Receiver-authorizer key** (scw_js) | Refund authorizations signable: buyer escrow returned early, claims stalled or unwound — revenue denial and channel DoS | No fund redirection (claims pay the channel's fixed receiver); no access to other contracts or upgrade paths | | **SCW_SECRET_KEY** | S3 bucket writable; email notifications spoofable | Generated images replaceable; no on-chain impact | | **BFL / IONOS key** | Image generation quota consumed | No on-chain or financial impact to users | From a81212b36ac562c32be128a61ec2e3a0fc10de8a Mon Sep 17 00:00:00 2001 From: fretchen Date: Sat, 10 Oct 2026 23:46:52 +0200 Subject: [PATCH 06/13] Update search_x402_buyer.ipynb --- scw_js/notebooks/search_x402_buyer.ipynb | 261 ++++++++++++++++------- 1 file changed, 180 insertions(+), 81 deletions(-) diff --git a/scw_js/notebooks/search_x402_buyer.ipynb b/scw_js/notebooks/search_x402_buyer.ipynb index 69a046fe5..fcabbb70c 100644 --- a/scw_js/notebooks/search_x402_buyer.ipynb +++ b/scw_js/notebooks/search_x402_buyer.ipynb @@ -4,46 +4,46 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# `search_api` Buyer Notebook \u2014 paying for a GET (Deno/TypeScript)\n", + "# `search_api` Buyer Notebook — paying for a GET (Deno/TypeScript)\n", "\n", "Drives `search_api.ts`'s two paid routes as a real client would: `GET /fetch` reads one web page,\n", "`GET /search` proxies Brave's LLM Context API. Third of this directory's `_buyer` notebooks, after\n", "`genimg_x402_buyer.ipynb` (scheme `exact`) and `sc_llm_x402_buyer.ipynb` (scheme\n", "`batch-settlement`), and it only ever talks to the resource server over plain HTTP via\n", - "`wrapFetchWithPayment` \u2014 the server advertises everything else.\n", + "`wrapFetchWithPayment` — the server advertises everything else.\n", "\n", "Three things here are not covered by `test/search_api.test.ts`, which mocks the SDK:\n", "\n", "1. **A GET can be paid for at all.** Both of the other paid endpoints are POSTs, and the payment\n", - " arrives in a header either way \u2014 there is no body to fall back on.\n", + " arrives in a header either way — there is no body to fall back on.\n", "2. **The 402 this handler emits is readable by `@x402/fetch`**, prices and all.\n", "3. **The second paid call opens no second channel.** That is the whole design: a tool call bills\n", " onto the channel the chat already funded.\n", "\n", - "## \u26a0\ufe0f This notebook always spends real money\n", + "## ⚠️ This notebook always spends real money\n", "\n", - "There is no testnet option. `/search` and `/fetch` spend on the caller's behalf \u2014 Brave bills per\n", - "query, `/fetch` is egress \u2014 so testnet USDC, which is free, would buy a metered API for nothing.\n", + "There is no testnet option. `/search` and `/fetch` spend on the caller's behalf — Brave bills per\n", + "query, `/fetch` is egress — so testnet USDC, which is free, would buy a metered API for nothing.\n", "`MAINNET_NETWORKS` in `search_api.ts` deliberately lists only Optimism and Base mainnet.\n", "\n", "A full run costs **$0.011** (one fetch at $0.001, one search at $0.01) plus deposit gas, out of a\n", "$0.10 channel deposit. The rest of the deposit stays escrowed until `llmx402cron` claims it\n", - "(within 12h) or a refund after the 24h `withdrawDelay` \u2014 it is not spent, but it is not immediately\n", + "(within 12h) or a refund after the 24h `withdrawDelay` — it is not spent, but it is not immediately\n", "back in your wallet either.\n", "\n", "## Prerequisites\n", "\n", - "1. **Deno Jupyter kernel** \u2014 `deno jupyter --install` (the same global kernel the sibling notebooks\n", + "1. **Deno Jupyter kernel** — `deno jupyter --install` (the same global kernel the sibling notebooks\n", " use).\n", "2. **`scw_js/.env`** needs:\n", - " - `TEST_WALLET_PRIVATE_KEY` \u2014 the buyer wallet, funded with **mainnet** USDC on Base or\n", + " - `TEST_WALLET_PRIVATE_KEY` — the buyer wallet, funded with **mainnet** USDC on Base or\n", " Optimism, plus a little ETH on that chain for the deposit tx. Notebook-testing convenience\n", " only; no scw_js production code reads this key.\n", - " - `NFT_WALLET_PUBLIC_KEY` \u2014 the receiver, shared with the chat endpoint. This is what makes the\n", + " - `NFT_WALLET_PUBLIC_KEY` — the receiver, shared with the chat endpoint. This is what makes the\n", " channel the same one.\n", - " - `RECEIVER_AUTHORIZER_PRIVATE_KEY` \u2014 needed for `createLLMResourceServer()` to construct at\n", + " - `RECEIVER_AUTHORIZER_PRIVATE_KEY` — needed for `createLLMResourceServer()` to construct at\n", " all. A pure off-chain signer, no funding.\n", - " - `BRAVE_API_KEY` \u2014 read by the **server**, not by this notebook.\n", + " - `BRAVE_API_KEY` — read by the **server**, not by this notebook.\n", "3. **The server**, either locally (`cd scw_js && npm run dev:search`, port 8084) or deployed. The\n", " `USE_DEPLOYED` flag below switches between them, and the channel is the same either way: it is\n", " on-chain state keyed by (buyer, receiver, network, asset), not anything a server holds." @@ -58,7 +58,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "\ud83d\udd0e search_api buyer notebook\n", + "🔎 search_api buyer notebook\n", " Buyer (payer): 0x553179556FC2A39e535D65b921e01fA995E79101\n", " Receiver : 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C\n" ] @@ -69,22 +69,22 @@ "import { load } from \"https://deno.land/std@0.224.0/dotenv/mod.ts\";\n", "import { privateKeyToAccount } from \"npm:viem@2/accounts\";\n", "\n", - "// scw_js's own .env, one level up \u2014 the single source of truth (buyer key, receiver address, and\n", + "// scw_js's own .env, one level up — the single source of truth (buyer key, receiver address, and\n", "// the server's own secrets all live here; the server reads the same file).\n", "const env = await load({ envPath: \"../.env\", examplePath: null, export: true });\n", "\n", "const PRIVATE_KEY = env.TEST_WALLET_PRIVATE_KEY;\n", "if (!PRIVATE_KEY) {\n", " throw new Error(\n", - " \"TEST_WALLET_PRIVATE_KEY missing from scw_js/.env \u2014 add a key funded with MAINNET USDC \" +\n", + " \"TEST_WALLET_PRIVATE_KEY missing from scw_js/.env — add a key funded with MAINNET USDC \" +\n", " \"(Base or Optimism). There is no testnet path for these routes; see the cell above.\",\n", " );\n", "}\n", "const account = privateKeyToAccount(`0x${PRIVATE_KEY.replace(/^0x/, \"\")}`);\n", "\n", - "console.log(\"\ud83d\udd0e search_api buyer notebook\");\n", + "console.log(\"🔎 search_api buyer notebook\");\n", "console.log(` Buyer (payer): ${account.address}`);\n", - "console.log(` Receiver : ${env.NFT_WALLET_PUBLIC_KEY ?? \"(not set \u2014 server will 500)\"}`);" + "console.log(` Receiver : ${env.NFT_WALLET_PUBLIC_KEY ?? \"(not set — server will 500)\"}`);" ] }, { @@ -93,13 +93,13 @@ "source": [ "## Network selection\n", "\n", - "Base or Optimism, both mainnet \u2014 see the warning above for why there is no third option. The two\n", + "Base or Optimism, both mainnet — see the warning above for why there is no third option. The two\n", "differ in one way that has bitten this repo before: **USDC's EIP-712 domain name is `USD Coin` on\n", "mainnet and `USDC` on testnet**, so a config copied from a testnet notebook signs vouchers no\n", - "server will accept. The table in `scw_js/README.md` \u2192 _Known USDC Domain Names_ is the verified\n", + "server will accept. The table in `scw_js/README.md` → _Known USDC Domain Names_ is the verified\n", "source.\n", "\n", - "**Base also offers EURC**, listed before USDC in the 402 (`scw_js/stablecoin_pricing.ts`) \u2014 so the\n", + "**Base also offers EURC**, listed before USDC in the 402 (`scw_js/stablecoin_pricing.ts`) — so the\n", "default `USE_BASE = true` pays EURC once the buyer-setup cell allows it. Optimism has no EURC\n", "deployment. Search and fetch have their own EURC prices (`PRICE_ATOMIC` in `search_schemas.ts`);\n", "nothing is converted from USDC.\n", @@ -116,9 +116,10 @@ "name": "stdout", "output_type": "stream", "text": [ - "\ud83d\udea8 REAL MONEY on Base Mainnet\n", - " eip155:8453 \u2022 USDC USD Coin @ 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\n", - " eip155:8453 \u2022 EURC @ 0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42 (server prefers this over USDC)\n", + "🚨 REAL MONEY on Base Mainnet\n", + " eip155:8453 • USDC USD Coin @ 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\n", + " eip155:8453 • EURC @ 0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42\n", + " eip155:8453 • EURe @ 0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C (server prefers this)\n", " Service: http://localhost:8084\n" ] } @@ -133,10 +134,10 @@ " caip2Network: \"eip155:8453\" as const, networkName: \"Base Mainnet\",\n", " chain: base, usdcName: \"USD Coin\",\n", " usdcAddress: \"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913\" as `0x${string}`,\n", - " // EURC on Base \u2014 see shared/chain-utils/src/addresses.ts and scw_js/README.md's EURC\n", + " // EURC on Base — see shared/chain-utils/src/addresses.ts and scw_js/README.md's EURC\n", " // domain-name table.\n", " eurcAddress: \"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42\" as `0x${string}` | undefined,\n", - " // EURe on Base (Monerium, 18 decimals, Permit2 + EIP-2612 permit \u2014 no approve()).\n", + " // EURe on Base (Monerium, 18 decimals, Permit2 + EIP-2612 permit — no approve()).\n", " eureAddress: \"0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C\" as `0x${string}` | undefined,\n", " rpcUrl: \"https://base-rpc.publicnode.com\",\n", " explorer: \"https://basescan.org\",\n", @@ -153,7 +154,7 @@ "};\n", "// RPCs pinned to publicnode rather than viem's bare defaults (mainnet.base.org /\n", "// mainnet.optimism.io): opening a channel does a Multicall3 read batch, which those defaults have\n", - "// already rate-limited in this repo \u2014 surfacing as a generic ..._deposit_transaction_failed. Same\n", + "// already rate-limited in this repo — surfacing as a generic ..._deposit_transaction_failed. Same\n", "// endpoints website/wagmi.config.ts settled on.\n", "const config = NETWORK_CONFIG[USE_BASE ? \"base-mainnet\" : \"optimism-mainnet\"];\n", "const NETWORK = config.caip2Network;\n", @@ -164,10 +165,13 @@ " ? \"https://web-agent.fretchen.eu\"\n", " : \"http://localhost:8084\";\n", "\n", - "console.log(`\ud83d\udea8 REAL MONEY on ${config.networkName}`);\n", - "console.log(` ${NETWORK} \u2022 USDC ${config.usdcName} @ ${config.usdcAddress}`);\n", + "console.log(`🚨 REAL MONEY on ${config.networkName}`);\n", + "console.log(` ${NETWORK} • USDC ${config.usdcName} @ ${config.usdcAddress}`);\n", "if (config.eurcAddress) {\n", - " console.log(` ${NETWORK} \u2022 EURC @ ${config.eurcAddress}`);\n}\nif (config.eureAddress) {\n console.log(` ${NETWORK} \u2022 EURe @ ${config.eureAddress} (server prefers this)`);\n", + " console.log(` ${NETWORK} • EURC @ ${config.eurcAddress}`);\n", + "}\n", + "if (config.eureAddress) {\n", + " console.log(` ${NETWORK} • EURe @ ${config.eureAddress} (server prefers this)`);\n", "}\n", "console.log(` Service: ${SERVICE_URL}`);" ] @@ -180,7 +184,7 @@ "\n", "Identical in shape to `sc_llm_x402_buyer.ipynb`'s buyer cell, and for the same reason: the scheme\n", "is `batch-settlement`, so the client needs a channel store, a deposit strategy and a signer that\n", - "can read contracts. The one difference is the deposit floor, which is smaller here \u2014 these calls\n", + "can read contracts. The one difference is the deposit floor, which is smaller here — these calls\n", "cost a tenth of a cent, not three tenths." ] }, @@ -190,11 +194,60 @@ "metadata": {}, "outputs": [ { - "name": "stdout", - "output_type": "stream", - "text": [ - "\u2705 buyer client ready, registered for eip155:8453\n" - ] + "data": { + "text/plain": [ + "_x402Client {\n", + " registeredClientSchemes: Map(1) {\n", + " \u001b[33m2\u001b[39m => Map(1) {\n", + " \u001b[32m\"eip155:8453\"\u001b[39m => Map(1) {\n", + " \u001b[32m\"batch-settlement\"\u001b[39m => BatchSettlementEvmScheme {\n", + " signer: \u001b[36m[Object]\u001b[39m,\n", + " scheme: \u001b[32m\"batch-settlement\"\u001b[39m,\n", + " findDefaultAsset: \u001b[36m[Function: findDefaultAsset]\u001b[39m,\n", + " storage: \u001b[36m[WebStorageClientChannelStorage]\u001b[39m,\n", + " depositPolicy: \u001b[90mundefined\u001b[39m,\n", + " depositStrategy: \u001b[36m[Function: depositStrategy]\u001b[39m,\n", + " salt: \u001b[32m\"0x0000000000000000000000000000000000000000000000000000000000000000\"\u001b[39m,\n", + " payerAuthorizer: \u001b[90mundefined\u001b[39m,\n", + " voucherSigner: \u001b[90mundefined\u001b[39m,\n", + " extensionRpcOptions: \u001b[90mundefined\u001b[39m,\n", + " schemeHooks: \u001b[36m[Object]\u001b[39m\n", + " }\n", + " }\n", + " }\n", + " },\n", + " schemeClientHookAdapters: Map(1) {\n", + " \u001b[33m2\u001b[39m => Map(1) {\n", + " \u001b[32m\"eip155:8453\"\u001b[39m => Map(1) {\n", + " \u001b[32m\"batch-settlement\"\u001b[39m => { onPaymentResponse: \u001b[36m[AsyncFunction: onPaymentResponse]\u001b[39m }\n", + " }\n", + " }\n", + " },\n", + " policies: [],\n", + " registeredExtensions: Map(0) {},\n", + " spendControls: {\n", + " allowedAssets: [\n", + " {\n", + " network: \u001b[32m\"eip155:8453\"\u001b[39m,\n", + " asset: \u001b[32m\"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42\"\u001b[39m\n", + " },\n", + " {\n", + " network: \u001b[32m\"eip155:8453\"\u001b[39m,\n", + " asset: \u001b[32m\"0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C\"\u001b[39m\n", + " }\n", + " ]\n", + " },\n", + " beforePaymentCreationHooks: [],\n", + " afterPaymentCreationHooks: [],\n", + " onPaymentCreationFailureHooks: [],\n", + " paymentResponseHooks: [],\n", + " paymentRequirementsSelector: \u001b[36m[Function (anonymous)]\u001b[39m\n", + "}" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" } ], "source": [ @@ -209,7 +262,7 @@ "import { createPublicClient, http } from \"npm:viem@2\";\n", "\n", "// readContract is documented as \"optional\" on ClientEvmSigner, but batch-settlement's\n", - "// corrective-402 recovery (processCorrectivePaymentRequired) unconditionally needs it \u2014 both\n", + "// corrective-402 recovery (processCorrectivePaymentRequired) unconditionally needs it — both\n", "// recoverFromSignature and recoverFromOnChainState bail out with `if (!deps.signer.readContract)\n", "// return false`. Without it, a client/server cumulative desync (an old channel reused across\n", "// kernel restarts) surfaces as a hard 402 instead of self-healing.\n", @@ -236,8 +289,8 @@ "const buyerStorage = new WebStorageClientChannelStorage(localStorage);\n", "\n", "// $0.10 of escrow: ~9 searches or ~100 fetches. Deliberately smaller than useX402Chat.ts's $0.50\n", - "// floor, because this is mainnet and a notebook \u2014 but note it is ESCROW, not spend. Whatever is\n", - "// not consumed stays locked until llmx402cron claims the channel (\u226412h) or a refund after the 24h\n", + "// floor, because this is mainnet and a notebook — but note it is ESCROW, not spend. Whatever is\n", + "// not consumed stays locked until llmx402cron claims the channel (≤12h) or a refund after the 24h\n", "// withdrawDelay. Going much lower just buys more top-up transactions.\n", "const MINIMUM_DEPOSIT_ATOMIC = 100_000n;\n", "\n", @@ -267,7 +320,7 @@ "const fetchWithPayment = wrapFetchWithPayment(fetch, client);\n", "\n", "/** The settlement receipt the server attaches to a paid 200. `transaction: \"\"` means a voucher\n", - " * was committed off-chain \u2014 no tx, which is the point of batch settlement. */\n", + " * was committed off-chain — no tx, which is the point of batch settlement. */\n", "function settlement(res: Response) {\n", " const header = res.headers.get(\"Payment-Response\");\n", " return header\n", @@ -287,7 +340,7 @@ "source": [ "## The 402 is the price list\n", "\n", - "Reading it costs nothing \u2014 no wallet, no payment, no channel. A bare `fetch` gets the terms in the\n", + "Reading it costs nothing — no wallet, no payment, no channel. A bare `fetch` gets the terms in the\n", "base64 `Payment-Required` header, which is how any client discovers what it is being asked for.\n", "\n", "Note the two prices, and that the quote is the same whatever the query string says: an unpaid\n", @@ -305,15 +358,19 @@ "output_type": "stream", "text": [ "\n", - "GET /fetch \u2192 402 \"The readable text of one public web page\"\n", - " resource: https://mypersonaljscloudivnad9dy-searchapi.functions.fnc.fr-par.scw.cloud/fetch\n", - " eip155:10 batch-settlement 1000 atomic ($0.0010) \u2192 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C lock 30s\n", - " eip155:8453 batch-settlement 1000 atomic ($0.0010) \u2192 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C lock 30s\n", + "GET /fetch → 402 \"The readable text of one public web page\"\n", + " resource: https://web-agent.fretchen.eu/fetch\n", + " eip155:10 batch-settlement 1000 USD Coin atomic (0.0010) → 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C lock 30s\n", + " eip155:8453 batch-settlement 1000000000000000 Monerium EURe atomic (0.0010) → 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C lock 30s\n", + " eip155:8453 batch-settlement 1000 EURC atomic (0.0010) → 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C lock 30s\n", + " eip155:8453 batch-settlement 1000 USD Coin atomic (0.0010) → 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C lock 30s\n", "\n", - "GET /search \u2192 402 \"Web search with extracted page content (Brave LLM Context API)\"\n", - " resource: https://mypersonaljscloudivnad9dy-searchapi.functions.fnc.fr-par.scw.cloud/search\n", - " eip155:10 batch-settlement 10000 atomic ($0.0100) \u2192 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C lock 30s\n", - " eip155:8453 batch-settlement 10000 atomic ($0.0100) \u2192 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C lock 30s\n" + "GET /search → 402 \"Web search with extracted page content (Brave LLM Context API)\"\n", + " resource: https://web-agent.fretchen.eu/search\n", + " eip155:10 batch-settlement 10000 USD Coin atomic (0.0100) → 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C lock 30s\n", + " eip155:8453 batch-settlement 10000000000000000 Monerium EURe atomic (0.0100) → 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C lock 30s\n", + " eip155:8453 batch-settlement 10000 EURC atomic (0.0100) → 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C lock 30s\n", + " eip155:8453 batch-settlement 10000 USD Coin atomic (0.0100) → 0xAAEBC1441323B8ad6Bdf6793A8428166b510239C lock 30s\n" ] } ], @@ -321,23 +378,26 @@ "async function quote(route: \"search\" | \"fetch\") {\n", " const res = await fetch(`${SERVICE_URL}/${route}`);\n", " const header = res.headers.get(\"Payment-Required\");\n", - " // Nothing reads the body here \u2014 the terms are in the header \u2014 so release it rather than\n", + " // Nothing reads the body here — the terms are in the header — so release it rather than\n", " // leaving an open response behind on every kernel run.\n", " await res.body?.cancel();\n", - " if (!header) throw new Error(`No Payment-Required header on ${res.status} \u2014 is the server running?`);\n", + " if (!header) throw new Error(`No Payment-Required header on ${res.status} — is the server running?`);\n", " return { status: res.status, terms: JSON.parse(atob(header)) };\n", "}\n", "\n", "for (const route of [\"fetch\", \"search\"] as const) {\n", " const { status, terms } = await quote(route);\n", - " console.log(`\\nGET /${route} \u2192 ${status} \"${terms.resource.description}\"`);\n", + " console.log(`\\nGET /${route} → ${status} \"${terms.resource.description}\"`);\n", " console.log(` resource: ${terms.resource.url}`);\n", " for (const accept of terms.accepts) {\n", - " // Per-token decimals: USDC/EURC are 6, EURe 18 \u2014 one 1e6 divisor would print\n // EURe's price twelve orders of magnitude too large.\n const decimals = accept.asset === config.eureAddress ? 18 : 6;\n const price = (Number(accept.amount) / 10 ** decimals).toFixed(4);\n", + " // Per-token decimals: USDC/EURC are 6, EURe 18 — one 1e6 divisor would print\n", + " // EURe's price twelve orders of magnitude too large.\n", + " const decimals = accept.asset === config.eureAddress ? 18 : 6;\n", + " const price = (Number(accept.amount) / 10 ** decimals).toFixed(4);\n", " const asset = accept.extra?.name ?? accept.asset;\n", " console.log(\n", " ` ${accept.network.padEnd(13)} ${accept.scheme} ${accept.amount} ${asset} atomic (${price})` +\n", - " ` \u2192 ${accept.payTo} lock ${accept.maxTimeoutSeconds}s`,\n", + " ` → ${accept.payTo} lock ${accept.maxTimeoutSeconds}s`,\n", " );\n", " }\n", "}" @@ -351,7 +411,7 @@ "\n", "This endpoint used to fork: a valid signature over `search-api:` was served free, and\n", "everything else paid. That path is gone. It was justified as what kept server-side callers\n", - "working without a funded wallet, and no such caller existed \u2014 `growth-agent` never called this\n", + "working without a funded wallet, and no such caller existed — `growth-agent` never called this\n", "endpoint, and the browser tools moved to paid fetch. `genimg` and `llm` never had one either.\n", "\n", "The cell below sends a correctly signed owner bearer anyway, to show it now buys nothing: the\n", @@ -360,9 +420,18 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 5, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "GET /fetch with a bearer → 402\n", + " quoted a price — the bearer was ignored, which is the point\n" + ] + } + ], "source": [ "const ts = Math.floor(Date.now() / 1000);\n", "const message = `search-api:${ts}`;\n", @@ -374,10 +443,10 @@ " headers: { Authorization: `Bearer ${token}` },\n", "});\n", "\n", - "console.log(`GET /fetch with a bearer \u2192 ${ownerRes.status}`);\n", + "console.log(`GET /fetch with a bearer → ${ownerRes.status}`);\n", "console.log(\n", " ownerRes.status === 402\n", - " ? \" quoted a price \u2014 the bearer was ignored, which is the point\"\n", + " ? \" quoted a price — the bearer was ignored, which is the point\"\n", " : ` unexpected: ${ownerRes.status}. A bearer should buy nothing here.`,\n", ");\n" ] @@ -404,19 +473,13 @@ "name": "stdout", "output_type": "stream", "text": [ - "GET /fetch \u2192 200\n", + "GET /fetch → 200\n", " finalUrl : https://www.fretchen.eu/blog/36/\n", - " contentType: text/html\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " html : 55452 chars\n", + " contentType: text/html\n", + " html : 55690 chars\n", " starts : \"\\n \\n \\n Date: Sat, 10 Oct 2026 23:48:52 +0200 Subject: [PATCH 07/13] Update index.test.ts --- shared/chain-utils/test/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/chain-utils/test/index.test.ts b/shared/chain-utils/test/index.test.ts index f241ea9f7..5c5204c97 100644 --- a/shared/chain-utils/test/index.test.ts +++ b/shared/chain-utils/test/index.test.ts @@ -410,7 +410,7 @@ describe("@fretchen/chain-utils", () => { test("leaves getStablecoins() unchanged — the website surface (regression guard)", () => { for (const network of ["eip155:8453", "eip155:10", "eip155:1"]) { expect(getStablecoins(network).map((c) => c.symbol)).toEqual( - getStablecoins(network).map((c) => c.symbol), + getStablecoins(network).map((c) => c.symbol) ); expect(getStablecoins("eip155:8453").map((c) => c.symbol)).toEqual(["USDC", "EURC"]); expect(getStablecoins(network).every((c) => c.symbol !== "EURe")).toBe(true); From 417a7e7b0a819fd77a67f84aef22ee0bfd58cb8b Mon Sep 17 00:00:00 2001 From: fretchen Date: Sun, 11 Oct 2026 00:15:59 +0200 Subject: [PATCH 08/13] First round with EURe --- shared/chain-utils/src/addresses.ts | 11 +++ shared/chain-utils/test/index.test.ts | 29 ++++++++ website/components/GetFundsModal.tsx | 62 +++++++++++++--- website/hooks/x402Currency.ts | 44 ++++++++---- website/hooks/x402SpendControls.ts | 33 ++++----- website/locales/de.ts | 6 +- website/locales/en.ts | 6 +- website/pages/x402/buyers/+Page.tsx | 14 ++-- website/pages/x402/sellers/+Page.tsx | 8 ++- website/test/AssistantChat.test.tsx | 22 +++--- website/test/GetFundsModal.test.tsx | 49 +++++++++++++ website/test/useX402Chat.test.ts | 4 +- website/test/x402Currency.test.ts | 99 ++++++++++++++++++++++++++ website/test/x402Prices.test.ts | 32 +++++++++ website/test/x402SpendControls.test.ts | 20 ++++-- website/utils/x402Prices.ts | 19 +++-- 16 files changed, 381 insertions(+), 77 deletions(-) create mode 100644 website/test/GetFundsModal.test.tsx create mode 100644 website/test/x402Currency.test.ts create mode 100644 website/test/x402Prices.test.ts diff --git a/shared/chain-utils/src/addresses.ts b/shared/chain-utils/src/addresses.ts index eb4d2ecdb..247895f9e 100644 --- a/shared/chain-utils/src/addresses.ts +++ b/shared/chain-utils/src/addresses.ts @@ -238,6 +238,17 @@ export function getSettlementTokens(network: string): SettlementTokenInfo[] { return coins; } +/** + * Identify a token address as one of the settlement tokens on `network`, case-insensitively. + * The settlement-token counterpart of `findStablecoin` — the website's buyer side resolves a + * 402's asset address to a payable currency through this, so it must know EURe. + * @returns null for any other token, or for a token not deployed on this network. + */ +export function findSettlementToken(network: string, address: string): SettlementTokenInfo | null { + const wanted = address.toLowerCase(); + return getSettlementTokens(network).find((token) => token.address.toLowerCase() === wanted) ?? null; +} + // ═══════════════════════════════════════════════════════════════ // EIP3009 Splitter // ═══════════════════════════════════════════════════════════════ diff --git a/shared/chain-utils/test/index.test.ts b/shared/chain-utils/test/index.test.ts index 5c5204c97..54b21bc24 100644 --- a/shared/chain-utils/test/index.test.ts +++ b/shared/chain-utils/test/index.test.ts @@ -35,6 +35,7 @@ import { EURC_ADDRESSES, EURE_ADDRESSES, findStablecoin, + findSettlementToken, getStablecoins, getSettlementTokens, } from "../src/addresses"; @@ -418,6 +419,34 @@ describe("@fretchen/chain-utils", () => { }); }); + describe("findSettlementToken()", () => { + const BASE_EURE = EURE_ADDRESSES["eip155:8453"]; + + test("identifies each token on Base with its metadata, case-insensitively", () => { + expect(findSettlementToken("eip155:8453", BASE_EURE)).toMatchObject({ + symbol: "EURe", + decimals: 18, + transferMethod: "permit2", + }); + expect(findSettlementToken("eip155:8453", BASE_EURE.toLowerCase())?.symbol).toBe("EURe"); + expect(findSettlementToken("eip155:84532", EURE_ADDRESSES["eip155:84532"])?.symbol).toBe( + "EURe" + ); + expect(findSettlementToken("eip155:8453", USDC_ADDRESSES["eip155:8453"])?.symbol).toBe( + "USDC" + ); + expect(findSettlementToken("eip155:8453", EURC_ADDRESSES["eip155:8453"])?.symbol).toBe( + "EURC" + ); + }); + + test("returns null for EURe on Optimism and for unknown tokens or networks", () => { + expect(findSettlementToken("eip155:10", BASE_EURE)).toBeNull(); + expect(findSettlementToken("eip155:8453", "0x0000000000000000000000000000000000000001")).toBeNull(); + expect(findSettlementToken("eip155:1", BASE_EURE)).toBeNull(); + }); + }); + describe("getUSDCConfig()", () => { test("should return complete config for Optimism Mainnet", () => { const config = getUSDCConfig("eip155:10"); diff --git a/website/components/GetFundsModal.tsx b/website/components/GetFundsModal.tsx index d0f824a6a..6c72c53a6 100644 --- a/website/components/GetFundsModal.tsx +++ b/website/components/GetFundsModal.tsx @@ -5,10 +5,34 @@ import { Modal } from "./Modal"; import { button } from "../styled-system/recipes"; import type { PaymentCurrency } from "../hooks/x402Currency"; -/** Where to get each token. EURC: Coinbase sells it directly on Base, which suits people with no crypto yet. */ -export const GET_FUNDS_URL: Record = { - EURC: "https://www.coinbase.com/de/how-to-buy/euro-coin-2", - USDC: "https://app.optimism.io/bridge", +/** One way to acquire a currency. `labelKey` may contain `{currency}`, filled at render. */ +export interface FundsRoute { + labelKey: string; + url: string; +} + +/** + * Where to get each token. The FIRST route renders as the modal's primary action; any further + * routes render as secondary links below it. EURC is Coinbase's direct Base offering, which + * suits people with no crypto yet; USDC is the Optimism bridge. EURe gets two routes: Monerium's + * bank-transfer signup (euro-native, KYC) and Uniswap's EURe token page for anyone who would + * rather stay out of KYC and SEPA — that route needs ETH for gas, and the swap note says so. + */ +export const GET_FUNDS_ROUTES: Record = { + EURe: [ + { labelKey: "assistent.fundsRouteBank", url: "https://monerium.app/signup" }, + { + labelKey: "assistent.fundsRouteSwap", + url: "https://app.uniswap.org/explore/tokens/base/0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C", + }, + ], + EURC: [ + { + labelKey: "assistent.fundsButton", + url: "https://www.coinbase.com/de/how-to-buy/euro-coin-2", + }, + ], + USDC: [{ labelKey: "assistent.fundsButton", url: "https://app.optimism.io/bridge" }], }; /** @@ -21,22 +45,38 @@ export function GetFundsModal({ currency, onClose }: { currency: PaymentCurrency const title = useLocale({ label: "assistent.fundsTitle" }); const body = fill(useLocale({ label: "assistent.fundsBody" })); const buttonLabel = fill(useLocale({ label: "assistent.fundsButton" })); + const bankLabel = fill(useLocale({ label: "assistent.fundsRouteBank" })); + const swapLabel = fill(useLocale({ label: "assistent.fundsRouteSwap" })); + const swapNote = useLocale({ label: "assistent.fundsSwapNote" }); const note = useLocale({ label: "assistent.fundsNote" }); const closeAria = useLocale({ label: "metadataLine.modalCloseAria" }); + const labels: Record = { + "assistent.fundsButton": buttonLabel, + "assistent.fundsRouteBank": bankLabel, + "assistent.fundsRouteSwap": swapLabel, + }; + const routes = GET_FUNDS_ROUTES[currency]; + const [primary, ...secondary] = routes; + // The swap caveat only exists for currencies that offer a swap route (EURe). + const hasSwap = routes.some((route) => route.labelKey === "assistent.fundsRouteSwap"); + return (

{body}

+ {secondary.map((route) => ( + + ))} + {hasSwap &&

{swapNote}

}

{note}

); diff --git a/website/hooks/x402Currency.ts b/website/hooks/x402Currency.ts index ed3eaedad..8b603f11d 100644 --- a/website/hooks/x402Currency.ts +++ b/website/hooks/x402Currency.ts @@ -1,25 +1,39 @@ /** - * Which stablecoin this site pays with, as a buyer. + * Which settlement token this site pays with, as a buyer. * - * One site-wide preference, EURC by default. The sellers list what they accept — on Base EURC and - * USDC, on Optimism USDC only (Circle has no EURC there) — and the client picks. This module is - * that pick: `preferCurrency` is the selector every x402 client here is built with, and - * `networksForCurrency` is why the network follows the currency (EURC means Base). + * The site-wide preference, EURe by default. EURe is the default because the sellers prefer it + * (the deployed `STABLECOIN_PREFERENCE` lists EURe first, so the 402 offers it first); the + * toggle keeps EURC and USDC one click away. A stored choice always wins over the default, so + * flipping the default only ever affects a visitor with no stored preference — existing users + * keep paying what they chose. The sellers list what they accept — on Base all three tokens, on + * Optimism USDC only — and the client picks. This module is that pick: `preferCurrency` is the + * selector every x402 client here is built with, and `networksForCurrency` is why the network + * follows the currency (a euro token means Base). * - * Both tokens must also pass the spend controls first (`x402SpendControls.ts`); the selector only - * chooses among what they let through. + * Both chosen and paid tokens must also pass the spend controls first (`x402SpendControls.ts`); + * the selector only chooses among what they let through. EURe is 18 decimals + * (`CURRENCY_DECIMALS`) — the display and spend-cap math must never assume 6. */ import { useSyncExternalStore } from "react"; -import { findStablecoin, getStablecoins, type StablecoinSymbol } from "@fretchen/chain-utils"; +import { findSettlementToken, getSettlementTokens, type SettlementTokenSymbol } from "@fretchen/chain-utils"; import { createLocalStorageStore } from "../utils/localStorageStore"; import type { AcceptsEntry } from "./x402Discovery"; -export type PaymentCurrency = StablecoinSymbol; +export type PaymentCurrency = SettlementTokenSymbol; -export const PAYMENT_CURRENCIES: readonly PaymentCurrency[] = ["EURC", "USDC"]; +export const PAYMENT_CURRENCIES: readonly PaymentCurrency[] = ["EURe", "EURC", "USDC"]; -export const DEFAULT_CURRENCY: PaymentCurrency = "EURC"; +export const DEFAULT_CURRENCY: PaymentCurrency = "EURe"; + +/** + * Decimals per currency, derived from the shared registry (a symbol's decimals never vary by + * network; Base is the one network carrying all three). EURe is 18 — the 6-decimal assumption + * that holds for USDC and EURC silently breaks EURe by 10¹². + */ +export const CURRENCY_DECIMALS: Record = Object.fromEntries( + getSettlementTokens("eip155:8453").map((token) => [token.symbol, token.decimals]), +) as Record; const CURRENCY_KEY = "x402-currency"; @@ -47,21 +61,21 @@ export function usePaymentCurrency(): PaymentCurrency { /** * The networks, of those given, on which `currency` exists — order kept, so a caller's default - * network stays first. EURC narrows the NFT networks to Base; USDC keeps them all. + * network stays first. A euro token narrows the NFT networks to Base; USDC keeps them all. */ export function networksForCurrency(currency: PaymentCurrency, networks: readonly string[]): string[] { - return networks.filter((network) => getStablecoins(network).some((coin) => coin.symbol === currency)); + return networks.filter((network) => getSettlementTokens(network).some((token) => token.symbol === currency)); } /** The currency an `accepts` entry is priced in, or null for a token this site does not pay with. */ function currencyOf(entry: { network?: string; asset?: string }): PaymentCurrency | null { if (!entry.network || !entry.asset) return null; - return findStablecoin(entry.network, entry.asset)?.symbol ?? null; + return findSettlementToken(entry.network, entry.asset)?.symbol ?? null; } /** * The `paymentRequirementsSelector` for an `x402Client`: the first entry in `currency`, otherwise - * the seller's own first entry. The fallback is what lets a EURC preference still pay a seller + * the seller's own first entry. The fallback is what lets an EURe preference still pay a seller * that only takes USDC — an Optimism-only third-party agent, say. * * `onPick` reports what was actually chosen, so a label can name the currency that was paid rather diff --git a/website/hooks/x402SpendControls.ts b/website/hooks/x402SpendControls.ts index f9a517f62..7e6458f58 100644 --- a/website/hooks/x402SpendControls.ts +++ b/website/hooks/x402SpendControls.ts @@ -12,23 +12,23 @@ * spendControls" production incident this fixes. * * Rather than disabling spend controls (`spendControls: false`), this explicitly - * allowlists every stablecoin the site pays with — USDC on every network, EURC on Base — so - * the safety rail stays in place, just extended to cover what the SDK's own registry doesn't - * know about. The registry knows no EURC at all, so without this entry every EURC offer would - * be dropped before the preference selector (`x402Currency.ts`) ever saw it. + * allowlists every settlement token the site pays with — USDC on every network, EURC and + * EURe on Base — so the safety rail stays in place, just extended to cover what the SDK's + * own registry doesn't know about. The registry knows no EURC and no EURe at all, so without + * these entries every EURC or EURe offer would be dropped before the preference selector + * (`x402Currency.ts`) ever saw it. + * + * The cap is **one whole token, in that token's own decimals**: `10^decimals` atomic. + * USDC and EURC get 10⁶; EURe gets 10¹⁸ — a 6-decimal cap applied to EURe would veto every + * EURe payment at 10⁻¹² tokens. The registry supplies each token's decimals, so a future + * token with yet other decimals is capped correctly without touching this file. */ -import { ALL_NETWORKS, getStablecoins } from "@fretchen/chain-utils"; - -// Mirrors the SDK's own default cap for a "default" (registry-recognized) asset ($1; USDC -// and EURC both have 6 decimals, so this is 1 token of either) — this allowlist only extends -// recognition to what the SDK's own findDefaultAsset doesn't know about (Optimism, EURC; see -// module doc above); it does not relax the cap that already applies to Base USDC. -const DEFAULT_MAX_AMOUNT_PER_PAYMENT_ATOMIC = "1000000"; +import { ALL_NETWORKS, getSettlementTokens } from "@fretchen/chain-utils"; /** - * Build the `spendControls.allowedAssets` list: every stablecoin on every network this site pays - * on (`getStablecoins`), each capped at `DEFAULT_MAX_AMOUNT_PER_PAYMENT_ATOMIC`. Pass to + * Build the `spendControls.allowedAssets` list: every settlement token on every network this site + * pays on (`getSettlementTokens`), each capped at one whole token in its own atomic units. Pass to * `x402Client#setSpendControls`. Which of them is actually paid is the selector's decision. * * No import of `SpendControlAsset` from `@x402/core` here — `@x402/core` is only a @@ -40,10 +40,11 @@ const DEFAULT_MAX_AMOUNT_PER_PAYMENT_ATOMIC = "1000000"; */ export function buildStablecoinAllowedAssets() { return ALL_NETWORKS.flatMap((network) => - getStablecoins(network).map((coin) => ({ + getSettlementTokens(network).map((token) => ({ network, - asset: coin.address, - maxAmountPerPayment: DEFAULT_MAX_AMOUNT_PER_PAYMENT_ATOMIC, + asset: token.address, + // One whole token in the token's own decimals — never a fixed 6-decimal figure. + maxAmountPerPayment: String(10n ** BigInt(token.decimals)), })), ); } diff --git a/website/locales/de.ts b/website/locales/de.ts index 5de1fdc47..4e46bba65 100644 --- a/website/locales/de.ts +++ b/website/locales/de.ts @@ -129,6 +129,10 @@ export default { fundsBody: "Zum Chatten brauchst du {currency} in deiner Wallet: Deine erste Nachricht legt 0,50 {currency} zurück.", fundsButton: "{currency} besorgen", + // EURe's two acquisition routes: the bank-native primary and the KYC-free secondary. + fundsRouteBank: "EURe per Banküberweisung holen (Monerium)", + fundsRouteSwap: "EURe auf Uniswap tauschen", + fundsSwapNote: "Für den Tausch brauchst du etwas ETH in deiner Wallet als Gas.", fundsNote: "Danach schick deine Nachricht noch einmal.", you: "Du", assistant: "Assistent", @@ -214,7 +218,7 @@ export default { // Mit welchem Stablecoin die Seite bezahlt, gemeinsam für /imagegen und /assistent. payment: { payWith: "Bezahlen mit", - eurcBaseOnly: "EURC gibt es nur auf Base.", + eurcBaseOnly: "EURe und EURC gibt es nur auf Base.", currencyFallback: "Dieser Agent nimmt diese Währung hier nicht an, die Zahlung läuft daher über", }, }; diff --git a/website/locales/en.ts b/website/locales/en.ts index 1b7a554d8..451a7ba63 100644 --- a/website/locales/en.ts +++ b/website/locales/en.ts @@ -124,6 +124,10 @@ export default { fundsTitle: "Top up your wallet", fundsBody: "To chat you need {currency} in your wallet: your first message sets aside 0.50 {currency}.", fundsButton: "Get {currency}", + // EURe's two acquisition routes: the bank-native primary and the KYC-free secondary. + fundsRouteBank: "Get EURe via bank transfer (Monerium)", + fundsRouteSwap: "Swap for EURe on Uniswap", + fundsSwapNote: "The swap route needs a little ETH in your wallet for gas.", fundsNote: "Then send your message again.", you: "You", assistant: "Assistant", @@ -204,7 +208,7 @@ export default { // Which stablecoin the site pays with, shared by /imagegen and /assistent. payment: { payWith: "Pay with", - eurcBaseOnly: "EURC is on Base only.", + eurcBaseOnly: "EURe and EURC are on Base only.", // Followed by the currency actually used, e.g. "… so payment will use USDC." currencyFallback: "This agent doesn't take that currency here, so payment will use", }, diff --git a/website/pages/x402/buyers/+Page.tsx b/website/pages/x402/buyers/+Page.tsx index 4073c4136..432b74dfc 100644 --- a/website/pages/x402/buyers/+Page.tsx +++ b/website/pages/x402/buyers/+Page.tsx @@ -43,8 +43,10 @@ export default function Page() { works — or just a private key, for a script.
  • - A small amount of USDC (a dollar stablecoin) on Optimism or Base — a few cents covers - many requests. No ETH needed: you pay no gas, the facilitator submits every transaction. + A small amount of a settlement token — USDC (a dollar stablecoin) on Optimism or Base, or + the euro tokens EURe and EURC on Base. This site defaults to EURe; the toggle keeps EURC and USDC one + click away. A few cents cover many requests. No ETH needed: you pay no gas — the facilitator submits every + transaction, and an EURe buyer's Permit2 approval rides an EIP-2612 permit inside the payment itself.
  • npm install @x402/evm @x402/fetch viem @@ -118,10 +120,10 @@ export default function Page() {

    Which scheme, when

    exact is one signature, one on-chain settlement, one result — the right shape for a single - paid call. batch-settlement opens a USDC payment channel with one on-chain deposit, then - every further message is an off-chain signed voucher against that channel — no transaction, no wallet - prompt, until the channel is claimed later. Use it for many small calls to the same service, like a chat - session. + paid call. batch-settlement opens a payment channel in your chosen token with one on-chain + deposit, then every further message is an off-chain signed voucher against that channel — no transaction, no + wallet prompt, until the channel is claimed later. Use it for many small calls to the same service, like a + chat session.

    TypeScript — exact scheme (image generation)

    diff --git a/website/pages/x402/sellers/+Page.tsx b/website/pages/x402/sellers/+Page.tsx index 8fd059c6d..ef3b19e5b 100644 --- a/website/pages/x402/sellers/+Page.tsx +++ b/website/pages/x402/sellers/+Page.tsx @@ -99,7 +99,13 @@ export default function Page() {
  • About 1 USDC of allowance for fees — USDC is a dollar stablecoin, pegged 1:1 to the US - dollar. That covers roughly 100 settlements. + dollar. That covers roughly 100 settlements. The fee is charged in the token each payment settles in, so a + token you also take in euros needs its own approval: 1 EURC for EURC, and for EURe{" "} + + 1 EURe (1018 atomic — 18 decimals, not 6) + {" "} + — a USDC-sized 1000000 allowance covers no EURe fee at all. The facilitator's{" "} + /supported lists every token's exact atomic fee and recommended approval.
  • To rehearse on testnet: testnet ETH and a buyer holding testnet USDC — see below.
  • diff --git a/website/test/AssistantChat.test.tsx b/website/test/AssistantChat.test.tsx index 540e9f781..ea1aabb4d 100644 --- a/website/test/AssistantChat.test.tsx +++ b/website/test/AssistantChat.test.tsx @@ -1296,13 +1296,13 @@ describe("AssistantChat", () => { describe("network and currency", () => { beforeEach(() => window.localStorage.clear()); - it("defaults to EURC on Base — the only network EURC exists on — and pays on it", () => { + it("defaults to EURe on Base — the euro tokens exist only there — and pays on it", () => { renderWithQuery(); - expect(useX402Chat).toHaveBeenCalledWith("eip155:8453", "https://llm-agent.fretchen.eu", "EURC"); + expect(useX402Chat).toHaveBeenCalledWith("eip155:8453", "https://llm-agent.fretchen.eu", "EURe"); }); - it("offers only Base while paying with EURC, with a note explaining why", () => { + it("offers only Base while paying a euro token, with a note explaining why", () => { renderWithQuery(); expect(screen.getByRole("button", { name: "Base" })).toBeInTheDocument(); @@ -1348,13 +1348,13 @@ describe("AssistantChat", () => { }); it("ignores a stored network the current currency does not offer", async () => { - // Chosen under USDC, but EURC (the default here) exists only on Base. + // Chosen under USDC, but EURe (the default here) exists only on Base. window.localStorage.setItem("x402-chat-network", "eip155:10"); renderWithQuery(); await waitFor(() => - expect(useX402Chat).toHaveBeenLastCalledWith("eip155:8453", "https://llm-agent.fretchen.eu", "EURC"), + expect(useX402Chat).toHaveBeenLastCalledWith("eip155:8453", "https://llm-agent.fretchen.eu", "EURe"), ); }); @@ -1439,7 +1439,7 @@ describe("AssistantChat", () => { renderWithQuery(); expect(screen.getByPlaceholderText("https://another-agent.example")).toBeInTheDocument(); - expect(useX402Chat).toHaveBeenCalledWith("eip155:8453", "https://llm-agent.fretchen.eu", "EURC"); + expect(useX402Chat).toHaveBeenCalledWith("eip155:8453", "https://llm-agent.fretchen.eu", "EURe"); }); it("pre-checks a pasted URL and then pays that agent instead", async () => { @@ -1449,7 +1449,7 @@ describe("AssistantChat", () => { pasteAndTry(CUSTOM_URL); await waitFor(() => expect(precheckLlmV1Agent).toHaveBeenCalledWith(CUSTOM_URL)); - await waitFor(() => expect(useX402Chat).toHaveBeenLastCalledWith("eip155:8453", CUSTOM_URL, "EURC")); + await waitFor(() => expect(useX402Chat).toHaveBeenLastCalledWith("eip155:8453", CUSTOM_URL, "EURe")); // Provenance of who is about to be paid. expect(screen.getByText("Someone Else")).toBeInTheDocument(); }); @@ -1477,7 +1477,7 @@ describe("AssistantChat", () => { fireEvent.click(back); await waitFor(() => - expect(useX402Chat).toHaveBeenLastCalledWith("eip155:8453", "https://llm-agent.fretchen.eu", "EURC"), + expect(useX402Chat).toHaveBeenLastCalledWith("eip155:8453", "https://llm-agent.fretchen.eu", "EURe"), ); }); @@ -1498,7 +1498,7 @@ describe("AssistantChat", () => { expect(screen.getAllByLabelText("Site analytics").length).toBeGreaterThan(0); pasteAndTry(CUSTOM_URL); - await waitFor(() => expect(useX402Chat).toHaveBeenLastCalledWith("eip155:8453", CUSTOM_URL, "EURC")); + await waitFor(() => expect(useX402Chat).toHaveBeenLastCalledWith("eip155:8453", CUSTOM_URL, "EURe")); // ...and no longer once a stranger is being paid. expect(screen.queryByLabelText("Site analytics")).not.toBeInTheDocument(); @@ -1513,7 +1513,7 @@ describe("AssistantChat", () => { renderWithQuery(); pasteAndTry(CUSTOM_URL); - await waitFor(() => expect(useX402Chat).toHaveBeenLastCalledWith("eip155:8453", CUSTOM_URL, "EURC")); + await waitFor(() => expect(useX402Chat).toHaveBeenLastCalledWith("eip155:8453", CUSTOM_URL, "EURe")); sendUserMessage("How is the site doing?"); @@ -1537,7 +1537,7 @@ describe("AssistantChat", () => { renderWithQuery(); pasteAndTry(CUSTOM_URL); - await waitFor(() => expect(useX402Chat).toHaveBeenLastCalledWith("eip155:8453", CUSTOM_URL, "EURC")); + await waitFor(() => expect(useX402Chat).toHaveBeenLastCalledWith("eip155:8453", CUSTOM_URL, "EURe")); sendUserMessage("What is x402?"); diff --git a/website/test/GetFundsModal.test.tsx b/website/test/GetFundsModal.test.tsx new file mode 100644 index 000000000..62c6419d0 --- /dev/null +++ b/website/test/GetFundsModal.test.tsx @@ -0,0 +1,49 @@ +/** + * The acquisition routes behind `GetFundsModal`. EURe is the only currency with two routes: + * Monerium's bank-transfer signup as the primary (the euro-native route), and Uniswap's EURe + * token page as a secondary for anyone the KYC/SEPA flow would spook. The other currencies + * keep their single link unchanged. + * + * setup.ts mocks `useLocale` to return the raw label, so links are asserted by label key. + */ +import React from "react"; +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { GetFundsModal } from "../components/GetFundsModal"; + +const onClose = () => {}; + +describe("GetFundsModal", () => { + it("offers EURe two ways: Monerium signup primary, Uniswap token page secondary", () => { + render(); + + const bank = screen.getByRole("link", { name: "assistent.fundsRouteBank" }); + expect(bank).toHaveAttribute("href", "https://monerium.app/signup"); + + const swap = screen.getByRole("link", { name: "assistent.fundsRouteSwap" }); + expect(swap).toHaveAttribute( + "href", + "https://app.uniswap.org/explore/tokens/base/0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C", + ); + + // The honest caveat for the KYC-free route: swapping needs ETH for gas. + expect(screen.getByText("assistent.fundsSwapNote")).toBeDefined(); + }); + + it("keeps a single route for EURC and USDC, with no swap note", () => { + const { unmount } = render(); + expect(screen.getByRole("link", { name: "assistent.fundsButton" })).toHaveAttribute( + "href", + "https://www.coinbase.com/de/how-to-buy/euro-coin-2", + ); + expect(screen.queryByText("assistent.fundsSwapNote")).toBeNull(); + unmount(); + + render(); + expect(screen.getByRole("link", { name: "assistent.fundsButton" })).toHaveAttribute( + "href", + "https://app.optimism.io/bridge", + ); + expect(screen.queryByText("assistent.fundsSwapNote")).toBeNull(); + }); +}); diff --git a/website/test/useX402Chat.test.ts b/website/test/useX402Chat.test.ts index 5b4640f25..ff49e2bfe 100644 --- a/website/test/useX402Chat.test.ts +++ b/website/test/useX402Chat.test.ts @@ -617,8 +617,8 @@ describe("useX402Chat", () => { ), ); - // Default currency (EURC) — no third argument. - const { result } = renderHook(() => useX402Chat(NETWORK)); + // Explicit EURC — the default is EURe now; this test's subject is EURC's error wording. + const { result } = renderHook(() => useX402Chat(NETWORK, undefined, "EURC")); let thrown: Error | undefined; await act(async () => { try { diff --git a/website/test/x402Currency.test.ts b/website/test/x402Currency.test.ts new file mode 100644 index 000000000..c86aaae6b --- /dev/null +++ b/website/test/x402Currency.test.ts @@ -0,0 +1,99 @@ +/** + * The site-wide currency choice: EURe is the default (the sellers prefer it — deployed + * `STABLECOIN_PREFERENCE`), EURC and USDC are one toggle away, and a stored choice always wins, + * so the default only ever applies to a visitor with no stored preference yet. + */ +import { beforeEach, describe, expect, it } from "vitest"; +import { + CURRENCY_DECIMALS, + DEFAULT_CURRENCY, + PAYMENT_CURRENCIES, + networksForCurrency, + preferCurrency, + readCurrency, + storeCurrency, +} from "../hooks/x402Currency"; + +const BASE_EURE = "0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C" as const; +const BASE_EURC = "0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42" as const; +const BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as const; + +const ALL_NETWORKS = ["eip155:10", "eip155:11155420", "eip155:8453", "eip155:84532"]; + +/** A minimal `accepts` entry — the selector only reads `network` and `asset`. */ +const accept = (network: string, asset: string) => ({ network, asset }); + +describe("readCurrency / storeCurrency", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("defaults to EURe when nothing is stored — the sellers' preferred token", () => { + expect(DEFAULT_CURRENCY).toBe("EURe"); + expect(readCurrency()).toBe("EURe"); + }); + + it("returns a stored choice unchanged — existing visitors never see the default flip", () => { + window.localStorage.setItem("x402-currency", "EURC"); + expect(readCurrency()).toBe("EURC"); + storeCurrency("USDC"); + expect(readCurrency()).toBe("USDC"); + }); + + it("falls back to the default for a stored value this site does not pay with", () => { + window.localStorage.setItem("x402-currency", "DOGE"); + expect(readCurrency()).toBe("EURe"); + }); + + it("lists the currencies in preference order", () => { + expect(PAYMENT_CURRENCIES).toEqual(["EURe", "EURC", "USDC"]); + }); +}); + +describe("CURRENCY_DECIMALS", () => { + it("knows each token's decimals — EURe is 18, the others 6", () => { + expect(CURRENCY_DECIMALS).toEqual({ EURe: 18, EURC: 6, USDC: 6 }); + }); +}); + +describe("networksForCurrency", () => { + it("narrows EURe and EURC to the Base networks, keeps USDC everywhere", () => { + expect(networksForCurrency("EURe", ALL_NETWORKS)).toEqual(["eip155:8453", "eip155:84532"]); + expect(networksForCurrency("EURC", ALL_NETWORKS)).toEqual(["eip155:8453", "eip155:84532"]); + expect(networksForCurrency("USDC", ALL_NETWORKS)).toEqual(ALL_NETWORKS); + }); + + it("keeps a caller's order", () => { + expect(networksForCurrency("EURe", ["eip155:84532", "eip155:8453", "eip155:10"])).toEqual([ + "eip155:84532", + "eip155:8453", + ]); + }); +}); + +describe("preferCurrency selector", () => { + it("picks the EURe entry out of a mixed offer", () => { + const accepts = [ + accept("eip155:8453", BASE_EURE), + accept("eip155:8453", BASE_EURC), + accept("eip155:8453", BASE_USDC), + ]; + expect(preferCurrency("EURe")(2, accepts)).toMatchObject({ asset: BASE_EURE }); + }); + + it("falls back to the seller's own first entry when the preferred currency is not offered", () => { + const accepts = [accept("eip155:10", "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85")]; + expect(preferCurrency("EURe")(2, accepts)).toBe(accepts[0]); + }); + + it("reports the currency actually paid via onPick — the fallback names the paid token", () => { + const accepts = [accept("eip155:8453", BASE_EURE), accept("eip155:8453", BASE_USDC)]; + let paid: string | null | undefined; + // EURC is not offered: the selector falls back to the seller's first entry and reports + // THAT currency — the label must name what was paid, not what was preferred. + preferCurrency("EURC", (p) => (paid = p))(2, accepts); + expect(paid).toBe("EURe"); + preferCurrency("EURe", (p) => (paid = p))(2, accepts); + expect(paid).toBe("EURe"); + }); +}); diff --git a/website/test/x402Prices.test.ts b/website/test/x402Prices.test.ts new file mode 100644 index 000000000..3900947c9 --- /dev/null +++ b/website/test/x402Prices.test.ts @@ -0,0 +1,32 @@ +/** + * Display-only prices and spend formatting, per currency — EURe is 18 decimals, so the atomic + * math must follow the currency, never a fixed 6-decimal divisor. + */ +import { describe, expect, it } from "vitest"; +import { IMAGE_PRICE, formatSpend } from "../utils/x402Prices"; + +describe("IMAGE_PRICE", () => { + it("carries the image price in every payable currency", () => { + expect(IMAGE_PRICE.EURe).toBe("€0.06 EURe"); + expect(IMAGE_PRICE.EURC).toBe("€0.06 EURC"); + expect(IMAGE_PRICE.USDC).toBe("$0.07 USDC"); + }); +}); + +describe("formatSpend", () => { + it("formats an EURe amount with its 18 decimals", () => { + // 0.01 EURe — the search price, settled on Base mainnet in the buyer notebook. + expect(formatSpend(10n ** 16n, "EURe")).toBe("€0.010 EURe"); + expect(formatSpend(60000000000000000n, "EURe")).toBe("€0.060 EURe"); + }); + + it("keeps the 6-decimal formats for EURC and USDC", () => { + expect(formatSpend(10_000n, "EURC")).toBe("€0.010 EURC"); + expect(formatSpend(10_000n, "USDC")).toBe("$0.010 USDC"); + }); + + it("does not silently divide an 18-decimal amount by a 6-decimal divisor", () => { + // The trap this guards: a fixed 1e6 divisor would render 0.01 EURe as €10000000.000. + expect(formatSpend(10n ** 16n, "EURe")).not.toContain("10000000"); + }); +}); diff --git a/website/test/x402SpendControls.test.ts b/website/test/x402SpendControls.test.ts index 3270cc725..e261fc010 100644 --- a/website/test/x402SpendControls.test.ts +++ b/website/test/x402SpendControls.test.ts @@ -6,11 +6,12 @@ * `eip155:10`/`eip155:11155420` in `@x402/evm`). These tests assert on the specific * networks that were missing, not just array length, so a future edit that silently * drops Optimism from the allowlist fails here instead of in production. The registry knows - * no EURC anywhere, so EURC is asserted the same way. + * no EURC or EURe anywhere, so both are asserted the same way — and the caps are per token: + * a 6-decimal cap on EURe would veto every EURe payment at 10⁻¹² tokens. */ import { describe, it, expect } from "vitest"; -import { ALL_NETWORKS, EURC_ADDRESSES, getUSDCAddress } from "@fretchen/chain-utils"; +import { ALL_NETWORKS, EURE_ADDRESSES, EURC_ADDRESSES, getUSDCAddress } from "@fretchen/chain-utils"; import { buildStablecoinAllowedAssets } from "../hooks/x402SpendControls"; describe("buildStablecoinAllowedAssets", () => { @@ -32,9 +33,16 @@ describe("buildStablecoinAllowedAssets", () => { expect(eurcEntries.map((entry) => entry.network).sort()).toEqual(["eip155:8453", "eip155:84532"]); }); - it("caps every entry at the same atomic amount, mirroring the SDK's own default cap", () => { - const caps = new Set(buildStablecoinAllowedAssets().map((entry) => entry.maxAmountPerPayment)); - expect(caps.size).toBe(1); - expect([...caps][0]).toBe("1000000"); // 1 token, 6 decimals + it("allowlists EURe on Base and Base Sepolia, and nowhere else", () => { + const eureEntries = buildStablecoinAllowedAssets().filter((entry) => entry.asset === EURE_ADDRESSES[entry.network]); + expect(eureEntries.map((entry) => entry.network).sort()).toEqual(["eip155:8453", "eip155:84532"]); + }); + + it("caps each entry at one whole token in that token's own decimals", () => { + const byAsset = new Map(buildStablecoinAllowedAssets().map((entry) => [entry.asset, entry.maxAmountPerPayment])); + expect(byAsset.get(getUSDCAddress("eip155:8453"))).toBe("1000000"); // 1 USDC, 6 decimals + expect(byAsset.get(EURC_ADDRESSES["eip155:8453"])).toBe("1000000"); // 1 EURC, 6 decimals + // 1 EURe is 10¹⁸ atomic — the 6-decimal figure would cap every EURe payment at 10⁻¹² tokens. + expect(byAsset.get(EURE_ADDRESSES["eip155:8453"])).toBe("1000000000000000000"); }); }); diff --git a/website/utils/x402Prices.ts b/website/utils/x402Prices.ts index 81cb19ccc..c9dc3458b 100644 --- a/website/utils/x402Prices.ts +++ b/website/utils/x402Prices.ts @@ -1,20 +1,25 @@ -import type { PaymentCurrency } from "../hooks/x402Currency"; +import { CURRENCY_DECIMALS, type PaymentCurrency } from "../hooks/x402Currency"; /** * What an image costs, per currency, for display only — what is actually charged is whatever the * seller's 402 asks for, and the spend controls cap it. * - * A mirror of `scw_js/serverless.yml` (`USDC_PAYMENT_AMOUNT` / `EURC_PAYMENT_AMOUNT`): two - * separate prices, not one converted into the other. Change both places together. + * A mirror of `scw_js/serverless.yml` (`USDC_PAYMENT_AMOUNT` / `EURC_PAYMENT_AMOUNT` / + * `EURE_PAYMENT_AMOUNT`): separately set prices, not one converted into the other. Change both + * places together. */ export const IMAGE_PRICE: Record = { + EURe: "€0.06 EURe", EURC: "€0.06 EURC", USDC: "$0.07 USDC", }; -/** An amount actually spent, in the channel token's atomic units (6 decimals for both), in the - * same "€0.06 EURC" shape as `IMAGE_PRICE`. Three decimals: a search is a cent, a page a tenth. */ +/** + * An amount actually spent, in the token's own atomic units — 6 decimals for USDC/EURC, 18 for + * EURe — in the same "€0.06 EURC" shape as `IMAGE_PRICE`. Three decimals: a search is a cent, a + * page a tenth. The €/$ prefix and the decimals both follow the currency. + */ export function formatSpend(atomic: bigint, currency: PaymentCurrency): string { - const amount = (Number(atomic) / 1_000_000).toFixed(3); - return currency === "EURC" ? `€${amount} EURC` : `$${amount} USDC`; + const amount = (Number(atomic) / 10 ** CURRENCY_DECIMALS[currency]).toFixed(3); + return currency === "USDC" ? `$${amount} USDC` : `€${amount} ${currency}`; } From 50d48b5027277d3e0f776a774f3a68abe73e2c83 Mon Sep 17 00:00:00 2001 From: fretchen Date: Sun, 11 Oct 2026 00:24:12 +0200 Subject: [PATCH 09/13] Update CurrencyToggle.tsx --- website/components/CurrencyToggle.tsx | 44 +++++++++++++++++++-------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/website/components/CurrencyToggle.tsx b/website/components/CurrencyToggle.tsx index 2e3ec6576..c8a0c1abb 100644 --- a/website/components/CurrencyToggle.tsx +++ b/website/components/CurrencyToggle.tsx @@ -5,27 +5,45 @@ import { useLocale } from "../hooks/useLocale"; import { PAYMENT_CURRENCIES, storeCurrency, usePaymentCurrency } from "../hooks/x402Currency"; /** - * The site-wide EURC/USDC choice (see `hooks/x402Currency.ts`). Reads and writes the shared + * The site-wide currency choice (see `hooks/x402Currency.ts`). Reads and writes the shared * preference itself, so every page that shows it stays in step without passing state around. + * + * Equal-width segments that wrap as a set: a grid with `auto-fit` tracks floored at each + * button's `min-content` puts as many equal segments on a line as fit, and a wrapped one + * keeps the same width as its row-mates. In the chat's fixed sidebar (~199px of content + * width) that is two per line with the third matching below; in wider containers (the + * imagegen card) all three share one line. Flex could not express this — flex items refuse + * to shrink below their content, and a lone wrapped item with `flex: 1` grows to the whole + * line, which is why the third button once rendered full-width on its own line. */ export function CurrencyToggle() { const currency = usePaymentCurrency(); const payWithLabel = useLocale({ label: "payment.payWith" }); return ( -
    +
    {payWithLabel} - {PAYMENT_CURRENCIES.map((option) => ( - - ))} +
    + {PAYMENT_CURRENCIES.map((option) => ( + + ))} +
    ); } From 5e9ae5e4ca3f0fcd3c56fa5a7cdf426acb6d4afd Mon Sep 17 00:00:00 2001 From: fretchen Date: Sun, 11 Oct 2026 00:30:17 +0200 Subject: [PATCH 10/13] clean merge --- shared/chain-utils/src/addresses.ts | 11 ++++++++++ shared/chain-utils/test/index.test.ts | 29 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/shared/chain-utils/src/addresses.ts b/shared/chain-utils/src/addresses.ts index eb4d2ecdb..247895f9e 100644 --- a/shared/chain-utils/src/addresses.ts +++ b/shared/chain-utils/src/addresses.ts @@ -238,6 +238,17 @@ export function getSettlementTokens(network: string): SettlementTokenInfo[] { return coins; } +/** + * Identify a token address as one of the settlement tokens on `network`, case-insensitively. + * The settlement-token counterpart of `findStablecoin` — the website's buyer side resolves a + * 402's asset address to a payable currency through this, so it must know EURe. + * @returns null for any other token, or for a token not deployed on this network. + */ +export function findSettlementToken(network: string, address: string): SettlementTokenInfo | null { + const wanted = address.toLowerCase(); + return getSettlementTokens(network).find((token) => token.address.toLowerCase() === wanted) ?? null; +} + // ═══════════════════════════════════════════════════════════════ // EIP3009 Splitter // ═══════════════════════════════════════════════════════════════ diff --git a/shared/chain-utils/test/index.test.ts b/shared/chain-utils/test/index.test.ts index 5c5204c97..54b21bc24 100644 --- a/shared/chain-utils/test/index.test.ts +++ b/shared/chain-utils/test/index.test.ts @@ -35,6 +35,7 @@ import { EURC_ADDRESSES, EURE_ADDRESSES, findStablecoin, + findSettlementToken, getStablecoins, getSettlementTokens, } from "../src/addresses"; @@ -418,6 +419,34 @@ describe("@fretchen/chain-utils", () => { }); }); + describe("findSettlementToken()", () => { + const BASE_EURE = EURE_ADDRESSES["eip155:8453"]; + + test("identifies each token on Base with its metadata, case-insensitively", () => { + expect(findSettlementToken("eip155:8453", BASE_EURE)).toMatchObject({ + symbol: "EURe", + decimals: 18, + transferMethod: "permit2", + }); + expect(findSettlementToken("eip155:8453", BASE_EURE.toLowerCase())?.symbol).toBe("EURe"); + expect(findSettlementToken("eip155:84532", EURE_ADDRESSES["eip155:84532"])?.symbol).toBe( + "EURe" + ); + expect(findSettlementToken("eip155:8453", USDC_ADDRESSES["eip155:8453"])?.symbol).toBe( + "USDC" + ); + expect(findSettlementToken("eip155:8453", EURC_ADDRESSES["eip155:8453"])?.symbol).toBe( + "EURC" + ); + }); + + test("returns null for EURe on Optimism and for unknown tokens or networks", () => { + expect(findSettlementToken("eip155:10", BASE_EURE)).toBeNull(); + expect(findSettlementToken("eip155:8453", "0x0000000000000000000000000000000000000001")).toBeNull(); + expect(findSettlementToken("eip155:1", BASE_EURE)).toBeNull(); + }); + }); + describe("getUSDCConfig()", () => { test("should return complete config for Optimism Mainnet", () => { const config = getUSDCConfig("eip155:10"); From 09897058ba84b3fd38d1e57973e21cd667b59366 Mon Sep 17 00:00:00 2001 From: fretchen Date: Sun, 11 Oct 2026 00:32:20 +0200 Subject: [PATCH 11/13] prettier --- shared/chain-utils/src/addresses.ts | 4 +++- shared/chain-utils/test/index.test.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/shared/chain-utils/src/addresses.ts b/shared/chain-utils/src/addresses.ts index 247895f9e..899bfc888 100644 --- a/shared/chain-utils/src/addresses.ts +++ b/shared/chain-utils/src/addresses.ts @@ -246,7 +246,9 @@ export function getSettlementTokens(network: string): SettlementTokenInfo[] { */ export function findSettlementToken(network: string, address: string): SettlementTokenInfo | null { const wanted = address.toLowerCase(); - return getSettlementTokens(network).find((token) => token.address.toLowerCase() === wanted) ?? null; + return ( + getSettlementTokens(network).find((token) => token.address.toLowerCase() === wanted) ?? null + ); } // ═══════════════════════════════════════════════════════════════ diff --git a/shared/chain-utils/test/index.test.ts b/shared/chain-utils/test/index.test.ts index 54b21bc24..40e990c4f 100644 --- a/shared/chain-utils/test/index.test.ts +++ b/shared/chain-utils/test/index.test.ts @@ -442,7 +442,9 @@ describe("@fretchen/chain-utils", () => { test("returns null for EURe on Optimism and for unknown tokens or networks", () => { expect(findSettlementToken("eip155:10", BASE_EURE)).toBeNull(); - expect(findSettlementToken("eip155:8453", "0x0000000000000000000000000000000000000001")).toBeNull(); + expect( + findSettlementToken("eip155:8453", "0x0000000000000000000000000000000000000001") + ).toBeNull(); expect(findSettlementToken("eip155:1", BASE_EURE)).toBeNull(); }); }); From f55a9c8670bff20d72a8312d0d5525eb0f310132 Mon Sep 17 00:00:00 2001 From: fretchen Date: Sun, 11 Oct 2026 00:33:57 +0200 Subject: [PATCH 12/13] Update AssistantChat.test.tsx --- website/test/AssistantChat.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/test/AssistantChat.test.tsx b/website/test/AssistantChat.test.tsx index ea1aabb4d..252fc6314 100644 --- a/website/test/AssistantChat.test.tsx +++ b/website/test/AssistantChat.test.tsx @@ -159,7 +159,7 @@ import claimsFixture from "./fixtures/bundestakt/claims.json"; import { OWNER_SCOPES } from "../utils/getChain"; import { PaymentError } from "../utils/x402PaidFetch"; import { MAX_HOPS } from "../utils/toolLoop"; -import { toolContractPrompt, teenPrompt, researchPrompt } from "../utils/prompts"; +import { toolContractPrompt, researchPrompt } from "../utils/prompts"; import { formatLanguageContext } from "../utils/languageContext"; import { usePageContext } from "vike-react/usePageContext"; From e8e8fc7457ec25ca66530df55def7ede2742420a Mon Sep 17 00:00:00 2001 From: fretchen Date: Sun, 11 Oct 2026 00:40:23 +0200 Subject: [PATCH 13/13] x402 skill: the website's buyer side reads the settlement-token registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seller-PR sentence said the website still pays only USDC/EURC until its buyer-side PR — this branch is that PR, so the sentence was stale guidance for every agent loading the skill. It now names the website's actual reads (getSettlementTokens/findSettlementToken, EURe default, per-token spend cap) and keeps the getStablecoins boundary explicit: nothing should read it for EURe. --- .agents/skills/x402/SKILL.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.agents/skills/x402/SKILL.md b/.agents/skills/x402/SKILL.md index f017d5aa5..ae66739d1 100644 --- a/.agents/skills/x402/SKILL.md +++ b/.agents/skills/x402/SKILL.md @@ -94,10 +94,13 @@ fallback (see `genimg_x402_token.ts`). so it is paid via **Permit2** (`exact`'s and batch-settlement's `permit2Authorization`, with the 402 advertising `eip2612GasSponsoring`), and the 0.01 facilitator fee is `10¹⁶` atomic units, a seller's fee approval `10¹⁸` (1 EURe). Domain `"Monerium EURe"`, version `"1"`. - Registered in `@fretchen/chain-utils` → `getSettlementTokens()` (the facilitator's fee tokens - and the sellers' offers both read it); the website still pays only USDC/EURC via - `getStablecoins()` until its buyer-side PR. See `scw_js/README.md` → _Stablecoins and pricing_ - and _Known EURe Domain Names_, and `x402_facilitator/README.md` → _EURe via Permit2_. + Registered in `@fretchen/chain-utils` → `getSettlementTokens()` — the facilitator's fee + tokens, the sellers' offers, and the website's buyer side (`getSettlementTokens`/ + `findSettlementToken`, default currency EURe with EURC and USDC one toggle away; its + spend-control cap is per-token, `10^decimals`) all read it. `getStablecoins()` remains + USDC+EURC only — nothing should read it for EURe. See `scw_js/README.md` → _Stablecoins + and pricing_ and _Known EURe Domain Names_, and `x402_facilitator/README.md` → _EURe via + Permit2_. ## Writing about x402 on the site