run402-mcp
Version:
MCP server for Run402 — full-stack backend infrastructure for AI agents: Postgres, auth, storage, serverless functions and atomic deploys. Paid with x402/MPP. Includes $0.03 image generation.
1,865 lines • 83 kB
JavaScript
/**
* Node-only x402-wrapped fetch. Reads the wallet file, checks on-chain
* USDC balances through independent RPC providers, and returns a fetch wrapper
* that auto-signs 402 responses only when the requested chain has confirmed
* funds.
*
* Balance reads are pre-payment, read-only operations. They may be retried and
* failed over safely; a payment payload has not been created or submitted yet.
* An exhausted RPC check is never represented as a zero balance.
*
* The viem / @x402/* / mppx imports live behind `./_paid-stack.ts` so the
* SDK's direct surface to those packages is auditable from one file and the
* packages can be declared as optional peer dependencies in package.json.
*
* Never calls `process.exit` — the SDK leaves exit-code decisions to the
* CLI edge.
*/
import { readWallet } from "../../core-dist/wallet.js";
import { getApiBase } from "../../core-dist/config.js";
import { isTerminalRoomInviteRefusal } from "../namespaces/rooms.js";
import { AsyncLocalStorage } from "node:async_hooks";
import { createHash } from "node:crypto";
import { LocalError, PaymentAttemptError, Run402Error, } from "../errors.js";
import { PaidStackUnavailable, loadMppStack, loadX402Stack } from "./_paid-stack.js";
import { createLightningFetch } from "./lightning-paid-fetch.js";
import { DEFAULT_PAYMENT_MAX_USD_MICROS, PaymentBuyerError, PaymentPolicyError, gatewayPaymentBuyerError, isTrustedRun402PaymentUrl, isTrustedRun402PendingResponse, payResponseMetadata, merchantReceiptRequiredError, paymentExceedsMaxError, readPaymentErrorEnvelope, responseSignalsReplay, paidStackUnavailableError, walletUnavailableError, } from "../namespaces/pay.js";
import { attemptIdFromRequest, createFilePaymentAttemptStore, createPaymentAttemptId, hasPaymentAuthorization, requestSummary, withPaymentAttemptHeader, } from "./payment-attempts.js";
import { sdkFetch } from "./http-dispatcher.js";
/**
* A machine-readable x402 balance-preflight failure.
*
* `safeToRetry` is true only for read-only RPC failures. A confirmed balance
* miss is not retryable without changing wallet funds or payment requirements.
* Both states are emitted before payment payload creation, so
* `mutationState` is always `not_started`.
*/
/**
* The largest amount a first-party (api.run402.com) challenge may ask for:
* the team tier. Every gateway price is at or under it, so a challenge above
* it is not a price, it is a compromised or misconfigured host — refuse to
* sign rather than trust a number the server chose. Arbitrary-URL purchases
* through `pay.fetch` are bounded separately by the caller's `maxUsdMicros`.
*/
export const FIRST_PARTY_MAX_PAYMENT_USD_MICROS = 20_000_000;
function originOf(url) {
if (!url)
return null;
try {
return new URL(url).origin;
}
catch {
return null;
}
}
function challengeOrigin(required) {
const resource = required?.resource;
return typeof resource?.url === "string" ? originOf(resource.url) : null;
}
/**
* Refuse a first-party challenge whose every accepted requirement exceeds the
* first-party cap. Requirements at or under the cap are left alone; the
* selector picks among them as usual.
*/
export function assertFirstPartyPaymentWithinCap(required, firstPartyOrigin) {
if (!firstPartyOrigin || challengeOrigin(required) !== firstPartyOrigin)
return;
const accepts = required.accepts;
if (!Array.isArray(accepts) || accepts.length === 0)
return;
const amounts = accepts
.map((entry) => Number(entry?.amount))
.filter((amount) => Number.isFinite(amount));
if (amounts.length === 0)
return;
const smallest = Math.min(...amounts);
if (smallest <= FIRST_PARTY_MAX_PAYMENT_USD_MICROS)
return;
throw new PaymentBuyerCapError(smallest);
}
/** A first-party challenge above the largest known price. Never retried blindly. */
export class PaymentBuyerCapError extends Run402Error {
kind = "local_error";
code = "X402_AMOUNT_EXCEEDS_FIRST_PARTY_CAP";
constructor(requestedUsdMicros) {
super("The run402 API asked for a payment above the largest known tier price; refusing to sign it.", null, {
error: "X402_AMOUNT_EXCEEDS_FIRST_PARTY_CAP",
code: "X402_AMOUNT_EXCEEDS_FIRST_PARTY_CAP",
category: "payment",
source: "sdk",
retryable: false,
safe_to_retry: false,
mutation_state: "not_started",
details: {
phase: "payment_signing",
payment_started: false,
requested_usd_micros: requestedUsdMicros,
cap_usd_micros: FIRST_PARTY_MAX_PAYMENT_USD_MICROS,
},
next_actions: [{
type: "verify_api_base",
why: "Check RUN402_API_BASE points at the real gateway; a genuine price never exceeds the team tier.",
}],
}, "signing an x402 payment");
}
}
/**
* A gateway 402 that names the caller's allowance (`allowance.allowance_usd_micros`,
* `allowance.price_usd_micros`, `allowance.shortfall_usd_micros`) turns a bare
* "insufficient USDC" into the actual situation: the organization's allowance
* was checked first and fell short by a known amount, and a voucher or top-up
* closes the gap without any on-chain funds. Only an insufficient-funds verdict
* is reshaped; an RPC failure stays an RPC failure.
*/
function withAllowanceShortfall(error, challengeBody) {
const block = challengeBody?.allowance;
if (error.code !== "X402_INSUFFICIENT_FUNDS" || !block || typeof block !== "object")
return error;
const a = block;
const allowance = Number(a.allowance_usd_micros);
const price = Number(a.price_usd_micros);
const shortfall = Number(a.shortfall_usd_micros);
if (![allowance, price, shortfall].every(Number.isFinite))
return error;
const priorDetails = error.body?.details ?? {};
return new X402BalanceError("X402_INSUFFICIENT_FUNDS", `The organization's allowance ($${(allowance / 1_000_000).toFixed(2)}) does not cover this ($${(price / 1_000_000).toFixed(2)}), and the wallet holds no USDC for the $${(shortfall / 1_000_000).toFixed(2)} shortfall.`, {
...priorDetails,
allowance: { allowance_usd_micros: allowance, price_usd_micros: price, shortfall_usd_micros: shortfall },
}, error.cause, [
{ type: "redeem_voucher", cli: "run402 redeem <code>", why: "A promo code adds to the organization's allowance, which settles a tier with no on-chain payment." },
{ type: "top_up", why: "Top up the organization's allowance (Lightning or card), then retry." },
{ type: "fund_wallet", why: "Or fund the wallet with USDC on an accepted network for the shortfall, then retry." },
]);
}
export class X402BalanceError extends Run402Error {
kind = "local_error";
code;
cause;
constructor(code, message, details, cause, nextActions) {
const rpcFailure = code !== "X402_INSUFFICIENT_FUNDS";
super(message, null, {
error: code,
message,
code,
category: rpcFailure ? "network" : "payment_required",
source: "sdk",
retryable: rpcFailure,
safe_to_retry: rpcFailure,
mutation_state: "not_started",
details: {
phase: "balance_preflight",
payment_started: false,
...details,
},
next_actions: nextActions ?? (rpcFailure
? [
{
type: "retry",
why: "Retry the identical request; no payment payload was created.",
},
]
: [
{
type: "fund_wallet",
why: "Fund the configured wallet on an accepted network, then retry.",
},
]),
}, "checking x402 USDC balance");
this.code = code;
if (cause !== undefined)
this.cause = cause;
}
}
const USDC_ABI = [
{
name: "balanceOf",
type: "function",
stateMutability: "view",
inputs: [{ name: "account", type: "address" }],
outputs: [{ name: "", type: "uint256" }],
},
];
const USDC_MAINNET = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const USDC_SEPOLIA = "0x036CbD53842c5426634e7929541eC2318f3dCF7e";
// Independent public providers. Do not add credential-bearing URLs here.
const BASE_RPC_URLS = [
"https://mainnet.base.org",
"https://base-rpc.publicnode.com",
"https://1rpc.io/base",
];
const BASE_SEPOLIA_RPC_URLS = [
"https://sepolia.base.org",
"https://base-sepolia-rpc.publicnode.com",
"https://base-sepolia.drpc.org",
];
const DEFAULT_ATTEMPTS_PER_PROVIDER = 2;
const DEFAULT_BASE_DELAY_MS = 100;
const MAX_PENDING_POLICY_ERRORS = 32;
let warnedMissingDeps = false;
let missingPaidStackPackages = null;
let policyErrorSequence = 0;
const policyErrors = new Map();
let stackLoaders = {
x402: loadX402Stack,
mpp: loadMppStack,
};
/** @internal Test seam; not re-exported from `@run402/sdk/node`. */
export function _setPaidStackLoadersForTest(loaders) {
stackLoaders = {
x402: loaders?.x402 ?? loadX402Stack,
mpp: loaders?.mpp ?? loadMppStack,
};
}
class AttemptJournalWriteError extends Error {
cause;
constructor(cause) {
super("Could not persist the x402 payment attempt journal");
this.cause = cause;
this.name = "AttemptJournalWriteError";
}
}
function classifyRpcFailure(err) {
const record = err && typeof err === "object" ? err : null;
const code = typeof record?.code === "string" ? record.code.toLowerCase() : "";
const status = typeof record?.status === "number" ? record.status : null;
const message = err instanceof Error ? err.message.toLowerCase() : "";
if (code.includes("timeout") ||
code === "etimedout" ||
code === "abort_err" ||
/timed? out|timeout|aborted/.test(message)) {
return "timeout";
}
if (status === 429 || code.includes("rate") || /rate.?limit|too many requests|\b429\b/.test(message)) {
return "rate_limited";
}
if (code === "econnreset" ||
code === "econnrefused" ||
code === "enotfound" ||
code === "eai_again" ||
/network|connection|socket|dns|fetch failed/.test(message)) {
return "network";
}
return "rpc_error";
}
function exhaustedRpcCode(failures) {
if (failures.length > 0 && failures.every((failure) => failure.reason === "timeout")) {
return "X402_RPC_TIMEOUT";
}
if (failures.length > 0 && failures.every((failure) => failure.reason === "rate_limited")) {
return "X402_RPC_RATE_LIMITED";
}
return "X402_RPC_UNAVAILABLE";
}
function delayForAttempt(baseDelayMs, retryIndex, random) {
const exponential = baseDelayMs * 2 ** retryIndex;
return Math.round(exponential + exponential * 0.25 * random());
}
/** @internal Exported only for deterministic source-level tests; not re-exported by the package. */
export async function checkBalanceAcrossProviders(clients, tokenAddress, walletAddress, network, options = {}) {
const attemptsPerProvider = options.attemptsPerProvider ?? DEFAULT_ATTEMPTS_PER_PROVIDER;
const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
const sleep = options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
const random = options.random ?? Math.random;
const failures = [];
let lastCause;
for (let providerIndex = 0; providerIndex < clients.length; providerIndex += 1) {
for (let attempt = 1; attempt <= attemptsPerProvider; attempt += 1) {
if (attempt > 1) {
await sleep(delayForAttempt(baseDelayMs, attempt - 2, random));
}
try {
return await clients[providerIndex].readContract({
address: tokenAddress,
abi: USDC_ABI,
functionName: "balanceOf",
args: [walletAddress],
});
}
catch (err) {
lastCause = err;
failures.push({
provider_index: providerIndex,
attempt,
reason: classifyRpcFailure(err),
});
}
}
}
const code = exhaustedRpcCode(failures);
throw new X402BalanceError(code, `Unable to confirm the x402 USDC balance on ${network}; no zero balance was assumed.`, {
network,
balance_status: "unknown",
providers_exhausted: true,
providers_attempted: clients.length,
attempts: failures,
}, lastCause);
}
function bigintAmount(value) {
if (typeof value !== "string" || !/^\d+$/.test(value))
return null;
try {
return BigInt(value);
}
catch {
return null;
}
}
/** @internal Exported only for deterministic source-level tests; not re-exported by the package. */
export function filterAffordableRequirements(requirements, balances,
/**
* Payer address per network. Surfaced in the insufficient-funds error so the
* `fund_wallet` next_action names WHERE to send money — "fund the wallet" is
* not executable without an address. Optional: omitting it only omits the
* `payers` detail.
*/
payers = {}) {
const recognized = requirements.filter((requirement) => typeof requirement.network === "string" && requirement.network in balances);
const known = recognized.filter((requirement) => typeof requirement.network === "string" &&
balances[requirement.network]?.status === "known" &&
bigintAmount(requirement.amount) !== null);
const affordable = known.filter((requirement) => {
const state = balances[requirement.network];
return state.balance >= bigintAmount(requirement.amount);
});
if (affordable.length > 0)
return affordable;
const unknown = recognized
.map((requirement) => (typeof requirement.network === "string" ? balances[requirement.network] : undefined))
.find((state) => state?.status === "unknown");
if (unknown)
throw unknown.error;
// Only call this confirmed-insufficient when every recognized requirement
// had a valid amount and a successful authoritative balance read.
if (recognized.length > 0 && known.length === recognized.length) {
const balanceDetails = Object.fromEntries(Object.entries(balances)
.filter((entry) => entry[1].status === "known")
.map(([network, state]) => [network, state.balance.toString()]));
const payerDetails = Object.fromEntries(Object.keys(balanceDetails)
.filter((network) => payers[network])
.map((network) => [network, payers[network]]));
throw new X402BalanceError("X402_INSUFFICIENT_FUNDS", "The configured wallet has insufficient confirmed USDC for the accepted x402 payment requirements.", {
balances: balanceDetails,
requirements: recognized.map((requirement) => ({
network: requirement.network,
amount: requirement.amount,
})),
...(Object.keys(payerDetails).length > 0 ? { payers: payerDetails } : {}),
});
}
// Preserve the x402 library's invalid/unsupported-requirement behavior; it
// would be unfaithful to label malformed input as a confirmed balance miss.
return [];
}
function wrapPolicyError(error) {
policyErrorSequence += 1;
const token = `run402-x402-preflight-${policyErrorSequence}`;
// @x402/fetch currently preserves the thrown message, so the outer wrapper
// removes this entry immediately. Keep the relay bounded anyway: if a future
// release rewrites messages, no process-lifetime sentinel leak can grow.
if (policyErrors.size >= MAX_PENDING_POLICY_ERRORS) {
const oldest = policyErrors.keys().next().value;
if (oldest)
policyErrors.delete(oldest);
}
policyErrors.set(token, error);
return new Error(`${token}: ${error.message}`);
}
function unwrapPolicyError(error) {
const message = error instanceof Error ? error.message : "";
const token = /run402-x402-preflight-\d+/.exec(message)?.[0];
if (!token)
return null;
const structured = policyErrors.get(token) ?? null;
policyErrors.delete(token);
return structured;
}
function createRpcClients(stack, chain, urls) {
return urls.map((url) => stack.createPublicClient({ chain, transport: stack.http(url) }));
}
async function balanceState(clients, tokenAddress, walletAddress, network) {
try {
return {
status: "known",
balance: await checkBalanceAcrossProviders(clients, tokenAddress, walletAddress, network),
};
}
catch (err) {
if (err instanceof X402BalanceError)
return { status: "unknown", error: err };
throw err;
}
}
/**
* True when `response` genuinely came back from `configuredApiBase`'s
* origin — never redirected, and never merely a matching string (an actual
* `fetch` return carries the real settled URL in `.url`). Deliberately NOT
* `isTrustedRun402PaymentUrl`'s tenant-hostname classifier: that function
* exists for `r.pay.fetch`'s arbitrary-URL buyer (a DIFFERENT trust
* question — "is this some run402-hosted tenant's own paid resource?") and
* excludes `api.run402.com` by name (a reserved label). This is the
* SIMPLER, narrower question the default fetch actually needs: "is this
* response from the one origin this SDK instance is configured to talk
* to?" — true for `api.run402.com` in production and for whatever
* `RUN402_API_BASE`/`apiBase` names in a test or staging environment.
*/
function isConfiguredApiOrigin(configuredApiBase, response) {
if (!response.url || response.redirected)
return false;
try {
return new URL(configuredApiBase).origin === new URL(response.url).origin;
}
catch {
return false;
}
}
export async function setupPaidFetch(options = {}) {
validatePaymentSource(options);
const configuredApiBase = options.apiBase ?? getApiBase();
// Malformed or missing selected local state degrades to an unwrapped 402,
// but it never falls back to a different wallet source.
const resolvedWallet = options.paymentSigner ? null : await resolveWallet(options);
const localWallet = resolvedWallet?.localWallet ?? null;
if (!localWallet && !options.paymentSigner)
return null;
const rail = options.railOverride ?? localWallet?.rail;
try {
if (rail === "lightning" && localWallet?.lightning?.nwc) {
// The Lightning wallet: pay the seller's Lightning challenge from the
// agent's budgeted wallet on Run402's Hub; fall back to the x402 buyer
// built from the same wallet when no Lightning challenge is offered.
const lightning = localWallet.lightning;
const fetchFn = createLightningFetch({
pairingUri: lightning.nwc,
baseFetch: sdkFetch,
fallback: async () => setupPaidFetch({ ...options, railOverride: "x402" }),
});
return withPayer(fetchFn, {
source: resolvedWallet.source,
rail: "lightning",
payers: [{ address: lightning.lightning_address ?? lightning.wallet_id }],
});
}
if (rail === "mpp" && localWallet) {
const stack = await stackLoaders.mpp();
const account = stack.privateKeyToAccount(localWallet.privateKey);
const mppx = stack.Mppx.create({
polyfill: false,
methods: [stack.tempo({ account })],
});
return withPayer(mppx.fetch, {
source: resolvedWallet.source,
rail: "mpp",
payers: [{ address: localWallet.address }],
});
}
// Default: x402 on Base + Base Sepolia. Each chain has its own independent
// provider list; one degraded chain does not erase a confirmed balance on
// the other chain.
const stack = await stackLoaders.x402();
const mainnetClients = createRpcClients(stack, stack.base, BASE_RPC_URLS);
const sepoliaClients = createRpcClients(stack, stack.baseSepolia, BASE_SEPOLIA_RPC_URLS);
const [mainnetSigner, sepoliaSigner] = options.paymentSigner
? await Promise.all([
options.paymentSigner.getSigner({ network: "eip155:8453", publicClient: mainnetClients[0] }),
options.paymentSigner.getSigner({ network: "eip155:84532", publicClient: sepoliaClients[0] }),
])
: localWalletSigners(stack, localWallet, mainnetClients[0], sepoliaClients[0]);
if (!mainnetSigner && !sepoliaSigner)
return null;
const balances = {};
const refreshBalances = async () => {
const [mainnet, sepolia] = await Promise.all([
mainnetSigner
? balanceState(mainnetClients, USDC_MAINNET, mainnetSigner.address, "eip155:8453")
: null,
sepoliaSigner
? balanceState(sepoliaClients, USDC_SEPOLIA, sepoliaSigner.address, "eip155:84532")
: null,
]);
if (mainnet)
balances["eip155:8453"] = mainnet;
else
delete balances["eip155:8453"];
if (sepolia)
balances["eip155:84532"] = sepolia;
else
delete balances["eip155:84532"];
};
await refreshBalances();
const client = new stack.x402Client();
// Spend controls are run402's, not the library's. `@x402/core` >= 2.22 ships
// a client-side default of $1 per payment; inherited silently, it refused to
// sign a $5 hobby or $20 team purchase from every fresh install (the
// 2026-09-20 launch-voucher rehearsal found it). The first-party cap below
// is explicit and derived from the price list; arbitrary-URL purchases are
// bounded by `pay.fetch`'s own `maxUsdMicros`. Asset selection is already
// run402's (known USDC per network), so nothing the library's default
// allow-list protected is left open.
if (typeof client.setSpendControls === "function")
client.setSpendControls(false);
const firstPartyOrigin = originOf(options.apiBase);
// Initialization may happen on an unpaid request (including the faucet),
// before funds arrive. Select requirements from a fresh balance snapshot
// whenever a new payment payload is created, in both automatic and buyer
// flows. Keep the selected signers and any replayable proof unchanged.
const createPaymentPayload = client.createPaymentPayload.bind(client);
client.createPaymentPayload = async (required) => {
assertFirstPartyPaymentWithinCap(required, firstPartyOrigin);
await refreshBalances();
return createPaymentPayload(required);
};
if (mainnetSigner) {
client.register("eip155:8453", new stack.ExactEvmScheme(stack.toClientEvmSigner(mainnetSigner, mainnetClients[0])));
}
if (sepoliaSigner) {
client.register("eip155:84532", new stack.ExactEvmScheme(stack.toClientEvmSigner(sepoliaSigner, sepoliaClients[0])));
}
client.registerPolicy((_version, requirements) => {
try {
return filterAffordableRequirements(requirements, balances, {
...(mainnetSigner ? { "eip155:8453": mainnetSigner.address } : {}),
...(sepoliaSigner ? { "eip155:84532": sepoliaSigner.address } : {}),
});
}
catch (err) {
// @x402/fetch currently wraps payment-creation errors in a plain Error.
// A per-call token lets the outer wrapper restore the original typed
// error without putting wallet or RPC details into the sentinel.
if (err instanceof X402BalanceError)
throw wrapPolicyError(err);
throw err;
}
});
const trackedFetch = createTrackedX402Fetch((baseFetch, rawClient) => {
const wrapped = stack.wrapFetchWithPayment(baseFetch, rawClient);
return async (input, init) => {
try {
return await wrapped(input, init);
}
catch (err) {
throw unwrapPolicyError(err) ?? err;
}
};
}, client, {
// Live-proof defect B: recognize this SDK's own gateway's terminal,
// never-settled refusal codes (see isTerminalRoomInviteRefusal) so
// the caller gets the gateway's own typed envelope instead of a
// synthesized X402_PAYMENT_OUTCOME_AMBIGUOUS. Scoped to responses
// genuinely from the configured API origin — an arbitrary
// third-party paid URL (this default fetch's other callers) stays
// ambiguous, unchanged, exactly as today.
async classifyPaymentResponse(response) {
if (!isConfiguredApiOrigin(configuredApiBase, response))
return "ambiguous";
const envelope = await readPaymentErrorEnvelope(response);
return isTerminalRoomInviteRefusal(envelope) ? "failed" : "ambiguous";
},
});
const supportedNetworks = [
...(mainnetSigner ? ["eip155:8453"] : []),
...(sepoliaSigner ? ["eip155:84532"] : []),
];
const buyer = createX402BuyerFetch(client, {
supportedNetworks,
payerAddresses: [
...(mainnetSigner ? [mainnetSigner.address] : []),
...(sepoliaSigner ? [sepoliaSigner.address] : []),
],
offerReceipt: stack.offerReceipt,
});
return withPayer(trackedFetch, {
source: options.paymentSigner ? "payment_signer" : resolvedWallet.source,
rail: "x402",
payers: [
...(mainnetSigner ? [{ address: mainnetSigner.address, network: "eip155:8453" }] : []),
...(sepoliaSigner ? [{ address: sepoliaSigner.address, network: "eip155:84532" }] : []),
],
}, refreshBalances, buyer);
}
catch (err) {
// Missing optional peers are a stable local capability state. Other setup
// failures are thrown so lazy initialization can try again on a later call
// instead of permanently caching an unwrapped/degraded fetch.
if (err instanceof PaidStackUnavailable) {
// Remember WHY the buyer is absent. Without this the eventual 402 is
// reported as an unfunded wallet, which is the wrong remedy entirely.
missingPaidStackPackages = err.missingPackages;
if (!warnedMissingDeps) {
warnedMissingDeps = true;
console.warn(`[run402] ${err.message}`);
}
return null;
}
throw err;
}
}
function isRetryableBalanceError(error) {
if (!error || typeof error !== "object")
return false;
const candidate = error;
return (typeof candidate.code === "string" &&
candidate.code.startsWith("X402_RPC_") &&
candidate.safeToRetry === true);
}
function isBalancePreflightError(error) {
if (!error || typeof error !== "object")
return false;
const code = error.code;
return typeof code === "string" &&
(code.startsWith("X402_RPC_") || code === "X402_INSUFFICIENT_FUNDS");
}
function createLazyPaidFetchFrom(setup) {
let cached;
let initializing;
return async (input, init) => {
if (cached === undefined) {
initializing ??= setup()
.then((initialized) => {
cached = initialized;
return initialized;
})
.finally(() => {
initializing = undefined;
});
await initializing;
}
// `sdkFetch` defers to `globalThis.fetch` whenever a test has replaced
// it (captured-original comparison), so suites that override the global
// after the SDK is constructed still see their mocks.
if (!cached)
return sdkFetch(input, init);
try {
return await cached(input, init);
}
catch (err) {
// A retryable RPC preflight never created a payment payload. Drop only
// that failed initialization so the next call re-reads provider health.
if (isRetryableBalanceError(err))
cached = undefined;
throw err;
}
};
}
/**
* Returns a fetch that lazily initializes the x402 wrapper on first call.
* Failed initialization is not cached; concurrent first calls share one
* attempt, and a later request can recover after transient RPC/setup failure.
*/
export function createLazyPaidFetch(options = {}) {
let cached;
let pending;
let refreshBeforeNextCall = false;
const initialize = async () => {
if (cached === undefined) {
pending ??= setupPaidFetch(options).finally(() => {
pending = undefined;
});
const initialized = await pending;
// Cache only a successful wrapper. Missing/recoverable local state is
// retried on the next request instead of pinning an unwrapped fetch for
// the lifetime of the client.
if (initialized)
cached = initialized;
}
return cached ?? null;
};
const lazy = async (input, init) => {
await initialize();
// `sdkFetch` (used below) defers to an overridden `globalThis.fetch`,
// preserving the test seam.
if (cached) {
if (refreshBeforeNextCall) {
await cached.refreshBalances();
refreshBeforeNextCall = false;
}
try {
return await cached(input, init);
}
catch (err) {
// Preserve the selected signer/address. Only mutable RPC-derived
// balance state is refreshed before the caller's next retry.
if (isBalancePreflightError(err))
refreshBeforeNextCall = true;
throw err;
}
}
return sdkFetch(input, init);
};
return Object.assign(lazy, {
async getPayer() {
return (await initialize())?.payer ?? null;
},
async pay(url, init, payOptions) {
const configured = await initialize();
if (configured?.pay)
return configured.pay(url, init, payOptions);
const response = await sdkFetch(url, init);
if (response.status !== 402) {
return {
response,
payment: null,
outcome: "not_required",
replay: await responseSignalsReplay(response, payOptions.idempotencyKey !== undefined),
...payResponseMetadata(response),
};
}
if (configured?.payer.rail === "mpp" || configured?.payer.rail === "lightning") {
throw paymentNetworkUnsupportedError(challengeNetworks(response), configured.payer.payers.flatMap((payer) => payer.network ? [payer.network] : []), { configured_rail: configured.payer.rail });
}
if (missingPaidStackPackages !== null) {
throw paidStackUnavailableError(missingPaidStackPackages, {
challenge_networks: challengeNetworks(response),
});
}
throw walletUnavailableError({ challenge_networks: challengeNetworks(response) });
},
});
}
function validatePaymentSource(options) {
if (options.paymentSigner && options.walletPath) {
throw new LocalError("Configure exactly one explicit payment source: paymentSigner or walletPath", "configuring paid fetch", {
code: "PAYMENT_SOURCE_CONFLICT",
details: { fields: ["paymentSigner", "walletPath"] },
});
}
}
async function resolveWallet(options) {
try {
// An explicit payment path is authoritative even when auth uses a custom
// credentials provider. Do not fall back if it is absent or malformed.
if (options.walletPath !== undefined) {
const localWallet = readWallet(options.walletPath);
return localWallet ? { localWallet, source: "wallet_path" } : null;
}
// Once a credentials provider is supplied it is the only implicit payment
// source. Providers without wallet capability fail closed; they never
// inherit the process-global wallet.
if (options.credentials !== undefined) {
if (typeof options.credentials.readWallet !== "function")
return null;
const localWallet = await options.credentials.readWallet();
return localWallet ? { localWallet, source: "credentials" } : null;
}
// Direct setupPaidFetch()/createLazyPaidFetch() calls retain the Node
// default for backwards compatibility.
const localWallet = readWallet();
return localWallet ? { localWallet, source: "default_wallet" } : null;
}
catch {
return null;
}
}
function withPayer(fetchFn, payer, refreshBalances = async () => { }, pay) {
return Object.assign(fetchFn, { payer, refreshBalances, ...(pay ? { pay } : {}) });
}
function localWalletSigners(stack, localWallet, mainnetClient, sepoliaClient) {
const account = stack.privateKeyToAccount(localWallet.privateKey);
return [
stack.toClientEvmSigner(account, mainnetClient),
stack.toClientEvmSigner(account, sepoliaClient),
];
}
/** @internal Source-level unit-test seam; not re-exported by the package. */
export const __paidFetchInternals = {
createLazyPaidFetchFrom,
};
/**
* Receipt-bearing buyer orchestration over an already-configured x402 client.
* Signed proofs live only in this SDK instance's memory. An ambiguous retry of
* the identical request re-presents that proof; it never mints a replacement.
*/
export function createX402BuyerFetch(client, options) {
const calls = new AsyncLocalStorage();
const proofs = new Map();
const creatingProofs = new Map();
const tracked = createTrackedX402Fetch((baseFetch) => async (input, init) => {
const call = calls.getStore();
if (!call) {
throw settlementError("The x402 buyer lost its request context before dispatch.", false);
}
const request = new Request(input, init);
const cached = proofs.get(call.fingerprint);
if (cached) {
call.proof = cached;
call.replayedProof = true;
return baseFetch(withProof(request, cached.headers));
}
const retryRequest = request.clone();
const firstResponse = await baseFetch(request);
if (firstResponse.status !== 402)
return firstResponse;
const required = await decodePaymentRequired(firstResponse);
const guarded = await guardPaymentRequired(required, options.supportedNetworks, call.maxUsdMicros, request.url, call.requireReceipt, options.offerReceipt, options.verifyAtSeconds?.() ?? Math.floor(Date.now() / 1000));
let pending = creatingProofs.get(call.fingerprint);
if (!pending) {
pending = createProof(client, guarded)
.finally(() => creatingProofs.delete(call.fingerprint));
creatingProofs.set(call.fingerprint, pending);
}
const proof = await pending;
proofs.set(call.fingerprint, proof);
call.proof = proof;
return baseFetch(withProof(retryRequest, proof.headers));
}, client, {
store: options.store,
createAttemptId: options.createAttemptId,
now: options.now,
fetch: options.fetch,
async classifyPaymentResponse(response) {
const call = calls.getStore();
const envelope = await readPaymentErrorEnvelope(response);
if (call && isTrustedRun402PendingResponse({
requestUrl: call.requestUrl,
response,
envelope,
paymentBearing: true,
redirectsDisabled: true,
})) {
return "intent_pending";
}
if (call && envelope && trustedRun402ResponseOrigin(call.requestUrl, response) &&
gatewayPaymentBuyerError(response, envelope)) {
return "failed";
}
const failure = await upstreamFailure(response);
if (call?.replayedProof && isAlreadyUsedFailure(failure)) {
call.alreadySettled = true;
return "already_settled";
}
return isProvenNoSettlementFailure(failure) ? "failed" : "ambiguous";
},
});
return async (url, init, payOptions) => {
const maxUsdMicros = payOptions.maxUsdMicros ?? DEFAULT_PAYMENT_MAX_USD_MICROS;
const requireReceipt = payOptions.requireReceipt === true;
const fingerprint = await paymentRequestFingerprint(url, init, maxUsdMicros, requireReceipt);
const call = {
fingerprint,
requestUrl: url,
maxUsdMicros,
replayedProof: false,
alreadySettled: false,
requireReceipt,
};
try {
const response = await calls.run(call, () => tracked(url, init));
if (!call.proof) {
return {
response,
payment: null,
outcome: "not_required",
replay: await responseSignalsReplay(response, payOptions.idempotencyKey !== undefined),
...payResponseMetadata(response),
};
}
if (call.alreadySettled) {
proofs.delete(fingerprint);
return {
response,
payment: null,
outcome: "already_settled",
replay: true,
...payResponseMetadata(response),
};
}
if (!response.ok) {
const envelope = await readPaymentErrorEnvelope(response);
if (envelope && trustedRun402ResponseOrigin(url, response)) {
const gatewayError = gatewayPaymentBuyerError(response, envelope);
if (gatewayError)
throw gatewayError;
}
const failure = await upstreamFailure(response);
proofs.delete(fingerprint);
if (failure.code === "payment_insufficient_funds") {
throw walletUnavailableError({
upstream_code: failure.code,
...(failure.details ?? {}),
});
}
throw settlementError("The x402 payment proof was rejected before settlement.", false, {
upstream_code: failure.code,
...(failure.x402Error ? { x402_error: failure.x402Error } : {}),
});
}
const payment = await receiptFromResponse(response, call.proof, url, requireReceipt, options.offerReceipt, options.payerAddresses ?? []);
proofs.delete(fingerprint);
const result = {
response,
payment,
outcome: "settled",
replay: call.replayedProof,
nextActions: [],
...payResponseMetadata(response),
};
if (requireReceipt &&
payment.merchantReceipt.status !== "verified") {
const recovery = receiptRecovery(url, response, payOptions.idempotencyKey);
throw new PaymentPolicyError({
response,
result,
message: "The payment settled, but the promised merchant receipt could not be verified.",
fundsMoved: payment.fundsMoved,
mutationState: payment.delivery.status === "fulfilled" ? "committed" : "unknown",
safeToRetry: recovery.safeToRetry,
nextActions: recovery.nextActions,
});
}
return result;
}
catch (cause) {
if (cause instanceof PaymentBuyerError)
throw cause;
if (cause instanceof X402BalanceError) {
if (cause.code === "X402_INSUFFICIENT_FUNDS") {
throw walletUnavailableError(asRecord(cause.details));
}
throw settlementError("The buyer could not confirm wallet funds before signing; no funds moved.", false, { upstream_code: cause.code, ...(asRecord(cause.details)) }, cause, true);
}
if (cause instanceof PaymentAttemptError) {
const fundsMoved = cause.mutationState === "ambiguous" ? "unknown" : false;
throw settlementError(fundsMoved === "unknown"
? "The payment-bearing request lost its response; settlement may have occurred. The same SDK instance retains the original proof for an identical retry."
: "The payment attempt failed before funds could move.", fundsMoved, {
payment_attempt_id: cause.paymentAttemptId,
phase: cause.phase,
provider_started: cause.providerStarted,
upstream_code: cause.code,
// The status the buyer actually saw. Omitting it forced a live
// repro with a patched SDK just to learn the response was a 402.
...(cause.responseStatus !== undefined && cause.responseStatus !== null
? { response_status: cause.responseStatus }
: {}),
}, cause, cause.safeToRetry, cause.nextActions);
}
throw settlementError("The x402 payment authorization could not be created; no funds moved.", false, {}, cause);
}
};
}
async function createProof(client, guarded) {
try {
const payload = await client.createPaymentPayload(guarded.required);
return {
accepted: payload.accepted,
offer: guarded.verifiedOffers.find((offer) => sameRequirement(offer.requirement, payload.accepted)) ?? null,
headers: {
[payload.x402Version === 1 ? "X-PAYMENT" : "PAYMENT-SIGNATURE"]: encodeBase64Json(payload),
},
};
}
catch (cause) {
throw unwrapPolicyError(cause) ?? cause;
}
}
async function guardPaymentRequired(required, supportedNetworks, maxUsdMicros, requestUrl, requireReceipt, offerReceipt, verifyAtSeconds) {
let challengeUrl;
try {
challengeUrl = new URL(required.resource.url).toString();
}
catch (cause) {
throw settlementError("The x402 challenge resource URL is invalid.", false, { challenge_url: required.resource.url, request_url: requestUrl }, cause);
}
if (challengeUrl !== new URL(requestUrl).toString()) {
throw settlementError("The x402 challenge is bound to a different resource URL.", false, { challenge_url: challengeUrl, request_url: requestUrl });
}
const supportedByNetwork = required.accepts.filter((accept) => accept.scheme === "exact" && supportedNetworks.includes(accept.network));
if (supportedByNetwork.length === 0) {
throw paymentNetworkUnsupportedError([...new Set(required.accepts.map((accept) => accept.network))], [...supportedNetworks]);
}
const supported = supportedByNetwork.filter((accept) => accept.asset.toLowerCase() === supportedPaymentAsset(accept.network)?.toLowerCase());
if (supported.length === 0) {
throw paymentNetworkUnsupportedError([...new Set(required.accepts.map((accept) => accept.network))], [...supportedNetworks], {
challenge_assets: [...new Set(supportedByNetwork.map((accept) => accept.asset))],
wallet_assets: [...new Set(supportedNetworks.flatMap((network) => {
const asset = supportedPaymentAsset(network);
return asset ? [asset] : [];
}))],
});
}
const valid = supported.map((accept) => ({ accept, amount: atomicAmount(accept.amount) }));
if (valid.some(({ amount }) => amount === null)) {
throw settlementError("The x402 challenge contains an invalid atomic amount.", false, { challenge_amounts: supported.map((accept) => accept.amount) });
}
const withinLimit = valid.filter((entry) => entry.amount !== null && entry.amount <= maxUsdMicros);
if (withinLimit.length === 0) {
const challenged = Math.min(...valid.map(({ amount }) => amount));
throw paymentExceedsMaxError(challenged, maxUsdMicros);
}
const accepted = withinLimit.map(({ accept }) => accept);
const verifiedOffers = offerReceipt
? await verifyDirectOffers(required, accepted, requestUrl, verifyAtSeconds, offerReceipt)
: [];
if (requireReceipt && verifiedOffers.length === 0) {
throw merchantReceiptRequiredError({
offered_requirements: accepted.length,
valid_wallet_rooted_offers: 0,
});
}
const eligible = requireReceipt
? accepted.filter((requirement) => verifiedOffers.some((offer) => sameRequirement(offer.requirement, requirement)))
: accepted;
return {
required: { ...required, accepts: eligible },
verifiedOffers,
};
}
function supportedPaymentAsset(network) {
if (network === "eip155:8453")
return USDC_MAINNET;
if (network === "eip155:84532")
return USDC_SEPOLIA;
return null;
}
async function verifyDirectOffers(required, eligibleRequirements, requestUrl, verifyAtSeconds, runtime) {
let decoded;
try {
decoded = runtime.decodeSignedOffers(runtime.extractOffersFromPaymentRequired(required));
}
catch {
return [];
}
const verified = [];
for (const value of decoded) {
const offer = asRecord(value);
const signed = offer.signedOffer;
if (!runtime.isEIP712SignedOffer(signed))
continue;
const requirement = runtime.findAcceptsObjectFromSignedOffer(offer, eligibleRequirements);
if (!requirement)
continue;
try {
const checked = await runtime.verifyOfferSignatureEIP712(signed);
const payload = checked.payload;
const validUntil = safeInteger(payload.validUntil);
if (payload.version !== 1 ||
normalizedUrl(payload.resourceUrl) !== normalizedUrl(requestUrl) ||
payload.scheme !== requirement.scheme ||
payload.network !== requirement.network ||
!sameAddressOrText(payload.asset, requirement.asset) ||
payload.amount !== requirement.amount ||
!sameAddressOrText(payload.payTo, requirement.payTo) ||
validUntil === null ||
validUntil <= verifyAtSeconds ||
!sameAddressOrText(checked.signer, requirement.payTo)) {
continue;
}
verified.push({
signed,
decoded: offer,
requirement,
signer: checked.signer,
relationship: "direct",
});
}
catch {
// A malformed, invalid, delegated, or untrusted offer is ineligible.
}
}
return verified;
}
function sameRequirement(left, right) {
return (left.scheme === right.scheme &&
left.network === right.network &&
sameAddressOrText(left.asset, right.asset) &&
left.amount === right.amount &&
sameAddressOrText(left.payTo, right.payTo));
}
function sameAddressOrText(left, right) {
if (typeof left !== "string" || typeof right !== "string")
return false;
return /^0x[0-9a-fA-F]{40}$/.test(left) &&
/^0x[0-9a-fA-F]{40}$/.test(right)
? left.toLowerCase() === right.toLowerCase()
: left === right;
}
function normalizedUrl(value) {
if (typeof value !== "string")
return null;
try {
return new URL(value).toString();
}
catch {
return null;
}
}
function safeInteger(value) {
return typeof value === "number" && Number.isSafeInteger(value)
? value
: null;
}
function atomicAmount(value) {
if (!/^\d+$/.test(value))
return null;
const amount = Number(value);
return Number.isSafeInteger(amount) ? amount : null;
}
function withProof(request, proofHeaders) {
const headers = new Headers(request.headers);
for (const [name, value] of Object.entries(proofHeaders))
headers.set(name, value);
return new Request(request, { headers });
}
async function decodePaymentRequired(response) {
const header = response.headers.get("PAYMENT-REQUIRED");
if (!header) {
throw settlementError("The 402 response did not include a PAYMENT-REQUIRED challenge header.", false);
}
let decoded;
try {
decoded = decodeBase64Json(header);
}
catch (cause) {
throw settlementError("The PAYMENT-REQUIRED challenge header is invalid.", false, {}, cause);
}
if (!isPaymentRequired(decoded)) {
throw settlementError("The PAYMENT-REQUIRED challenge has an invalid shape.", false);
}
return decoded;
}
function isPaymentRequired(value) {
if (!value || typeof value !== "object" || Array.isArray(value))
return false;
const candidate = value;
return Number.isInteger(candidate.x402Version) &&
Boolean(candidate.resource && typeof candidate.resource.url === "string") &&
Array.isArray(candidate.accepts) &&
candidate.accepts.every((accept) => Boolean(accept &&
typeof accept.scheme === "string" &&
typeof accept.network === "string" &&
typeof accept.asset === "string" &&
typeof accept.amount === "string" &&
typeof accept.payTo === "string"));
}
async function receiptFromResponse(response, proof, url, requireReceipt, runtime, payerAddresses) {
const accepted = proof.accepted;
const header = response.headers.get("PAYMENT-RESPONSE") ?? response.headers.get("X-PAYMENT-RESPONSE");
if (!header) {
throw settlementError("The paid response omitted the standard PAYMENT-RESPONSE settlement receipt, so settlement cannot be reported faithfully.", "unknown", { response_status: response.status });
}
let settlement;
try {
settlement = decodeBase64Json(header);
}
catch (cause) {
throw settlementError("The PAYMENT-RESPONSE settlement receipt is invalid.", "unknown", { response_status: response.status }, cause);
}
const record = asRecord(settlement);
if (record.success !== true || typeof record.transaction !== "string" || record.transaction === "") {
throw settlementError("The payment response does not prove a successful on-chain settlement.", record.success === false ? false : "unknown", { response_status: response.status });
}
if (record.network !== accepted.network) {
throw settlementError("The payment response network does not match the accepted x402 challenge.", "unknown", {
accepted_network: accepted.network,
receipt_network: typeof record.network === "string" ? record.network : null,
response_status: response.status,
});
}
const amount = atomicAmount(accepted.amount);
if (amount === null) {
throw settlementError("The selected payment amount cannot be represented as usd_micros.", "unknown");
}
const metadata = payResponseMetadata(response);
const settlementPayer = typeof record.payer === "string" && record.payer.length > 0
? record.payer
: null;
let receiptStatus = proof.offer
? "unavailable"
: "absent";
let signedReceipt = null;
let receiptIssuedAt = null;
let receiptSigner = null;
if (runtime) {
try {
signedReceipt = runtime.extractReceiptFromResponse(response) ?? null;
if (signedReceipt && !proof.offer) {
receiptStatus = "untrusted";
}
else if (signedReceipt && !runtime.isEIP712SignedReceipt(signedReceipt)) {
receiptStatus = "untrusted";
}
else if (signedReceipt && proof.offer) {
const checked = await runtime.verifyReceiptSignatureEIP712(signedReceipt);
const payload = checked.payload;
const issuedAt = safeInteger(payload.issuedAt);
const payerMatches = typeof payload.payer === "string" &&
[
...(settlementPayer ? [settlementPayer] : []),
...payerAddresses,
].some((payer) => sameAddressOrText(payload.payer, payer));
const transactionMatches = typeof payload.transaction === "string" &&
payload.transaction === record.transaction;
if (payload.version === 1 &&
normalizedUrl(payload.resourceUrl) === normalizedUrl(url) &&
payload.network === accepted.network &&
payerMatches &&
transactionMatches &&
issuedAt !== null &&
sameAddressOrText(checked.signer, proof.offer.signer) &&
sameAddressOrText(checked.signer, accepted.payTo)) {
receiptStatus = "verified";
receiptSigner = checked.signer;
receiptIssuedAt = new Date(issuedAt * 1000).toISOString();
}
else {
receiptStatus = "invalid";
}
}
}
catch {
receiptStatus = signedReceipt ? "invalid" : receiptStatus;
}
}
const fundsMoved = metadata.fundsMoved ?? true;
const deliveryReplay = metadata.delivery === "replay" || metadata.deduplicated === true;
const offerPayload = proof.offer?.decoded ?? null;
const offerValidUntil = safeInteger(offerPayload?.validUntil);
const noSignerAuthorizationExpiry = null;
return {
network: record.network,
paymentId: metadata.paymentId,
amountUsdMicros: amount,
asset: accepted.asset,
payer: settlementPayer,
payTo: accepted.payTo,
transaction: record.transaction,
resourceUrl: url,
settlement: { status: "verified" },
fundsMoved,
deduplicated: metadata.deduplicated ?? false,
delivery: {
status: response.ok ? "fulfilled" : "failed",
replay: deliveryReplay,
},
offer: {
status: proof.offer ? "verified" : "absent",
resourceUrl: typeof offerPayload?.resourceUrl === "string"
? offerPayload.resourceUrl
: null,
validUntil: offerValidUntil === null
? null
: new Date(offerValidUntil * 1000).toISOString(),
},
merchantReceipt: {
status: receiptStatus,
claim: receiptStatus === "verified" ? "service_delivered" : null,
issuedAt: receiptIssuedAt,
},
signerRelationship: {
kind: receiptStatus === "verified"
? proof.offer?.relationship ?? null
: proof.offer
? "unverified"
: null,
merchantRoot: proof.offer?.relationship === "direct" ? accepted.payTo : null,
signer: receiptSigner ?? proof.offer?.signer ?? null,
authorizationExpiresAt: noSignerAuthorizationExpiry,
},
policy: {
requireReceipt,
status: requireReceipt
? receiptStatus === "verified"
? "satisfied"
: "unsatisfied"
: "not_required",
},
evidence: {
offer: proof.offer?.signed ?? null,
merchantReceipt: signedReceipt,
signerAuthorization: null,
},
};
}
function receiptRecovery(url, response, idempotencyKey) {
const recoverable = Boolean(idempotencyKey) &&
isTrustedRun402PaymentUrl(url, { allowTestLocalhost: true }) &&
response.headers.get("x-run402-merchant-evidence-state") ===
"unavailable";
if (recoverable) {
return {
safeToRetry: true,
nextActions: [
{
type: "retry",
request: "repeat_identical",
reusePayer: true,
reuseIdempotencyKey: true,
why: "Repeat the identical request with the same payer and idempotency key to recover the original receipt without another settlement.",
},
],
};
}
return {
safeToRetry: false,
nextActions: [
{
type: "reconcile_payment",
why: "Reconcile the existing settlement with the merchant; do not authorize a second payment.",
},
],
};
}
async function upstreamFailure(response) {
const challengeError = paymentRequiredError(response);
if (!response.headers.get("content-type")?.includes("application/json")) {
return { code: null, x402Error: challengeError, details: null };
}
try {
const body = asRecord(await response.clone().json());
const details = asRecord(body.details);
return {
code: typeof body.code === "string" ? body.code : null,
x402Error: typeof details.x402_error === "string" ? details.x402_error : challengeError,
details: Object.keys(details).length > 0 ? details : null,
};
}
catch {
return { code: null, x402Error: challengeError, details: null };
}
}
function paymentRequiredError(response) {
const header = response.headers.get("PAYMENT-REQUIRED");
if (!header)
return null;
try {
const decoded = asRecord(decodeBase64Json(header));
return typeof decoded.error === "string" ? decoded.error : null;
}
catch {
return null;
}
}
function isAlreadyUsedFailure(failure) {
if (failure.code !== "TENANT_X402_PAYMENT_INVALID")
return false;
return /already.{0,20}used|used.{0,20}(authorization|nonce)|nonce.{0,20}used/i.test(failure.x402Error ?? "");
}
function isProvenNoSettlementFailure(failure) {
if (failure.code === "payment_insufficient_funds")
return true;
if (failure.code === "TENANT_X402_SETTLEMENT_FAILED")
return true;
// A bare `PAYMENT_REQUIRED` on the PAID retry is the seller re-issuing its
// 402 challenge: it did not accept the payment, so it did not settle.
// Without this the outcome fell through to `ambiguous`, telling callers their
// funds might have moved when they provably had not — and because correct
// policy on ambiguity is stop-and-reconcile with safe_to_retry false, an
// unattended agent is stranded permanently on a payment that never
// happened.
//
// Distinct from the replay case: a consumed authorization reports
// TENANT_X402_PAYMENT_INVALID with "nonce already used", which stays
// ambiguous via isAlreadyUsedFailure below — there the proof WAS spent.
if (failure.code === "PAYMENT_REQUIRED")
return true;
return failure.code === "TENANT_X402_PAYMENT_INVALID" && !isAlreadyUsedFailure(failure);
}
export function paymentNetworkUnsupportedError(challengeNetworkValues, walletNetworkValues, extra = {}) {
return new PaymentBuyerError({
code: "PAYMENT_NETWORK_UNSUPPORTED",
message: "The x402 challenge does not offer an exact-scheme network and asset supported by this wallet.",
fundsMoved: false,
details: {
challenge_networks: challengeNetworkValues,
wallet_networks: walletNetworkValues,
...extra,
},
nextActions: [
{
type: "edit_request",
why: "Use a priced endpoint on one of the wallet networks, or configure a signer for a challenge network.",
},
],
});
}
function settlementError(message, fundsMoved, details = {}, cause, retryable = false, nextActions) {
return new PaymentBuyerError({
code: "PAYMENT_SETTLEMENT_FAILED",
message,
fundsMoved,
details,
retryable,
safeToRetry: fundsMoved === false,
cause,
nextActions: nextActions && nextActions.length > 0
? nextActions
: fundsMoved === "unknown"
? [
{
type: "reconcile_payment",
safe_to_auto_execute: false,
why: "Confirm the original settlement before authorizing a different payment proof.",
},
]
: [
{
type: "retry",
why: "No funds moved; retry after correcting the reported pre-settlement failure.",
},
],
});
}
function challengeNetworks(response) {
const header = response.headers.get("PAYMENT-REQUIRED");
if (!header)
return [];
try {
const decoded = decodeBase64Json(header);
return [...new Set((decoded.accepts ?? []).flatMap((accept) => typeof accept.network === "string" ? [accept.network] : []))];
}
catch {
return [];
}
}
async function paymentRequestFingerprint(url, init, maxUsdMicros, requireReceipt) {
let request;
try {
request = new Request(url, init);
}
catch (cause) {
throw settlementError("The paid request could not be constructed.", false, {}, cause);
}
const headerEntries = [];
request.headers.forEach((value, name) => headerEntries.push([name, value]));
const headers = headerEntries
.filter(([name]) => ![
"payment-signature",
"x-payment",
"x-run402-payment-attempt-id",
].includes(name.toLowerCase()))
.sort(([a], [b]) => a.localeCompare(b));
let bodyHash = "";
try {
const body = new Uint8Array(await request.clone().arrayBuffer());
bodyHash = createHash("sha256").update(body).digest("hex");
}
catch (cause) {
throw settlementError("The paid request body cannot be replayed safely.", false, {}, cause);
}
return createHash("sha256")
.update(JSON.stringify({
method: request.method,
url: request.url,
headers,
bodyHash,
maxUsdMicros,
requireReceipt,
}))
.digest("hex");
}
/**
* Encode an x402 wire payload — STANDARD base64, never base64url.
*
* The x402 `PAYMENT-SIGNATURE` / `X-PAYMENT` header is standard base64. This
* encoded base64url, which substitutes `-` for `+` and `_` for `/` and drops
* the `=` padding, so any payload whose bytes produced a `+` or `/` reached the
* seller as a string its decoder could not read. The seller then saw NO payment
* at all and re-issued its original 402 challenge — indistinguishable, from the
* outside, from "the payment was rejected", which is why this survived so long:
* there is no verification error anywhere to find, on either side.
*
* A real proof is ~2.4 KB, so it essentially always contains a `+` or `/`.
* Verified against our own seller: the SAME signed authorization, re-encoded as
* standard base64 and presented once, settles 200 with the paid response.
*
* `decodeBase64Json` deliberately stays on `base64url`: Node's base64url decoder
* accepts BOTH alphabets, so it reads standard-base64 seller headers correctly
* and is the more forgiving choice for input we do not control.
*/
function encodeBase64Json(value) {
return Buffer.from(JSON.stringify(value)).toString("base64");
}
function decodeBase64Json(value) {
return JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
}
function asRecord(value) {
return value && typeof value === "object" && !Array.isArray(value)
? value
: {};
}
/**
* Request-scoped payment tracker around `@x402/fetch`.
*
* The wrapped package may call its base fetch twice: first without payment to
* obtain the 402 challenge, then with a signed payment authorization. The
* AsyncLocalStorage context lets the base-fetch boundary update the correct
* durable attempt even when multiple paid requests run concurrently.
*
* Exported from this module for deterministic boundary tests, but intentionally
* not re-exported from the package entry point.
*/
export function createTrackedX402Fetch(wrapFetchWithPayment, client, opts = {}) {
const storage = new AsyncLocalStorage();
const store = opts.store ?? createFilePaymentAttemptStore();
const makeId = opts.createAttemptId ?? createPaymentAttemptId;
const now = opts.now ?? (() => new Date().toISOString());
const baseFetch = async (input, init) => {
const context = storage.getStore();
if (!context)
return (opts.fetch ?? sdkFetch)(input, init);
const paymentBearing = hasPaymentAuthorization(input, init);
if (paymentBearing) {
ensureIntent(context, store, now);
context.phase = "payment_submission";
const startedAt = now();
writeRecord(context, store, {
state: "submitting",
mutation_state: "in_progress",
provider_started_at: startedAt,
});
}
const [nextInput, nextInit] = withPaymentAttemptHeader(input, init, context.id, paymentBearing);
if (paymentBearing) {
// This assignment is deliberately immediately before the external call.
// From this line onward a thrown transport error has an unknown outcome.
context.providerStarted = true;
}
let response;
try {
response = await (opts.fetch ?? sdkFetch)(nextInput, nextInit);
}
catch (cause) {
context.transportFailure = cause;
throw cause;
}
if (!paymentBearing && response.status === 402) {
context.phase = "challenge_received";
context.challengeBody = await response.clone().json().then((body) => (body && typeof body === "object" && !Array.isArray(body) ? body : null), () => null);
ensureIntent(context, store, now);
}
else if (paymentBearing) {
context.phase = "payment_response";
context.responseStatus = response.status;
writeRecord(context, store, {
state: "response_received",
mutation_state: "in_progress",
response_status: response.status,
});
}
return response;
};
const paidFetch = wrapFetchWithPayment(baseFetch, client);
return async (input, init) => {
const suppliedAttemptId = attemptIdFromRequest(input, init);
const request = requestSummary(input, init);
if (suppliedAttemptId) {
let existing;
try {
existing = store.read(suppliedAttemptId);
}
catch (cause) {
const code = cause instanceof Run402Error && cause.code
? cause.code
: "X402_ATTEMPT_JOURNAL_FAILED";
throw new PaymentAttemptError({
code,
message: "The existing x402 payment attempt could not be inspected; no payment was dispatched.",
phase: "initial_request",
paymentAttemptId: suppliedAttemptId,
providerStarted: false,
mutationState: "not_started",
safeToRetry: true,
retryable: false,
nextActions: [
{
type: "contact_support",
payment_attempt_id: suppliedAttemptId,
safe_to_auto_execute: false,
why: "Preserve and repair the unreadable local payment-attempt record before creating a fresh attempt.",
},
],
cause,
request,
});
}
if (existing) {
throw new PaymentAttemptError({
code: "X402_ATTEMPT_ID_ALREADY_EXISTS",
message: "This x402 payment attempt id already exists; reconcile it before authorizing another payment.",
phase: "payment_response",
paymentAttemptId: suppliedAttemptId,
providerStarted: existing.mutation_state !== "not_started",
responseStatus: existing.response_status ?? null,
mutationState: existing.mutation_state,
safeToRetry: false,
cause: null,
request,
});
}
}
const context = {
id: suppliedAttemptId ?? makeId(),
request,
createdAt: now(),
phase: "initial_request",
providerStarted: false,
responseStatus: null,
record: null,
journalFailure: false,
now,
};
if (suppliedAttemptId) {
let claimed;
try {
claimed = claimIntent(context, store, now);
}
catch (cause) {
throw new PaymentAttemptError({
code: "X402_ATTEMPT_JOURNAL_FAILED",
message: "The x402 payment attempt id could not be reserved durably; no request was dispatched.",
phase: "initial_request",
paymentAttemptId: suppliedAttemptId,
providerStarted: false,
mutationState: "not_started",
safeToRetry: true,
cause: cause instanceof AttemptJournalWriteError ? cause.cause : cause,
request,
});
}
if (!claimed) {
throw new PaymentAttemptError({
code: "X402_ATTEMPT_ID_ALREADY_EXISTS",
message: "This x402 payment attempt id was claimed concurrently; reconcile it before authorizing another payment.",
phase: "initial_request",
paymentAttemptId: suppliedAttemptId,
providerStarted: true,
mutationState: "ambiguous",
safeToRetry: false,
cause: null,
request,
});
}
}
return storage.run(context, async () => {
try {
const response = await paidFetch(input, init);
if (context.providerStarted) {
// A 402 on the PAID retry is a clean rejection, not an ambiguity: the
// seller is re-issuing its payment challenge, which means it did not
// accept the payment and therefore did not settle. Reporting that as
// `ambiguous` tells an agent its funds may have moved when they
// provably have not, and correct agent policy on ambiguity is to stop
// and reconcile — so the caller halts on a payment that never
// happened. Verified against a live seller: the paid request is
// re-challenged 402 with the original `PAYMENT_REQUIRED` body and the
// payer's on-chain balance is unchanged.
const classification = response.ok
? "completed"
: await opts.classifyPaymentResponse?.(response) ?? "ambiguous";
const completed = classification === "completed" || classification === "already_settled";
const failed = classification === "failed";
const intentPending = classification === "intent_pending";
const retryAfter = retryAfterSeconds(response);
writeRecordBestEffort(context, store, {
state: completed ? "completed" : failed ? "failed" : intentPending ? "intent_pending" : "ambiguous",
mutation_state: completed ? "completed" : failed ? "not_started" : "ambiguous",
response_status: response.status,
...(intentPending ? {
last_error_code: "PAYMENT_INTENT_PENDING",
...(nonEmptyHeader(response, "x-run402-payment-id")
? { payment_id: nonEmptyHeader(response, "x-run402-payment-id") }
: {}),
intent_state: "pending",
...(retryAfter !== null ? { retry_after_seconds: retryAfter } : {}),
} : {}),
});
if (intentPending)
return response;
if (!completed && !failed) {
throw new PaymentAttemptError({
code: "X402_PAYMENT_OUTCOME_AMBIGUOUS",
message: "The x402 payment target returned a non-success response after provider dispatch; reconcile the attempt before paying again.",
phase: "payment_response",
paymentAttemptId: context.id,
providerStarted: true,
responseStatus: response.status,
mutationState: "ambiguous",
safeToRetry: false,
cause: null,
request: context.request,
});
}
}
else if (context.record && context.phase === "initial_request") {
writeRecordBestEffort(context, store, {
state: "completed",
mutation_state: "not_started",
response_status: response.status,
});
}
return response;
}
catch (cause) {
const balanceError = cause instanceof X402BalanceError ? cause : unwrapPolicyError(cause);
if (balanceError) {
writeRecordBestEffort(context, store, {
state: "failed",
mutation_state: "not_started",
last_error_code: balanceError.code,
});
throw withAllowanceShortfall(balanceError, context.challengeBody ?? null);
}
if (cause instanceof PaymentAttemptError)
throw cause;
if (cause instanceof Run402Error && !context.providerStarted) {
writeRecordBestEffort(context, store, {
state: "failed",
mutation_state: "not_started",
last_error_code: cause.code,
});
throw cause;
}
const providerStarted = context.providerStarted;
const journalFailure = cause instanceof AttemptJournalWriteError || context.journalFailure;
const code = journalFailure
? "X402_ATTEMPT_JOURNAL_FAILED"
: providerStarted
? "X402_PAYMENT_OUTCOME_AMBIGUOUS"
: context.phase === "challenge_received"
? "X402_PAYMENT_SIGNING_FAILED"
: "X402_INITIAL_REQUEST_FAILED";
const phase = providerStarted
? context.responseStatus === null
? "payment_submission"
: "payment_response"
: context.phase === "challenge_received"
? "payment_signing"
: context.phase;
const mutationState = providerStarted ? "ambiguous" : "not_started";
writeRecordBestEffort(context, store, {
state: providerStarted ? "ambiguous" : "failed",
mutation_state: mutationState,
...(context.responseStatus !== null ? { response_status: context.responseStatus } : {}),
last_error_code: code,
});
const transportCode = code === "X402_INITIAL_REQUEST_FAILED" && context.transportFailure === cause ? initialTransportCode(cause) : null;
throw new PaymentAttemptError({
code,
...(transportCode ? { transportCode } : {}),
category: code === "X402_INITIAL_REQUEST_FAILED" && context.transportFailure === cause ? "network" : "payment",
message: providerStarted
? "The x402 payment request failed after provider dispatch; its outcome is unknown."
: journalFailure
? "The x402 payment was not dispatched because its durable attempt could not be recorded."
: phase === "payment_signing"
? "The x402 payment authorization could not be created; no payment was dispatched."
: context.transportFailure === cause ? `Network request failed${transportCode ? " (" + transportCode + ")" : ""} before payment dispatch. Check connectivity to the API; no payment was dispatched.` : "The initial request failed before an x402 payment was dispatched.",
phase,
paymentAttemptId: context.id,
providerStarted,
responseStatus: context.responseStatus,
mutationState,
safeToRetry: !providerStarted,
cause: cause instanceof AttemptJournalWriteError ? cause.cause : cause,
request: context.request,
});
}
});
};
}
function ensureIntent(context, store, now) {
if (context.record)
return;
if (!claimIntent(context, store, now)) {
throw new AttemptJournalWriteError(new LocalError("The generated x402 payment attempt id already exists.", "reserving x402 payment attempt", { code: "X402_ATTEMPT_ID_COLLISION" }));
}
}
function claimIntent(context, store, now) {
if (context.record)
return true;
const updatedAt = now();
const record = {
version: 1,
payment_attempt_id: context.id,
rail: "x402",
state: "intent",
mutation_state: "not_started",
method: context.request.method,
origin: context.request.origin,
path_sha256: context.request.path_sha256,
...(context.request.caller_key_sha256
? { caller_key_sha256: context.request.caller_key_sha256 }
: {}),
created_at: context.createdAt,
updated_at: updatedAt,
};
try {
const claimed = store.claim(record);
if (claimed)
context.record = record;
return claimed;
}
catch (cause) {
context.journalFailure = true;
throw new AttemptJournalWriteError(cause);
}
}
function trustedRun402ResponseOrigin(requestUrl, response) {
if (!response.url || response.redirected)
return false;
try {
const request = new URL(requestUrl);
const returned = new URL(response.url);
return request.origin === returned.origin && isTrustedRun402PaymentUrl(request);
}
catch {
return false;
}
}
function retryAfterSeconds(response) {
const raw = response.headers.get("retry-after");
if (!raw || !/^\d+$/.test(raw))
return null;
const seconds = Number(raw);
return Number.isSafeInteger(seconds) ? seconds : null;
}
function nonEmptyHeader(response, name) {
const value = response.headers.get(name)?.trim();
return value ? value : null;
}
function writeRecord(context, store, patch) {
if (!context.record)
throw new AttemptJournalWriteError("payment intent missing");
const next = {
...context.record,
...patch,
updated_at: context.now(),
};
try {
store.write(next);
context.record = next;
}
catch (cause) {
context.journalFailure = true;
throw new AttemptJournalWriteError(cause);
}
}
function writeRecordBestEffort(context, store, patch) {
if (!context.record)
return;
try {
const next = {
...context.record,
...patch,
updated_at: context.now(),
};
store.write(next);
context.record = next;
}
catch {
// The failure already carries the in-process structured outcome. Never
// replace a known successful response with a local journal write error.
}
}
/** Safe cause-code vocabulary only: never expose raw request/error text. */
function initialTransportCode(cause) {
const known = new Set(["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "ENETUNREACH", "EHOSTUNREACH", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_SOCKET"]);
let current = cause;
for (let i = 0; i < 5 && current && typeof current === "object"; i++) {
const error = current;
if (typeof error.code === "string" && known.has(error.code))
return error.code;
current = error.cause;
}
return null;
}
//# sourceMappingURL=paid-fetch.js.map