From 5be24f62366b77a5842a20b9db6e8cab9149ed76 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Tue, 5 May 2026 05:44:37 -0700 Subject: [PATCH 1/2] fix: scope rail balance lookup + surface merchant error.code in CLI envelope Two findings from the agents.agentscore.sh redemption-flow smoke today. 1. selectRail used to call listHeldCandidates with no chain filter, so every `pay --chain X` ran a Promise.all over base + solana + tempo. With the public Solana mainnet RPC's 40-req/10s cap, legitimate tempo + base settles intermittently surfaced as `rpc_error: solana mainnet RPC call failed: HTTP error (429): Too Many Requests`. listHeldCandidates now takes an optional filterChain and selectRail forwards chainOverride through, so explicit overrides do exactly one balance query. 2. The merchant_error wrapper printed only "Merchant returned 400 Bad Request", discarding the structured `error.code` / `error.message` the merchant returns in its 4xx envelope. An agent following 4xx recovery instructions had to re-curl manually to learn whether the failure was, e.g., codes_not_accepted vs product_not_found vs unsupported_jurisdiction. The CLI message now lifts `error.code` and `error.message` (when present) into the display string and into the error.extra block; falls back to bare status text when the body isn't envelope-shaped. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/pay.ts | 24 ++++++++++++++++++++++-- src/selection.ts | 10 ++++++++-- tests/selection.test.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 4 deletions(-) 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 }); From b819faf3b4a8795eb78007a930df7dd1e67c188b Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Tue, 5 May 2026 05:45:20 -0700 Subject: [PATCH 2/2] chore: bump to 0.1.0-rc.17 Carries the rail-balance scoping + merchant_error.code surfacing fixes from this PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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",