diff --git a/package.json b/package.json index aa932f8..526ff1f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agent-score/pay", - "version": "0.1.0-rc.16", + "version": "0.1.0-rc.17", "description": "CLI wallet for one-shell-command agent payments across x402 (Base) and MPP (Tempo, Solana)", "type": "module", "main": "./dist/index.js", diff --git a/src/commands/pay.ts b/src/commands/pay.ts index 12dec10..2a9ee6e 100644 --- a/src/commands/pay.ts +++ b/src/commands/pay.ts @@ -372,8 +372,28 @@ export async function pay(input: PayInput): Promise { body: parsed ?? text, }; if (!res.ok) { - throw new CliError('merchant_error', `Merchant returned ${res.status} ${res.statusText}`, { - extra: { status: res.status, chain: wallet.chain, body: result.body }, + // Lift the merchant's structured `error.code` / `error.message` (when the body is + // JSON-shaped per the canonical 4xx envelope) into the CLI message so an agent + // doesn't have to re-curl to learn whether the failure was, e.g., `codes_not_accepted` + // vs `product_not_found` vs `unsupported_jurisdiction`. Falls back to the bare + // status text when the body isn't JSON or isn't envelope-shaped. + const merchantErr = (parsed as Record | null)?.error as + | Record + | undefined; + const merchantCode = typeof merchantErr?.code === 'string' ? merchantErr.code : undefined; + const merchantMessage = + typeof merchantErr?.message === 'string' ? merchantErr.message : undefined; + const display = merchantCode + ? `Merchant returned ${res.status} ${merchantCode}${merchantMessage ? ': ' + merchantMessage : ''}` + : `Merchant returned ${res.status} ${res.statusText}`; + throw new CliError('merchant_error', display, { + extra: { + status: res.status, + chain: wallet.chain, + ...(merchantCode ? { merchant_code: merchantCode } : {}), + ...(merchantMessage ? { merchant_message: merchantMessage } : {}), + body: result.body, + }, }); } return result; diff --git a/src/selection.ts b/src/selection.ts index 3d163cc..475f83c 100644 --- a/src/selection.ts +++ b/src/selection.ts @@ -37,8 +37,14 @@ function formatBalance(chain: Chain, raw: bigint): string { export async function listHeldCandidates( network: Network = 'mainnet', name: string = DEFAULT_WALLET_NAME, + filterChain?: Chain, ): Promise { - const chains = [...SUPPORTED_CHAINS]; + // When the caller already knows which chain it wants (e.g. `pay --chain tempo`), + // skip the balance fetch on every other chain. Without this, we hit each chain's + // RPC even for an explicit override — and the public Solana mainnet endpoint + // (40 req/10s per IP) bubbles transient 429s back as `rpc_error: solana mainnet + // RPC call failed` on legitimate tempo / base settles. + const chains = filterChain ? [filterChain] : [...SUPPORTED_CHAINS]; const candidates = await Promise.all( chains.map(async (chain) => { if (!(await keystoreExists(chain, name))) return null; @@ -58,7 +64,7 @@ export async function listHeldCandidates( export async function selectRail(input: SelectInput = {}): Promise { const { chainOverride, walletName = DEFAULT_WALLET_NAME, minBalanceRaw, network = 'mainnet' } = input; - const heldCandidates = await listHeldCandidates(network, walletName); + const heldCandidates = await listHeldCandidates(network, walletName, chainOverride); const walletSuffix = walletName === DEFAULT_WALLET_NAME ? '' : ` (wallet: ${walletName})`; const createSuggestion = diff --git a/tests/selection.test.ts b/tests/selection.test.ts index 8280055..e25a7fe 100644 --- a/tests/selection.test.ts +++ b/tests/selection.test.ts @@ -87,6 +87,34 @@ describe('selectRail', () => { expect(picked.chain).toBe('tempo'); }); + it('with --chain override, only queries the chosen chain (no balance fetch on others)', async () => { + // Tracks how many times each chain's balance() was called. The selection path + // used to query every held chain regardless of override, which trips the public + // Solana mainnet RPC's 40-req/10s limit on legitimate tempo / base settles. + await writeKeystore('base', '0xbase'); + await writeKeystore('solana', 'SoLaNa1111111111111111111111111111111111111'); + await writeKeystore('tempo', '0xtempo'); + const calls = { base: 0, solana: 0, tempo: 0 }; + vi.doMock('../src/chains/base', async () => { + const actual = await vi.importActual('../src/chains/base'); + return { ...actual, balance: async () => { calls.base++; return 100n; } }; + }); + vi.doMock('../src/chains/solana', async () => { + const actual = await vi.importActual('../src/chains/solana'); + return { ...actual, balance: async () => { calls.solana++; return 100n; } }; + }); + vi.doMock('../src/chains/tempo', async () => { + const actual = await vi.importActual('../src/chains/tempo'); + return { ...actual, balance: async () => { calls.tempo++; return 100n; } }; + }); + const { selectRail } = await import('../src/selection'); + const picked = await selectRail({ chainOverride: 'tempo' }); + expect(picked.chain).toBe('tempo'); + expect(calls.tempo).toBe(1); + expect(calls.base).toBe(0); + expect(calls.solana).toBe(0); + }); + it('errors on chain override with no wallet', async () => { await writeKeystore('base', '0xbase'); await mockBalances({ base: 100n });