diff --git a/src/balance.ts b/src/balance.ts index 3cb741c..e67baaf 100644 --- a/src/balance.ts +++ b/src/balance.ts @@ -1,7 +1,9 @@ /** * Balance Monitor for ClawRouter * - * Monitors USDC balance on Base network with intelligent caching. + * Monitors stablecoin balance on Base network with intelligent caching. + * Supports any EIP-3009 stablecoin (USDC, fxUSD, EURC, etc.) with + * automatic normalization from native decimals to USD micros (6 decimals). * Provides pre-request balance checks to prevent failed payments. * * Caching Strategy: @@ -13,14 +15,12 @@ import { createPublicClient, http, erc20Abi } from "viem"; import { base } from "viem/chains"; import { RpcError } from "./errors.js"; - -/** USDC contract address on Base mainnet */ -const USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as const; +import { DEFAULT_BASE_PAYMENT_ASSET, type BasePaymentAsset } from "./payment-asset.js"; /** Cache TTL in milliseconds (30 seconds) */ const CACHE_TTL_MS = 30_000; -/** Balance thresholds in USDC smallest unit (6 decimals) */ +/** Balance thresholds in USD micros (6 decimals, normalized from any stablecoin) */ export const BALANCE_THRESHOLDS = { /** Low balance warning threshold: $1.00 */ LOW_BALANCE_MICROS: 1_000_000n, @@ -30,10 +30,12 @@ export const BALANCE_THRESHOLDS = { /** Balance information returned by checkBalance() */ export type BalanceInfo = { - /** Raw balance in USDC smallest unit (6 decimals) */ + /** Raw balance normalized to USD micros (6 decimals, regardless of the underlying asset's native decimals) */ balance: bigint; /** Formatted balance as "$X.XX" */ balanceUSD: string; + /** Symbol of the active Base payment asset */ + assetSymbol: string; /** True if balance < $1.00 */ isLow: boolean; /** True if balance < $0.0001 (effectively zero) */ @@ -53,7 +55,7 @@ export type SufficiencyResult = { }; /** - * Monitors USDC balance on Base network. + * Monitors stablecoin balance on Base network. * * Usage: * const monitor = new BalanceMonitor("0x..."); @@ -63,14 +65,16 @@ export type SufficiencyResult = { export class BalanceMonitor { private readonly client; private readonly walletAddress: `0x${string}`; + private asset: BasePaymentAsset; /** Cached balance (null = not yet fetched) */ private cachedBalance: bigint | null = null; /** Timestamp when cache was last updated */ private cachedAt = 0; - constructor(walletAddress: string) { + constructor(walletAddress: string, asset: BasePaymentAsset = DEFAULT_BASE_PAYMENT_ASSET) { this.walletAddress = walletAddress as `0x${string}`; + this.asset = asset; this.client = createPublicClient({ chain: base, transport: http(undefined, { @@ -110,7 +114,7 @@ export class BalanceMonitor { /** * Check if balance is sufficient for an estimated cost. * - * @param estimatedCostMicros - Estimated cost in USDC smallest unit (6 decimals) + * @param estimatedCostMicros - Estimated cost in USD micros (6 decimals) */ async checkSufficient(estimatedCostMicros: bigint): Promise { const info = await this.checkBalance(); @@ -123,7 +127,7 @@ export class BalanceMonitor { return { sufficient: false, info, - shortfall: this.formatUSDC(shortfall), + shortfall: this.formatUSD(shortfall), }; } @@ -131,7 +135,7 @@ export class BalanceMonitor { * Optimistically deduct estimated cost from cached balance. * Call this after a successful payment to keep cache accurate. * - * @param amountMicros - Amount to deduct in USDC smallest unit + * @param amountMicros - Amount to deduct in USD micros */ deductEstimated(amountMicros: bigint): void { if (this.cachedBalance !== null && this.cachedBalance >= amountMicros) { @@ -156,11 +160,25 @@ export class BalanceMonitor { return this.checkBalance(); } + setAsset(asset: BasePaymentAsset): void { + if ( + this.asset.asset.toLowerCase() !== asset.asset.toLowerCase() || + this.asset.symbol !== asset.symbol || + this.asset.decimals !== asset.decimals + ) { + this.asset = asset; + this.invalidate(); + } + } + + getAsset(): BasePaymentAsset { + return this.asset; + } + /** - * Format USDC amount (in micros) as "$X.XX". + * Format a stablecoin amount (normalized to USD micros) as "$X.XX". */ - formatUSDC(amountMicros: bigint): string { - // USDC has 6 decimals + formatUSD(amountMicros: bigint): string { const dollars = Number(amountMicros) / 1_000_000; return `$${dollars.toFixed(2)}`; } @@ -172,16 +190,20 @@ export class BalanceMonitor { return this.walletAddress; } + getAssetSymbol(): string { + return this.asset.symbol; + } + /** Fetch balance from RPC */ private async fetchBalance(): Promise { try { const balance = await this.client.readContract({ - address: USDC_BASE, + address: this.asset.asset, abi: erc20Abi, functionName: "balanceOf", args: [this.walletAddress], }); - return balance; + return this.toUsdMicros(balance); } catch (error) { // Throw typed error instead of silently returning 0 // This allows callers to distinguish "node down" from "wallet empty" @@ -193,10 +215,19 @@ export class BalanceMonitor { private buildInfo(balance: bigint): BalanceInfo { return { balance, - balanceUSD: this.formatUSDC(balance), + balanceUSD: this.formatUSD(balance), + assetSymbol: this.asset.symbol, isLow: balance < BALANCE_THRESHOLDS.LOW_BALANCE_MICROS, isEmpty: balance < BALANCE_THRESHOLDS.ZERO_THRESHOLD, walletAddress: this.walletAddress, }; } + + private toUsdMicros(rawAmount: bigint): bigint { + if (this.asset.decimals === 6) return rawAmount; + if (this.asset.decimals > 6) { + return rawAmount / 10n ** BigInt(this.asset.decimals - 6); + } + return rawAmount * 10n ** BigInt(6 - this.asset.decimals); + } } diff --git a/src/payment-asset.test.ts b/src/payment-asset.test.ts new file mode 100644 index 0000000..0796caa --- /dev/null +++ b/src/payment-asset.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it, vi } from "vitest"; +import { + DEFAULT_BASE_PAYMENT_ASSET, + fetchBasePaymentAsset, + fetchBasePaymentAssets, + normalizeBasePaymentAsset, + normalizeBasePaymentAssets, +} from "./payment-asset.js"; + +describe("payment asset helpers", () => { + it("normalizes a valid flat response", () => { + const asset = normalizeBasePaymentAsset({ + asset: "0x1111111111111111111111111111111111111111", + symbol: "eurc", + decimals: 6, + name: "Euro Coin", + transferMethod: "eip3009", + }); + + expect(asset).toEqual({ + chain: "base", + asset: "0x1111111111111111111111111111111111111111", + symbol: "EURC", + decimals: 6, + name: "Euro Coin", + transferMethod: "eip3009", + }); + }); + + it("rejects non-eip3009 assets", () => { + const asset = normalizeBasePaymentAsset({ + asset: "0x1111111111111111111111111111111111111111", + symbol: "USDT", + decimals: 6, + name: "Tether", + transferMethod: "permit2", + }); + + expect(asset).toBeUndefined(); + }); + + it("parses nested paymentAsset responses", async () => { + const mockFetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + paymentAsset: { + asset: "0x2222222222222222222222222222222222222222", + symbol: "EURC", + decimals: 6, + name: "Euro Coin", + transferMethod: "eip3009", + }, + }), + { status: 200 }, + ), + ); + + const asset = await fetchBasePaymentAsset( + "https://blockrun.ai/api", + mockFetch as unknown as typeof fetch, + ); + expect(asset?.asset).toBe("0x2222222222222222222222222222222222222222"); + expect(asset?.symbol).toBe("EURC"); + expect(mockFetch).toHaveBeenCalledWith( + "https://blockrun.ai/api/v1/payment-metadata?chain=base", + expect.any(Object), + ); + }); + + it("falls back to the default asset for invalid metadata responses", async () => { + const mockFetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ paymentAsset: { symbol: "EURC" } }), { status: 200 }), + ); + + const asset = await fetchBasePaymentAsset( + "https://blockrun.ai/api", + mockFetch as unknown as typeof fetch, + ); + expect(asset).toEqual(DEFAULT_BASE_PAYMENT_ASSET); + }); + + it("parses and sorts multiple assets by priority", () => { + const assets = normalizeBasePaymentAssets({ + paymentAssets: [ + { + asset: "0x3333333333333333333333333333333333333333", + symbol: "FXUSD", + decimals: 18, + name: "fxUSD", + transferMethod: "eip3009", + priority: 2, + }, + { + asset: "0x1111111111111111111111111111111111111111", + symbol: "USDC", + decimals: 6, + name: "USD Coin", + transferMethod: "eip3009", + priority: 1, + }, + ], + }); + + expect(assets.map((asset) => asset.symbol)).toEqual(["USDC", "FXUSD"]); + }); + + it("fetches multiple payment assets", async () => { + const mockFetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + paymentAssets: [ + { + asset: "0x1111111111111111111111111111111111111111", + symbol: "USDC", + decimals: 6, + name: "USD Coin", + transferMethod: "eip3009", + priority: 1, + }, + { + asset: "0x2222222222222222222222222222222222222222", + symbol: "EURC", + decimals: 6, + name: "Euro Coin", + transferMethod: "eip3009", + priority: 2, + }, + ], + }), + { status: 200 }, + ), + ); + + const assets = await fetchBasePaymentAssets( + "https://blockrun.ai/api", + mockFetch as unknown as typeof fetch, + ); + expect(assets).toHaveLength(2); + expect(assets[0]?.symbol).toBe("USDC"); + expect(assets[1]?.symbol).toBe("EURC"); + }); + + it("keeps USDC as the safe default asset", () => { + expect(DEFAULT_BASE_PAYMENT_ASSET.symbol).toBe("USDC"); + expect(DEFAULT_BASE_PAYMENT_ASSET.transferMethod).toBe("eip3009"); + }); + + it("normalizes fxUSD with 18 decimals correctly", () => { + const asset = normalizeBasePaymentAsset({ + asset: "0x55380fe7a1910dff29a47b622057ab4139da42c5", + symbol: "fxusd", + decimals: 18, + name: "fxUSD", + transferMethod: "eip3009", + }); + + expect(asset).toEqual({ + chain: "base", + asset: "0x55380fe7a1910dff29a47b622057ab4139da42c5", + symbol: "FXUSD", + decimals: 18, + name: "fxUSD", + transferMethod: "eip3009", + }); + }); + + it("handles mixed-decimal assets in normalizeBasePaymentAssets", () => { + const assets = normalizeBasePaymentAssets({ + paymentAssets: [ + { + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + symbol: "USDC", + decimals: 6, + name: "USD Coin", + transferMethod: "eip3009", + priority: 1, + }, + { + asset: "0x55380fe7a1910dff29a47b622057ab4139da42c5", + symbol: "FXUSD", + decimals: 18, + name: "fxUSD", + transferMethod: "eip3009", + priority: 2, + }, + { + asset: "0x0000000000000000000000000000000000000000", + symbol: "DISABLED", + decimals: 6, + name: "Disabled Token", + transferMethod: "eip3009", + enabled: false, + }, + ], + }); + + expect(assets).toHaveLength(2); + expect(assets[0]?.symbol).toBe("USDC"); + expect(assets[0]?.decimals).toBe(6); + expect(assets[1]?.symbol).toBe("FXUSD"); + expect(assets[1]?.decimals).toBe(18); + }); + + it("accepts the real fxUSD Base contract address", () => { + const asset = normalizeBasePaymentAsset({ + asset: "0x55380fe7A1910dFf29a47B622057AB4139DA42C5", + symbol: "FXUSD", + decimals: 18, + name: "fxUSD", + transferMethod: "eip3009", + }); + + expect(asset).toBeDefined(); + expect(asset?.asset).toBe("0x55380fe7A1910dFf29a47B622057AB4139DA42C5"); + }); +}); diff --git a/src/payment-asset.ts b/src/payment-asset.ts new file mode 100644 index 0000000..760264f --- /dev/null +++ b/src/payment-asset.ts @@ -0,0 +1,141 @@ +/** + * EIP-3009 payment asset on Base network. + * Represents a stablecoin that supports `transferWithAuthorization` + * for gasless, single-step payment settlements. + */ +export type BasePaymentAsset = { + chain: "base"; + asset: `0x${string}`; + symbol: string; + decimals: number; + name: string; + transferMethod: "eip3009"; + priority?: number; + enabled?: boolean; +}; + +/** Default payment asset: USDC on Base (6 decimals, EIP-3009). */ +export const DEFAULT_BASE_PAYMENT_ASSET: BasePaymentAsset = { + chain: "base", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + symbol: "USDC", + decimals: 6, + name: "USD Coin", + transferMethod: "eip3009", +}; + +type PaymentMetadataResponse = + | Partial + | { paymentAssets?: Array> } + | { paymentAsset?: Partial } + | { base?: Partial }; + +/** Check if a value is a valid 0x-prefixed ERC-20 contract address (40 hex chars). */ +function isHexAddress(value: unknown): value is `0x${string}` { + return typeof value === "string" && /^0x[0-9a-fA-F]{40}$/.test(value); +} + +/** + * Validate and normalize a single payment asset from an API response. + * Returns undefined if the input is missing required fields or uses a non-EIP-3009 transfer method. + * Symbols are uppercased; names are trimmed. + */ +export function normalizeBasePaymentAsset( + value: unknown, +): BasePaymentAsset | undefined { + if (!value || typeof value !== "object") return undefined; + + const candidate = value as Partial; + if (!isHexAddress(candidate.asset)) return undefined; + if (typeof candidate.symbol !== "string" || candidate.symbol.trim() === "") return undefined; + if ( + typeof candidate.decimals !== "number" || + !Number.isInteger(candidate.decimals) || + candidate.decimals < 0 + ) { + return undefined; + } + if (typeof candidate.name !== "string" || candidate.name.trim() === "") return undefined; + if (candidate.transferMethod !== undefined && candidate.transferMethod !== "eip3009") { + return undefined; + } + + return { + chain: "base", + asset: candidate.asset, + symbol: candidate.symbol.trim().toUpperCase(), + decimals: candidate.decimals, + name: candidate.name.trim(), + transferMethod: "eip3009", + priority: typeof candidate.priority === "number" ? candidate.priority : undefined, + enabled: typeof candidate.enabled === "boolean" ? candidate.enabled : undefined, + }; +} + +/** Sort assets by priority (ascending). Assets without priority go last. */ +function sortAssets(assets: BasePaymentAsset[]): BasePaymentAsset[] { + return [...assets].sort( + (a, b) => (a.priority ?? Number.MAX_SAFE_INTEGER) - (b.priority ?? Number.MAX_SAFE_INTEGER), + ); +} + +/** + * Normalize a payment metadata response into an array of valid EIP-3009 assets. + * Handles flat, nested (`paymentAsset`, `base`), and array (`paymentAssets`) response shapes. + * Filters out disabled and non-EIP-3009 assets. Falls back to USDC if no valid assets found. + */ +export function normalizeBasePaymentAssets(value: unknown): BasePaymentAsset[] { + if (!value || typeof value !== "object") return [DEFAULT_BASE_PAYMENT_ASSET]; + + const payload = value as PaymentMetadataResponse & { paymentAssets?: unknown }; + const candidateList = Array.isArray(payload.paymentAssets) + ? (payload.paymentAssets as unknown[]) + : [ + (payload as { paymentAsset?: unknown }).paymentAsset, + (payload as { base?: unknown }).base, + payload, + ]; + + const normalized = candidateList + .map((candidate: unknown) => normalizeBasePaymentAsset(candidate)) + .filter((asset: BasePaymentAsset | undefined): asset is BasePaymentAsset => Boolean(asset)) + .filter((asset) => asset.enabled !== false && asset.transferMethod === "eip3009"); + + return sortAssets( + normalized.length > 0 ? normalized : [DEFAULT_BASE_PAYMENT_ASSET], + ); +} + +/** + * Fetch all available EIP-3009 payment assets from the API. + * Falls back to the default USDC asset on network error or non-OK response. + */ +export async function fetchBasePaymentAssets( + apiBase: string, + baseFetch: typeof fetch = fetch, +): Promise { + try { + const response = await baseFetch(`${apiBase.replace(/\/+$/, "")}/v1/payment-metadata?chain=base`, { + headers: { Accept: "application/json" }, + }); + if (!response.ok) return [DEFAULT_BASE_PAYMENT_ASSET]; + + const payload = (await response.json()) as PaymentMetadataResponse; + return normalizeBasePaymentAssets(payload); + } catch { + // Network error, JSON parse failure, or normalize exception — fall back to USDC + return [DEFAULT_BASE_PAYMENT_ASSET]; + } +} + +/** + * Fetch the highest-priority EIP-3009 payment asset from the API. + * Convenience wrapper around {@link fetchBasePaymentAssets} that returns only the first asset. + */ +export async function fetchBasePaymentAsset( + apiBase: string, + baseFetch: typeof fetch = fetch, +): Promise { + const assets = await fetchBasePaymentAssets(apiBase, baseFetch); + return assets[0]; +} diff --git a/src/proxy.ts b/src/proxy.ts index 8db49fa..08f72e9 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -64,6 +64,12 @@ import { RequestDeduplicator } from "./dedup.js"; import { ResponseCache, type ResponseCacheConfig } from "./response-cache.js"; import { BalanceMonitor } from "./balance.js"; import type { SolanaBalanceMonitor } from "./solana-balance.js"; +import { + DEFAULT_BASE_PAYMENT_ASSET, + fetchBasePaymentAsset, + fetchBasePaymentAssets, + type BasePaymentAsset, +} from "./payment-asset.js"; /** Union type for chain-agnostic balance monitoring */ type AnyBalanceMonitor = BalanceMonitor | SolanaBalanceMonitor; @@ -157,7 +163,12 @@ async function readBodyWithTimeout( * Transform upstream payment errors into user-friendly messages. * Parses the raw x402 error and formats it nicely. */ -function transformPaymentError(errorBody: string): string { +function transformPaymentError( + errorBody: string, + opts?: { baseAssetSymbol?: string; baseAssetDecimals?: number }, +): string { + const baseAssetSymbol = opts?.baseAssetSymbol || DEFAULT_BASE_PAYMENT_ASSET.symbol; + const baseAssetDecimals = opts?.baseAssetDecimals ?? DEFAULT_BASE_PAYMENT_ASSET.decimals; try { // Try to parse the error JSON const parsed = JSON.parse(errorBody) as { @@ -187,22 +198,24 @@ function transformPaymentError(errorBody: string): string { /insufficient balance:\s*(\d+)\s*<\s*(\d+)/i, ); if (balanceMatch) { - const currentMicros = parseInt(balanceMatch[1], 10); - const requiredMicros = parseInt(balanceMatch[2], 10); - const currentUSD = (currentMicros / 1_000_000).toFixed(6); - const requiredUSD = (requiredMicros / 1_000_000).toFixed(6); + const currentRaw = parseInt(balanceMatch[1], 10); + const requiredRaw = parseInt(balanceMatch[2], 10); + // Upstream error amounts are in the asset's native decimals (e.g. 6 for USDC, 18 for fxUSD) + const divisor = 10 ** baseAssetDecimals; + const currentUSD = (currentRaw / divisor).toFixed(6); + const requiredUSD = (requiredRaw / divisor).toFixed(6); const wallet = innerJson.payer || "unknown"; const shortWallet = wallet.length > 12 ? `${wallet.slice(0, 6)}...${wallet.slice(-4)}` : wallet; return JSON.stringify({ error: { - message: `Insufficient USDC balance. Current: $${currentUSD}, Required: ~$${requiredUSD}`, + message: `Insufficient ${baseAssetSymbol} balance. Current: $${currentUSD}, Required: ~$${requiredUSD}`, type: "insufficient_funds", wallet: wallet, current_balance_usd: currentUSD, required_usd: requiredUSD, - help: `Fund wallet ${shortWallet} with USDC on Base, or use free model: /model free`, + help: `Fund wallet ${shortWallet} with ${baseAssetSymbol} on Base, or use free model: /model free`, }, }); } @@ -513,7 +526,16 @@ export function getProxyPort(): number { */ async function checkExistingProxy( port: number, -): Promise<{ wallet: string; paymentChain?: string } | undefined> { +): Promise< + | { + wallet: string; + paymentChain?: string; + paymentAsset?: string; + paymentAssetSymbol?: string; + paymentAssetDecimals?: number; + } + | undefined +> { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT_MS); @@ -528,9 +550,18 @@ async function checkExistingProxy( status?: string; wallet?: string; paymentChain?: string; + paymentAsset?: string; + paymentAssetSymbol?: string; + paymentAssetDecimals?: number; }; if (data.status === "ok" && data.wallet) { - return { wallet: data.wallet, paymentChain: data.paymentChain }; + return { + wallet: data.wallet, + paymentChain: data.paymentChain, + paymentAsset: data.paymentAsset, + paymentAssetSymbol: data.paymentAssetSymbol, + paymentAssetDecimals: data.paymentAssetDecimals, + }; } } return undefined; @@ -1070,6 +1101,7 @@ function stripThinkingTokens(content: string): string { export type LowBalanceInfo = { balanceUSD: string; walletAddress: string; + assetSymbol: string; }; /** Callback info for insufficient funds error */ @@ -1077,6 +1109,7 @@ export type InsufficientFundsInfo = { balanceUSD: string; requiredUSD: string; walletAddress: string; + assetSymbol: string; }; /** @@ -1160,6 +1193,8 @@ export type ProxyHandle = { baseUrl: string; walletAddress: string; solanaAddress?: string; + paymentAsset?: BasePaymentAsset; + paymentAssets?: BasePaymentAsset[]; balanceMonitor: AnyBalanceMonitor; close: () => Promise; }; @@ -1219,8 +1254,9 @@ function mergeRoutingConfig(overrides?: Partial): RoutingConfig { } /** - * Estimate USDC cost for a request based on model pricing. - * Returns amount string in USDC smallest unit (6 decimals) or undefined if unknown. + * Estimate stablecoin cost for a request based on model pricing. + * Returns amount string in USD micros (6-decimal integer, i.e. 1 = $0.000001) or undefined if unknown. + * This is an internal unit used for balance checks; conversion to on-chain token units happens elsewhere. */ function estimateAmount( modelId: string, @@ -1238,7 +1274,7 @@ function estimateAmount( (estimatedInputTokens / 1_000_000) * model.inputPrice + (estimatedOutputTokens / 1_000_000) * model.outputPrice; - // Convert to USDC 6-decimal integer, add 20% buffer for estimation error + // Convert to USD micros (6-decimal integer), add 20% buffer for estimation error // Minimum 1000 ($0.001) to match CDP Facilitator's enforced minimum payment const amountMicros = Math.max(1000, Math.ceil(costUsd * 1.2 * 1_000_000)); return amountMicros.toString(); @@ -1408,6 +1444,12 @@ export async function startProxy(options: ProxyOptions): Promise { const apiBase = options.apiBase ?? (paymentChain === "solana" && solanaPrivateKeyBytes ? BLOCKRUN_SOLANA_API : BLOCKRUN_API); + let activeBasePaymentAssets = + paymentChain === "base" + ? (await fetchBasePaymentAssets(apiBase).catch(() => undefined)) ?? [DEFAULT_BASE_PAYMENT_ASSET] + : [DEFAULT_BASE_PAYMENT_ASSET]; + let activeBasePaymentAsset = activeBasePaymentAssets[0] ?? DEFAULT_BASE_PAYMENT_ASSET; + let lastSelectedBasePaymentAsset = activeBasePaymentAsset; if (paymentChain === "solana" && !solanaPrivateKeyBytes) { console.warn( `[ClawRouter] ⚠ Payment chain is Solana but no mnemonic found — falling back to Base (EVM).`, @@ -1470,7 +1512,19 @@ export async function startProxy(options: ProxyOptions): Promise { const { SolanaBalanceMonitor } = await import("./solana-balance.js"); balanceMonitor = new SolanaBalanceMonitor(reuseSolanaAddress); } else { - balanceMonitor = new BalanceMonitor(account.address); + if (existingProxy.paymentAsset && existingProxy.paymentAssetSymbol) { + activeBasePaymentAsset = { + ...activeBasePaymentAsset, + asset: existingProxy.paymentAsset as `0x${string}`, + symbol: existingProxy.paymentAssetSymbol, + ...(typeof existingProxy.paymentAssetDecimals === "number" && { + decimals: existingProxy.paymentAssetDecimals, + }), + }; + activeBasePaymentAssets = [activeBasePaymentAsset]; + lastSelectedBasePaymentAsset = activeBasePaymentAsset; + } + balanceMonitor = new BalanceMonitor(account.address, activeBasePaymentAsset); } options.onReady?.(listenPort); @@ -1480,6 +1534,8 @@ export async function startProxy(options: ProxyOptions): Promise { baseUrl, walletAddress: existingProxy.wallet, solanaAddress: reuseSolanaAddress, + paymentAsset: paymentChain === "base" ? activeBasePaymentAsset : undefined, + paymentAssets: paymentChain === "base" ? activeBasePaymentAssets : undefined, balanceMonitor, close: async () => { // No-op: we didn't start this proxy, so we shouldn't close it @@ -1511,6 +1567,14 @@ export async function startProxy(options: ProxyOptions): Promise { // Log which chain is used for each payment x402.onAfterPaymentCreation(async (context) => { const network = context.selectedRequirements.network; + if (network.startsWith("eip155")) { + activeBasePaymentAssets = + (await fetchBasePaymentAssets(apiBase).catch(() => undefined)) ?? activeBasePaymentAssets; + activeBasePaymentAsset = activeBasePaymentAssets[0] ?? activeBasePaymentAsset; + if (balanceMonitor instanceof BalanceMonitor) { + balanceMonitor.setAsset(activeBasePaymentAsset); + } + } const chain = network.startsWith("eip155") ? "Base (EVM)" : network.startsWith("solana") @@ -1531,7 +1595,7 @@ export async function startProxy(options: ProxyOptions): Promise { const { SolanaBalanceMonitor } = await import("./solana-balance.js"); balanceMonitor = new SolanaBalanceMonitor(solanaAddress); } else { - balanceMonitor = new BalanceMonitor(account.address); + balanceMonitor = new BalanceMonitor(account.address, activeBasePaymentAsset); } // Build router options (100% local — no external API calls for routing) @@ -1595,16 +1659,44 @@ export async function startProxy(options: ProxyOptions): Promise { wallet: account.address, paymentChain, }; + if (paymentChain === "base") { + response.paymentAsset = activeBasePaymentAsset.asset; + response.paymentAssetSymbol = activeBasePaymentAsset.symbol; + response.paymentAssetDecimals = activeBasePaymentAsset.decimals; + response.paymentAssets = activeBasePaymentAssets; + response.selectedPaymentAsset = lastSelectedBasePaymentAsset.asset; + response.selectedPaymentAssetSymbol = lastSelectedBasePaymentAsset.symbol; + } if (solanaAddress) { response.solana = solanaAddress; } if (full) { try { - const balanceInfo = await balanceMonitor.checkBalance(); - response.balance = balanceInfo.balanceUSD; - response.isLow = balanceInfo.isLow; - response.isEmpty = balanceInfo.isEmpty; + if (paymentChain === "base") { + const assetBalances = await Promise.all( + activeBasePaymentAssets.map(async (asset) => { + const monitor = new BalanceMonitor(account.address, asset); + const balanceInfo = await monitor.checkBalance(); + return { + asset: asset.asset, + symbol: asset.symbol, + balance: balanceInfo.balanceUSD, + isLow: balanceInfo.isLow, + isEmpty: balanceInfo.isEmpty, + }; + }), + ); + response.assetBalances = assetBalances; + response.balance = assetBalances[0]?.balance ?? "$0.00"; + response.isLow = assetBalances[0]?.isLow ?? true; + response.isEmpty = assetBalances.every((asset) => asset.isEmpty); + } else { + const balanceInfo = await balanceMonitor.checkBalance(); + response.balance = balanceInfo.balanceUSD; + response.isLow = balanceInfo.isLow; + response.isEmpty = balanceInfo.isEmpty; + } } catch { response.balanceError = "Could not fetch balance"; } @@ -1956,6 +2048,15 @@ export async function startProxy(options: ProxyOptions): Promise { sessionStore, responseCache, sessionJournal, + () => activeBasePaymentAsset.symbol, + () => activeBasePaymentAssets, + (asset) => { + lastSelectedBasePaymentAsset = asset; + activeBasePaymentAsset = asset; + if (balanceMonitor instanceof BalanceMonitor) { + balanceMonitor.setAsset(asset); + } + }, ); } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); @@ -2140,6 +2241,8 @@ export async function startProxy(options: ProxyOptions): Promise { baseUrl, walletAddress: account.address, solanaAddress, + paymentAsset: paymentChain === "base" ? activeBasePaymentAsset : undefined, + paymentAssets: paymentChain === "base" ? activeBasePaymentAssets : undefined, balanceMonitor, close: () => new Promise((res, rej) => { @@ -2319,6 +2422,9 @@ async function proxyRequest( sessionStore: SessionStore, responseCache: ResponseCache, sessionJournal: SessionJournal, + getBaseAssetSymbol: () => string, + getBasePaymentAssets: () => BasePaymentAsset[], + onBaseAssetSelected: (asset: BasePaymentAsset) => void, ): Promise { const startTime = Date.now(); @@ -2352,6 +2458,8 @@ async function proxyRequest( let accumulatedContent = ""; // For session journal event extraction let responseInputTokens: number | undefined; let responseOutputTokens: number | undefined; + let requestBalanceMonitor = balanceMonitor; + let requestBasePaymentAsset = getBasePaymentAssets()[0]; const isChatCompletion = req.url?.includes("/chat/completions"); // Extract session ID early for journal operations (header-only at this point) @@ -3326,8 +3434,40 @@ async function proxyRequest( const bufferedCostMicros = (estimatedCostMicros * BigInt(Math.ceil(BALANCE_CHECK_BUFFER * 100))) / 100n; + if (balanceMonitor instanceof BalanceMonitor) { + const baseAssets = getBasePaymentAssets(); + let selected: + | { + asset: BasePaymentAsset; + monitor: BalanceMonitor; + sufficiency: Awaited>; + } + | undefined; + + for (const asset of baseAssets) { + const monitor = new BalanceMonitor(balanceMonitor.getWalletAddress(), asset); + const sufficiency = await monitor.checkSufficient(bufferedCostMicros); + if (!selected) { + selected = { asset, monitor, sufficiency }; + } + if (sufficiency.sufficient) { + selected = { asset, monitor, sufficiency }; + break; + } + } + + if (selected) { + requestBasePaymentAsset = selected.asset; + requestBalanceMonitor = selected.monitor; + onBaseAssetSelected(selected.asset); + console.log( + `[ClawRouter] Base payment asset selected: ${selected.asset.symbol} (${selected.sufficiency.info.balanceUSD})`, + ); + } + } + // Check balance before proceeding (using buffered amount) - const sufficiency = await balanceMonitor.checkSufficient(bufferedCostMicros); + const sufficiency = await requestBalanceMonitor.checkSufficient(bufferedCostMicros); if (sufficiency.info.isEmpty || !sufficiency.sufficient) { // Wallet is empty or insufficient — ALWAYS fallback to free model @@ -3358,12 +3498,14 @@ async function proxyRequest( options.onLowBalance?.({ balanceUSD: sufficiency.info.balanceUSD, walletAddress: sufficiency.info.walletAddress, + assetSymbol: sufficiency.info.assetSymbol, }); } else if (sufficiency.info.isLow) { // Balance is low but sufficient — warn and proceed options.onLowBalance?.({ balanceUSD: sufficiency.info.balanceUSD, walletAddress: sufficiency.info.walletAddress, + assetSymbol: sufficiency.info.assetSymbol, }); } } @@ -4021,7 +4163,10 @@ async function proxyRequest( const errStatus = lastError?.status || 502; // Transform payment errors into user-friendly messages - const transformedErr = transformPaymentError(rawErrBody); + const transformedErr = transformPaymentError(rawErrBody, { + baseAssetSymbol: getBaseAssetSymbol(), + baseAssetDecimals: getBasePaymentAssets()[0]?.decimals, + }); if (headersSentEarly) { // Streaming: send error as SSE event @@ -4427,7 +4572,7 @@ async function proxyRequest( // --- Optimistic balance deduction after successful response --- if (estimatedCostMicros !== undefined) { - balanceMonitor.deductEstimated(estimatedCostMicros); + requestBalanceMonitor.deductEstimated(estimatedCostMicros); } // Mark request as completed (for client disconnect cleanup) @@ -4446,7 +4591,7 @@ async function proxyRequest( deduplicator.removeInflight(dedupKey); // Invalidate balance cache on payment failure (might be out of date) - balanceMonitor.invalidate(); + requestBalanceMonitor.invalidate(); // Convert abort error to more descriptive timeout error if (err instanceof Error && err.name === "AbortError") {