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
4 changes: 2 additions & 2 deletions website/components/AssistantChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down
44 changes: 32 additions & 12 deletions website/test/useX402Chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
16 changes: 16 additions & 0 deletions website/test/x402PaidFetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ vi.mock("@x402/evm/batch-settlement/client", () => ({

import {
createPaidFetch,
minimumDepositAtomic,
PaymentError,
WebStorageClientChannelStorage,
readChargedTotal,
Expand Down Expand Up @@ -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();
Expand Down
46 changes: 30 additions & 16 deletions website/utils/x402PaidFetch.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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();
};
}

/**
Expand Down Expand Up @@ -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.
Expand Down
Loading