From d8d344e4ea0134c64cab524d9445d02fe00f3f52 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Mon, 4 May 2026 07:31:51 -0700 Subject: [PATCH] fix(fund): render receive QR + address on stderr before polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-existing UX bug surfaced by a user: agentscore-pay fund --chain base --amount 10 hung silently in the terminal. fund() was polling balance for up to 15 minutes before printing anything — the QR + address only landed in the final result that incur renders after the call returns. Now fund() emits a `funding_started` event via emitProgress (stderr, JSON in non-TTY, plain in TTY) before entering the poll loop, and in TTY mode also writes the rendered ASCII QR + the actionable status message to stderr immediately. JSON consumers see one structured event on stderr; humans see scannable QR + address + polling cadence right away. Stdout (incur's structured result) is unchanged — same FundResult shape with qr_uri, initial_usdc, etc. landing at the end. Tempo testnet path is unaffected (programmatic mint returns instantly, doesn't enter the poll loop). Brings fund's UX in line with the rest of pay's polling commands — passport login, passport resume/bootstrap, and retry already emit to stderr before/during their waits via onVerifyUrl / onRetry callbacks. Bumps to 0.1.0-rc.15. Test plan: 352/352 vitest pass; tsc --noEmit clean; live test against the smoke wallet on all 3 chains confirms structured event + QR URI shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- src/commands/fund.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 0eec105..cbce692 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agent-score/pay", - "version": "0.1.0-rc.14", + "version": "0.1.0-rc.15", "description": "CLI wallet for one-shell-command agent payments across x402 (Base, Solana) and MPP (Tempo)", "type": "module", "main": "./dist/index.js", diff --git a/src/commands/fund.ts b/src/commands/fund.ts index dc4b7c7..71911c5 100644 --- a/src/commands/fund.ts +++ b/src/commands/fund.ts @@ -1,10 +1,12 @@ import { setTimeout as sleep } from 'timers/promises'; +import qrcode from 'qrcode-terminal'; import * as baseChain from '../chains/base'; import * as solanaChain from '../chains/solana'; import * as tempoChain from '../chains/tempo'; import { type Chain, type Network } from '../constants'; import { loadKeystore } from '../keystore'; import { DEFAULT_WALLET_NAME } from '../paths'; +import { emitProgress } from '../progress'; const POLL_INTERVAL_MS = 5_000; const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000; @@ -87,6 +89,32 @@ export async function fund(input: FundInput): Promise { const uri = buildQrUri(input.chain, ks.address, input.amountUsd, network); const initial = await readBalance(input.chain, ks.address, network); + + // Surface the receive surface BEFORE the poll loop so the user sees actionable + // info immediately. Without this, fund() silently polls for up to 15 minutes + // and only renders at the end. JSON consumers get the structured event on + // stderr; TTY users additionally see the rendered QR + status message. + emitProgress('funding_started', { + chain: input.chain, + network, + address: ks.address, + amount_usd: input.amountUsd ?? null, + qr_uri: uri, + poll_interval_seconds: POLL_INTERVAL_MS / 1000, + timeout_seconds: DEFAULT_TIMEOUT_MS / 1000, + }); + if (process.stderr.isTTY) { + const ascii = await new Promise((resolve) => { + qrcode.generate(uri, { small: true }, (q) => resolve(q)); + }); + const minutes = Math.round(DEFAULT_TIMEOUT_MS / 60_000); + const seconds = POLL_INTERVAL_MS / 1000; + process.stderr.write( + `\nSend USDC on ${input.chain} (${network}) to:\n ${ks.address}\n\n${ascii}\n` + + `Polling balance every ${seconds}s (timeout ${minutes}m). Send from any wallet, exchange, or fiat onramp; pay will detect the deposit and exit.\n\n`, + ); + } + const deadline = Date.now() + DEFAULT_TIMEOUT_MS; let current = initial; while (Date.now() < deadline) {