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 (
-
);
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.
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}`;
}