diff --git a/src/commands/fetch.ts b/src/commands/fetch.ts index 4d7c60c..7bb713f 100644 --- a/src/commands/fetch.ts +++ b/src/commands/fetch.ts @@ -106,17 +106,28 @@ export function registerFetch402Command(program: Command) { // the only supported rail is BTC/sats over lightning — the cap is // inherently a sats spend limit — but it goes through the shared // classifier so the surface matches the rest of the CLI and can grow. + // The classifier speaks the whole CLI's vocabulary (fiat codes, chain + // networks), so its raw errors would advertise rails fetch doesn't + // accept and contradict this command's help; collapse any rejection — + // and any non-BTC/sats rail it does classify — into one message that + // matches what fetch actually supports. if (options.maxAmount !== undefined) { - const rail = classifyRail({ - currency: options.currency, - unit: options.unit, - network: options.network, - }); + const capRailError = new Error( + "fetch's --max-amount spend cap currently supports only " + + "--currency BTC --unit sats --network lightning", + ); + let rail; + try { + rail = classifyRail({ + currency: options.currency, + unit: options.unit, + network: options.network, + }); + } catch { + throw capRailError; + } if (rail.kind !== "bitcoin" || rail.unit !== "sats") { - throw new Error( - "fetch's --max-amount spend cap currently supports only " + - "--currency BTC --unit sats --network lightning", - ); + throw capRailError; } } else if (options.currency || options.unit || options.network) { throw new Error( diff --git a/src/test/fetch-max-amount.test.ts b/src/test/fetch-max-amount.test.ts index 530379b..a1c1663 100644 --- a/src/test/fetch-max-amount.test.ts +++ b/src/test/fetch-max-amount.test.ts @@ -46,4 +46,28 @@ describe("fetch --max-amount strict parsing", () => { "currently supports only --currency BTC --unit sats --network lightning", ); }); + + // The cap goes through the shared amount model, whose errors speak the whole + // CLI's vocabulary (fiat codes, chain names like "arbitrum"). Those would + // advertise rails fetch can't pay a cap in and contradict its help, so every + // rejected combination must surface fetch's own single message instead. + test.each([ + ["--max-amount 5", "no denomination at all"], + ["--max-amount 5 --currency USD --unit sats", "fiat with a sub-unit"], + ["--max-amount 5 --currency BTC", "BTC without --network"], + ["--max-amount 5 --currency USDC --network arbitrum", "a token on a chain"], + ])( + "surfaces fetch's own cap message, not the shared classifier's, for %s (%s)", + (flags) => { + const result = runCli(`fetch http://example.invalid ${flags}`); + expect(result.success).toBe(false); + expect(result.output.error).toBe( + "fetch's --max-amount spend cap currently supports only " + + "--currency BTC --unit sats --network lightning", + ); + // None of the shared model's cross-rail vocabulary leaks through. + expect(result.output.error).not.toContain("arbitrum"); + expect(result.output.error).not.toContain("BTC|USD|EUR|USDC"); + }, + ); });