From e163e013267b61e61ea8fd6374d4161c9c164f98 Mon Sep 17 00:00:00 2001 From: sebayaki Date: Mon, 13 Jul 2026 17:14:25 +0900 Subject: [PATCH 1/2] fix(cli): retry pending settlements without replacement signing --- SKILL.md | 15 +- packages/cli/README.md | 15 +- packages/cli/src/api.ts | 17 +- packages/cli/src/commands.ts | 97 +++++- packages/cli/tests/api.test.ts | 36 ++- .../cli/tests/call-settlement-retry.test.ts | 301 ++++++++++++++++++ packages/cli/tests/docs.test.ts | 11 + 7 files changed, 468 insertions(+), 24 deletions(-) create mode 100644 packages/cli/tests/call-settlement-retry.test.ts diff --git a/SKILL.md b/SKILL.md index 4284e63..6866951 100644 --- a/SKILL.md +++ b/SKILL.md @@ -103,16 +103,23 @@ a `PAYMENT-SIGNATURE` header, and retry the same request. 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** start a new call unless you intentionally accept a new payment, except when the server returns a matching `payment_settlement_failed` response with `paid: false` and `safeToStartNewCall: true` for the original authorization. ## Notes diff --git a/packages/cli/README.md b/packages/cli/README.md index 42cf7b6..8209ee8 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -93,11 +93,16 @@ 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 -call again. +`--idempotency-key` is double-charge protection, not result replay. If the server reports +`payment_settlement_pending`, the running CLI resends the exact `PAYMENT-SIGNATURE`, key, +method, path, provider, and body for bounded reconciliation attempts. One CLI invocation +creates at most one payment authorization: server-issued replacement challenges are +refused, and a separate explicit call is required to create a new payment. The CLI does +not persist payment signatures, so after the process exits it cannot reconstruct the +original signed request. Pending, reconciled, network, and gateway errors after a signed +send warn that settlement may still have occurred; only a matching +`payment_settlement_failed` response with `paid: false` and `safeToStartNewCall: true` +confirms that the original authorization was not paid. ## Agents & automation diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index 291d9c3..ad44346 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,12 +123,23 @@ 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" +]); + +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 !== "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."; + 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 552e390..991d3d2 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, 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"; @@ -415,6 +415,63 @@ 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 REPLACEMENT_IDEMPOTENCY_KEY_HEADER = "x-h402-replacement-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)); +} + +// Once a signed request is sent, a network drop or non-terminal response can +// hide a completed settlement. Keep that uncertainty visible so callers do not +// create a second authorization accidentally. +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 isConclusiveSettlementFailure(error: unknown, idempotencyKey: string) { + if (!(error instanceof CliError) || !error.detail || typeof error.detail !== "object") { + return false; + } + const backendError = (error.detail as { error?: unknown }).error; + return ( + backendError !== null && + typeof backendError === "object" && + backendErrorCode(error.detail) === "payment_settlement_failed" && + (backendError as { idempotencyKey?: unknown }).idempotencyKey === idempotencyKey && + (backendError as { paid?: unknown }).paid === false && + (backendError as { safeToStartNewCall?: unknown }).safeToStartNewCall === true + ); +} + +function isReplacementPaymentResponse(response: ApiResponse) { + return response.headers.has(REPLACEMENT_IDEMPOTENCY_KEY_HEADER); +} + +function automaticReplacementRefused(response: ApiResponse, idempotencyKey: string) { + return withSettlementRiskGuidance( + new CliError( + "Automatic replacement payment refused. This invocation did not sign a replacement authorization. Start a separate explicit h402 call only if you intentionally accept a new payment; the original settlement remains unknown.", + { + code: "automatic_replacement_refused", + idempotencyKey, + settlementStatus: "unknown", + replacementAuthorizationSigned: false, + separateCallRequired: true, + url: response.url + } + ) + ); +} + 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 +486,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,13 +495,18 @@ export async function callCommand(args: ParsedArgs) { headers.authorization = `Bearer ${token}`; } + let signedRequestSent = false; try { const first = await requestJson(apiUrl, path, { method, headers, - body: body === undefined ? undefined : JSON.stringify(body) + body: requestBody }); + if (isReplacementPaymentResponse(first)) { + throw automaticReplacementRefused(first, idempotencyKey); + } + 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). @@ -465,17 +528,29 @@ export async function callCommand(args: ParsedArgs) { 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 paidHeaders = { + "idempotency-key": idempotencyKey, + [X402_HEADERS.paymentSignature]: paymentSignature + }; + const sendSignedRequest = () => requestJson(apiUrl, path, { method, headers: paidHeaders, body: requestBody }); + + signedRequestSent = true; + let paid = await sendSignedRequest(); + if (isReplacementPaymentResponse(paid)) { + throw automaticReplacementRefused(paid, idempotencyKey); + } + for (const delayMs of PAYMENT_SETTLEMENT_RETRY_DELAYS_MS) { + if (!isPaymentSettlementPending(paid)) break; + await waitForPaymentSettlement(delayMs); + paid = await sendSignedRequest(); + if (isReplacementPaymentResponse(paid)) { + throw automaticReplacementRefused(paid, idempotencyKey); + } + } await printJson(withSignedAmount(assertOk(paid), accepted)); } catch (error) { - throw withIdempotencyKey(error, idempotencyKey); + const guardedError = signedRequestSent && !isConclusiveSettlementFailure(error, idempotencyKey) ? withSettlementRiskGuidance(error) : error; + throw withIdempotencyKey(guardedError, idempotencyKey); } } diff --git a/packages/cli/tests/api.test.ts b/packages/cli/tests/api.test.ts index d775023..b027372 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", @@ -117,4 +123,32 @@ describe("requestJson", () => { }); }); } + + it("does not add uncertain-settlement guidance to a conclusive payment failure", () => { + const result = { + backendUrl: "https://staging.example", + url: "https://staging.example/routes/auto/web/search", + status: 409, + statusText: "Conflict", + headers: new Headers(), + body: { + error: { + code: "payment_settlement_failed", + message: "The original payment authorization was not settled; start a separate call to try again." + } + } + }; + + const error = (() => { + try { + assertOk(result); + } catch (thrown) { + return thrown; + } + })(); + + expect(error).toBeInstanceOf(CliError); + expect((error as Error).message).toMatch(/original payment authorization was not settled/i); + expect((error as Error).message).not.toMatch(/may already be completed, charged, or still settling/i); + }); }); 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..b01afab --- /dev/null +++ b/packages/cli/tests/call-settlement-retry.test.ts @@ -0,0 +1,301 @@ +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"}', + provider: "demo", + "idempotency-key": IDEMPOTENCY_KEY, + ...flags + } + }; +} + +function challenge() { + return { + x402Version: 2, + accepts: [ + { + scheme: "exact", + network: "eip155:8453", + asset: BASE_USDC, + amount: "1000", + payTo: ADDR, + maxTimeoutSeconds: 60 + } + ] + }; +} + +function backendError(code: string, message: string) { + return { error: { code, message, idempotencyKey: IDEMPOTENCY_KEY, ledgerEntryId: "ledger-43" } }; +} + +function pending() { + return backendError("payment_settlement_pending", "Payment settlement status could not be confirmed yet."); +} + +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(retryHeaders.get("PAYMENT-SIGNATURE")).toBe(firstPaidHeaders.get("PAYMENT-SIGNATURE")); + expect(retryHeaders.get("idempotency-key")).toBe(IDEMPOTENCY_KEY); + + const firstPaid = fetch.mock.calls[1] as [string, RequestInit]; + const retry = fetch.mock.calls[2] as [string, RequestInit]; + expect(firstPaid[0]).toBe("https://test.example/routes/demo/web/search"); + expect(retry[0]).toBe(firstPaid[0]); + expect(retry[1].method).toBe(firstPaid[1].method); + expect(retry[1].body).toBe(firstPaid[1].body); + expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"ok": true')); + }); + + it("stops after bounded retries without creating another authorization", 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 as Error).message).toMatch(/do NOT sign or pay with a new idempotency key/i); + 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("refuses a replacement response after pending without signing again", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(res(402, challenge())) + .mockResolvedValueOnce(res(409, pending())) + .mockResolvedValueOnce( + res(402, challenge(), { + "x-h402-previous-idempotency-key": IDEMPOTENCY_KEY, + "x-h402-replacement-idempotency-key": REPLACEMENT_KEY + }) + ); + vi.stubGlobal("fetch", fetch); + + const execution = callCommand(args()).catch((error: unknown) => error); + await vi.runAllTimersAsync(); + const error = await execution; + + expect(error).toMatchObject({ + detail: { + code: "automatic_replacement_refused", + idempotencyKey: IDEMPOTENCY_KEY, + settlementStatus: "unknown", + replacementAuthorizationSigned: false, + separateCallRequired: true + } + }); + expect((error as Error).message).toMatch(/did not sign a replacement authorization/i); + expect((error as Error).message).toMatch(/separate explicit h402 call/i); + expect((error as Error).message).toMatch(/original settlement remains unknown/i); + expect((error as Error).message).not.toMatch(/safe to/i); + expect(signOwsTypedData).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledTimes(3); + }); + + it("refuses an initial replacement response before resolving or signing a wallet", async () => { + const fetch = vi.fn().mockResolvedValueOnce( + res(402, challenge(), { + "x-h402-previous-idempotency-key": IDEMPOTENCY_KEY, + "x-h402-replacement-idempotency-key": REPLACEMENT_KEY + }) + ); + vi.stubGlobal("fetch", fetch); + + const error = await callCommand(args()).catch((thrown: unknown) => thrown); + + expect(error).toMatchObject({ + detail: { + code: "automatic_replacement_refused", + settlementStatus: "unknown", + replacementAuthorizationSigned: false, + separateCallRequired: true + } + }); + expect((error as Error).message).toMatch(/did not sign a replacement authorization/i); + expect((error as Error).message).toMatch(/original settlement remains unknown/i); + expect((error as Error).message).toMatch(/do NOT sign or pay with a new idempotency key/i); + expect(signOwsTypedData).not.toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("preserves do-not-repay guidance after a signed network failure", 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 as Error).message).toContain("ECONNRESET"); + expect((error as Error).message).toMatch(/do NOT sign or pay with a new idempotency key/i); + expect(signOwsTypedData).toHaveBeenCalledTimes(1); + }); + + it("preserves do-not-repay guidance after a signed gateway failure", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(res(402, challenge())) + .mockResolvedValueOnce(res(502, { error: { message: "gateway lost the response" } })); + vi.stubGlobal("fetch", fetch); + + const error = await callCommand(args()).catch((thrown: unknown) => thrown); + + expect((error as Error).message).toContain("gateway lost the response"); + expect((error as Error).message).toMatch(/do NOT sign or pay with a new idempotency key/i); + expect(signOwsTypedData).toHaveBeenCalledTimes(1); + }); + + it("does not contradict a conclusive unpaid settlement response", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(res(402, challenge())) + .mockResolvedValueOnce(res(409, pending())) + .mockResolvedValueOnce( + res(409, { + error: { + code: "payment_settlement_failed", + message: "The original payment authorization was not settled; start a separate call to try again.", + idempotencyKey: IDEMPOTENCY_KEY, + paid: false, + safeToStartNewCall: true + } + }) + ); + vi.stubGlobal("fetch", fetch); + + const execution = callCommand(args()).catch((error: unknown) => error); + await vi.runAllTimersAsync(); + const error = await execution; + + expect(error).toMatchObject({ + detail: { + error: { code: "payment_settlement_failed", paid: false, safeToStartNewCall: true }, + idempotencyKey: IDEMPOTENCY_KEY + } + }); + expect((error as Error).message).toMatch(/original payment authorization was not settled/i); + expect((error as Error).message).not.toMatch(/may already be completed, charged, or still settling/i); + expect(signOwsTypedData).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledTimes(3); + }); + + it.each([ + { + label: "omits its safety proof", + error: { code: "payment_settlement_failed", message: "Payment settlement failed.", idempotencyKey: IDEMPOTENCY_KEY } + }, + { + label: "belongs to another idempotency key", + error: { + code: "payment_settlement_failed", + message: "Payment settlement failed.", + idempotencyKey: "idem-other", + paid: false, + safeToStartNewCall: true + } + } + ])("keeps do-not-repay guidance when a terminal response $label", async ({ error: backendError }) => { + const fetch = vi + .fn() + .mockResolvedValueOnce(res(402, challenge())) + .mockResolvedValueOnce(res(409, { error: backendError })); + vi.stubGlobal("fetch", fetch); + + const error = await callCommand(args()).catch((thrown: unknown) => thrown); + + expect((error as Error).message).toMatch(/may already be completed, charged, or still settling/i); + expect(signOwsTypedData).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/cli/tests/docs.test.ts b/packages/cli/tests/docs.test.ts index 5fb86fb..13bc9bd 100644 --- a/packages/cli/tests/docs.test.ts +++ b/packages/cli/tests/docs.test.ts @@ -119,6 +119,17 @@ describe("doc examples stay runnable against the catalog contract", () => { } }); + it("documents one-authorization pending-settlement recovery", () => { + for (const label of ["package README.md", "SKILL.md"]) { + const text = readFileSync(DOC_FILES[label], "utf8"); + expect(text).toMatch(/resends the exact\s+`PAYMENT-SIGNATURE`, key,\s+method, path, provider, and body/); + expect(text).toMatch(/One CLI invocation\s+creates at most one payment authorization/); + expect(text).toMatch(/server-issued\s+replacement challenges are\s+refused/i); + expect(text).toMatch(/does\s+not persist payment signatures/); + expect(text).toMatch(/matching\s+`payment_settlement_failed` response with `paid: false` and `safeToStartNewCall: true`\s+confirms that the original authorization was not paid/); + } + }); + 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 7646146ebdc85473a54da041d25d455666ae6763 Mon Sep 17 00:00:00 2001 From: sebayaki Date: Mon, 13 Jul 2026 17:45:57 +0900 Subject: [PATCH 2/2] fix(cli): guard historical settlement failures --- packages/cli/src/commands.ts | 11 +++++--- packages/cli/tests/api.test.ts | 3 ++- .../cli/tests/call-settlement-retry.test.ts | 26 +++++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 991d3d2..fb62632 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -437,15 +437,18 @@ function withSettlementRiskGuidance(error: unknown) { return new CliError(`${message}. ${IDEMPOTENCY_MONEY_GUIDANCE}`, error instanceof CliError ? error.detail : undefined); } +function isPaymentSettlementFailure(error: unknown): error is CliError { + return error instanceof CliError && backendErrorCode(error.detail) === "payment_settlement_failed"; +} + function isConclusiveSettlementFailure(error: unknown, idempotencyKey: string) { - if (!(error instanceof CliError) || !error.detail || typeof error.detail !== "object") { + if (!isPaymentSettlementFailure(error) || !error.detail || typeof error.detail !== "object") { return false; } const backendError = (error.detail as { error?: unknown }).error; return ( backendError !== null && typeof backendError === "object" && - backendErrorCode(error.detail) === "payment_settlement_failed" && (backendError as { idempotencyKey?: unknown }).idempotencyKey === idempotencyKey && (backendError as { paid?: unknown }).paid === false && (backendError as { safeToStartNewCall?: unknown }).safeToStartNewCall === true @@ -550,7 +553,9 @@ export async function callCommand(args: ParsedArgs) { await printJson(withSignedAmount(assertOk(paid), accepted)); } catch (error) { - const guardedError = signedRequestSent && !isConclusiveSettlementFailure(error, idempotencyKey) ? withSettlementRiskGuidance(error) : error; + const settlementRiskIsUnresolved = + (signedRequestSent || isPaymentSettlementFailure(error)) && !isConclusiveSettlementFailure(error, idempotencyKey); + const guardedError = settlementRiskIsUnresolved ? withSettlementRiskGuidance(error) : error; throw withIdempotencyKey(guardedError, idempotencyKey); } } diff --git a/packages/cli/tests/api.test.ts b/packages/cli/tests/api.test.ts index b027372..db174e6 100644 --- a/packages/cli/tests/api.test.ts +++ b/packages/cli/tests/api.test.ts @@ -124,7 +124,7 @@ describe("requestJson", () => { }); } - it("does not add uncertain-settlement guidance to a conclusive payment failure", () => { + it("leaves payment settlement proof validation to the call command", () => { const result = { backendUrl: "https://staging.example", url: "https://staging.example/routes/auto/web/search", @@ -148,6 +148,7 @@ describe("requestJson", () => { })(); expect(error).toBeInstanceOf(CliError); + expect(error).toMatchObject({ detail: { error: { code: "payment_settlement_failed" } } }); expect((error as Error).message).toMatch(/original payment authorization was not settled/i); expect((error as Error).message).not.toMatch(/may already be completed, charged, or still settling/i); }); diff --git a/packages/cli/tests/call-settlement-retry.test.ts b/packages/cli/tests/call-settlement-retry.test.ts index b01afab..b17be8d 100644 --- a/packages/cli/tests/call-settlement-retry.test.ts +++ b/packages/cli/tests/call-settlement-retry.test.ts @@ -298,4 +298,30 @@ describe("callCommand pending settlement reconciliation", () => { expect(signOwsTypedData).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledTimes(2); }); + + it.each([ + { + label: "omits its safety proof", + error: { code: "payment_settlement_failed", message: "Payment settlement failed.", idempotencyKey: IDEMPOTENCY_KEY } + }, + { + label: "belongs to another idempotency key", + error: { + code: "payment_settlement_failed", + message: "Payment settlement failed.", + idempotencyKey: "idem-other", + paid: false, + safeToStartNewCall: true + } + } + ])("keeps do-not-repay guidance when an initial settlement failure $label", async ({ error: backendError }) => { + const fetch = vi.fn().mockResolvedValueOnce(res(409, { error: backendError })); + vi.stubGlobal("fetch", fetch); + + const error = await callCommand(args()).catch((thrown: unknown) => thrown); + + expect((error as Error).message).toMatch(/may already be completed, charged, or still settling/i); + expect(signOwsTypedData).not.toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledTimes(1); + }); });