diff --git a/website/components/AssistantChat.tsx b/website/components/AssistantChat.tsx
index 44b2ac115..bb094f0b8 100644
--- a/website/components/AssistantChat.tsx
+++ b/website/components/AssistantChat.tsx
@@ -1,6 +1,7 @@
/**
* AssistantChat — the interactive core of the x402 batch-settlement chat assistant
- * (`/assistent`). Pays per message via `useX402Chat` (USDC payment channels): the
+ * (`/assistent`). Pays per message via `useX402Chat` (stablecoin payment channels —
+ * EURe by default, EURC and USDC one toggle away): the
* first message opens a channel (one wallet-signed deposit), later messages are
* off-chain voucher signatures reusing the open channel.
*/
@@ -554,6 +555,10 @@ export function AssistantChat() {
const networkFallbackLabel = useLocale({ label: "assistent.networkFallback" });
const eurcBaseOnlyLabel = useLocale({ label: "payment.eurcBaseOnly" });
const currencyFallbackLabel = useLocale({ label: "payment.currencyFallback" });
+ // The wallet-side note: guarded-B fell back to a token the wallet can fund. Named apart from
+ // `payment.currencyFallback` (the agent-side note below) because the reasons differ — "the
+ // agent doesn't take it" and "your wallet holds none of it" must not share a sentence.
+ const walletFallbackLabel = useLocale({ label: "assistent.walletFallback" });
// Mobile detection
React.useEffect(() => {
@@ -704,6 +709,7 @@ export function AssistantChat() {
paymentReceipt,
paymentNetwork,
paymentCurrency,
+ currencyFallback,
status: chatStatus,
} = useX402Chat(desiredNetwork, agentUrl, currency);
const { network, switchIfNeeded, getSwitchError } = useAutoNetwork([paymentNetwork]);
@@ -1413,8 +1419,10 @@ export function AssistantChat() {
)}
{/* Separately from the network fallback above: the agent may accept the negotiated
- network but only in the other currency (e.g. USDC-only on Base). */}
- {paymentCurrency !== currency && (
+ network but only in the other currency (e.g. USDC-only on Base). Suppressed when
+ the wallet note is active — then the deviation is the wallet's, not the agent's,
+ and the wrong attribution would be worse than silence. */}
+ {paymentCurrency !== currency && !currencyFallback && (
+ {walletFallbackLabel
+ .replaceAll("{used}", currencyFallback.used)
+ .replaceAll("{preferred}", currencyFallback.preferred)}
+
+ )}
+
{isLoading && (
{isDefaultAgent &&
}
diff --git a/website/hooks/useX402Chat.ts b/website/hooks/useX402Chat.ts
index 542a19b5f..8857126fc 100644
--- a/website/hooks/useX402Chat.ts
+++ b/website/hooks/useX402Chat.ts
@@ -17,8 +17,14 @@ import { useWalletClient } from "wagmi";
import { getConfiguredPublicClient } from "./useConfiguredPublicClient";
import { useIsWalletConnected } from "./useIsWalletConnected";
import { probeAccepts, negotiateNetwork, LLM_V1_FLOOR } from "./x402Discovery";
-import { DEFAULT_CURRENCY, offeredCurrency, type PaymentCurrency } from "./x402Currency";
-import { createPaidFetch, type PaidFetch } from "../utils/x402PaidFetch";
+import {
+ DEFAULT_CURRENCY,
+ offeredCurrency,
+ offeredCurrencies,
+ selectFundedCurrency,
+ type PaymentCurrency,
+} from "./x402Currency";
+import { createPaidFetch, readFundableTokens, type PaidFetch } from "../utils/x402PaidFetch";
import type {
X402ChatMessage,
X402ChatResponse,
@@ -68,6 +74,14 @@ export interface UseX402ChatResult {
* `currency` until the agent has been probed.
*/
paymentCurrency: PaymentCurrency;
+ /**
+ * When the token this send would have paid in could not fund its deposit and another
+ * token the wallet holds and the agent offers was used instead: `{ preferred, used }`,
+ * per-payment and never persisted (the stored preference stays the user's alone). Null
+ * on every send that paid in the negotiated currency. The component announces it; the
+ * deposit signature was the consent gate.
+ */
+ currencyFallback: { preferred: PaymentCurrency; used: PaymentCurrency } | null;
}
/**
@@ -101,6 +115,11 @@ export function useX402Chat(
network: string;
paid: PaymentCurrency;
} | null>(null);
+ // The guarded-B fallback of the most recent send, or null — set per send, never persisted.
+ const [currencyFallback, setCurrencyFallback] = useState<{
+ preferred: PaymentCurrency;
+ used: PaymentCurrency;
+ } | null>(null);
const isCurrent =
negotiated?.agentUrl === agentUrl && negotiated?.preferred === network && negotiated?.currency === currency;
const paymentNetwork = isCurrent ? negotiated.network : network;
@@ -155,10 +174,6 @@ export function useX402Chat(
// that — proceed on the preferred network and let the real 402 be the judge.
const payNetwork = resolved ?? network;
const paid = offeredCurrency(accepts, payNetwork, currency) ?? currency;
- setNegotiated({ agentUrl, preferred: network, currency, network: payNetwork, paid });
- // Synchronously, unlike the state above: this turn's tool calls read it during the very
- // loop this send belongs to, long before a re-render could deliver the state.
- payRef.current = { network: payNetwork, currency };
// A readContract-capable client is required: batch-settlement's corrective-402
// recovery reads channel state on-chain, unlike the exact scheme. Resolved here, not
@@ -168,6 +183,27 @@ export function useX402Chat(
throw new Error(`No public client for network ${payNetwork}`);
}
+ // Guarded-B currency fallback (selectFundedCurrency in x402Currency.ts): when the
+ // token this send would pay in cannot fund its deposit floor, pay in a token the
+ // wallet actually holds and the agent actually offers. `paid` is passed, not the raw
+ // preference, so the seller-side negotiation runs first — a fallback can only replace
+ // a token that could not pay anyway, and never chooses among funded options. The
+ // switch is never persisted, the deposit signature stays the consent gate, and the
+ // component announces the deviation via `currencyFallback`.
+ const candidates = offeredCurrencies(accepts, payNetwork);
+ const fundable =
+ candidates.length > 0
+ ? await readFundableTokens(walletClient.account.address, publicClient, payNetwork)
+ : () => false;
+ const effective = selectFundedCurrency(paid, candidates, fundable);
+ setCurrencyFallback(effective.fellBack ? { preferred: paid, used: effective.currency } : null);
+ setNegotiated({ agentUrl, preferred: network, currency, network: payNetwork, paid: effective.currency });
+ // Synchronously, unlike the state above: this turn's tool calls read it during the very
+ // loop this send belongs to, long before a re-render could deliver the state. The
+ // effective currency, not the preference — the tools must spend on the channel this
+ // turn actually opened.
+ payRef.current = { network: payNetwork, currency: effective.currency };
+
setStatus("awaiting-signature");
setError(null);
// Don't clear paymentReceipt here: it represents the currently-open channel's
@@ -269,7 +305,18 @@ export function useX402Chat(
setPaymentReceipt(null);
}, []);
- return { sendMessage, paidFetch, status, error, paymentReceipt, reset, isReady, paymentNetwork, paymentCurrency };
+ return {
+ sendMessage,
+ paidFetch,
+ status,
+ error,
+ paymentReceipt,
+ reset,
+ isReady,
+ paymentNetwork,
+ paymentCurrency,
+ currencyFallback,
+ };
}
export default useX402Chat;
diff --git a/website/hooks/x402Currency.ts b/website/hooks/x402Currency.ts
index 8b603f11d..f44738730 100644
--- a/website/hooks/x402Currency.ts
+++ b/website/hooks/x402Currency.ts
@@ -108,3 +108,47 @@ export function offeredCurrency(
const first = onNetwork[0];
return first ? currencyOf(first) : null;
}
+
+/**
+ * The settlement currencies the agent offers on `network`, in `PAYMENT_CURRENCIES` order —
+ * the candidate list for `selectFundedCurrency`. A fallback is only ever chosen from what
+ * the seller actually takes, so a funded token it does not offer is never picked. Empty for
+ * an unreadable offer (`accepts === null`): with no candidates the fallback cannot fire,
+ * and the send proceeds exactly as before.
+ */
+export function offeredCurrencies(accepts: AcceptsEntry[] | null, network: string): PaymentCurrency[] {
+ if (!accepts) return [];
+ const onNetwork = accepts.filter((entry) => entry.network === network);
+ return PAYMENT_CURRENCIES.filter((currency) => onNetwork.some((entry) => currencyOf(entry) === currency));
+}
+
+/**
+ * The guarded-B currency fallback: pay in a token the wallet can actually fund, when the
+ * token the send would pay in cannot cover its deposit floor.
+ *
+ * Pure on purpose — the caller gathers the facts, this only decides:
+ * - `paid` is what the send would pay in after seller-side negotiation (`offeredCurrency`),
+ * not the raw user preference.
+ * - `candidates` are the tokens the seller offers on the pay network, in
+ * `PAYMENT_CURRENCIES` order (euro-first), so among two funded alternatives EURC beats USDC.
+ * - `fundable` answers "can the wallet balance cover this token's deposit floor".
+ *
+ * The rules, each of which is a guard agreed in the plan: the paid currency always wins when
+ * it can fund the deposit; a fallback is only ever chosen from what the seller actually offers
+ * (a fundable preference the seller does not take is unusable by construction); and when
+ * nothing is fundable the paid currency stands unchanged — the send then fails with a message
+ * that is honest exactly because this fallback ran first ("recharge" is only correct advice
+ * when no held token could fund it). A fallback never persists: the stored preference is the
+ * user's alone, and the deposit signature remains the consent gate for the new token.
+ */
+export function selectFundedCurrency(
+ paid: PaymentCurrency,
+ candidates: readonly PaymentCurrency[],
+ fundable: (currency: PaymentCurrency) => boolean,
+): { currency: PaymentCurrency; fellBack: boolean } {
+ if (candidates.includes(paid) && fundable(paid)) {
+ return { currency: paid, fellBack: false };
+ }
+ const funded = candidates.find((candidate) => fundable(candidate));
+ return funded ? { currency: funded, fellBack: true } : { currency: paid, fellBack: false };
+}
diff --git a/website/locales/de.ts b/website/locales/de.ts
index 04357b6a9..45720a2a7 100644
--- a/website/locales/de.ts
+++ b/website/locales/de.ts
@@ -178,6 +178,7 @@ export default {
analyticsSource: "Quelle: Analytics",
braveSource: "Quelle: Brave Search",
transitousSource: "Quelle: Transitous",
+ walletFallback: "Mit {used} gezahlt — deine Wallet hält kein {preferred}. Umstellen unter Erweitert.",
errorPrefix: "Fehler:",
unknownError: "Unbekannter Fehler",
loading: "Lädt...",
diff --git a/website/locales/en.ts b/website/locales/en.ts
index a55a24d34..364a5be95 100644
--- a/website/locales/en.ts
+++ b/website/locales/en.ts
@@ -169,6 +169,7 @@ export default {
analyticsSource: "Source: Analytics",
braveSource: "Source: Brave Search",
transitousSource: "Source: Transitous",
+ walletFallback: "Paid with {used} — your wallet holds no {preferred}. Switch under Advanced.",
errorPrefix: "Error:",
unknownError: "Unknown error",
loading: "Loading...",
diff --git a/website/pages/assistent/+description.ts b/website/pages/assistent/+description.ts
index 5100894f7..e69288f1b 100644
--- a/website/pages/assistent/+description.ts
+++ b/website/pages/assistent/+description.ts
@@ -1,3 +1,3 @@
export function description() {
- return "An AI assistant that pays per message with x402 batch-settlement USDC payment channels — no prepaid balance, no subscription. The channel opens on your first message; each message after is a cheap off-chain voucher.";
+ return "An AI assistant that pays per message with x402 batch-settlement stablecoin payment channels — no prepaid balance, no subscription. The channel opens on your first message; each message after is a cheap off-chain voucher.";
}
diff --git a/website/pages/assistent/+title.ts b/website/pages/assistent/+title.ts
index 25d4e49a7..e2eb64ccb 100644
--- a/website/pages/assistent/+title.ts
+++ b/website/pages/assistent/+title.ts
@@ -1,3 +1,3 @@
export function title() {
- return "AI Assistant with USDC Payment Channels | fretchen.eu";
+ return "AI Assistant with Stablecoin Payment Channels | fretchen.eu";
}
diff --git a/website/pages/lab/+Page.tsx b/website/pages/lab/+Page.tsx
index 08d6ca68b..f805d2673 100644
--- a/website/pages/lab/+Page.tsx
+++ b/website/pages/lab/+Page.tsx
@@ -21,7 +21,7 @@ const LabPage: React.FC = () => {
diff --git a/website/test/AssistantChat.test.tsx b/website/test/AssistantChat.test.tsx
index 205ba1839..86d983fe7 100644
--- a/website/test/AssistantChat.test.tsx
+++ b/website/test/AssistantChat.test.tsx
@@ -58,6 +58,7 @@ vi.mock("../hooks/useX402Chat", () => ({
isReady: true,
paymentNetwork: "eip155:10",
paymentCurrency: "USDC",
+ currencyFallback: null,
})),
DEFAULT_LLM_AGENT_URL: "https://llm-agent.fretchen.eu",
}));
@@ -280,6 +281,7 @@ describe("AssistantChat", () => {
isReady: true,
paymentNetwork: "eip155:10",
paymentCurrency: "USDC",
+ currencyFallback: null,
});
vi.mocked(useWalletConnection).mockReturnValue({
address: "0x1234567890123456789012345678901234567890",
@@ -410,6 +412,7 @@ describe("AssistantChat", () => {
isReady: true,
paymentNetwork: "eip155:10",
paymentCurrency: "USDC",
+ currencyFallback: null,
});
mockSendMessage.mockImplementation(() => new Promise(() => {})); // never resolves: stay loading
@@ -494,6 +497,7 @@ describe("AssistantChat", () => {
isReady: true,
paymentNetwork: "eip155:8453",
paymentCurrency: "EURC",
+ currencyFallback: null,
});
renderWithQuery(
);
@@ -571,6 +575,7 @@ describe("AssistantChat", () => {
isReady: true,
paymentNetwork: "eip155:8453",
paymentCurrency: "EURC",
+ currencyFallback: null,
});
mockSendMessage.mockRejectedValueOnce(
new PaymentError(402, JSON.stringify({ error: "insufficient_balance" }), "EURC"),
@@ -918,6 +923,34 @@ describe("AssistantChat", () => {
expect(names).not.toContain("get_departures");
});
+ it("announces a wallet-driven currency switch, and does not blame the agent for it", () => {
+ const { unmount } = renderWithQuery(
);
+ expect(screen.queryByText(/assistent\.walletFallback/)).not.toBeInTheDocument();
+ unmount();
+
+ // EURe preferred, the wallet could only fund USDC — the hook fell back and reports it.
+ // paymentCurrency differs from the preference too, which alone would trigger the
+ // agent-side note; the wallet note must supersede it, or the UI would attribute the
+ // switch to the agent when it was the wallet.
+ vi.mocked(useX402Chat).mockReturnValue({
+ sendMessage: mockSendMessage,
+ paidFetch: mockPaidFetch,
+ status: "idle",
+ error: null,
+ paymentReceipt: null,
+ reset: vi.fn(),
+ isReady: true,
+ paymentNetwork: "eip155:10",
+ paymentCurrency: "USDC",
+ currencyFallback: { preferred: "EURe", used: "USDC" },
+ });
+
+ renderWithQuery(
);
+
+ expect(screen.getByText(/assistent\.walletFallback/)).toBeInTheDocument();
+ expect(screen.queryByText(/payment\.currencyFallback/)).not.toBeInTheDocument();
+ });
+
it("keeps the image tool on offer when a Bundestakt lookup fails", async () => {
// The regression this guards: a single shared `toolFailed` flag would have disabled
// generate_image for the rest of the turn because an unrelated, free lookup failed.
@@ -1532,6 +1565,7 @@ describe("AssistantChat", () => {
isReady: true,
paymentNetwork: "eip155:8453",
paymentCurrency: "USDC",
+ currencyFallback: null,
});
renderWithQuery(
);
@@ -1555,6 +1589,7 @@ describe("AssistantChat", () => {
isReady: true,
paymentNetwork: "eip155:8453",
paymentCurrency: "USDC",
+ currencyFallback: null,
});
renderWithQuery(
);
diff --git a/website/test/failure.test.ts b/website/test/failure.test.ts
index 10483d9e4..40e5cf2c2 100644
--- a/website/test/failure.test.ts
+++ b/website/test/failure.test.ts
@@ -20,6 +20,9 @@ describe("paymentFailed", () => {
expect(result?.status).toBe("payment_failed");
expect(result?.reason).toMatch(/USDC/i);
+ // The model's advice must point at the in-chat switch, not at buying the missing token —
+ // the guarded-B fallback makes this error a race-era residue, and the switch is the fix.
+ expect(result?.reason).toMatch(/switch the payment currency in the assistant's advanced settings/i);
});
/**
diff --git a/website/test/useX402Chat.test.ts b/website/test/useX402Chat.test.ts
index 699205531..4b089b60c 100644
--- a/website/test/useX402Chat.test.ts
+++ b/website/test/useX402Chat.test.ts
@@ -58,6 +58,16 @@ vi.mock("@x402/evm/batch-settlement/client", () => ({
mockReadChannelBalanceAndTotalClaimed(signer, channelId),
}));
+// The funding-facts reader is unit-tested in x402PaidFetch.test.ts; here its answer is
+// injected, so these tests cover exactly the wiring: candidates, selection, announcement
+// state, and what reaches createPaidFetch. vi.hoisted because the factory below spreads
+// this in immediately — a plain const is still uninitialised when the hoisted vi.mock runs.
+const mockReadFundableTokens = vi.hoisted(() => vi.fn());
+vi.mock("../utils/x402PaidFetch", async (importOriginal) => {
+ const actual = await importOriginal
();
+ return { ...actual, readFundableTokens: mockReadFundableTokens };
+});
+
const NETWORK = "eip155:84532";
const mockWalletClient = {
account: { address: "0x1234567890123456789012345678901234567890" },
@@ -71,6 +81,9 @@ describe("useX402Chat", () => {
// Every case here probes the same agent URL; a cached hit would serve the previous case's
// accepts[] and silently defeat the per-case fetch mocks.
resetAcceptsCache();
+ // The default: everything fundable, so the paid currency always wins and the existing
+ // cases see today's behavior. Tests that need a fallback override this themselves.
+ mockReadFundableTokens.mockResolvedValue(() => true);
mockGetPaymentSettleResponse.mockReturnValue({
success: true,
transaction: "0xdeposit",
@@ -952,6 +965,142 @@ describe("useX402Chat", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
+
+ describe("guarded-B currency fallback", () => {
+ // LLM_V1_FLOOR is mainnet-only, and mainnet Base is the one network carrying all three
+ // settlement tokens — so this describe negotiates on eip155:8453, not the Sepolia NETWORK
+ // the rest of the file uses.
+ const PAY_BASE = "eip155:8453";
+ // Base Sepolia's registered settlement tokens (chain-utils' own addresses) — the asset
+ // field is what maps an accepts entry to a currency the site can pay with.
+ const BASE_EURE = "0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C";
+ const BASE_EURC = "0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42";
+ const BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
+
+ const isProbe = (init?: RequestInit) => {
+ try {
+ return (JSON.parse(init?.body as string) as { model?: string }).model === "probe";
+ } catch {
+ return false;
+ }
+ };
+
+ /** An agent that 402s the probe with all three tokens offered on NETWORK, then succeeds. */
+ function stubAgentOfferingAllTokens() {
+ const accepts = [
+ { scheme: "batch-settlement", network: PAY_BASE, payTo: "0xabc", asset: BASE_EURE },
+ { scheme: "batch-settlement", network: PAY_BASE, payTo: "0xabc", asset: BASE_EURC },
+ { scheme: "batch-settlement", network: PAY_BASE, payTo: "0xabc", asset: BASE_USDC },
+ ];
+ const header = btoa(JSON.stringify({ accepts }));
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((_input: string, init?: RequestInit) =>
+ Promise.resolve(
+ isProbe(init)
+ ? new Response("{}", { status: 402, headers: { "Payment-Required": header } })
+ : new Response(JSON.stringify({ content: "hi" }), { status: 200 }),
+ ),
+ ),
+ );
+ }
+
+ beforeEach(() => {
+ vi.mocked(useWalletClient).mockReturnValue(buildWalletClientData({ data: mockWalletClient }));
+ vi.mocked(useAccount).mockReturnValue(buildAccountData({ isConnected: true }));
+ });
+
+ it("keeps the paid currency and reports no fallback when it can fund the deposit", async () => {
+ stubAgentOfferingAllTokens();
+
+ const { result } = renderHook(() => useX402Chat(PAY_BASE));
+ await act(async () => {
+ await result.current.sendMessage([{ role: "user", content: "Hi" }]);
+ });
+
+ expect(result.current.paymentCurrency).toBe("EURe");
+ expect(result.current.currencyFallback).toBeNull();
+ });
+
+ it("falls back to the first funded candidate and reports the deviation", async () => {
+ stubAgentOfferingAllTokens();
+ // EURe (the default and the paid currency) is unfunded; both EURC and USDC are funded,
+ // so the registry order picks EURC — the euro-first candidate.
+ mockReadFundableTokens.mockResolvedValue((c: string) => c === "EURC" || c === "USDC");
+
+ const { result } = renderHook(() => useX402Chat(PAY_BASE));
+ await act(async () => {
+ await result.current.sendMessage([{ role: "user", content: "Hi" }]);
+ });
+
+ expect(result.current.paymentCurrency).toBe("EURC");
+ expect(result.current.currencyFallback).toEqual({ preferred: "EURe", used: "EURC" });
+ });
+
+ it("keeps today's behavior when nothing is fundable — the failure wording is then honest advice", async () => {
+ stubAgentOfferingAllTokens();
+ mockReadFundableTokens.mockResolvedValue(() => false);
+
+ const { result } = renderHook(() => useX402Chat(PAY_BASE));
+ await act(async () => {
+ await result.current.sendMessage([{ role: "user", content: "Hi" }]);
+ });
+
+ expect(result.current.paymentCurrency).toBe("EURe");
+ expect(result.current.currencyFallback).toBeNull();
+ });
+
+ it("never reads balances when the agent offer is unreadable — unknown must not switch tokens", async () => {
+ // No 402 on the probe: accepts is null, candidates are empty, and the read is skipped.
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((_input: string, init?: RequestInit) =>
+ Promise.resolve(
+ isProbe(init)
+ ? new Response("{}", { status: 200 })
+ : new Response(JSON.stringify({ content: "hi" }), { status: 200 }),
+ ),
+ ),
+ );
+
+ const { result } = renderHook(() => useX402Chat(PAY_BASE));
+ await act(async () => {
+ await result.current.sendMessage([{ role: "user", content: "Hi" }]);
+ });
+
+ expect(mockReadFundableTokens).not.toHaveBeenCalled();
+ expect(result.current.paymentCurrency).toBe("EURe");
+ expect(result.current.currencyFallback).toBeNull();
+ });
+
+ it("does not fall back to a token the agent does not offer, even when it is the only funded one", async () => {
+ // The agent offers EURe and USDC only; EURC is funded but unofferable.
+ const accepts = [
+ { scheme: "batch-settlement", network: PAY_BASE, payTo: "0xabc", asset: BASE_EURE },
+ { scheme: "batch-settlement", network: PAY_BASE, payTo: "0xabc", asset: BASE_USDC },
+ ];
+ const header = btoa(JSON.stringify({ accepts }));
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((_input: string, init?: RequestInit) =>
+ Promise.resolve(
+ isProbe(init)
+ ? new Response("{}", { status: 402, headers: { "Payment-Required": header } })
+ : new Response(JSON.stringify({ content: "hi" }), { status: 200 }),
+ ),
+ ),
+ );
+ mockReadFundableTokens.mockResolvedValue((c: string) => c === "EURC");
+
+ const { result } = renderHook(() => useX402Chat(PAY_BASE));
+ await act(async () => {
+ await result.current.sendMessage([{ role: "user", content: "Hi" }]);
+ });
+
+ expect(result.current.paymentCurrency).toBe("EURe");
+ expect(result.current.currencyFallback).toBeNull();
+ });
+ });
});
describe("WebStorageClientChannelStorage", () => {
diff --git a/website/test/x402Currency.test.ts b/website/test/x402Currency.test.ts
index c86aaae6b..087b216b5 100644
--- a/website/test/x402Currency.test.ts
+++ b/website/test/x402Currency.test.ts
@@ -9,9 +9,12 @@ import {
DEFAULT_CURRENCY,
PAYMENT_CURRENCIES,
networksForCurrency,
+ offeredCurrencies,
preferCurrency,
readCurrency,
+ selectFundedCurrency,
storeCurrency,
+ type PaymentCurrency,
} from "../hooks/x402Currency";
const BASE_EURE = "0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C" as const;
@@ -97,3 +100,62 @@ describe("preferCurrency selector", () => {
expect(paid).toBe("EURe");
});
});
+
+describe("selectFundedCurrency", () => {
+ /** `fundable` answers "can this token's wallet balance cover the deposit floor". */
+ const funded =
+ (...held: PaymentCurrency[]) =>
+ (c: PaymentCurrency) =>
+ held.includes(c);
+ const all = ["EURe", "EURC", "USDC"] as const;
+
+ it("keeps the paid currency when it can fund the deposit", () => {
+ expect(selectFundedCurrency("EURe", all, () => true)).toEqual({ currency: "EURe", fellBack: false });
+ });
+
+ it("falls back to the first funded candidate in preference order — EURC before USDC", () => {
+ expect(selectFundedCurrency("EURe", all, funded("EURC", "USDC"))).toEqual({ currency: "EURC", fellBack: true });
+ });
+
+ it("skips an unfunded candidate to the next funded one", () => {
+ expect(selectFundedCurrency("EURe", all, funded("USDC"))).toEqual({ currency: "USDC", fellBack: true });
+ });
+
+ it("keeps today's behavior when nothing is fundable — the failure wording is then honest advice", () => {
+ expect(selectFundedCurrency("EURe", all, () => false)).toEqual({ currency: "EURe", fellBack: false });
+ });
+
+ it("keeps the paid currency when no candidates are offered", () => {
+ expect(selectFundedCurrency("USDC", [], () => true)).toEqual({ currency: "USDC", fellBack: false });
+ });
+
+ it("never picks a currency the seller does not offer, even when its balance would fund it", () => {
+ expect(selectFundedCurrency("EURe", ["USDC"], funded("EURe", "USDC"))).toEqual({
+ currency: "USDC",
+ fellBack: true,
+ });
+ });
+});
+
+describe("offeredCurrencies", () => {
+ it("lists the seller's offered tokens on the network, in preference order", () => {
+ const accepts = [
+ accept("eip155:8453", BASE_USDC),
+ accept("eip155:8453", BASE_EURE),
+ accept("eip155:8453", BASE_EURC),
+ ];
+ expect(offeredCurrencies(accepts, "eip155:8453")).toEqual(["EURe", "EURC", "USDC"]);
+ });
+
+ it("ignores offers on other networks and tokens this site does not pay with", () => {
+ const accepts = [
+ accept("eip155:10", BASE_USDC),
+ accept("eip155:8453", "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85"), // not a settlement token
+ ];
+ expect(offeredCurrencies(accepts, "eip155:8453")).toEqual([]);
+ });
+
+ it("is empty for an unreadable offer — no candidates, no fallback", () => {
+ expect(offeredCurrencies(null, "eip155:8453")).toEqual([]);
+ });
+});
diff --git a/website/test/x402PaidFetch.test.ts b/website/test/x402PaidFetch.test.ts
index 40031c472..bad777f38 100644
--- a/website/test/x402PaidFetch.test.ts
+++ b/website/test/x402PaidFetch.test.ts
@@ -45,6 +45,7 @@ import {
PaymentError,
WebStorageClientChannelStorage,
readChargedTotal,
+ readFundableTokens,
} from "../utils/x402PaidFetch";
import { formatSpend } from "../utils/x402Prices";
@@ -355,3 +356,51 @@ describe("formatSpend", () => {
expect(formatSpend(1_000n, "EURC")).toBe("€0.001 EURC");
});
});
+
+describe("readFundableTokens", () => {
+ // Base carries all three settlement tokens; the addresses are chain-utils' own.
+ const BASE_EURE = "0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C";
+ const BASE_EURC = "0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42";
+ const BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
+ const wallet = "0x1234567890123456789012345678901234567890" as `0x${string}`;
+
+ /** A public client that answers `balanceOf` from a per-address table. */
+ function clientWith(balances: Record) {
+ return {
+ readContract: vi.fn(async ({ address }: { address: string }) => {
+ const balance = balances[address];
+ if (balance === "boom") throw new Error("RPC down");
+ return balance;
+ }),
+ };
+ }
+
+ it("funds only the tokens whose wallet balance covers the deposit floor", async () => {
+ // 0 EURe, 1.00 EURC (>= 0.50), 0.10 USDC (< 0.50).
+ const client = clientWith({
+ [BASE_EURE]: 0n,
+ [BASE_EURC]: 1_000_000n,
+ [BASE_USDC]: 100_000n,
+ });
+
+ const fundable = await readFundableTokens(wallet, client, "eip155:8453");
+
+ expect(fundable("EURe")).toBe(false);
+ expect(fundable("EURC")).toBe(true);
+ expect(fundable("USDC")).toBe(false);
+ });
+
+ it("treats a failing read as unfunded — unknown must not trigger a fallback", async () => {
+ const client = clientWith({
+ [BASE_EURE]: "boom",
+ [BASE_EURC]: "boom",
+ [BASE_USDC]: "boom",
+ });
+
+ const fundable = await readFundableTokens(wallet, client, "eip155:8453");
+
+ expect(fundable("EURe")).toBe(false);
+ expect(fundable("EURC")).toBe(false);
+ expect(fundable("USDC")).toBe(false);
+ });
+});
diff --git a/website/utils/x402PaidFetch.ts b/website/utils/x402PaidFetch.ts
index 739791c3d..0fe69c531 100644
--- a/website/utils/x402PaidFetch.ts
+++ b/website/utils/x402PaidFetch.ts
@@ -1,4 +1,5 @@
import { privateKeyToAccount, generatePrivateKey } from "viem/accounts";
+import { getSettlementTokens } from "@fretchen/chain-utils";
import { buildStablecoinAllowedAssets } from "../hooks/x402SpendControls";
import { CURRENCY_DECIMALS, preferCurrency, type PaymentCurrency } from "../hooks/x402Currency";
// Type-only import — erased at compile time, so no @x402 runtime is pulled into SSR.
@@ -215,6 +216,53 @@ export function minimumDepositAtomic(currency: PaymentCurrency): bigint {
return 5n * 10n ** BigInt(CURRENCY_DECIMALS[currency] - 1);
}
+/**
+ * Which settlement tokens this wallet could fund a *new* channel deposit in — the funding
+ * facts behind `selectFundedCurrency` (hooks/x402Currency.ts). Reads `balanceOf` for every
+ * token registered on `network` (three parallel reads), and a token counts as fundable when
+ * the balance covers its `minimumDepositAtomic` floor — the same 0.50 the deposit itself
+ * would move, so the answer cannot drift from what the SDK will actually ask for.
+ *
+ * A failing read (RPC down, unknown token) counts as *unfunded*, never as "try another":
+ * "unknown" must not trigger a currency fallback, or a flaky RPC would silently switch the
+ * token the user chose. Per-call state by design — balances are read fresh at send time,
+ * never cached, matching the "in context of the action" rule of the chain switch.
+ *
+ * `publicClient` is `unknown` and cast inside, the file's own convention: viem's generic
+ * `readContract` cannot be matched structurally, and `createPaidFetch` casts the same value
+ * for the same reason (see the call at `toClientEvmSigner`).
+ */
+export async function readFundableTokens(
+ walletAddress: `0x${string}`,
+ // Unknown, cast below to the minimal shape actually called: viem's generic readContract
+ // cannot be matched structurally, and `createPaidFetch` accepts the same value as unknown
+ // for the same reason. The cast keeps this function any-free.
+ publicClient: unknown,
+ network: string,
+): Promise<(currency: PaymentCurrency) => boolean> {
+ const client = publicClient as { readContract: (call: unknown) => Promise };
+ const tokens = getSettlementTokens(network);
+ const held = new Map();
+ await Promise.all(
+ tokens.map(async (token) => {
+ try {
+ const balance = await client.readContract({
+ address: token.address,
+ functionName: "balanceOf",
+ args: [walletAddress],
+ });
+ held.set(token.symbol, typeof balance === "bigint" ? balance : null);
+ } catch {
+ held.set(token.symbol, null);
+ }
+ }),
+ );
+ return (currency) => {
+ const balance = held.get(currency);
+ return balance !== undefined && balance !== null && balance >= minimumDepositAtomic(currency);
+ };
+}
+
/**
* Custom deposit sizing: always deposit/top-up to at least `minimumDepositAtomic(currency)`,
* regardless of the SDK's default multiplier-of-ceiling formula — see that function's
@@ -274,10 +322,17 @@ function describePaymentError(status: number, body: string, currency: PaymentCur
return DRAINED_CHANNEL_MESSAGE.chat;
}
if (errorCode?.includes("insufficient_balance")) {
- // The USDC.e note is USDC's alone: bridged EURC is not a thing people end up holding by accident.
+ // The switch hint is the actionable advice: the guarded-B fallback (useX402Chat) has
+ // already moved the send to a funded token when one exists, so reaching this message
+ // means either a race between the balance read and the deposit, or nothing funded — and
+ // the hint points at the one in-chat fix either way, instead of "go buy ".
+ // Both variants are written to fit `describeFailure`'s 200-char cap: the hint is the part
+ // the model must not lose, and the USDC.e note is shortened, not the hint.
+ // (USDC's note is USDC's alone: bridged EURC is not a thing people end up holding by accident.)
+ const switchHint = "Alternatively, switch the payment currency in the assistant's advanced settings.";
return currency === "USDC"
- ? "Not enough USDC in your wallet to fund the payment channel. Note that only native USDC works — a bridged variant such as USDC.e cannot be used."
- : `Not enough ${currency} in your wallet to fund the payment channel.`;
+ ? `Not enough USDC in your wallet to fund the payment channel. Note that only native USDC works, not USDC.e. ${switchHint}`
+ : `Not enough ${currency} in your wallet to fund the payment channel. ${switchHint}`;
}
return `Request failed: ${status} - ${body}`;
}