Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .agents/skills/x402/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions shared/chain-utils/src/addresses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ═══════════════════════════════════════════════════════════════
Expand Down
31 changes: 31 additions & 0 deletions shared/chain-utils/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
EURC_ADDRESSES,
EURE_ADDRESSES,
findStablecoin,
findSettlementToken,
getStablecoins,
getSettlementTokens,
} from "../src/addresses";
Expand Down Expand Up @@ -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");
Expand Down
44 changes: 31 additions & 13 deletions website/components/CurrencyToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div role="group" aria-label={payWithLabel} className={css({ display: "flex", alignItems: "center", gap: "2" })}>
<div className={css({ display: "flex", flexDirection: "column", gap: "sm" })}>
<span className={css({ fontSize: "sm", color: "textMuted" })}>{payWithLabel}</span>
{PAYMENT_CURRENCIES.map((option) => (
<button
key={option}
type="button"
onClick={() => storeCurrency(option)}
aria-pressed={currency === option}
className={button({ visual: "secondary", size: "sm", active: currency === option })}
>
{option}
</button>
))}
<div
role="group"
aria-label={payWithLabel}
className={css({
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(min-content, 1fr))",
gap: "2",
})}
>
{PAYMENT_CURRENCIES.map((option) => (
<button
key={option}
type="button"
onClick={() => storeCurrency(option)}
aria-pressed={currency === option}
className={button({ visual: "secondary", size: "sm", active: currency === option })}
>
{option}
</button>
))}
</div>
</div>
);
}
Expand Down
62 changes: 51 additions & 11 deletions website/components/GetFundsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<PaymentCurrency, string> = {
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<PaymentCurrency, FundsRoute[]> = {
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" }],
};

/**
Expand All @@ -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<string, string> = {
"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 (
<Modal onClose={onClose} title={title} closeLabel={closeAria} lightClose>
<p className={styles.modal.text}>{body}</p>
<div className={styles.modal.primaryAction}>
<a
className={button({ visual: "support" })}
href={GET_FUNDS_URL[currency]}
target="_blank"
rel="noopener noreferrer"
>
{buttonLabel}
<a className={button({ visual: "support" })} href={primary.url} target="_blank" rel="noopener noreferrer">
{labels[primary.labelKey]}
</a>
</div>
{secondary.map((route) => (
<div className={styles.modal.primaryAction} key={route.labelKey}>
<a className={button({ visual: "secondary" })} href={route.url} target="_blank" rel="noopener noreferrer">
{labels[route.labelKey]}
</a>
</div>
))}
{hasSwap && <p className={styles.modal.note}>{swapNote}</p>}
<p className={styles.modal.note}>{note}</p>
</Modal>
);
Expand Down
44 changes: 29 additions & 15 deletions website/hooks/x402Currency.ts
Original file line number Diff line number Diff line change
@@ -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<PaymentCurrency, number> = Object.fromEntries(
getSettlementTokens("eip155:8453").map((token) => [token.symbol, token.decimals]),
) as Record<PaymentCurrency, number>;

const CURRENCY_KEY = "x402-currency";

Expand Down Expand Up @@ -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
Expand Down
33 changes: 17 additions & 16 deletions website/hooks/x402SpendControls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)),
})),
);
}
6 changes: 5 additions & 1 deletion website/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
},
};
6 changes: 5 additions & 1 deletion website/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
},
Expand Down
Loading
Loading