Skip to content
Closed
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
12 changes: 8 additions & 4 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,16 +83,20 @@ are charged the exact per-call price and get the result back. Pass `--max-usd <a
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 automatically resends the exact
`PAYMENT-SIGNATURE` with the same request and key for bounded 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.

## 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** change to a new key unless you intentionally accept buying the call again.

## Notes

Expand Down
11 changes: 7 additions & 4 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
167 changes: 144 additions & 23 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,69 @@ 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<unknown>) {
return response.status === 409 && backendErrorCode(response.body) === "payment_settlement_pending";
}

function waitForPaymentSettlement(delayMs: number) {
return new Promise<void>((resolve) => setTimeout(resolve, delayMs));
}

// 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)) {
return error;
}
return new CliError(`${message}. ${IDEMPOTENCY_MONEY_GUIDANCE}`, error instanceof CliError ? error.detail : undefined);
}

function replacementPaymentFromResponse(response: ApiResponse<unknown>, 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 !== 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.", {
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();
Expand All @@ -429,6 +492,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 +501,25 @@ export async function callCommand(args: ParsedArgs) {
headers.authorization = `Bearer ${token}`;
}

let activeIdempotencyKey = idempotencyKey;
let settlementMayHaveOccurred = false;
try {
const first = await requestJson<unknown>(apiUrl, path, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body)
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).
Expand All @@ -453,29 +529,74 @@ 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<unknown>(apiUrl, path, {
method,
headers: {
"idempotency-key": idempotencyKey,
[X402_HEADERS.paymentSignature]: paymentSignature
},
body: body === undefined ? undefined : JSON.stringify(body)
});
const signPayment = async (
nextPaymentRequired: NonNullable<ReturnType<typeof paymentRequiredFromResponse>>,
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<unknown>(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) {
if (!isPaymentSettlementPending(paid)) break;
sawPendingSettlement = true;
settlementMayHaveOccurred = 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(settlementMayHaveOccurred ? withSettlementRiskGuidance(error) : error, activeIdempotencyKey);
}
}
8 changes: 7 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
Loading
Loading