diff --git a/website/components/AssistantChat.tsx b/website/components/AssistantChat.tsx index e5ed6186b..66efb4398 100644 --- a/website/components/AssistantChat.tsx +++ b/website/components/AssistantChat.tsx @@ -342,9 +342,9 @@ const CHAT_NETWORKS = ["eip155:10", "eip155:8453"] as const; // localStorage key for the user's explicit network choice. Worth persisting rather than // re-deriving from the wallet each visit: a channel is per (network, receiver) and each one -// escrows MINIMUM_DEPOSIT_ATOMIC ($0.50, see useX402Chat). If the paid network drifted with +// escrows 0.50 of the channel token (see useX402Chat). If the paid network drifted with // whatever chain the wallet happened to be on, a user would silently open a second channel -// and lock a second $0.50 that only comes back via a refund or the 24h withdrawDelay. +// and lock a second 0.50 that only comes back via a refund or the 24h withdrawDelay. const NETWORK_PREFERENCE_KEY = "x402-chat-network"; /** diff --git a/website/test/useX402Chat.test.ts b/website/test/useX402Chat.test.ts index ff49e2bfe..699205531 100644 --- a/website/test/useX402Chat.test.ts +++ b/website/test/useX402Chat.test.ts @@ -157,28 +157,48 @@ describe("useX402Chat", () => { expect(mockSetSpendControls.mock.invocationCallOrder[0]).toBeLessThan(mockRegister.mock.invocationCallOrder[0]); }); - it("deposit strategy floors deposits/top-ups at $0.50, ignoring the SDK's smaller default", async () => { + // The floor is 0.50 in the channel token's own atomic units. EURe (the hook's default + // currency) has 18 decimals, so its floor is 5×10^17 — the regression this guards against + // hardcoded the 6-decimal USDC figure, which made every EURe deposit one-search-sized and + // prompted the wallet on every paid call. + it("deposit strategy floors deposits/top-ups at 0.50 of the channel token, ignoring the SDK's smaller default", async () => { + // A fresh Response per call: this test runs two hooks, and a Response body is single-use. vi.stubGlobal( "fetch", - vi.fn().mockResolvedValue(new Response(JSON.stringify({ content: "hi" }), { status: 200 })), + vi + .fn() + .mockImplementation(() => Promise.resolve(new Response(JSON.stringify({ content: "hi" }), { status: 200 }))), ); - const { result } = renderHook(() => useX402Chat(NETWORK)); + // Default currency: EURe, 18 decimals. + const eure = renderHook(() => useX402Chat(NETWORK)); await act(async () => { - await result.current.sendMessage([{ role: "user", content: "Hi" }]); + await eure.result.current.sendMessage([{ role: "user", content: "Hi" }]); }); + const eureStrategy = mockBatchSettlementEvmScheme.mock.calls[0][1].depositStrategy as (ctx: { + minimumDepositAmount: string; + }) => string; - const { depositStrategy } = mockBatchSettlementEvmScheme.mock.calls[0][1] as { - depositStrategy: (ctx: { minimumDepositAmount: string }) => string; - }; - - // Below the floor (e.g. the SDK's own ~1-3 cent default): clamp up to $0.50. - expect(depositStrategy({ minimumDepositAmount: "15000" })).toBe("500000"); + // Below the floor (the SDK's own ~1-3 cent default, or a single search's minimum): + // clamp up to 0.50 EURe = 5×10^17 atomic. + expect(eureStrategy({ minimumDepositAmount: "10000000000000000" })).toBe("500000000000000000"); // Above the floor (an unusually expensive top-up): the SDK requires >= this amount, // so it must be respected, not clamped down. - expect(depositStrategy({ minimumDepositAmount: "600000" })).toBe("600000"); + expect(eureStrategy({ minimumDepositAmount: "600000000000000000" })).toBe("600000000000000000"); // Exactly at the floor: either value is correct; assert it's still >= minimum. - expect(BigInt(depositStrategy({ minimumDepositAmount: "500000" }))).toBeGreaterThanOrEqual(500_000n); + expect(BigInt(eureStrategy({ minimumDepositAmount: "500000000000000000" }))).toBeGreaterThanOrEqual( + 500_000_000_000_000_000n, + ); + + // A USDC channel keeps the 6-decimal floor: $0.50 = 500_000 atomic. + const usdc = renderHook(() => useX402Chat(NETWORK, undefined, "USDC")); + await act(async () => { + await usdc.result.current.sendMessage([{ role: "user", content: "Hi" }]); + }); + const usdcStrategy = mockBatchSettlementEvmScheme.mock.calls[1][1].depositStrategy as (ctx: { + minimumDepositAmount: string; + }) => string; + expect(usdcStrategy({ minimumDepositAmount: "15000" })).toBe("500000"); }); it("reuses the same delegated voucher signer across multiple messages", async () => { diff --git a/website/test/x402PaidFetch.test.ts b/website/test/x402PaidFetch.test.ts index 9902e136c..40031c472 100644 --- a/website/test/x402PaidFetch.test.ts +++ b/website/test/x402PaidFetch.test.ts @@ -41,6 +41,7 @@ vi.mock("@x402/evm/batch-settlement/client", () => ({ import { createPaidFetch, + minimumDepositAtomic, PaymentError, WebStorageClientChannelStorage, readChargedTotal, @@ -122,6 +123,21 @@ describe("PaymentError", () => { }); }); +describe("minimumDepositAtomic", () => { + /** + * The floor is 0.50 in each token's own atomic units. The shipped bug hardcoded the + * 6-decimal figure, so EURe's floor was 5×10⁻¹³ EURe — a no-op that let every deposit + * collapse to the current request and prompted the wallet per paid call. The decimals + * come from the registry via CURRENCY_DECIMALS, so a future token cannot silently + * re-introduce this by having a third decimal count. + */ + it("floors at 0.50 in each token's own atomic units", () => { + expect(minimumDepositAtomic("USDC")).toBe(500_000n); + expect(minimumDepositAtomic("EURC")).toBe(500_000n); + expect(minimumDepositAtomic("EURe")).toBe(500_000_000_000_000_000n); + }); +}); + describe("createPaidFetch", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/website/utils/x402PaidFetch.ts b/website/utils/x402PaidFetch.ts index 54482222a..739791c3d 100644 --- a/website/utils/x402PaidFetch.ts +++ b/website/utils/x402PaidFetch.ts @@ -1,6 +1,6 @@ import { privateKeyToAccount, generatePrivateKey } from "viem/accounts"; import { buildStablecoinAllowedAssets } from "../hooks/x402SpendControls"; -import { preferCurrency, type PaymentCurrency } from "../hooks/x402Currency"; +import { CURRENCY_DECIMALS, preferCurrency, type PaymentCurrency } from "../hooks/x402Currency"; // Type-only import — erased at compile time, so no @x402 runtime is pulled into SSR. import type { ClientChannelStorage, @@ -190,35 +190,44 @@ function getOrCreateVoucherSigner(walletAddress: string) { return privateKeyToAccount(privateKey); } -// Floor for channel deposits/top-ups, in the channel token's atomic units (6 decimals for both -// USDC and EURC) — 0.50 of whichever token the channel is in. The reasoning below is in dollars -// but holds the same in euros. +// Floor for channel deposits/top-ups: 0.50 of whichever token the channel is in, in that +// token's own atomic units. There is no exchange rate anywhere (prices are parallel +// per-token systems), so "0.50" means the same figure in dollars for USDC and in euros for +// EURe — only the decimals differ, and `CURRENCY_DECIMALS` carries them (6 for USDC/EURC, +// 18 for EURe). A fixed atomic number instead of this lookup was the per-search-permit bug: +// 500_000 atomic is $0.50 of USDC but 5×10⁻¹³ EURe, a no-op floor that collapsed every EURe +// deposit to exactly the current request and prompted the wallet on every paid call. +// // The SDK's own default (depositMultiplier x per-message ceiling) tracks whatever the // ceiling happens to be, currently ~$0.003/message, so it sizes deposits at ~1-3 cents: // enough for only ~5 messages worst-case before another on-chain top-up (a real tx, a -// real wallet-adjacent wait) is needed. $0.50 comfortably covers a full multi-message +// real wallet-adjacent wait) is needed. 0.50 comfortably covers a full multi-message // session (100s of messages even at worst-case per-message pricing) while keeping the // number small on the two axes that actually matter for this app: it's the blast radius // of the localStorage voucher-signer above if it ever leaks (bounded to this amount, // never more), and the capital a user has locked up if the server stops cooperating and -// they have to wait out withdrawDelay to exit unilaterally. Both are trivial at $0.50; +// they have to wait out withdrawDelay to exit unilaterally. Both are trivial at 0.50; // neither improves by going lower, so lower just buys more top-up friction for no benefit. // -// The paid tool calls do not move this: a search is $0.01 and a fetch $0.001 against a -// $0.50 escrow. -export const MINIMUM_DEPOSIT_ATOMIC = 500_000n; +// The paid tool calls do not move this: a search is 0.01 and a fetch 0.001 (in the +// channel token's own system) against a 0.50 escrow. +export function minimumDepositAtomic(currency: PaymentCurrency): bigint { + return 5n * 10n ** BigInt(CURRENCY_DECIMALS[currency] - 1); +} /** - * Custom deposit sizing: always deposit/top-up to at least `MINIMUM_DEPOSIT_ATOMIC`, - * regardless of the SDK's default multiplier-of-ceiling formula — see the constant's + * 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 * comment for why a fixed floor is the right lever here, not `depositPolicy.depositMultiplier` * (which would still scale with the ceiling rather than decoupling from it). * `minimumDepositAmount` is the true minimum the SDK needs for the top-up in progress; the * SDK requires the returned amount be >= it, so it's respected as a floor of its own. */ -function depositStrategy(context: BatchSettlementDepositStrategyContext): string { - const required = BigInt(context.minimumDepositAmount); - return (required > MINIMUM_DEPOSIT_ATOMIC ? required : MINIMUM_DEPOSIT_ATOMIC).toString(); +function depositStrategyFor(floor: bigint): (context: BatchSettlementDepositStrategyContext) => string { + return (context) => { + const required = BigInt(context.minimumDepositAmount); + return (required > floor ? required : floor).toString(); + }; } /** @@ -402,9 +411,14 @@ export async function createPaidFetch({ // rather than "this channel is on another chain" — see resyncFromChain. const storage = new WebStorageClientChannelStorage(window.localStorage, network); // Delegate voucher signing to a persisted local key so only the deposit/top-up prompts the - // real wallet — see getOrCreateVoucherSigner's doc comment. + // real wallet — see getOrCreateVoucherSigner's doc comment. The deposit floor follows the + // channel's currency: a USDC channel escrows $0.50, an EURe one 0.50 EURe. const voucherSigner = getOrCreateVoucherSigner(walletClient.account.address); - const scheme = new BatchSettlementEvmScheme(signer, { storage, voucherSigner, depositStrategy }); + const scheme = new BatchSettlementEvmScheme(signer, { + storage, + voucherSigner, + depositStrategy: depositStrategyFor(minimumDepositAtomic(currency)), + }); // The selector picks the entry in `currency` among what the spend controls let through (see // x402Currency.ts); the seller lists both tokens on Base.