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 diff --git a/shared/chain-utils/src/addresses.ts b/shared/chain-utils/src/addresses.ts index eb4d2ecdb..899bfc888 100644 --- a/shared/chain-utils/src/addresses.ts +++ b/shared/chain-utils/src/addresses.ts @@ -238,6 +238,19 @@ 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..40e990c4f 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,36 @@ 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/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) => ( + + ))} +
); } 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..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"; @@ -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}`; }