Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,16 +103,23 @@ a `PAYMENT-SIGNATURE` header, and retry the same request. Pass `--max-usd <amoun
signing a challenge above that USDC cap. Paid call output includes `h402.signedAmount`
as a receipt of the amount signed. The CLI uses the first 402 response's `Date` header
when building the EIP-3009 validity window, reducing client clock-skew failures on paid
calls. `--idempotency-key` is double-charge protection, not result replay: reuse the
same key after a lost response, but do not switch to a new key unless you intentionally
accept buying the call again.
calls. `--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.

## Running non-interactively (agents)

- Defaults to the production backend (`https://h402.hunt.town`); set `H402_API_URL` or `--api-url` only to override.
- Wallets are passphrase-less by default, so signing needs no flags and never prompts. Only if a wallet was created with an opt-in passphrase: `export H402_WALLET_PASSPHRASE=...` (the CLI says so when it hits such a wallet).
- Read stdout as JSON; check the process exit code (non-zero = failure, message on stderr).
- Pass `--idempotency-key <uuid>` 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 <uuid>` 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

Expand Down
15 changes: 10 additions & 5 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 14 additions & 3 deletions packages/cli/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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<T>(response: ApiResponse<T>): T {
Expand Down
102 changes: 91 additions & 11 deletions packages/cli/src/commands.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -415,6 +415,66 @@ 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<unknown>) {
return response.status === 409 && backendErrorCode(response.body) === "payment_settlement_pending";
}

function waitForPaymentSettlement(delayMs: number) {
return new Promise<void>((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 isPaymentSettlementFailure(error: unknown): error is CliError {
return error instanceof CliError && backendErrorCode(error.detail) === "payment_settlement_failed";
}

function isConclusiveSettlementFailure(error: unknown, idempotencyKey: string) {
if (!isPaymentSettlementFailure(error) || !error.detail || typeof error.detail !== "object") {
return false;
}
const backendError = (error.detail as { error?: unknown }).error;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept flat settlement failure proofs

When the backend returns the flat error shape that backendErrorCode already accepts, e.g. { code: "payment_settlement_failed", idempotencyKey, paid: false, safeToStartNewCall: true }, this line ignores the proof because there is no nested error object. isConclusiveSettlementFailure then returns false, so the catch path appends the do-not-repay warning even though the server has confirmed the original authorization was not paid, leaving callers unable to tell that it is safe to start the replacement call.

Useful? React with 👍 / 👎.

return (
backendError !== null &&
typeof backendError === "object" &&
(backendError as { idempotencyKey?: unknown }).idempotencyKey === idempotencyKey &&
(backendError as { paid?: unknown }).paid === false &&
(backendError as { safeToStartNewCall?: unknown }).safeToStartNewCall === true
);
}

function isReplacementPaymentResponse(response: ApiResponse<unknown>) {
return response.headers.has(REPLACEMENT_IDEMPOTENCY_KEY_HEADER);
}

function automaticReplacementRefused(response: ApiResponse<unknown>, 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();
Expand All @@ -429,6 +489,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<string, string> = {
"idempotency-key": idempotencyKey
};
Expand All @@ -437,13 +498,18 @@ export async function callCommand(args: ParsedArgs) {
headers.authorization = `Bearer ${token}`;
}

let signedRequestSent = false;
try {
const first = await requestJson<unknown>(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).
Expand All @@ -465,17 +531,31 @@ export async function callCommand(args: ParsedArgs) {
authorizationNow: authorizationClockFromResponseDate(first.headers)
})
);
const paid = await requestJson<unknown>(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<unknown>(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 settlementRiskIsUnresolved =
(signedRequestSent || isPaymentSettlementFailure(error)) && !isConclusiveSettlementFailure(error, idempotencyKey);
const guardedError = settlementRiskIsUnresolved ? withSettlementRiskGuidance(error) : error;
throw withIdempotencyKey(guardedError, idempotencyKey);
}
}
37 changes: 36 additions & 1 deletion packages/cli/tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -117,4 +123,33 @@ describe("requestJson", () => {
});
});
}

it("leaves payment settlement proof validation to the call command", () => {
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).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);
});
});
Loading
Loading