run402 4.5.0 → 4.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * Node-only x402-wrapped fetch. Reads the allowance file, checks on-chain
3
- * USDC balances in parallel, and returns a fetch wrapper that auto-signs 402
4
- * responses when the wallet has balance.
3
+ * USDC balances through independent RPC providers, and returns a fetch wrapper
4
+ * that auto-signs 402 responses only when the requested chain has confirmed
5
+ * funds.
5
6
  *
6
- * Graceful degradation: returns null if no allowance is configured or the
7
- * optional payment libraries fail to load. Callers can fall back to an
8
- * unwrapped fetch and let 402s surface as `PaymentRequired` errors.
7
+ * Balance reads are pre-payment, read-only operations. They may be retried and
8
+ * failed over safely; a payment payload has not been created or submitted yet.
9
+ * An exhausted RPC check is never represented as a zero balance.
9
10
  *
10
11
  * The viem / @x402/* / mppx imports live behind `./_paid-stack.ts` so the
11
12
  * SDK's direct surface to those packages is auditable from one file and the
@@ -15,7 +16,57 @@
15
16
  * CLI edge.
16
17
  */
17
18
  import { readAllowance } from "../../core-dist/allowance.js";
19
+ import { AsyncLocalStorage } from "node:async_hooks";
20
+ import { LocalError, PaymentAttemptError, Run402Error, } from "../errors.js";
18
21
  import { PaidStackUnavailable, loadMppStack, loadX402Stack } from "./_paid-stack.js";
22
+ import { attemptIdFromRequest, createFilePaymentAttemptStore, createPaymentAttemptId, hasPaymentAuthorization, requestSummary, withPaymentAttemptHeader, } from "./payment-attempts.js";
23
+ /**
24
+ * A machine-readable x402 balance-preflight failure.
25
+ *
26
+ * `safeToRetry` is true only for read-only RPC failures. A confirmed balance
27
+ * miss is not retryable without changing wallet funds or payment requirements.
28
+ * Both states are emitted before payment payload creation, so
29
+ * `mutationState` is always `not_started`.
30
+ */
31
+ export class X402BalanceError extends Run402Error {
32
+ kind = "local_error";
33
+ code;
34
+ cause;
35
+ constructor(code, message, details, cause) {
36
+ const rpcFailure = code !== "X402_INSUFFICIENT_FUNDS";
37
+ super(message, null, {
38
+ error: code,
39
+ message,
40
+ code,
41
+ category: rpcFailure ? "network" : "payment_required",
42
+ source: "sdk",
43
+ retryable: rpcFailure,
44
+ safe_to_retry: rpcFailure,
45
+ mutation_state: "not_started",
46
+ details: {
47
+ phase: "balance_preflight",
48
+ payment_started: false,
49
+ ...details,
50
+ },
51
+ next_actions: rpcFailure
52
+ ? [
53
+ {
54
+ type: "retry",
55
+ why: "Retry the identical request; no payment payload was created.",
56
+ },
57
+ ]
58
+ : [
59
+ {
60
+ type: "fund_wallet",
61
+ why: "Fund the configured allowance wallet on an accepted network, then retry.",
62
+ },
63
+ ],
64
+ }, "checking x402 USDC balance");
65
+ this.code = code;
66
+ if (cause !== undefined)
67
+ this.cause = cause;
68
+ }
69
+ }
19
70
  const USDC_ABI = [
20
71
  {
21
72
  name: "balanceOf",
@@ -27,108 +78,767 @@ const USDC_ABI = [
27
78
  ];
28
79
  const USDC_MAINNET = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
29
80
  const USDC_SEPOLIA = "0x036CbD53842c5426634e7929541eC2318f3dCF7e";
81
+ // Independent public providers. Do not add credential-bearing URLs here.
82
+ const BASE_RPC_URLS = [
83
+ "https://mainnet.base.org",
84
+ "https://base-rpc.publicnode.com",
85
+ "https://1rpc.io/base",
86
+ ];
87
+ const BASE_SEPOLIA_RPC_URLS = [
88
+ "https://sepolia.base.org",
89
+ "https://base-sepolia-rpc.publicnode.com",
90
+ "https://base-sepolia.drpc.org",
91
+ ];
92
+ const DEFAULT_ATTEMPTS_PER_PROVIDER = 2;
93
+ const DEFAULT_BASE_DELAY_MS = 100;
94
+ const MAX_PENDING_POLICY_ERRORS = 32;
30
95
  let warnedMissingDeps = false;
31
- async function checkBalance(publicClient, tokenAddress, walletAddress) {
32
- try {
33
- const raw = await publicClient.readContract({
34
- address: tokenAddress,
35
- abi: USDC_ABI,
36
- functionName: "balanceOf",
37
- args: [walletAddress],
38
- });
39
- return Number(raw);
96
+ let policyErrorSequence = 0;
97
+ const policyErrors = new Map();
98
+ let stackLoaders = {
99
+ x402: loadX402Stack,
100
+ mpp: loadMppStack,
101
+ };
102
+ /** @internal Test seam; not re-exported from `@run402/sdk/node`. */
103
+ export function _setPaidStackLoadersForTest(loaders) {
104
+ stackLoaders = {
105
+ x402: loaders?.x402 ?? loadX402Stack,
106
+ mpp: loaders?.mpp ?? loadMppStack,
107
+ };
108
+ }
109
+ class AttemptJournalWriteError extends Error {
110
+ cause;
111
+ constructor(cause) {
112
+ super("Could not persist the x402 payment attempt journal");
113
+ this.cause = cause;
114
+ this.name = "AttemptJournalWriteError";
40
115
  }
41
- catch {
42
- return 0;
116
+ }
117
+ function classifyRpcFailure(err) {
118
+ const record = err && typeof err === "object" ? err : null;
119
+ const code = typeof record?.code === "string" ? record.code.toLowerCase() : "";
120
+ const status = typeof record?.status === "number" ? record.status : null;
121
+ const message = err instanceof Error ? err.message.toLowerCase() : "";
122
+ if (code.includes("timeout") ||
123
+ code === "etimedout" ||
124
+ code === "abort_err" ||
125
+ /timed? out|timeout|aborted/.test(message)) {
126
+ return "timeout";
127
+ }
128
+ if (status === 429 || code.includes("rate") || /rate.?limit|too many requests|\b429\b/.test(message)) {
129
+ return "rate_limited";
130
+ }
131
+ if (code === "econnreset" ||
132
+ code === "econnrefused" ||
133
+ code === "enotfound" ||
134
+ code === "eai_again" ||
135
+ /network|connection|socket|dns|fetch failed/.test(message)) {
136
+ return "network";
43
137
  }
138
+ return "rpc_error";
44
139
  }
45
- export async function setupPaidFetch() {
46
- // GH-194: readAllowance throws on a malformed-shape file. The SDK's
47
- // contract is graceful degradation here (return null and let the caller
48
- // fall back to unwrapped fetch), so swallow the error — the CLI's own
49
- // readAllowance wrapper has already surfaced a structured envelope to
50
- // the user.
51
- let allowance;
140
+ function exhaustedRpcCode(failures) {
141
+ if (failures.length > 0 && failures.every((failure) => failure.reason === "timeout")) {
142
+ return "X402_RPC_TIMEOUT";
143
+ }
144
+ if (failures.length > 0 && failures.every((failure) => failure.reason === "rate_limited")) {
145
+ return "X402_RPC_RATE_LIMITED";
146
+ }
147
+ return "X402_RPC_UNAVAILABLE";
148
+ }
149
+ function delayForAttempt(baseDelayMs, retryIndex, random) {
150
+ const exponential = baseDelayMs * 2 ** retryIndex;
151
+ return Math.round(exponential + exponential * 0.25 * random());
152
+ }
153
+ /** @internal Exported only for deterministic source-level tests; not re-exported by the package. */
154
+ export async function checkBalanceAcrossProviders(clients, tokenAddress, walletAddress, network, options = {}) {
155
+ const attemptsPerProvider = options.attemptsPerProvider ?? DEFAULT_ATTEMPTS_PER_PROVIDER;
156
+ const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
157
+ const sleep = options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
158
+ const random = options.random ?? Math.random;
159
+ const failures = [];
160
+ let lastCause;
161
+ for (let providerIndex = 0; providerIndex < clients.length; providerIndex += 1) {
162
+ for (let attempt = 1; attempt <= attemptsPerProvider; attempt += 1) {
163
+ if (attempt > 1) {
164
+ await sleep(delayForAttempt(baseDelayMs, attempt - 2, random));
165
+ }
166
+ try {
167
+ return await clients[providerIndex].readContract({
168
+ address: tokenAddress,
169
+ abi: USDC_ABI,
170
+ functionName: "balanceOf",
171
+ args: [walletAddress],
172
+ });
173
+ }
174
+ catch (err) {
175
+ lastCause = err;
176
+ failures.push({
177
+ provider_index: providerIndex,
178
+ attempt,
179
+ reason: classifyRpcFailure(err),
180
+ });
181
+ }
182
+ }
183
+ }
184
+ const code = exhaustedRpcCode(failures);
185
+ throw new X402BalanceError(code, `Unable to confirm the x402 USDC balance on ${network}; no zero balance was assumed.`, {
186
+ network,
187
+ balance_status: "unknown",
188
+ providers_exhausted: true,
189
+ providers_attempted: clients.length,
190
+ attempts: failures,
191
+ }, lastCause);
192
+ }
193
+ function bigintAmount(value) {
194
+ if (typeof value !== "string" || !/^\d+$/.test(value))
195
+ return null;
52
196
  try {
53
- allowance = readAllowance();
197
+ return BigInt(value);
54
198
  }
55
199
  catch {
56
200
  return null;
57
201
  }
58
- if (!allowance)
202
+ }
203
+ /** @internal Exported only for deterministic source-level tests; not re-exported by the package. */
204
+ export function filterAffordableRequirements(requirements, balances) {
205
+ const recognized = requirements.filter((requirement) => typeof requirement.network === "string" && requirement.network in balances);
206
+ const known = recognized.filter((requirement) => typeof requirement.network === "string" &&
207
+ balances[requirement.network]?.status === "known" &&
208
+ bigintAmount(requirement.amount) !== null);
209
+ const affordable = known.filter((requirement) => {
210
+ const state = balances[requirement.network];
211
+ return state.balance >= bigintAmount(requirement.amount);
212
+ });
213
+ if (affordable.length > 0)
214
+ return affordable;
215
+ const unknown = recognized
216
+ .map((requirement) => (typeof requirement.network === "string" ? balances[requirement.network] : undefined))
217
+ .find((state) => state?.status === "unknown");
218
+ if (unknown)
219
+ throw unknown.error;
220
+ // Only call this confirmed-insufficient when every recognized requirement
221
+ // had a valid amount and a successful authoritative balance read.
222
+ if (recognized.length > 0 && known.length === recognized.length) {
223
+ const balanceDetails = Object.fromEntries(Object.entries(balances)
224
+ .filter((entry) => entry[1].status === "known")
225
+ .map(([network, state]) => [network, state.balance.toString()]));
226
+ throw new X402BalanceError("X402_INSUFFICIENT_FUNDS", "The configured allowance wallet has insufficient confirmed USDC for the accepted x402 payment requirements.", {
227
+ balances: balanceDetails,
228
+ requirements: recognized.map((requirement) => ({
229
+ network: requirement.network,
230
+ amount: requirement.amount,
231
+ })),
232
+ });
233
+ }
234
+ // Preserve the x402 library's invalid/unsupported-requirement behavior; it
235
+ // would be unfaithful to label malformed input as a confirmed balance miss.
236
+ return [];
237
+ }
238
+ function wrapPolicyError(error) {
239
+ policyErrorSequence += 1;
240
+ const token = `run402-x402-preflight-${policyErrorSequence}`;
241
+ // @x402/fetch currently preserves the thrown message, so the outer wrapper
242
+ // removes this entry immediately. Keep the relay bounded anyway: if a future
243
+ // release rewrites messages, no process-lifetime sentinel leak can grow.
244
+ if (policyErrors.size >= MAX_PENDING_POLICY_ERRORS) {
245
+ const oldest = policyErrors.keys().next().value;
246
+ if (oldest)
247
+ policyErrors.delete(oldest);
248
+ }
249
+ policyErrors.set(token, error);
250
+ return new Error(`${token}: ${error.message}`);
251
+ }
252
+ function unwrapPolicyError(error) {
253
+ const message = error instanceof Error ? error.message : "";
254
+ const token = /run402-x402-preflight-\d+/.exec(message)?.[0];
255
+ if (!token)
256
+ return null;
257
+ const structured = policyErrors.get(token) ?? null;
258
+ policyErrors.delete(token);
259
+ return structured;
260
+ }
261
+ function createRpcClients(stack, chain, urls) {
262
+ return urls.map((url) => stack.createPublicClient({ chain, transport: stack.http(url) }));
263
+ }
264
+ async function balanceState(clients, tokenAddress, walletAddress, network) {
265
+ try {
266
+ return {
267
+ status: "known",
268
+ balance: await checkBalanceAcrossProviders(clients, tokenAddress, walletAddress, network),
269
+ };
270
+ }
271
+ catch (err) {
272
+ if (err instanceof X402BalanceError)
273
+ return { status: "unknown", error: err };
274
+ throw err;
275
+ }
276
+ }
277
+ export async function setupPaidFetch(options = {}) {
278
+ validatePaymentSource(options);
279
+ // Malformed or missing selected local state degrades to an unwrapped 402,
280
+ // but it never falls back to a different wallet source.
281
+ const resolvedAllowance = options.paymentSigner ? null : await resolveAllowance(options);
282
+ const allowance = resolvedAllowance?.allowance ?? null;
283
+ if (!allowance && !options.paymentSigner)
59
284
  return null;
60
285
  try {
61
- if (allowance.rail === "mpp") {
62
- const stack = await loadMppStack();
286
+ if (allowance?.rail === "mpp") {
287
+ const stack = await stackLoaders.mpp();
63
288
  const account = stack.privateKeyToAccount(allowance.privateKey);
64
289
  const mppx = stack.Mppx.create({
65
290
  polyfill: false,
66
291
  methods: [stack.tempo({ account })],
67
292
  });
68
- return mppx.fetch;
69
- }
70
- // Default: x402 on Base + Base Sepolia
71
- const stack = await loadX402Stack();
72
- const account = stack.privateKeyToAccount(allowance.privateKey);
73
- const mainnetClient = stack.createPublicClient({ chain: stack.base, transport: stack.http() });
74
- const sepoliaClient = stack.createPublicClient({ chain: stack.baseSepolia, transport: stack.http() });
75
- const [mainnetBalance, sepoliaBalance] = await Promise.all([
76
- checkBalance(mainnetClient, USDC_MAINNET, allowance.address),
77
- checkBalance(sepoliaClient, USDC_SEPOLIA, allowance.address),
78
- ]);
79
- const client = new stack.x402Client();
80
- client.register("eip155:8453", new stack.ExactEvmScheme(stack.toClientEvmSigner(account, mainnetClient)));
81
- client.register("eip155:84532", new stack.ExactEvmScheme(stack.toClientEvmSigner(account, sepoliaClient)));
82
- if (mainnetBalance > 0 || sepoliaBalance > 0) {
83
- // Compare against the requested amount, not > 0. A chain with dust below
84
- // the request amount would otherwise be picked by x402Client and fail
85
- // at settlement (silent 402 leak when dust sits on one chain and funds
86
- // sit on another).
87
- client.registerPolicy((_version, reqs) => {
88
- return reqs.filter((r) => {
89
- const typed = r;
90
- const required = Number(typed.amount ?? 0);
91
- if (typed.network === "eip155:8453")
92
- return mainnetBalance >= required;
93
- if (typed.network === "eip155:84532")
94
- return sepoliaBalance >= required;
95
- return false;
96
- });
293
+ return withPayer(mppx.fetch, {
294
+ source: resolvedAllowance.source,
295
+ rail: "mpp",
296
+ payers: [{ address: allowance.address }],
97
297
  });
98
298
  }
99
- // Read `globalThis.fetch` fresh each call so test suites that swap the
100
- // global fetch after setupPaidFetch has already run still see their mocks.
101
- const dynamicFetch = (input, init) => globalThis.fetch(input, init);
102
- return stack.wrapFetchWithPayment(dynamicFetch, client);
299
+ // Default: x402 on Base + Base Sepolia. Each chain has its own independent
300
+ // provider list; one degraded chain does not erase a confirmed balance on
301
+ // the other chain.
302
+ const stack = await stackLoaders.x402();
303
+ const mainnetClients = createRpcClients(stack, stack.base, BASE_RPC_URLS);
304
+ const sepoliaClients = createRpcClients(stack, stack.baseSepolia, BASE_SEPOLIA_RPC_URLS);
305
+ const [mainnetSigner, sepoliaSigner] = options.paymentSigner
306
+ ? await Promise.all([
307
+ options.paymentSigner.getSigner({ network: "eip155:8453", publicClient: mainnetClients[0] }),
308
+ options.paymentSigner.getSigner({ network: "eip155:84532", publicClient: sepoliaClients[0] }),
309
+ ])
310
+ : localAllowanceSigners(stack, allowance, mainnetClients[0], sepoliaClients[0]);
311
+ if (!mainnetSigner && !sepoliaSigner)
312
+ return null;
313
+ const balances = {};
314
+ const refreshBalances = async () => {
315
+ const [mainnet, sepolia] = await Promise.all([
316
+ mainnetSigner
317
+ ? balanceState(mainnetClients, USDC_MAINNET, mainnetSigner.address, "eip155:8453")
318
+ : null,
319
+ sepoliaSigner
320
+ ? balanceState(sepoliaClients, USDC_SEPOLIA, sepoliaSigner.address, "eip155:84532")
321
+ : null,
322
+ ]);
323
+ if (mainnet)
324
+ balances["eip155:8453"] = mainnet;
325
+ else
326
+ delete balances["eip155:8453"];
327
+ if (sepolia)
328
+ balances["eip155:84532"] = sepolia;
329
+ else
330
+ delete balances["eip155:84532"];
331
+ };
332
+ await refreshBalances();
333
+ const client = new stack.x402Client();
334
+ if (mainnetSigner) {
335
+ client.register("eip155:8453", new stack.ExactEvmScheme(stack.toClientEvmSigner(mainnetSigner, mainnetClients[0])));
336
+ }
337
+ if (sepoliaSigner) {
338
+ client.register("eip155:84532", new stack.ExactEvmScheme(stack.toClientEvmSigner(sepoliaSigner, sepoliaClients[0])));
339
+ }
340
+ client.registerPolicy((_version, requirements) => {
341
+ try {
342
+ return filterAffordableRequirements(requirements, balances);
343
+ }
344
+ catch (err) {
345
+ // @x402/fetch currently wraps payment-creation errors in a plain Error.
346
+ // A per-call token lets the outer wrapper restore the original typed
347
+ // error without putting wallet or RPC details into the sentinel.
348
+ if (err instanceof X402BalanceError)
349
+ throw wrapPolicyError(err);
350
+ throw err;
351
+ }
352
+ });
353
+ const trackedFetch = createTrackedX402Fetch((baseFetch, rawClient) => {
354
+ const wrapped = stack.wrapFetchWithPayment(baseFetch, rawClient);
355
+ return async (input, init) => {
356
+ try {
357
+ return await wrapped(input, init);
358
+ }
359
+ catch (err) {
360
+ throw unwrapPolicyError(err) ?? err;
361
+ }
362
+ };
363
+ }, client);
364
+ return withPayer(trackedFetch, {
365
+ source: options.paymentSigner ? "payment_signer" : resolvedAllowance.source,
366
+ rail: "x402",
367
+ payers: [
368
+ ...(mainnetSigner ? [{ address: mainnetSigner.address, network: "eip155:8453" }] : []),
369
+ ...(sepoliaSigner ? [{ address: sepoliaSigner.address, network: "eip155:84532" }] : []),
370
+ ],
371
+ }, refreshBalances);
103
372
  }
104
373
  catch (err) {
105
- // Emit a clear one-time warning when the failure is "optional peer deps
106
- // not installed" otherwise the caller silently falls back to unwrapped
107
- // fetch and the user has no way to know they need to `npm install viem`.
108
- // Other errors stay silent to preserve the graceful-degradation contract.
109
- if (err instanceof PaidStackUnavailable && !warnedMissingDeps) {
110
- warnedMissingDeps = true;
111
- console.warn(`[run402] ${err.message}`);
374
+ // Missing optional peers are a stable local capability state. Other setup
375
+ // failures are thrown so lazy initialization can try again on a later call
376
+ // instead of permanently caching an unwrapped/degraded fetch.
377
+ if (err instanceof PaidStackUnavailable) {
378
+ if (!warnedMissingDeps) {
379
+ warnedMissingDeps = true;
380
+ console.warn(`[run402] ${err.message}`);
381
+ }
382
+ return null;
112
383
  }
113
- return null;
384
+ throw err;
114
385
  }
115
386
  }
387
+ function isRetryableBalanceError(error) {
388
+ if (!error || typeof error !== "object")
389
+ return false;
390
+ const candidate = error;
391
+ return (typeof candidate.code === "string" &&
392
+ candidate.code.startsWith("X402_RPC_") &&
393
+ candidate.safeToRetry === true);
394
+ }
395
+ function isBalancePreflightError(error) {
396
+ if (!error || typeof error !== "object")
397
+ return false;
398
+ const code = error.code;
399
+ return typeof code === "string" &&
400
+ (code.startsWith("X402_RPC_") || code === "X402_INSUFFICIENT_FUNDS");
401
+ }
402
+ function createLazyPaidFetchFrom(setup) {
403
+ let cached;
404
+ let initializing;
405
+ return async (input, init) => {
406
+ if (cached === undefined) {
407
+ initializing ??= setup()
408
+ .then((initialized) => {
409
+ cached = initialized;
410
+ return initialized;
411
+ })
412
+ .finally(() => {
413
+ initializing = undefined;
414
+ });
415
+ await initializing;
416
+ }
417
+ // Read `globalThis.fetch` fresh each call so test suites that override it
418
+ // after the SDK is constructed still see their mocks.
419
+ if (!cached)
420
+ return globalThis.fetch(input, init);
421
+ try {
422
+ return await cached(input, init);
423
+ }
424
+ catch (err) {
425
+ // A retryable RPC preflight never created a payment payload. Drop only
426
+ // that failed initialization so the next call re-reads provider health.
427
+ if (isRetryableBalanceError(err))
428
+ cached = undefined;
429
+ throw err;
430
+ }
431
+ };
432
+ }
116
433
  /**
117
434
  * Returns a fetch that lazily initializes the x402 wrapper on first call.
118
- * This lets the Node `run402()` factory remain synchronous while deferring
119
- * the (async) on-chain balance check until the first request.
435
+ * Failed initialization is not cached; concurrent first calls share one
436
+ * attempt, and a later request can recover after transient RPC/setup failure.
120
437
  */
121
- export function createLazyPaidFetch() {
438
+ export function createLazyPaidFetch(options = {}) {
122
439
  let cached;
123
- return async (input, init) => {
440
+ let pending;
441
+ let refreshBeforeNextCall = false;
442
+ const initialize = async () => {
124
443
  if (cached === undefined) {
125
- cached = await setupPaidFetch();
444
+ pending ??= setupPaidFetch(options).finally(() => {
445
+ pending = undefined;
446
+ });
447
+ const initialized = await pending;
448
+ // Cache only a successful wrapper. Missing/recoverable local state is
449
+ // retried on the next request instead of pinning an unwrapped fetch for
450
+ // the lifetime of the client.
451
+ if (initialized)
452
+ cached = initialized;
126
453
  }
454
+ return cached ?? null;
455
+ };
456
+ const lazy = async (input, init) => {
457
+ await initialize();
127
458
  // Read `globalThis.fetch` fresh each call so test suites that override
128
459
  // it after the SDK is constructed still see their mocks.
129
- if (cached)
130
- return cached(input, init);
460
+ if (cached) {
461
+ if (refreshBeforeNextCall) {
462
+ await cached.refreshBalances();
463
+ refreshBeforeNextCall = false;
464
+ }
465
+ try {
466
+ return await cached(input, init);
467
+ }
468
+ catch (err) {
469
+ // Preserve the selected signer/address. Only mutable RPC-derived
470
+ // balance state is refreshed before the caller's next retry.
471
+ if (isBalancePreflightError(err))
472
+ refreshBeforeNextCall = true;
473
+ throw err;
474
+ }
475
+ }
131
476
  return globalThis.fetch(input, init);
132
477
  };
478
+ return Object.assign(lazy, {
479
+ async getPayer() {
480
+ return (await initialize())?.payer ?? null;
481
+ },
482
+ });
483
+ }
484
+ function validatePaymentSource(options) {
485
+ if (options.paymentSigner && options.allowancePath) {
486
+ throw new LocalError("Configure exactly one explicit payment source: paymentSigner or allowancePath", "configuring paid fetch", {
487
+ code: "PAYMENT_SOURCE_CONFLICT",
488
+ details: { fields: ["paymentSigner", "allowancePath"] },
489
+ });
490
+ }
491
+ }
492
+ async function resolveAllowance(options) {
493
+ try {
494
+ // An explicit payment path is authoritative even when auth uses a custom
495
+ // credentials provider. Do not fall back if it is absent or malformed.
496
+ if (options.allowancePath !== undefined) {
497
+ const allowance = readAllowance(options.allowancePath);
498
+ return allowance ? { allowance, source: "allowance_path" } : null;
499
+ }
500
+ // Once a credentials provider is supplied it is the only implicit payment
501
+ // source. Providers without allowance capability fail closed; they never
502
+ // inherit the process-global wallet.
503
+ if (options.credentials !== undefined) {
504
+ if (typeof options.credentials.readAllowance !== "function")
505
+ return null;
506
+ const allowance = await options.credentials.readAllowance();
507
+ return allowance ? { allowance, source: "credentials" } : null;
508
+ }
509
+ // Direct setupPaidFetch()/createLazyPaidFetch() calls retain the Node
510
+ // default for backwards compatibility.
511
+ const allowance = readAllowance();
512
+ return allowance ? { allowance, source: "default_allowance" } : null;
513
+ }
514
+ catch {
515
+ return null;
516
+ }
517
+ }
518
+ function withPayer(fetchFn, payer, refreshBalances = async () => { }) {
519
+ return Object.assign(fetchFn, { payer, refreshBalances });
520
+ }
521
+ function localAllowanceSigners(stack, allowance, mainnetClient, sepoliaClient) {
522
+ const account = stack.privateKeyToAccount(allowance.privateKey);
523
+ return [
524
+ stack.toClientEvmSigner(account, mainnetClient),
525
+ stack.toClientEvmSigner(account, sepoliaClient),
526
+ ];
527
+ }
528
+ /** @internal Source-level unit-test seam; not re-exported by the package. */
529
+ export const __paidFetchInternals = {
530
+ createLazyPaidFetchFrom,
531
+ };
532
+ /**
533
+ * Request-scoped payment tracker around `@x402/fetch`.
534
+ *
535
+ * The wrapped package may call its base fetch twice: first without payment to
536
+ * obtain the 402 challenge, then with a signed payment authorization. The
537
+ * AsyncLocalStorage context lets the base-fetch boundary update the correct
538
+ * durable attempt even when multiple paid requests run concurrently.
539
+ *
540
+ * Exported from this module for deterministic boundary tests, but intentionally
541
+ * not re-exported from the package entry point.
542
+ */
543
+ export function createTrackedX402Fetch(wrapFetchWithPayment, client, opts = {}) {
544
+ const storage = new AsyncLocalStorage();
545
+ const store = opts.store ?? createFilePaymentAttemptStore();
546
+ const makeId = opts.createAttemptId ?? createPaymentAttemptId;
547
+ const now = opts.now ?? (() => new Date().toISOString());
548
+ const baseFetch = async (input, init) => {
549
+ const context = storage.getStore();
550
+ if (!context)
551
+ return (opts.fetch ?? globalThis.fetch)(input, init);
552
+ const paymentBearing = hasPaymentAuthorization(input, init);
553
+ if (paymentBearing) {
554
+ ensureIntent(context, store, now);
555
+ context.phase = "payment_submission";
556
+ const startedAt = now();
557
+ writeRecord(context, store, {
558
+ state: "submitting",
559
+ mutation_state: "in_progress",
560
+ provider_started_at: startedAt,
561
+ });
562
+ }
563
+ const [nextInput, nextInit] = withPaymentAttemptHeader(input, init, context.id, paymentBearing);
564
+ if (paymentBearing) {
565
+ // This assignment is deliberately immediately before the external call.
566
+ // From this line onward a thrown transport error has an unknown outcome.
567
+ context.providerStarted = true;
568
+ }
569
+ const response = await (opts.fetch ?? globalThis.fetch)(nextInput, nextInit);
570
+ if (!paymentBearing && response.status === 402) {
571
+ context.phase = "challenge_received";
572
+ ensureIntent(context, store, now);
573
+ }
574
+ else if (paymentBearing) {
575
+ context.phase = "payment_response";
576
+ context.responseStatus = response.status;
577
+ writeRecord(context, store, {
578
+ state: "response_received",
579
+ mutation_state: "in_progress",
580
+ response_status: response.status,
581
+ });
582
+ }
583
+ return response;
584
+ };
585
+ const paidFetch = wrapFetchWithPayment(baseFetch, client);
586
+ return async (input, init) => {
587
+ const suppliedAttemptId = attemptIdFromRequest(input, init);
588
+ const request = requestSummary(input, init);
589
+ if (suppliedAttemptId) {
590
+ let existing;
591
+ try {
592
+ existing = store.read(suppliedAttemptId);
593
+ }
594
+ catch (cause) {
595
+ const code = cause instanceof Run402Error && cause.code
596
+ ? cause.code
597
+ : "X402_ATTEMPT_JOURNAL_FAILED";
598
+ throw new PaymentAttemptError({
599
+ code,
600
+ message: "The existing x402 payment attempt could not be inspected; no payment was dispatched.",
601
+ phase: "initial_request",
602
+ paymentAttemptId: suppliedAttemptId,
603
+ providerStarted: false,
604
+ mutationState: "not_started",
605
+ safeToRetry: true,
606
+ retryable: false,
607
+ nextActions: [
608
+ {
609
+ type: "contact_support",
610
+ payment_attempt_id: suppliedAttemptId,
611
+ safe_to_auto_execute: false,
612
+ why: "Preserve and repair the unreadable local payment-attempt record before creating a fresh attempt.",
613
+ },
614
+ ],
615
+ cause,
616
+ request,
617
+ });
618
+ }
619
+ if (existing) {
620
+ throw new PaymentAttemptError({
621
+ code: "X402_ATTEMPT_ID_ALREADY_EXISTS",
622
+ message: "This x402 payment attempt id already exists; reconcile it before authorizing another payment.",
623
+ phase: "payment_response",
624
+ paymentAttemptId: suppliedAttemptId,
625
+ providerStarted: existing.mutation_state !== "not_started",
626
+ responseStatus: existing.response_status ?? null,
627
+ mutationState: existing.mutation_state,
628
+ safeToRetry: false,
629
+ cause: null,
630
+ request,
631
+ });
632
+ }
633
+ }
634
+ const context = {
635
+ id: suppliedAttemptId ?? makeId(),
636
+ request,
637
+ createdAt: now(),
638
+ phase: "initial_request",
639
+ providerStarted: false,
640
+ responseStatus: null,
641
+ record: null,
642
+ journalFailure: false,
643
+ now,
644
+ };
645
+ if (suppliedAttemptId) {
646
+ let claimed;
647
+ try {
648
+ claimed = claimIntent(context, store, now);
649
+ }
650
+ catch (cause) {
651
+ throw new PaymentAttemptError({
652
+ code: "X402_ATTEMPT_JOURNAL_FAILED",
653
+ message: "The x402 payment attempt id could not be reserved durably; no request was dispatched.",
654
+ phase: "initial_request",
655
+ paymentAttemptId: suppliedAttemptId,
656
+ providerStarted: false,
657
+ mutationState: "not_started",
658
+ safeToRetry: true,
659
+ cause: cause instanceof AttemptJournalWriteError ? cause.cause : cause,
660
+ request,
661
+ });
662
+ }
663
+ if (!claimed) {
664
+ throw new PaymentAttemptError({
665
+ code: "X402_ATTEMPT_ID_ALREADY_EXISTS",
666
+ message: "This x402 payment attempt id was claimed concurrently; reconcile it before authorizing another payment.",
667
+ phase: "initial_request",
668
+ paymentAttemptId: suppliedAttemptId,
669
+ providerStarted: true,
670
+ mutationState: "ambiguous",
671
+ safeToRetry: false,
672
+ cause: null,
673
+ request,
674
+ });
675
+ }
676
+ }
677
+ return storage.run(context, async () => {
678
+ try {
679
+ const response = await paidFetch(input, init);
680
+ if (context.providerStarted) {
681
+ const completed = response.ok;
682
+ writeRecordBestEffort(context, store, {
683
+ state: completed ? "completed" : "ambiguous",
684
+ mutation_state: completed ? "completed" : "ambiguous",
685
+ response_status: response.status,
686
+ });
687
+ if (!completed) {
688
+ throw new PaymentAttemptError({
689
+ code: "X402_PAYMENT_OUTCOME_AMBIGUOUS",
690
+ message: "The x402 payment target returned a non-success response after provider dispatch; reconcile the attempt before paying again.",
691
+ phase: "payment_response",
692
+ paymentAttemptId: context.id,
693
+ providerStarted: true,
694
+ responseStatus: response.status,
695
+ mutationState: "ambiguous",
696
+ safeToRetry: false,
697
+ cause: null,
698
+ request: context.request,
699
+ });
700
+ }
701
+ }
702
+ else if (context.record && context.phase === "initial_request") {
703
+ writeRecordBestEffort(context, store, {
704
+ state: "completed",
705
+ mutation_state: "not_started",
706
+ response_status: response.status,
707
+ });
708
+ }
709
+ return response;
710
+ }
711
+ catch (cause) {
712
+ const balanceError = cause instanceof X402BalanceError ? cause : unwrapPolicyError(cause);
713
+ if (balanceError) {
714
+ writeRecordBestEffort(context, store, {
715
+ state: "failed",
716
+ mutation_state: "not_started",
717
+ last_error_code: balanceError.code,
718
+ });
719
+ throw balanceError;
720
+ }
721
+ if (cause instanceof PaymentAttemptError)
722
+ throw cause;
723
+ if (cause instanceof Run402Error && !context.providerStarted) {
724
+ writeRecordBestEffort(context, store, {
725
+ state: "failed",
726
+ mutation_state: "not_started",
727
+ last_error_code: cause.code,
728
+ });
729
+ throw cause;
730
+ }
731
+ const providerStarted = context.providerStarted;
732
+ const journalFailure = cause instanceof AttemptJournalWriteError || context.journalFailure;
733
+ const code = journalFailure
734
+ ? "X402_ATTEMPT_JOURNAL_FAILED"
735
+ : providerStarted
736
+ ? "X402_PAYMENT_OUTCOME_AMBIGUOUS"
737
+ : context.phase === "challenge_received"
738
+ ? "X402_PAYMENT_SIGNING_FAILED"
739
+ : "X402_INITIAL_REQUEST_FAILED";
740
+ const phase = providerStarted
741
+ ? context.responseStatus === null
742
+ ? "payment_submission"
743
+ : "payment_response"
744
+ : context.phase === "challenge_received"
745
+ ? "payment_signing"
746
+ : context.phase;
747
+ const mutationState = providerStarted ? "ambiguous" : "not_started";
748
+ writeRecordBestEffort(context, store, {
749
+ state: providerStarted ? "ambiguous" : "failed",
750
+ mutation_state: mutationState,
751
+ ...(context.responseStatus !== null ? { response_status: context.responseStatus } : {}),
752
+ last_error_code: code,
753
+ });
754
+ throw new PaymentAttemptError({
755
+ code,
756
+ message: providerStarted
757
+ ? "The x402 payment request failed after provider dispatch; its outcome is unknown."
758
+ : journalFailure
759
+ ? "The x402 payment was not dispatched because its durable attempt could not be recorded."
760
+ : phase === "payment_signing"
761
+ ? "The x402 payment authorization could not be created; no payment was dispatched."
762
+ : "The initial request failed before an x402 payment was dispatched.",
763
+ phase,
764
+ paymentAttemptId: context.id,
765
+ providerStarted,
766
+ responseStatus: context.responseStatus,
767
+ mutationState,
768
+ safeToRetry: !providerStarted,
769
+ cause: cause instanceof AttemptJournalWriteError ? cause.cause : cause,
770
+ request: context.request,
771
+ });
772
+ }
773
+ });
774
+ };
775
+ }
776
+ function ensureIntent(context, store, now) {
777
+ if (context.record)
778
+ return;
779
+ if (!claimIntent(context, store, now)) {
780
+ throw new AttemptJournalWriteError(new LocalError("The generated x402 payment attempt id already exists.", "reserving x402 payment attempt", { code: "X402_ATTEMPT_ID_COLLISION" }));
781
+ }
782
+ }
783
+ function claimIntent(context, store, now) {
784
+ if (context.record)
785
+ return true;
786
+ const updatedAt = now();
787
+ const record = {
788
+ version: 1,
789
+ payment_attempt_id: context.id,
790
+ rail: "x402",
791
+ state: "intent",
792
+ mutation_state: "not_started",
793
+ method: context.request.method,
794
+ origin: context.request.origin,
795
+ path_sha256: context.request.path_sha256,
796
+ created_at: context.createdAt,
797
+ updated_at: updatedAt,
798
+ };
799
+ try {
800
+ const claimed = store.claim(record);
801
+ if (claimed)
802
+ context.record = record;
803
+ return claimed;
804
+ }
805
+ catch (cause) {
806
+ context.journalFailure = true;
807
+ throw new AttemptJournalWriteError(cause);
808
+ }
809
+ }
810
+ function writeRecord(context, store, patch) {
811
+ if (!context.record)
812
+ throw new AttemptJournalWriteError("payment intent missing");
813
+ const next = {
814
+ ...context.record,
815
+ ...patch,
816
+ updated_at: context.now(),
817
+ };
818
+ try {
819
+ store.write(next);
820
+ context.record = next;
821
+ }
822
+ catch (cause) {
823
+ context.journalFailure = true;
824
+ throw new AttemptJournalWriteError(cause);
825
+ }
826
+ }
827
+ function writeRecordBestEffort(context, store, patch) {
828
+ if (!context.record)
829
+ return;
830
+ try {
831
+ const next = {
832
+ ...context.record,
833
+ ...patch,
834
+ updated_at: context.now(),
835
+ };
836
+ store.write(next);
837
+ context.record = next;
838
+ }
839
+ catch {
840
+ // The failure already carries the in-process structured outcome. Never
841
+ // replace a known successful response with a local journal write error.
842
+ }
133
843
  }
134
844
  //# sourceMappingURL=paid-fetch.js.map