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
25 changes: 22 additions & 3 deletions website/components/AssistantChat.tsx
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -704,6 +709,7 @@ export function AssistantChat() {
paymentReceipt,
paymentNetwork,
paymentCurrency,
currencyFallback,
status: chatStatus,
} = useX402Chat(desiredNetwork, agentUrl, currency);
const { network, switchIfNeeded, getSwitchError } = useAutoNetwork([paymentNetwork]);
Expand Down Expand Up @@ -1413,8 +1419,10 @@ export function AssistantChat() {
</p>
)}
{/* 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 && (
<p className={chat.networkNote}>
{currencyFallbackLabel} {paymentCurrency}.
</p>
Expand Down Expand Up @@ -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.
<div className={chat.messageSource}>
{walletFallbackLabel
.replaceAll("{used}", currencyFallback.used)
.replaceAll("{preferred}", currencyFallback.preferred)}
</div>
)}

{isLoading && (
<div className={chat.loadingMessage}>
{isDefaultAgent && <div className={chat.assistantAvatar} />}
Expand Down
61 changes: 54 additions & 7 deletions website/hooks/useX402Chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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;
44 changes: 44 additions & 0 deletions website/hooks/x402Currency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
1 change: 1 addition & 0 deletions website/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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...",
Expand Down
1 change: 1 addition & 0 deletions website/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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...",
Expand Down
2 changes: 1 addition & 1 deletion website/pages/assistent/+description.ts
Original file line number Diff line number Diff line change
@@ -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.";
}
2 changes: 1 addition & 1 deletion website/pages/assistent/+title.ts
Original file line number Diff line number Diff line change
@@ -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";
}
2 changes: 1 addition & 1 deletion website/pages/lab/+Page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const LabPage: React.FC = () => {

<Card
title="AI Assistant"
description="Chat with an AI assistant. Pay per message with x402 batch-settlement USDC payment channels — no prepaid balance."
description="Chat with an AI assistant. Pay per message with x402 batch-settlement stablecoin payment channels — no prepaid balance."
link="/assistent"
/>

Expand Down
35 changes: 35 additions & 0 deletions website/test/AssistantChat.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}));
Expand Down Expand Up @@ -280,6 +281,7 @@ describe("AssistantChat", () => {
isReady: true,
paymentNetwork: "eip155:10",
paymentCurrency: "USDC",
currencyFallback: null,
});
vi.mocked(useWalletConnection).mockReturnValue({
address: "0x1234567890123456789012345678901234567890",
Expand Down Expand Up @@ -410,6 +412,7 @@ describe("AssistantChat", () => {
isReady: true,
paymentNetwork: "eip155:10",
paymentCurrency: "USDC",
currencyFallback: null,
});
mockSendMessage.mockImplementation(() => new Promise(() => {})); // never resolves: stay loading

Expand Down Expand Up @@ -494,6 +497,7 @@ describe("AssistantChat", () => {
isReady: true,
paymentNetwork: "eip155:8453",
paymentCurrency: "EURC",
currencyFallback: null,
});

renderWithQuery(<AssistantChat />);
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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(<AssistantChat />);
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(<AssistantChat />);

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.
Expand Down Expand Up @@ -1532,6 +1565,7 @@ describe("AssistantChat", () => {
isReady: true,
paymentNetwork: "eip155:8453",
paymentCurrency: "USDC",
currencyFallback: null,
});

renderWithQuery(<AssistantChat />);
Expand All @@ -1555,6 +1589,7 @@ describe("AssistantChat", () => {
isReady: true,
paymentNetwork: "eip155:8453",
paymentCurrency: "USDC",
currencyFallback: null,
});

renderWithQuery(<AssistantChat />);
Expand Down
3 changes: 3 additions & 0 deletions website/test/failure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

/**
Expand Down
Loading
Loading