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 && (

{currencyFallbackLabel} {paymentCurrency}.

@@ -1599,6 +1607,17 @@ export function AssistantChat() { )) )} + {currencyFallback && ( + // One honest line when the send switched token: the deviation is announced, the + // stored preference stays untouched (the hook never persists a fallback), and + // the line quietly teaches where the currency setting lives. +
+ {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}`; }