From 0d71a9c1e9d872ab5423dceb36bd0f14803963ec Mon Sep 17 00:00:00 2001 From: h1-hunt Date: Fri, 10 Jul 2026 05:17:30 +0000 Subject: [PATCH 1/4] fix(cli): resume pending settlement safely --- SKILL.md | 12 +- packages/cli/README.md | 11 +- packages/cli/src/api.ts | 12 +- packages/cli/src/commands.ts | 136 ++++++-- packages/cli/tests/api.test.ts | 8 +- .../cli/tests/call-settlement-retry.test.ts | 291 ++++++++++++++++++ packages/cli/tests/docs.test.ts | 10 + 7 files changed, 446 insertions(+), 34 deletions(-) create mode 100644 packages/cli/tests/call-settlement-retry.test.ts diff --git a/SKILL.md b/SKILL.md index 8d471f8..d498d8b 100644 --- a/SKILL.md +++ b/SKILL.md @@ -83,16 +83,20 @@ are charged the exact per-call price and get the result back. Pass `--max-usd ` when you retry a `call` after a lost response. Keep the same key and do **not** change to a new key unless you intentionally accept buying the call again. +- Record an explicit `--idempotency-key ` before a money-sensitive `call`. If the process exits after a lost response, keep that key for server-side duplicate protection, but expect settlement/conflict guidance rather than result or signature replay; do **not** change to a new key unless you intentionally accept buying the call again. ## Notes diff --git a/packages/cli/README.md b/packages/cli/README.md index 8b69a91..9fe5f3e 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -86,10 +86,13 @@ when building the EIP-3009 validity window, reducing client clock-skew failures calls. If you've run `h402 auth`, bonus credits are drawn before USDC unless you pass `--no-credit`. -`--idempotency-key` is double-charge protection, not result replay. If a paid response is -lost, reusing the same key prevents a duplicate server-side operation, but the server may -return `idempotency_key_already_used`/`idempotency_key_in_progress` instead of replaying -the previous result. Do not switch to a new key unless you intentionally accept buying the +`--idempotency-key` is double-charge protection, not result replay. If the server reports +`payment_settlement_pending`, the running CLI automatically resends the exact +`PAYMENT-SIGNATURE` with the same request and idempotency key for a bounded number of +reconciliation attempts; it never creates a fresh authorization for that pending key. +The CLI does not persist payment signatures, so after the process exits a later invocation +cannot reconstruct the original signed request and may fail safely with conflict or +settlement guidance. Do not switch to a new key unless you intentionally accept buying the call again. ## Agents & automation diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index 291d9c3..db90e4e 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -111,7 +111,7 @@ function backendMessage(body: unknown): string | undefined { return typeof record.message === "string" ? record.message : undefined; } -function backendErrorCode(body: unknown): string | undefined { +export function backendErrorCode(body: unknown): string | undefined { if (!body || typeof body !== "object") { return undefined; } @@ -123,9 +123,17 @@ function backendErrorCode(body: unknown): string | undefined { return typeof record.code === "string" ? record.code : undefined; } +const MONEY_SENSITIVE_IDEMPOTENCY_CODES = new Set([ + "idempotency_key_already_used", + "idempotency_key_in_progress", + "idempotency_key_conflict", + "payment_settlement_pending", + "payment_settlement_reconciled" +]); + function idempotencyGuidance(body: unknown): string | undefined { const code = backendErrorCode(body); - if (code !== "idempotency_key_already_used" && code !== "idempotency_key_in_progress") { + if (!code || !MONEY_SENSITIVE_IDEMPOTENCY_CODES.has(code)) { return undefined; } return "The earlier request for this idempotency key may already be completed, charged, or still settling; do NOT sign or pay with a new idempotency key unless you intentionally accept a second charge."; diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 552e390..699cad9 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { assertOk, requestJson } from "./api.js"; +import { assertOk, backendErrorCode, requestJson, type ApiResponse } from "./api.js"; import { BASE_USDC_BALANCE_ASSET, BASE_USDC_BALANCE_NETWORK, getBaseUsdcBalance } from "./base-usdc-balance.js"; import { backendUrl, loadConfig, updateConfig, type CliConfig } from "./config.js"; import { CliError } from "./errors.js"; @@ -415,6 +415,51 @@ function authorizationClockFromResponseDate(headers: Headers) { return Number.isFinite(millis) ? Math.floor(millis / 1000) : undefined; } +const PAYMENT_SETTLEMENT_RETRY_DELAYS_MS = [250, 1_000, 2_000] as const; +const MAX_REPLACEMENT_PAYMENTS = 1; +const REPLACEMENT_IDEMPOTENCY_KEY_HEADER = "x-h402-replacement-idempotency-key"; +const PREVIOUS_IDEMPOTENCY_KEY_HEADER = "x-h402-previous-idempotency-key"; + +function isPaymentSettlementPending(response: ApiResponse) { + return response.status === 409 && backendErrorCode(response.body) === "payment_settlement_pending"; +} + +function waitForPaymentSettlement(delayMs: number) { + return new Promise((resolve) => setTimeout(resolve, delayMs)); +} + +function replacementPaymentFromResponse(response: ApiResponse, currentIdempotencyKey: string) { + if (response.status !== 402) return undefined; + const replacementIdempotencyKey = response.headers.get(REPLACEMENT_IDEMPOTENCY_KEY_HEADER); + if (!replacementIdempotencyKey) return undefined; + + const previousIdempotencyKey = response.headers.get(PREVIOUS_IDEMPOTENCY_KEY_HEADER); + if (previousIdempotencyKey && previousIdempotencyKey !== currentIdempotencyKey) { + throw new CliError("Replacement payment response refers to a different previous idempotency key; refusing to sign.", { + previousIdempotencyKey, + currentIdempotencyKey, + replacementIdempotencyKey, + url: response.url + }); + } + if (replacementIdempotencyKey === currentIdempotencyKey) { + throw new CliError("Replacement payment response reused the pending idempotency key; refusing to create a fresh authorization for it.", { + currentIdempotencyKey, + url: response.url + }); + } + + const paymentRequired = paymentRequiredFromResponse(response.headers, response.body); + if (!paymentRequired) { + throw new CliError("Replacement payment response did not contain a valid PAYMENT-REQUIRED challenge; refusing to sign.", { + currentIdempotencyKey, + replacementIdempotencyKey, + url: response.url + }); + } + return { idempotencyKey: replacementIdempotencyKey, paymentRequired }; +} + export async function callCommand(args: ParsedArgs) { rejectExtraPositionals(args, 2, "call", "Did you forget --json for a request body or --query for URL parameters? Run: h402 call --help"); const config = await loadConfig(); @@ -429,6 +474,7 @@ export async function callCommand(args: ParsedArgs) { const paymentCap = maxUsd(args, config); const token = config.sessions[apiUrl]; const path = buildProxyPath(routeId, query, provider); + const requestBody = body === undefined ? undefined : JSON.stringify(body); const headers: Record = { "idempotency-key": idempotencyKey }; @@ -437,11 +483,12 @@ export async function callCommand(args: ParsedArgs) { headers.authorization = `Bearer ${token}`; } + let activeIdempotencyKey = idempotencyKey; try { const first = await requestJson(apiUrl, path, { method, headers, - body: body === undefined ? undefined : JSON.stringify(body) + body: requestBody }); const paymentRequired = first.status === 402 ? paymentRequiredFromResponse(first.headers, first.body) : null; @@ -453,29 +500,72 @@ export async function callCommand(args: ParsedArgs) { return; } - const accepted = selectBaseUsdcRequirement(paymentRequired); - assertUnderMaxUsd(accepted.amount, paymentCap); + // Enforce the cap before resolving a wallet. Replacement challenges are checked + // through the same signing helper before any fresh authorization is created. + const initialAccepted = selectBaseUsdcRequirement(paymentRequired); + assertUnderMaxUsd(initialAccepted.amount, paymentCap); const { name, address: walletAddress } = await resolveSigningWallet(args, config); - const paymentSignature = await signWithWalletPassphrase(args, name, (passphrase) => - createPaymentSignatureHeader({ - paymentRequired, - walletAddress, - walletName: name, - passphrase, - authorizationNow: authorizationClockFromResponseDate(first.headers) - }) - ); - const paid = await requestJson(apiUrl, path, { - method, - headers: { - "idempotency-key": idempotencyKey, - [X402_HEADERS.paymentSignature]: paymentSignature - }, - body: body === undefined ? undefined : JSON.stringify(body) - }); + const signPayment = async ( + nextPaymentRequired: NonNullable>, + responseHeaders: Headers, + nextIdempotencyKey: string + ) => { + const accepted = selectBaseUsdcRequirement(nextPaymentRequired); + assertUnderMaxUsd(accepted.amount, paymentCap); + const paymentSignature = await signWithWalletPassphrase(args, name, (passphrase) => + createPaymentSignatureHeader({ + paymentRequired: nextPaymentRequired, + walletAddress, + walletName: name, + passphrase, + authorizationNow: authorizationClockFromResponseDate(responseHeaders) + }) + ); + return { accepted, idempotencyKey: nextIdempotencyKey, paymentSignature }; + }; + + let signedPayment = await signPayment(paymentRequired, first.headers, idempotencyKey); + let replacementPayments = 0; + while (true) { + const paidHeaders = { + "idempotency-key": signedPayment.idempotencyKey, + [X402_HEADERS.paymentSignature]: signedPayment.paymentSignature + }; + const sendSignedRequest = () => requestJson(apiUrl, path, { method, headers: paidHeaders, body: requestBody }); + let paid = await sendSignedRequest(); + let sawPendingSettlement = false; + for (const delayMs of PAYMENT_SETTLEMENT_RETRY_DELAYS_MS) { + if (!isPaymentSettlementPending(paid)) break; + sawPendingSettlement = true; + await waitForPaymentSettlement(delayMs); + paid = await sendSignedRequest(); + } - await printJson(withSignedAmount(assertOk(paid), accepted)); + const replacementPayment = replacementPaymentFromResponse(paid, signedPayment.idempotencyKey); + if (replacementPayment && !sawPendingSettlement) { + throw new CliError("Replacement payment challenge arrived without a preceding pending-settlement response; refusing to sign.", { + currentIdempotencyKey: signedPayment.idempotencyKey, + replacementIdempotencyKey: replacementPayment.idempotencyKey, + url: paid.url + }); + } + if (!replacementPayment) { + await printJson(withSignedAmount(assertOk(paid), signedPayment.accepted)); + return; + } + if (replacementPayments >= MAX_REPLACEMENT_PAYMENTS) { + throw new CliError("Server returned repeated replacement payment challenges; refusing to continue signing.", { + currentIdempotencyKey: signedPayment.idempotencyKey, + replacementIdempotencyKey: replacementPayment.idempotencyKey, + url: paid.url + }); + } + + replacementPayments += 1; + activeIdempotencyKey = replacementPayment.idempotencyKey; + signedPayment = await signPayment(replacementPayment.paymentRequired, paid.headers, replacementPayment.idempotencyKey); + } } catch (error) { - throw withIdempotencyKey(error, idempotencyKey); + throw withIdempotencyKey(error, activeIdempotencyKey); } } diff --git a/packages/cli/tests/api.test.ts b/packages/cli/tests/api.test.ts index d775023..9ee6d42 100644 --- a/packages/cli/tests/api.test.ts +++ b/packages/cli/tests/api.test.ts @@ -87,7 +87,13 @@ describe("requestJson", () => { }); }); - for (const code of ["idempotency_key_already_used", "idempotency_key_in_progress"]) { + for (const code of [ + "idempotency_key_already_used", + "idempotency_key_in_progress", + "idempotency_key_conflict", + "payment_settlement_pending", + "payment_settlement_reconciled" + ]) { it(`adds no-double-charge guidance for ${code}`, () => { const result = { backendUrl: "https://staging.example", diff --git a/packages/cli/tests/call-settlement-retry.test.ts b/packages/cli/tests/call-settlement-retry.test.ts new file mode 100644 index 0000000..a664a53 --- /dev/null +++ b/packages/cli/tests/call-settlement-retry.test.ts @@ -0,0 +1,291 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ParsedArgs } from "../src/utils"; + +const ADDR = "0x1111111111111111111111111111111111111111"; +const BASE_USDC = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"; +const IDEMPOTENCY_KEY = "idem-pending-43"; +const REPLACEMENT_KEY = "idem-replacement-43"; + +const { loadConfig, signOwsTypedData } = vi.hoisted(() => ({ + loadConfig: vi.fn(), + signOwsTypedData: vi.fn(async () => `0x${"11".repeat(65)}` as `0x${string}`) +})); + +vi.mock("../src/config.js", () => ({ + loadConfig, + updateConfig: vi.fn(), + backendUrl: () => "https://test.example" +})); + +vi.mock("../src/ows.js", () => ({ + createOwsWallet: vi.fn(), + getOwsWallet: vi.fn(), + listOwsWallets: vi.fn(), + signOwsMessage: vi.fn(), + signOwsTypedData +})); + +const { callCommand } = await import("../src/commands"); + +function args(flags: ParsedArgs["flags"] = {}): ParsedArgs { + return { + positional: ["call", "web/search"], + flags: { + json: '{"query":"h402"}', + "idempotency-key": IDEMPOTENCY_KEY, + ...flags + } + }; +} + +function challenge(amount = "1000") { + return { + x402Version: 2, + accepts: [ + { + scheme: "exact", + network: "eip155:8453", + asset: BASE_USDC, + amount, + payTo: ADDR, + maxTimeoutSeconds: 60 + } + ] + }; +} + +function pending(idempotencyKey = IDEMPOTENCY_KEY) { + return { + error: { + code: "payment_settlement_pending", + message: "Payment settlement status could not be confirmed yet; retry this idempotency key later.", + idempotencyKey, + ledgerEntryId: "ledger-43" + } + }; +} + +function res(status: number, body: unknown, headers: Record = {}) { + return { + status, + statusText: status === 200 ? "OK" : status === 409 ? "Conflict" : "Payment Required", + text: async () => JSON.stringify(body), + headers: new Headers(headers) + }; +} + +function requestHeaders(fetch: ReturnType, index: number) { + const init = fetch.mock.calls[index]?.[1] as RequestInit | undefined; + return new Headers(init?.headers); +} + +describe("callCommand pending settlement reconciliation", () => { + let stdout: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + loadConfig.mockResolvedValue({ + backendUrl: "https://test.example", + sessions: {}, + wallets: { h402: { address: ADDR } } + }); + }); + + afterEach(() => { + stdout.mockRestore(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + loadConfig.mockReset(); + signOwsTypedData.mockClear(); + }); + + it("reuses the byte-identical signed request while settlement is pending", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(res(402, challenge())) + .mockResolvedValueOnce(res(409, pending())) + .mockResolvedValueOnce(res(200, { data: { ok: true }, h402: { provider: "demo" } })); + vi.stubGlobal("fetch", fetch); + + const execution = callCommand(args()); + await vi.runAllTimersAsync(); + await execution; + + expect(fetch).toHaveBeenCalledTimes(3); + expect(signOwsTypedData).toHaveBeenCalledTimes(1); + + const firstPaidHeaders = requestHeaders(fetch, 1); + const retryHeaders = requestHeaders(fetch, 2); + expect(firstPaidHeaders.get("PAYMENT-SIGNATURE")).toBeTruthy(); + expect(retryHeaders.get("PAYMENT-SIGNATURE")).toBe(firstPaidHeaders.get("PAYMENT-SIGNATURE")); + expect(retryHeaders.get("idempotency-key")).toBe(IDEMPOTENCY_KEY); + + const firstPaidInit = fetch.mock.calls[1]?.[1] as RequestInit; + const retryInit = fetch.mock.calls[2]?.[1] as RequestInit; + expect(fetch.mock.calls[2]?.[0]).toBe(fetch.mock.calls[1]?.[0]); + expect(retryInit.method).toBe(firstPaidInit.method); + expect(retryInit.body).toBe(firstPaidInit.body); + expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"ok": true')); + }); + + it("stops after bounded retries and warns against signing a new payment", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(res(402, challenge())) + .mockResolvedValueOnce(res(409, pending())) + .mockResolvedValueOnce(res(409, pending())) + .mockResolvedValueOnce(res(409, pending())) + .mockResolvedValueOnce(res(409, pending())); + vi.stubGlobal("fetch", fetch); + + const execution = callCommand(args()).catch((error: unknown) => error); + await vi.runAllTimersAsync(); + const error = await execution; + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch(/do NOT sign or pay with a new idempotency key/i); + expect((error as Error).message).toContain(IDEMPOTENCY_KEY); + expect(fetch).toHaveBeenCalledTimes(5); + expect(signOwsTypedData).toHaveBeenCalledTimes(1); + + const signatures = fetch.mock.calls.slice(1).map((_, index) => requestHeaders(fetch, index + 1).get("PAYMENT-SIGNATURE")); + expect(new Set(signatures).size).toBe(1); + }); + + it("signs a server-issued replacement challenge with its replacement idempotency key", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(res(402, challenge("1000"))) + .mockResolvedValueOnce(res(409, pending())) + .mockResolvedValueOnce( + res(402, challenge("2000"), { + "x-h402-previous-idempotency-key": IDEMPOTENCY_KEY, + "x-h402-replacement-idempotency-key": REPLACEMENT_KEY + }) + ) + .mockResolvedValueOnce(res(200, { data: { ok: true }, h402: { provider: "demo" } })); + vi.stubGlobal("fetch", fetch); + + const execution = callCommand(args({ "max-usd": "0.01" })); + await vi.runAllTimersAsync(); + await execution; + + expect(fetch).toHaveBeenCalledTimes(4); + expect(signOwsTypedData).toHaveBeenCalledTimes(2); + + const originalHeaders = requestHeaders(fetch, 1); + const reconciliationHeaders = requestHeaders(fetch, 2); + const replacementHeaders = requestHeaders(fetch, 3); + expect(reconciliationHeaders.get("PAYMENT-SIGNATURE")).toBe(originalHeaders.get("PAYMENT-SIGNATURE")); + expect(replacementHeaders.get("idempotency-key")).toBe(REPLACEMENT_KEY); + expect(replacementHeaders.get("PAYMENT-SIGNATURE")).toBeTruthy(); + expect(replacementHeaders.get("PAYMENT-SIGNATURE")).not.toBe(originalHeaders.get("PAYMENT-SIGNATURE")); + + const printed = JSON.parse(stdout.mock.calls.map((call) => String(call[0])).join("")); + expect(printed.h402.signedAmount).toMatchObject({ amount: "2000", usd: "0.002" }); + }); + + it("rechecks max-usd before signing a replacement challenge", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(res(402, challenge("1000"))) + .mockResolvedValueOnce(res(409, pending())) + .mockResolvedValueOnce( + res(402, challenge("20000"), { + "x-h402-previous-idempotency-key": IDEMPOTENCY_KEY, + "x-h402-replacement-idempotency-key": REPLACEMENT_KEY + }) + ); + vi.stubGlobal("fetch", fetch); + + const execution = callCommand(args({ "max-usd": "0.01" })).catch((error: unknown) => error); + await vi.runAllTimersAsync(); + const error = await execution; + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch(/exceeds --max-usd 0\.01/); + expect((error as Error).message).toContain(REPLACEMENT_KEY); + expect(fetch).toHaveBeenCalledTimes(3); + expect(signOwsTypedData).toHaveBeenCalledTimes(1); + }); + + it("surfaces a reconciled settlement without signing again", async () => { + const reconciled = { + error: { + code: "payment_settlement_reconciled", + message: "Payment was already settled, but the original response was lost.", + idempotencyKey: IDEMPOTENCY_KEY, + ledgerEntryId: "ledger-43", + paymentReference: "0xsettled" + } + }; + const fetch = vi + .fn() + .mockResolvedValueOnce(res(402, challenge())) + .mockResolvedValueOnce(res(409, pending())) + .mockResolvedValueOnce(res(409, reconciled)); + vi.stubGlobal("fetch", fetch); + + const execution = callCommand(args()).catch((error: unknown) => error); + await vi.runAllTimersAsync(); + const error = await execution; + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch(/already settled.*original response was lost/i); + expect((error as Error).message).toMatch(/do NOT sign or pay with a new idempotency key/i); + expect(fetch).toHaveBeenCalledTimes(3); + expect(signOwsTypedData).toHaveBeenCalledTimes(1); + }); + + it("refuses a replacement challenge without a preceding pending-settlement response", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(res(402, challenge("1000"))) + .mockResolvedValueOnce( + res(402, challenge("2000"), { + "x-h402-previous-idempotency-key": IDEMPOTENCY_KEY, + "x-h402-replacement-idempotency-key": REPLACEMENT_KEY + }) + ); + vi.stubGlobal("fetch", fetch); + + const error = await callCommand(args({ "max-usd": "0.01" })).catch((thrown: unknown) => thrown); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch(/without a preceding pending-settlement response/i); + expect(signOwsTypedData).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("refuses repeated replacement challenges", async () => { + const secondReplacementKey = "idem-replacement-43-b"; + const fetch = vi + .fn() + .mockResolvedValueOnce(res(402, challenge("1000"))) + .mockResolvedValueOnce(res(409, pending())) + .mockResolvedValueOnce( + res(402, challenge("2000"), { + "x-h402-previous-idempotency-key": IDEMPOTENCY_KEY, + "x-h402-replacement-idempotency-key": REPLACEMENT_KEY + }) + ) + .mockResolvedValueOnce(res(409, pending(REPLACEMENT_KEY))) + .mockResolvedValueOnce( + res(402, challenge("3000"), { + "x-h402-previous-idempotency-key": REPLACEMENT_KEY, + "x-h402-replacement-idempotency-key": secondReplacementKey + }) + ); + vi.stubGlobal("fetch", fetch); + + const execution = callCommand(args({ "max-usd": "0.01" })).catch((thrown: unknown) => thrown); + await vi.runAllTimersAsync(); + const error = await execution; + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch(/repeated replacement payment challenges/i); + expect(signOwsTypedData).toHaveBeenCalledTimes(2); + expect(fetch).toHaveBeenCalledTimes(5); + }); +}); diff --git a/packages/cli/tests/docs.test.ts b/packages/cli/tests/docs.test.ts index 2b9f274..76eac89 100644 --- a/packages/cli/tests/docs.test.ts +++ b/packages/cli/tests/docs.test.ts @@ -44,6 +44,16 @@ describe("doc examples stay runnable against the catalog contract", () => { expect(text).not.toContain("| `--api-url ` | all |"); }); + it("documents pending-settlement recovery as in-process exact-signature reuse", () => { + for (const label of ["package README.md", "SKILL.md"]) { + const text = readFileSync(DOC_FILES[label], "utf8"); + expect(text).toMatch(/automatically re(?:sends|uses) the exact\s+`PAYMENT-SIGNATURE`/i); + expect(text).toMatch(/does not persist\s+(?:the\s+)?(?:signed\s+(?:request|payment|authorization)|payment\s+signatures)/i); + expect(text).toMatch(/process exits/i); + expect(text).not.toMatch(/reuse the same key after a lost response/i); + } + }); + it("core README scopes selectExactRequirement to h402 canonical challenges", () => { const text = readFileSync(path.join(here, "..", "..", "core", "README.md"), "utf8"); expect(text).toContain("`selectExactRequirement` is intentionally h402-opinionated"); From 78e034fed0c050844e7628c16bb0686aa3f47bef Mon Sep 17 00:00:00 2001 From: sebayaki Date: Fri, 10 Jul 2026 17:50:17 +0900 Subject: [PATCH 2/4] fix(cli): keep settlement-risk guidance and bind replacement challenges - carry the do-not-repay guidance across network and 5xx failures that follow an observed pending settlement, so a lost retry can no longer read as a plain error inviting a fresh key - require the x-h402-previous-idempotency-key binding (presence and match) before signing a replacement challenge Co-Authored-By: Claude Fable 5 --- packages/cli/src/api.ts | 5 +- packages/cli/src/commands.ts | 38 +++++++++--- .../cli/tests/call-settlement-retry.test.ts | 59 +++++++++++++++++++ 3 files changed, 92 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index db90e4e..ad44346 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -131,12 +131,15 @@ const MONEY_SENSITIVE_IDEMPOTENCY_CODES = new Set([ "payment_settlement_reconciled" ]); +export const IDEMPOTENCY_MONEY_GUIDANCE = + "The earlier request for this idempotency key may already be completed, charged, or still settling; do NOT sign or pay with a new idempotency key unless you intentionally accept a second charge."; + function idempotencyGuidance(body: unknown): string | undefined { const code = backendErrorCode(body); if (!code || !MONEY_SENSITIVE_IDEMPOTENCY_CODES.has(code)) { return undefined; } - return "The earlier request for this idempotency key may already be completed, charged, or still settling; do NOT sign or pay with a new idempotency key unless you intentionally accept a second charge."; + return IDEMPOTENCY_MONEY_GUIDANCE; } export function assertOk(response: ApiResponse): T { diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 699cad9..784175b 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { assertOk, backendErrorCode, requestJson, type ApiResponse } from "./api.js"; +import { assertOk, backendErrorCode, IDEMPOTENCY_MONEY_GUIDANCE, requestJson, type ApiResponse } from "./api.js"; import { BASE_USDC_BALANCE_ASSET, BASE_USDC_BALANCE_NETWORK, getBaseUsdcBalance } from "./base-usdc-balance.js"; import { backendUrl, loadConfig, updateConfig, type CliConfig } from "./config.js"; import { CliError } from "./errors.js"; @@ -428,19 +428,37 @@ function waitForPaymentSettlement(delayMs: number) { return new Promise((resolve) => setTimeout(resolve, delayMs)); } +// Once a pending-settlement response was observed, any later failure — a network +// drop, a gateway 5xx — leaves the charge state unknown, so the do-not-repay +// guidance must survive even when the final error body carries no settlement code. +function withSettlementRiskGuidance(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes(IDEMPOTENCY_MONEY_GUIDANCE)) { + return error; + } + return new CliError(`${message}. ${IDEMPOTENCY_MONEY_GUIDANCE}`, error instanceof CliError ? error.detail : undefined); +} + function replacementPaymentFromResponse(response: ApiResponse, currentIdempotencyKey: string) { if (response.status !== 402) return undefined; const replacementIdempotencyKey = response.headers.get(REPLACEMENT_IDEMPOTENCY_KEY_HEADER); if (!replacementIdempotencyKey) return undefined; + // The server contract emits both headers; without the previous-key binding the + // challenge cannot be correlated to the pending authorization, so never sign it. const previousIdempotencyKey = response.headers.get(PREVIOUS_IDEMPOTENCY_KEY_HEADER); - if (previousIdempotencyKey && previousIdempotencyKey !== currentIdempotencyKey) { - throw new CliError("Replacement payment response refers to a different previous idempotency key; refusing to sign.", { - previousIdempotencyKey, - currentIdempotencyKey, - replacementIdempotencyKey, - url: response.url - }); + if (previousIdempotencyKey !== currentIdempotencyKey) { + throw new CliError( + previousIdempotencyKey + ? "Replacement payment response refers to a different previous idempotency key; refusing to sign." + : "Replacement payment response did not identify the pending idempotency key it replaces; refusing to sign.", + { + previousIdempotencyKey, + currentIdempotencyKey, + replacementIdempotencyKey, + url: response.url + } + ); } if (replacementIdempotencyKey === currentIdempotencyKey) { throw new CliError("Replacement payment response reused the pending idempotency key; refusing to create a fresh authorization for it.", { @@ -484,6 +502,7 @@ export async function callCommand(args: ParsedArgs) { } let activeIdempotencyKey = idempotencyKey; + let settlementMayHaveOccurred = false; try { const first = await requestJson(apiUrl, path, { method, @@ -537,6 +556,7 @@ export async function callCommand(args: ParsedArgs) { for (const delayMs of PAYMENT_SETTLEMENT_RETRY_DELAYS_MS) { if (!isPaymentSettlementPending(paid)) break; sawPendingSettlement = true; + settlementMayHaveOccurred = true; await waitForPaymentSettlement(delayMs); paid = await sendSignedRequest(); } @@ -566,6 +586,6 @@ export async function callCommand(args: ParsedArgs) { signedPayment = await signPayment(replacementPayment.paymentRequired, paid.headers, replacementPayment.idempotencyKey); } } catch (error) { - throw withIdempotencyKey(error, activeIdempotencyKey); + throw withIdempotencyKey(settlementMayHaveOccurred ? withSettlementRiskGuidance(error) : error, activeIdempotencyKey); } } diff --git a/packages/cli/tests/call-settlement-retry.test.ts b/packages/cli/tests/call-settlement-retry.test.ts index a664a53..f0f6654 100644 --- a/packages/cli/tests/call-settlement-retry.test.ts +++ b/packages/cli/tests/call-settlement-retry.test.ts @@ -238,6 +238,65 @@ describe("callCommand pending settlement reconciliation", () => { expect(signOwsTypedData).toHaveBeenCalledTimes(1); }); + it("keeps the do-not-repay guidance when a post-pending retry fails at the network layer", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(res(402, challenge())) + .mockResolvedValueOnce(res(409, pending())) + .mockRejectedValueOnce(Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNRESET" } })); + vi.stubGlobal("fetch", fetch); + + const execution = callCommand(args()).catch((error: unknown) => error); + await vi.runAllTimersAsync(); + const error = await execution; + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("ECONNRESET"); + expect((error as Error).message).toMatch(/do NOT sign or pay with a new idempotency key/i); + expect((error as Error).message).toContain(IDEMPOTENCY_KEY); + expect(signOwsTypedData).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledTimes(3); + }); + + it("keeps the do-not-repay guidance when a post-pending retry returns a generic 5xx", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(res(402, challenge())) + .mockResolvedValueOnce(res(409, pending())) + .mockResolvedValueOnce(res(500, { error: { message: "upstream exploded" } })); + vi.stubGlobal("fetch", fetch); + + const execution = callCommand(args()).catch((error: unknown) => error); + await vi.runAllTimersAsync(); + const error = await execution; + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("upstream exploded"); + expect((error as Error).message).toMatch(/do NOT sign or pay with a new idempotency key/i); + expect((error as Error).message).toContain(IDEMPOTENCY_KEY); + expect(signOwsTypedData).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledTimes(3); + }); + + it("refuses a replacement challenge that does not identify the pending idempotency key", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(res(402, challenge("1000"))) + .mockResolvedValueOnce(res(409, pending())) + .mockResolvedValueOnce(res(402, challenge("2000"), { "x-h402-replacement-idempotency-key": REPLACEMENT_KEY })); + vi.stubGlobal("fetch", fetch); + + const execution = callCommand(args()).catch((thrown: unknown) => thrown); + await vi.runAllTimersAsync(); + const error = await execution; + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch(/did not identify the pending idempotency key/i); + expect((error as Error).message).toMatch(/do NOT sign or pay with a new idempotency key/i); + expect(signOwsTypedData).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledTimes(3); + }); + it("refuses a replacement challenge without a preceding pending-settlement response", async () => { const fetch = vi .fn() From a4c839e3964fade35a833658c4451ccba68e030d Mon Sep 17 00:00:00 2001 From: h1-hunt Date: Fri, 10 Jul 2026 11:47:03 +0000 Subject: [PATCH 3/4] fix(cli): preserve settlement warning after first signed send --- packages/cli/src/commands.ts | 7 ++-- .../cli/tests/call-settlement-retry.test.ts | 42 +++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 784175b..e439b1a 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -428,9 +428,9 @@ function waitForPaymentSettlement(delayMs: number) { return new Promise((resolve) => setTimeout(resolve, delayMs)); } -// Once a pending-settlement response was observed, any later failure — a network -// drop, a gateway 5xx — leaves the charge state unknown, so the do-not-repay -// guidance must survive even when the final error body carries no settlement code. +// Once any signed request is sent, a network drop or gateway 5xx can hide a +// completed settlement. Preserve the do-not-repay guidance even when the final +// error body carries no settlement code. function withSettlementRiskGuidance(error: unknown) { const message = error instanceof Error ? error.message : String(error); if (message.includes(IDEMPOTENCY_MONEY_GUIDANCE)) { @@ -551,6 +551,7 @@ export async function callCommand(args: ParsedArgs) { [X402_HEADERS.paymentSignature]: signedPayment.paymentSignature }; const sendSignedRequest = () => requestJson(apiUrl, path, { method, headers: paidHeaders, body: requestBody }); + settlementMayHaveOccurred = true; let paid = await sendSignedRequest(); let sawPendingSettlement = false; for (const delayMs of PAYMENT_SETTLEMENT_RETRY_DELAYS_MS) { diff --git a/packages/cli/tests/call-settlement-retry.test.ts b/packages/cli/tests/call-settlement-retry.test.ts index f0f6654..d76ba41 100644 --- a/packages/cli/tests/call-settlement-retry.test.ts +++ b/packages/cli/tests/call-settlement-retry.test.ts @@ -238,6 +238,48 @@ describe("callCommand pending settlement reconciliation", () => { expect(signOwsTypedData).toHaveBeenCalledTimes(1); }); + it("keeps the do-not-repay guidance when the first signed request fails at the network layer", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(res(402, challenge())) + .mockRejectedValueOnce(Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNRESET" } })); + vi.stubGlobal("fetch", fetch); + + const error = await callCommand(args()).catch((thrown: unknown) => thrown); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("ECONNRESET"); + expect((error as Error).message).toMatch(/do NOT sign or pay with a new idempotency key/i); + expect((error as Error).message).toContain(IDEMPOTENCY_KEY); + expect(signOwsTypedData).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledTimes(2); + expect(requestHeaders(fetch, 0).get("idempotency-key")).toBe(IDEMPOTENCY_KEY); + expect(requestHeaders(fetch, 0).get("PAYMENT-SIGNATURE")).toBeNull(); + expect(requestHeaders(fetch, 1).get("idempotency-key")).toBe(IDEMPOTENCY_KEY); + expect(requestHeaders(fetch, 1).get("PAYMENT-SIGNATURE")).toBeTruthy(); + }); + + it("keeps the do-not-repay guidance when the first signed request returns a generic 5xx", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(res(402, challenge())) + .mockResolvedValueOnce(res(500, { error: { message: "upstream exploded" } })); + vi.stubGlobal("fetch", fetch); + + const error = await callCommand(args()).catch((thrown: unknown) => thrown); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("upstream exploded"); + expect((error as Error).message).toMatch(/do NOT sign or pay with a new idempotency key/i); + expect((error as Error).message).toContain(IDEMPOTENCY_KEY); + expect(signOwsTypedData).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledTimes(2); + expect(requestHeaders(fetch, 0).get("idempotency-key")).toBe(IDEMPOTENCY_KEY); + expect(requestHeaders(fetch, 0).get("PAYMENT-SIGNATURE")).toBeNull(); + expect(requestHeaders(fetch, 1).get("idempotency-key")).toBe(IDEMPOTENCY_KEY); + expect(requestHeaders(fetch, 1).get("PAYMENT-SIGNATURE")).toBeTruthy(); + }); + it("keeps the do-not-repay guidance when a post-pending retry fails at the network layer", async () => { const fetch = vi .fn() From 03687d5925ff3d4e589275dba2c7a40d1010b276 Mon Sep 17 00:00:00 2001 From: h1-hunt Date: Mon, 13 Jul 2026 02:52:42 +0000 Subject: [PATCH 4/4] fix(cli): reject initial replacement challenges --- packages/cli/src/commands.ts | 10 ++++++++++ .../cli/tests/call-settlement-retry.test.ts | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index e439b1a..2a2beb2 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -510,6 +510,16 @@ export async function callCommand(args: ParsedArgs) { body: requestBody }); + if (first.status === 402 && first.headers.has(REPLACEMENT_IDEMPOTENCY_KEY_HEADER)) { + throw withSettlementRiskGuidance( + new CliError("Replacement payment challenge arrived without a preceding pending-settlement response; refusing to sign.", { + currentIdempotencyKey: idempotencyKey, + replacementIdempotencyKey: first.headers.get(REPLACEMENT_IDEMPOTENCY_KEY_HEADER), + url: first.url + }) + ); + } + const paymentRequired = first.status === 402 ? paymentRequiredFromResponse(first.headers, first.body) : null; if (!paymentRequired) { // A 2xx means the route answered without payment (free, or covered by credit). diff --git a/packages/cli/tests/call-settlement-retry.test.ts b/packages/cli/tests/call-settlement-retry.test.ts index d76ba41..d6d8915 100644 --- a/packages/cli/tests/call-settlement-retry.test.ts +++ b/packages/cli/tests/call-settlement-retry.test.ts @@ -339,6 +339,25 @@ describe("callCommand pending settlement reconciliation", () => { expect(fetch).toHaveBeenCalledTimes(3); }); + it("refuses an initial replacement challenge before signing", async () => { + const fetch = vi.fn().mockResolvedValueOnce( + res(402, challenge("2000"), { + "x-h402-previous-idempotency-key": IDEMPOTENCY_KEY, + "x-h402-replacement-idempotency-key": REPLACEMENT_KEY + }) + ); + vi.stubGlobal("fetch", fetch); + + const error = await callCommand(args({ "max-usd": "0.01" })).catch((thrown: unknown) => thrown); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch(/without a preceding pending-settlement response/i); + expect((error as Error).message).toMatch(/do NOT sign or pay with a new idempotency key/i); + expect((error as Error).message).toContain(IDEMPOTENCY_KEY); + expect(signOwsTypedData).not.toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledTimes(1); + }); + it("refuses a replacement challenge without a preceding pending-settlement response", async () => { const fetch = vi .fn()