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()