agent402-client 0.8.21 → 0.8.23

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.
package/README.md CHANGED
@@ -166,6 +166,14 @@ What the server does with it depends on how the call was paid:
166
166
  route with the same body, from its first answer inside the replay window,
167
167
  with no second settlement, and marks it `X-Replayed-Answer: 1` (identity-bound
168
168
  routes, routes with a lasting effect and streamed calls excepted).
169
+ - **A connection reset before any answer:** the SDK's own requests (proof-of-work,
170
+ credits, discovery) are resent once when the connection failed before the
171
+ server answered, for example on a kept-alive socket the edge had already
172
+ closed (`retryResets: false` turns this off). Your `payFetch` is not wrapped,
173
+ because a retry over a payment wrapper would sign a fresh authorization. To get
174
+ the same resend on the wallet path, put it under the wrapper:
175
+ `wrapFetchWithPayment(resetRetryFetch(distrustFetch()), client)`. The resend
176
+ carries the same signed header, and one authorization settles at most once.
169
177
  - **Circle Gateway batched option:** a payment the server has not had confirmed
170
178
  by Gateway yet is answered `402` with reason `settlement-pending`; the SDK
171
179
  resends the same signed header after the server's `Retry-After` (never a new
@@ -246,8 +254,9 @@ minutes, longer than many HTTP clients wait. Their `/api/pricing` row carries
246
254
  waiting `Retry-After` seconds between polls, until the job has succeeded or
247
255
  failed (it reads the job's `state` and falls back to its `status`, working,
248
256
  completed or failed, on a server that sends only that). Payment settles only on
249
- the delivered answer, so a failed job is a
250
- failed call and its spend is released.
257
+ the delivered answer, so a failed job is a failed call; the payment it carried
258
+ stays held against the caps (see Spending caps), since only the seller knows
259
+ it was not redeemed.
251
260
 
252
261
  ```js
253
262
  const a = new Agent402({
@@ -279,7 +288,7 @@ that runs as one.
279
288
  | `await a.collectJob(jobUrl, { timeoutMs?, onProgress? })` | Collect a job link (from `AsyncJobPendingError.jobUrl`) until it has the answer |
280
289
  | `Agent402.solvePow(pow)` | Solve a proof-of-work challenge object → an `X-Pow-Solution` value |
281
290
  | `a.guardX402Client(client)` | Enforce this client's caps on every payment the given `@x402/core` client signs (the same as passing `x402Client`); returns the client |
282
- | `a.spendingSummary()` | Rolling-24h paid spend so far: `{ dailyUsd, calls, byHost, limits }` |
291
+ | `a.spendingSummary()` | Rolling-24h paid spend so far: `{ dailyUsd, calls, byHost, heldUsd, heldCalls, heldByHost, limits }`; `dailyUsd`, `calls` and `byHost` are settled calls, the `held*` fields are payments sent without a delivered answer |
283
292
  | `a.clearCache()` | Drop the in-memory result cache |
284
293
 
285
294
  ## Response size ceiling
@@ -380,11 +389,32 @@ try {
380
389
  }
381
390
  ```
382
391
 
383
- Only **settled** paid calls count against the rolling window. A call refused or
384
- failed before settlement never consumes budget. A paid HTTP-success response
385
- that later fails byte, JSON, or output validation remains settled spend. Free
386
- proof-of-work calls are never counted. Omit a cap (or leave it `null`) for no
387
- limit; with none set, behavior is unchanged.
392
+ A call refused before any payment header leaves the process never consumes
393
+ budget. Once a header has been sent, its signed amount counts against the caps
394
+ whatever the seller answers. A delivered answer books it **settled**. Anything
395
+ else (an error status, a refused payment, a dropped connection, a redirect, a
396
+ job still running) books it **held**: the seller holds a signed authorization it
397
+ can still redeem until its `validBefore`, so the error carries `paid: "unknown"`
398
+ and `heldUsd`, and `spendingSummary()` reports it under `heldUsd`. A held
399
+ amount counts for the rolling 24 hours, and longer when the authorization
400
+ stays valid longer (a seven-day Circle Gateway authorization is held until its
401
+ `validBefore` passes). Pass your `x402Client` so the client sees exactly which
402
+ payments were signed; through a pre-wrapped `fetch` alone it cannot tell, so any
403
+ failed paid call is held. A paid HTTP-success response that later fails byte,
404
+ JSON, or output validation remains settled spend. Free proof-of-work calls are
405
+ never counted. Omit a cap (or leave it `null`) for no limit; with none set,
406
+ behavior is unchanged.
407
+
408
+ **Held payments.** `heldUsd` is money this client sent a signed payment for
409
+ without getting a delivered answer. It is held, not spent: a seller that kept
410
+ the authorization could still redeem it, so the caps count it until it can no
411
+ longer settle. When the authorization expires unused, the held amount is
412
+ released and was never charged. On the built-in default host
413
+ (`https://agent402.tools`, matched by exact origin), an error status (400 or
414
+ above) is never settled, so a hold for one ends at the authorization's
415
+ `validBefore` plus 10 minutes, with no 24-hour floor, or 15 minutes after the
416
+ answer when the client cannot read `validBefore` (no `x402Client`). Every other
417
+ `baseUrl` keeps the rule above.
388
418
 
389
419
  **Caps are enforced on the payment that is signed.** Pass the `x402Client` your
390
420
  `fetch` wraps (or call `a.guardX402Client(client)`) and the client registers a
package/index.js CHANGED
@@ -23,6 +23,8 @@ import { quoteOf, guardSigning, networkIdsOf, acceptUsd } from "./known-assets.j
23
23
  export { KNOWN_ASSETS, NETWORK_NAMES, knownAsset, acceptUsd, networkIdsOf } from "./known-assets.js";
24
24
  import { distrustFetch } from "./tls-distrust.js";
25
25
  export { distrustFetch, distrustDispatcher, distrustCheckServerIdentity, DISTRUSTED_SPKI_SHA256 } from "./tls-distrust.js";
26
+ export { resetRetryFetch, isConnectionReset } from "./reset-retry.js";
27
+ import { resetRetryFetch } from "./reset-retry.js";
26
28
  import { awaitJob, shouldRequestAsync, AsyncJobPendingError, DEFAULT_ASYNC_TIMEOUT_MS } from "./async-job.js";
27
29
  export { AsyncJobPendingError } from "./async-job.js";
28
30
 
@@ -30,7 +32,7 @@ export { AsyncJobPendingError } from "./async-job.js";
30
32
  // `User-Agent: agent402-client/<version>` - a standard header, no extra
31
33
  // network calls - so a seller can attribute traffic (and settled payments)
32
34
  // to this SDK. Product token only; nothing about the caller rides along.
33
- const VERSION = "0.8.21";
35
+ const VERSION = "0.8.23";
34
36
  const USER_AGENT = `agent402-client/${VERSION}`;
35
37
  // A package built on this SDK (an adapter) names itself first: its own
36
38
  // product token, then this one. Anything that is not one well-formed token
@@ -108,6 +110,62 @@ export const GATEWAY_MAX_TIMEOUT_SECONDS = 7 * 24 * 3600 + 3600;
108
110
  const GATEWAY_DOCS_URL = "https://github.com/MikeyPetrillo/Agent402/wiki/Circle-Gateway";
109
111
  const EVM_ADDR = /^0x[0-9a-fA-F]{40}$/;
110
112
 
113
+ // The rolling window the daily and per-host caps count over.
114
+ const SPEND_WINDOW_MS = 24 * 60 * 60 * 1000;
115
+ // Added to a held authorization's own expiry (validBefore, or a Permit2
116
+ // deadline) before the hold may end: the chain judges expiry by block time,
117
+ // which can trail this process's clock.
118
+ export const HOLD_SLACK_MS = 10 * 60 * 1000;
119
+
120
+ /** When a signed payment payload can no longer settle, in ms since the epoch
121
+ * plus HOLD_SLACK_MS: an EIP-3009 `authorization.validBefore` or a Permit2
122
+ * `deadline`, in seconds. null when the payload names no expiry this reads
123
+ * (a Solana transaction, an unknown scheme). */
124
+ export function signedValidUntilMs(paymentPayload) {
125
+ const p = paymentPayload?.payload;
126
+ const v = p?.authorization?.validBefore ?? p?.permit2Authorization?.deadline;
127
+ if (v == null || !/^\d{1,15}$/.test(String(v))) return null;
128
+ return Number(v) * 1000 + HOLD_SLACK_MS;
129
+ }
130
+
131
+ /** A spend-log entry the caps still count at `now`: anything recorded inside
132
+ * the rolling window, and a held payment whose authorization can still
133
+ * settle, however long ago it was signed. */
134
+ const countsAt = (e, now) => e.state === "held" && e.ownHost
135
+ ? e.until > now
136
+ : e.ts >= now - SPEND_WINDOW_MS || (e.state === "held" && e.until != null && e.until > now);
137
+
138
+ // The client's built-in seller. It settles a payment only on an answer below
139
+ // 400 (the handler runs first), so a sent payment it answered with an error
140
+ // status was not settled and can be redeemed only until its authorization
141
+ // expires: such a hold ends at that expiry plus HOLD_SLACK_MS, with no 24 h
142
+ // floor, or DEFAULT_HOST_BLIND_HOLD_MS after the answer when the expiry is not
143
+ // readable (its challenges expire in about five minutes). Matched by exact
144
+ // origin; every other seller keeps the rolling-window rule.
145
+ export const DEFAULT_BASE_URL = "https://agent402.tools";
146
+ const DEFAULT_ORIGIN = new URL(DEFAULT_BASE_URL).origin;
147
+ export const DEFAULT_HOST_BLIND_HOLD_MS = 15 * 60 * 1000;
148
+ function isDefaultOrigin(url) { try { return new URL(url).origin === DEFAULT_ORIGIN; } catch { return false; } }
149
+
150
+ /** Mark an error thrown after a payment header was sent: `paid: "unknown"`
151
+ * (the seller holds a signed authorization it may still redeem) and
152
+ * `heldUsd`, the amount held against the caps for it. An Agent402 refusal
153
+ * also says so in its message. */
154
+ function markOutcomeUnknown(e, slug, heldUsd, { seen = true } = {}) {
155
+ if (!e || typeof e !== "object") return e;
156
+ try {
157
+ const own = e.paid === false && e.slug === slug;
158
+ e.paid = "unknown";
159
+ e.heldUsd = heldUsd;
160
+ if (own && typeof e.message === "string" && !/spending caps/.test(e.message)) {
161
+ e.message += seen
162
+ ? `. A payment was sent, so its outcome is unknown: its signed amount stays held against this client's spending caps (spendingSummary().heldUsd).`
163
+ : `. This client cannot see whether its payment fetch sent a payment (pass x402Client to let it see), so the amount stays held against its spending caps (spendingSummary().heldUsd).`;
164
+ }
165
+ } catch { /* a frozen error keeps what it says */ }
166
+ return e;
167
+ }
168
+
111
169
  // A pre-wrapped payment fetch with no x402Client is the one mode where the caps
112
170
  // are checked on an unpaid preflight only, not on the payment that is signed.
113
171
  // Said once per process, on the first capped paid call in that mode.
@@ -129,6 +187,8 @@ export class Agent402 {
129
187
  * sent before agent402-client's in the User-Agent
130
188
  * @param {boolean} [opts.tlsDistrust=true] send the default fetch through distrustFetch (refuses a TLS
131
189
  * chain through a distrusted intermediate CA); false sends it as is
190
+ * @param {boolean} [opts.retryResets=true] resend the SDK's own request once when its connection failed
191
+ * before any answer (see resetRetryFetch); false sends each once
132
192
  * @param {number} [opts.maxPerCallUsd] hard ceiling on a single paid call (USD); over → SpendingLimitError before paying
133
193
  * @param {number} [opts.dailyLimitUsd] hard ceiling on rolling-24h paid spend (USD)
134
194
  * @param {number} [opts.maxPerHostUsd] hard ceiling on rolling-24h paid spend to one seller host (USD)
@@ -160,10 +220,10 @@ export class Agent402 {
160
220
  * @param {function} [opts.onProgress] called with each job document
161
221
  * ({ status, stage, progress, etaSeconds, ... }) while a job runs
162
222
  */
163
- constructor({ baseUrl = "https://agent402.tools", fetch: payFetch, cache = true, fetchImpl = undefined, tlsDistrust = true,
223
+ constructor({ baseUrl = DEFAULT_BASE_URL, fetch: payFetch, cache = true, fetchImpl = undefined, tlsDistrust = true,
164
224
  maxPerCallUsd = null, dailyLimitUsd = null, maxPerHostUsd = null, creditsKey = null, x402Client = null, payNetworks = null,
165
225
  maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, outputValidator = null,
166
- respondAsync = "auto", asyncTimeoutMs = DEFAULT_ASYNC_TIMEOUT_MS, onProgress = null, gateway = null, userAgent = null } = {}) {
226
+ respondAsync = "auto", asyncTimeoutMs = DEFAULT_ASYNC_TIMEOUT_MS, onProgress = null, gateway = null, userAgent = null, retryResets = true } = {}) {
167
227
  this.userAgent = userAgentOf(userAgent);
168
228
  this.respondAsync = respondAsync;
169
229
  this.gateway = normalizeGateway(gateway);
@@ -175,6 +235,10 @@ export class Agent402 {
175
235
  if (typeof fetchImpl === "function" && tlsDistrust !== false) fetchImpl = distrustFetch(fetchImpl);
176
236
  }
177
237
  if (typeof fetchImpl !== "function") throw new Error("No fetch available - pass { fetchImpl } on Node < 18");
238
+ // The SDK's own requests resend once when the connection failed before any
239
+ // answer (a closed keep-alive socket). A caller's payFetch is not wrapped:
240
+ // retrying over a payment wrapper would sign a fresh authorization.
241
+ if (retryResets !== false) fetchImpl = resetRetryFetch(fetchImpl);
178
242
  this.baseUrl = String(baseUrl).replace(/\/$/, "");
179
243
  this.payFetch = payFetch || null;
180
244
  // Wrap the plain fetch so every request identifies the SDK (see USER_AGENT
@@ -196,13 +260,16 @@ export class Agent402 {
196
260
  // failure mode): optional hard ceilings enforced BEFORE any payment is
197
261
  // signed. A malicious or misconfigured 402 that quotes an inflated price is
198
262
  // refused instead of paid. null = no limit (default - behavior unchanged).
199
- // Amounts commit to the rolling window only on a settled paid call, so a
200
- // failed/blocked call never counts against the budget.
263
+ // A call refused before any payment left this process never counts. Once
264
+ // a payment header has been sent, its signed amount counts whatever the
265
+ // seller answers: "settled" on a delivered answer, otherwise "held" (the
266
+ // authorization can still be redeemed) until it can no longer settle and
267
+ // the rolling window has passed it.
201
268
  this._spend = {
202
269
  maxPerCall: numOrNull(maxPerCallUsd),
203
270
  daily: numOrNull(dailyLimitUsd),
204
271
  perHost: numOrNull(maxPerHostUsd),
205
- log: [], // [{ ts, host, usd }] - settled paid calls in the last 24h
272
+ log: [], // [{ ts, host, usd, state: "reserved"|"held"|"settled", until? }]
206
273
  };
207
274
  // The paid call being signed right now, so the signing guard can tell this
208
275
  // call's own reservation from every other one (see guardX402Client).
@@ -246,6 +313,15 @@ export class Agent402 {
246
313
  if (ctx) ctx.refusal = { ...detail, limit: this._signAllowance().limit };
247
314
  },
248
315
  });
316
+ // Registered after the guard's own check of the signed amount, so it runs
317
+ // only for a payload that check let through: the payload the payment
318
+ // fetch sends next. Each one is recorded with its amount and expiry (a
319
+ // fetch that retries with a fresh payload sends two).
320
+ client.onAfterPaymentCreation(async ({ paymentPayload }) => {
321
+ const ctx = this._signCtx.getStore();
322
+ if (!ctx || ctx.signedUsd == null) return;
323
+ ctx.sent.push({ usd: ctx.signedUsd, until: signedValidUntilMs(paymentPayload) });
324
+ });
249
325
  this._guarded = true;
250
326
  return client;
251
327
  }
@@ -255,15 +331,19 @@ export class Agent402 {
255
331
  * (this call's own one is what is being replaced). */
256
332
  _signAllowance() {
257
333
  const s = this._spend;
258
- const own = this._signCtx.getStore()?.reservation || null;
259
- const cutoff = Date.now() - 24 * 60 * 60 * 1000;
260
- const others = s.log.filter((e) => e.ts >= cutoff && e !== own);
334
+ const ctx = this._signCtx.getStore();
335
+ const own = ctx?.reservation || null;
336
+ // A payload this call already sent (a fetch that retries with a fresh
337
+ // one) is outstanding too, beside every other call's entry.
338
+ const ownSent = (ctx?.sent || []).reduce((a, p) => a + p.usd, 0);
339
+ const now = Date.now();
340
+ const others = s.log.filter((e) => countsAt(e, now) && e !== own);
261
341
  const host = hostOf(this.baseUrl);
262
342
  let cap = Infinity, limit = null;
263
343
  const tighten = (value, name) => { if (value < cap) { cap = value; limit = name; } };
264
344
  if (s.maxPerCall != null) tighten(s.maxPerCall, "maxPerCallUsd");
265
- if (s.daily != null) tighten(s.daily - others.reduce((a, e) => a + e.usd, 0), "dailyLimitUsd");
266
- if (s.perHost != null) tighten(s.perHost - others.filter((e) => e.host === host).reduce((a, e) => a + e.usd, 0), "maxPerHostUsd");
345
+ if (s.daily != null) tighten(s.daily - ownSent - others.reduce((a, e) => a + e.usd, 0), "dailyLimitUsd");
346
+ if (s.perHost != null) tighten(s.perHost - ownSent - others.filter((e) => e.host === host).reduce((a, e) => a + e.usd, 0), "maxPerHostUsd");
267
347
  return { cap: Math.max(0, cap), limit };
268
348
  }
269
349
 
@@ -578,34 +658,40 @@ export class Agent402 {
578
658
  const usd = await this._quotedUsd(paidSend, listUsd, { slug, mode: guarded ? "guarded" : "wallet" });
579
659
  // Reserve the amount synchronously (before the await) so concurrent calls
580
660
  // can't each observe the pre-commit total and collectively blow a rolling
581
- // cap; release the reservation if the call doesn't settle. With the
582
- // signing guard on, the reservation is replaced by the amount actually
583
- // signed, and a call that signed nothing spends nothing.
661
+ // cap. With the signing guard on, the reservation is replaced by the
662
+ // amount actually signed, and each payload the guard let through is
663
+ // recorded as sent (ctx.sent). Without it the payment fetch is opaque:
664
+ // any request it made may have carried a signature.
584
665
  const reservation = this._spendReserve(host, usd, slug, listUsd, { always: guarded });
585
- const ctx = { reservation, signedUsd: null, refusal: null };
586
- const signedNothing = () => guarded && ctx.signedUsd == null;
666
+ const ctx = { reservation, signedUsd: null, refusal: null, sent: [] };
667
+ const sentPayment = () => !guarded || ctx.sent.length > 0;
587
668
  let settled = false;
588
669
  try {
589
670
  const r = await collect(await this._signCtx.run(ctx, () => paidSend(asyncHeaders, this.payFetch)));
590
671
  if (!r.ok) throw this._fail(slug, r, await this._failureDetail(slug, r));
591
- if (signedNothing()) this._spendRelease(reservation);
592
- else this._spendSettle(reservation); // confirm the reservation as settled spend
672
+ if (!sentPayment()) this._spendRelease(reservation);
673
+ else this._spendHold(reservation, guarded ? ctx.sent : null, { settleLast: true });
593
674
  settled = true;
594
675
  return this._deliverResponse(slug, r, cacheKey, cache, validator, { maxResponseBytes, paid: true });
595
676
  } catch (e) {
596
677
  // An invalid or oversized paid HTTP-success body is failed delivery,
597
- // not a rollback of money that already moved. A job still running
598
- // past the wait may yet settle, so its reservation stands.
599
- // A redirect after a signature was sent leaves that payment's outcome
600
- // unknown, so it counts against the caps, like a job still running.
601
- if ((e instanceof AsyncJobPendingError || e instanceof PaymentRedirectError) && !signedNothing()) this._spendSettle(reservation);
602
- else if (!settled) this._spendRelease(reservation);
678
+ // not a rollback of money that already moved. Any other failure once
679
+ // a payment header was sent (an error status, a dropped connection,
680
+ // a redirect, a job still running) leaves an authorization the seller
681
+ // can still redeem, so its signed amount is held, not released. Only
682
+ // a call that sent no payment releases its reservation.
683
+ if (!settled) {
684
+ if (sentPayment()) markOutcomeUnknown(e, slug, this._spendHold(reservation, guarded ? ctx.sent : null, { ownHost: this._unsettledAnswer(e) }), { seen: guarded });
685
+ else this._spendRelease(reservation);
686
+ }
603
687
  if (ctx.refusal) {
604
688
  const d = ctx.refusal;
605
689
  throw new SpendingLimitError(d.reason === "unknown-asset"
606
690
  ? `refusing to pay for "${slug}" - no payment option it asked to sign is in a known asset, so none can be priced`
691
+ : d.reason === "signed-amount"
692
+ ? `refusing to pay for "${slug}" - ${d.detail}, so the payment was not sent`
607
693
  : `refusing to pay $${d.priceUsd} for "${slug}" - the payment it asked to sign is over ${d.limit || "the cap"} ($${d.allowanceUsd} left to sign)`,
608
- { limit: d.limit || "signing", slug, priceUsd: d.priceUsd, cap: d.allowanceUsd, atSigning: true, cause: e });
694
+ { limit: d.limit || "signing", slug, priceUsd: d.priceUsd, cap: d.allowanceUsd, atSigning: true, cause: e, ...(e?.paid === "unknown" ? { paid: "unknown", heldUsd: e.heldUsd } : {}) });
609
695
  }
610
696
  throw e;
611
697
  }
@@ -634,7 +720,8 @@ export class Agent402 {
634
720
  settled = true;
635
721
  return this._deliverResponse(slug, r, cacheKey, cache, validator, { maxResponseBytes, paid: true });
636
722
  } catch (e) {
637
- if (e instanceof AsyncJobPendingError) this._spendSettle(reservation);
723
+ // A job still running may yet be debited, so its amount is held.
724
+ if (e instanceof AsyncJobPendingError) markOutcomeUnknown(e, slug, this._spendHold(reservation));
638
725
  else if (!settled) this._spendRelease(reservation);
639
726
  throw e;
640
727
  }
@@ -681,8 +768,10 @@ export class Agent402 {
681
768
  * which would sign the plain accept). Returns NO_GATEWAY_ACCEPT, with
682
769
  * nothing reserved or signed, when the answer is not a 402 or offers no
683
770
  * batched accept this signer may pay, so the caller falls back. Once the
684
- * signed header has been sent the amount is spent for the caps whatever
685
- * comes back; only a signer that throws releases the reservation.
771
+ * signed header has been sent its amount is held against the caps until a
772
+ * delivered answer settles it, or until the authorization can no longer
773
+ * settle and the rolling window has passed it; only a signer that throws
774
+ * releases the reservation.
686
775
  */
687
776
  async _gatewayCall(slug, tool, { paidSend, asyncHeaders, collect, cacheKey, cache, validator, maxResponseBytes }) {
688
777
  const pre = await paidSend();
@@ -692,14 +781,17 @@ export class Agent402 {
692
781
  if (!accept) return NO_GATEWAY_ACCEPT;
693
782
  const host = hostOf(this.baseUrl);
694
783
  const listUsd = parseUsd(tool.price);
695
- // The amount the accept asks for, priced from the known-asset table
696
- // (gatewayAcceptOf keeps only an accept that table can price); the
697
- // catalog's figure never lowers what the caps are checked against.
698
- const usd = Math.max(listUsd, acceptUsd(accept));
784
+ // The amount the accept asks for, read from the field the challenge's
785
+ // version names and priced from the known-asset table; an accept that
786
+ // version does not price is not paid here. The catalog's figure never
787
+ // lowers what the caps are checked against.
788
+ const acceptedUsd = acceptUsd(accept, Number(env?.x402Version));
789
+ if (acceptedUsd == null) return NO_GATEWAY_ACCEPT;
790
+ const usd = Math.max(listUsd, acceptedUsd);
699
791
  const reservation = this._spendReserve(host, usd, slug, listUsd);
700
- let header;
792
+ let header, payload;
701
793
  try {
702
- ({ header } = await signGatewayPayment(accept, { signer: this.gateway.signer, resource: env?.resource }));
794
+ ({ header, payload } = await signGatewayPayment(accept, { signer: this.gateway.signer, resource: env?.resource }));
703
795
  } catch (e) {
704
796
  // Nothing signed: the reservation is the only thing to undo.
705
797
  this._spendRelease(reservation);
@@ -707,26 +799,35 @@ export class Agent402 {
707
799
  }
708
800
  // From here the signed authorization leaves this process. It is a bearer
709
801
  // claim on the deposit until its validBefore whatever the seller answers
710
- // (a refusal, a failure, a redirect, a job still running), so it counts
711
- // as spent against the caps from the first send.
712
- this._spendSettle(reservation);
802
+ // (a refusal, a failure, a redirect, a job still running), so its signed
803
+ // amount is held against the caps from the first send.
804
+ const sent = [{ usd: acceptedUsd, until: signedValidUntilMs(payload) }];
805
+ const heldUsd = this._spendHold(reservation, sent);
713
806
  const paidHeaders = { "PAYMENT-SIGNATURE": header, ...asyncHeaders };
714
- let r = await collect(await paidSend(paidHeaders));
715
- let body = null;
716
- for (let resend = 0; ; resend++) {
717
- if (r.status !== 402) break;
718
- body = await readJsonQuietly(r);
719
- const pending = body && typeof body === "object" && (body.reason === "settlement-pending" || body.retry === "same-payment");
720
- if (!pending) break;
721
- // Gateway may yet settle it: the header is handed back so the same
722
- // payment can be resent later.
723
- if (resend >= GATEWAY_PENDING_RESENDS) throw new GatewayPendingError(slug, header, body);
724
- const retryAfterSec = Number(r.headers?.get?.("retry-after")) || Number(body.retryAfterSeconds) || 10;
725
- await this._sleep(Math.min(Math.max(0, retryAfterSec) * 1000, GATEWAY_PENDING_MAX_WAIT_MS));
807
+ let r;
808
+ try {
726
809
  r = await collect(await paidSend(paidHeaders));
810
+ let body = null;
811
+ for (let resend = 0; ; resend++) {
812
+ if (r.status !== 402) break;
813
+ body = await readJsonQuietly(r);
814
+ const pending = body && typeof body === "object" && (body.reason === "settlement-pending" || body.retry === "same-payment");
815
+ if (!pending) break;
816
+ // Gateway may yet settle it: the header is handed back so the same
817
+ // payment can be resent later.
818
+ if (resend >= GATEWAY_PENDING_RESENDS) throw new GatewayPendingError(slug, header, body);
819
+ const retryAfterSec = Number(r.headers?.get?.("retry-after")) || Number(body.retryAfterSeconds) || 10;
820
+ await this._sleep(Math.min(Math.max(0, retryAfterSec) * 1000, GATEWAY_PENDING_MAX_WAIT_MS));
821
+ r = await collect(await paidSend(paidHeaders));
822
+ }
823
+ if (r.status === 402) throw this._fail(slug, r, gatewayRefusalMessage(slug, body, accept, this.gateway.signer.address));
824
+ if (!r.ok) throw this._fail(slug, r, await this._failureDetail(slug, r));
825
+ } catch (e) {
826
+ if (this._unsettledAnswer(e)) this._spendHold(reservation, sent, { ownHost: true });
827
+ markOutcomeUnknown(e, slug, heldUsd);
828
+ throw e;
727
829
  }
728
- if (r.status === 402) throw this._fail(slug, r, gatewayRefusalMessage(slug, body, accept, this.gateway.signer.address));
729
- if (!r.ok) throw this._fail(slug, r, await this._failureDetail(slug, r));
830
+ this._spendSettle(reservation);
730
831
  return this._deliverResponse(slug, r, cacheKey, cache, validator, { maxResponseBytes, paid: true });
731
832
  }
732
833
 
@@ -827,6 +928,8 @@ export class Agent402 {
827
928
  * a caller can see the quote is not the number they budgeted from. */
828
929
  _spendCheck(host, usd, slug, listUsd = null) {
829
930
  const s = this._spend;
931
+ const now = Date.now();
932
+ s.log = s.log.filter((e) => countsAt(e, now));
830
933
  if (s.maxPerCall == null && s.daily == null && s.perHost == null) return;
831
934
  const quotedNote = listUsd != null && listUsd !== usd
832
935
  ? ` (this request is quoted $${usd}; the catalog lists $${listUsd})` : "";
@@ -835,22 +938,27 @@ export class Agent402 {
835
938
  `refusing to pay $${usd} for "${slug}" - exceeds maxPerCallUsd $${s.maxPerCall}${quotedNote}`,
836
939
  { limit: "maxPerCallUsd", slug, priceUsd: usd, cap: s.maxPerCall, ...(listUsd != null ? { listPriceUsd: listUsd } : {}) });
837
940
  }
838
- const cutoff = Date.now() - 24 * 60 * 60 * 1000;
839
- s.log = s.log.filter((e) => e.ts >= cutoff);
941
+ // Held payments count: each is a signed authorization that was sent and
942
+ // may still be redeemed. The refusal says how much of the total they are.
943
+ const heldOf = (entries) => entries.filter((e) => e.state === "held").reduce((a, e) => a + e.usd, 0);
944
+ const heldNote = (held) => held > 0 ? ` (including $${Number(held.toFixed(6))} held for sent payments whose outcome is unknown)` : "";
840
945
  if (s.daily != null) {
841
946
  const spent = s.log.reduce((a, e) => a + e.usd, 0);
842
947
  if (spent + usd > s.daily) {
948
+ const held = heldOf(s.log);
843
949
  throw new SpendingLimitError(
844
- `refusing to pay $${usd} for "${slug}" - would bring 24h spend to $${(spent + usd).toFixed(6)}, over dailyLimitUsd $${s.daily}`,
845
- { limit: "dailyLimitUsd", slug, priceUsd: usd, spent, cap: s.daily });
950
+ `refusing to pay $${usd} for "${slug}" - would bring 24h spend to $${(spent + usd).toFixed(6)}${heldNote(held)}, over dailyLimitUsd $${s.daily}`,
951
+ { limit: "dailyLimitUsd", slug, priceUsd: usd, spent, heldUsd: Number(held.toFixed(6)), cap: s.daily });
846
952
  }
847
953
  }
848
954
  if (s.perHost != null) {
849
- const spentHost = s.log.filter((e) => e.host === host).reduce((a, e) => a + e.usd, 0);
955
+ const onHost = s.log.filter((e) => e.host === host);
956
+ const spentHost = onHost.reduce((a, e) => a + e.usd, 0);
850
957
  if (spentHost + usd > s.perHost) {
958
+ const held = heldOf(onHost);
851
959
  throw new SpendingLimitError(
852
- `refusing to pay $${usd} for "${slug}" - would bring 24h spend to ${host} to $${(spentHost + usd).toFixed(6)}, over maxPerHostUsd $${s.perHost}`,
853
- { limit: "maxPerHostUsd", slug, host, priceUsd: usd, spent: spentHost, cap: s.perHost });
960
+ `refusing to pay $${usd} for "${slug}" - would bring 24h spend to ${host} to $${(spentHost + usd).toFixed(6)}${heldNote(held)}, over maxPerHostUsd $${s.perHost}`,
961
+ { limit: "maxPerHostUsd", slug, host, priceUsd: usd, spent: spentHost, heldUsd: Number(held.toFixed(6)), cap: s.perHost });
854
962
  }
855
963
  }
856
964
  }
@@ -866,8 +974,9 @@ export class Agent402 {
866
974
  * protected nothing and lost the part of the message an agent needs to
867
975
  * act on. A body that is not JSON contributes nothing but its status. */
868
976
  /** A refusal a caller can branch on: `status` (the HTTP status), `slug` and
869
- * `paid: false` ride on the Error, so a 402 is not something to regex out of
870
- * the message. Nothing was spent on any path that reaches this. */
977
+ * `paid` ride on the Error, so a 402 is not something to regex out of the
978
+ * message. `paid: false` here; a failure after a payment header was sent
979
+ * is re-marked `paid: "unknown"` (markOutcomeUnknown) by the paid path. */
871
980
  _fail(slug, r, message) {
872
981
  return Object.assign(new Error(message), { status: r?.status, slug, paid: false });
873
982
  }
@@ -899,29 +1008,74 @@ export class Agent402 {
899
1008
  _spendReserve(host, usd, slug, listUsd = null, { always = false } = {}) {
900
1009
  this._spendCheck(host, usd, slug, listUsd);
901
1010
  if (!(usd > 0) && !always) return null;
902
- const entry = { ts: Date.now(), host, usd, pending: true };
1011
+ const entry = { ts: Date.now(), host, usd, state: "reserved" };
903
1012
  this._spend.log.push(entry);
904
1013
  return entry;
905
1014
  }
906
- /** Confirm a reservation as settled spend. */
907
- _spendSettle(entry) { if (entry) entry.pending = false; }
908
- /** Roll back a reservation whose call did not settle (failed / errored). */
1015
+ /** Confirm a reservation (or a hold) as settled spend. */
1016
+ _spendSettle(entry) { if (entry) { entry.state = "settled"; entry.until = null; entry.ownHost = false; } }
1017
+ /** True when a paid call failed on an answer the built-in seller does not
1018
+ * settle: an error status (400 or above) from DEFAULT_BASE_URL's exact
1019
+ * origin. A Gateway payment still pending, a job still running, a redirect
1020
+ * or a dropped connection may have settled, so none of them counts. */
1021
+ _unsettledAnswer(e) {
1022
+ return isDefaultOrigin(this.baseUrl) && e instanceof Error && e.paid === false
1023
+ && Number.isInteger(e.status) && e.status >= 400 && !(e instanceof GatewayPendingError);
1024
+ }
1025
+ /** Hold a reservation whose payment header was sent: it counts against the
1026
+ * caps until its authorization can no longer settle (`until`) and the
1027
+ * rolling window has passed it. `sent` lists each payload that left the
1028
+ * process ({ usd, until }); the reservation holds the first, and each
1029
+ * further one gets its own entry. With `settleLast`, the last payload is
1030
+ * the one a delivered answer settled, so it is booked settled instead.
1031
+ * null `sent` holds the reservation at its own amount, expiry unknown.
1032
+ * Returns the total now held. */
1033
+ _spendHold(entry, sent = null, { settleLast = false, ownHost = false } = {}) {
1034
+ if (!entry) return 0;
1035
+ const list = sent && sent.length ? sent : [{ usd: entry.usd, until: null }];
1036
+ const blindUntil = Date.now() + DEFAULT_HOST_BLIND_HOLD_MS;
1037
+ const book = (e, p, state) => {
1038
+ e.usd = p.usd; e.state = state;
1039
+ e.ownHost = state === "held" && ownHost;
1040
+ e.until = state !== "held" ? null : p.until ?? (e.ownHost ? blindUntil : null);
1041
+ };
1042
+ let held = 0;
1043
+ list.forEach((p, i) => {
1044
+ const state = settleLast && i === list.length - 1 ? "settled" : "held";
1045
+ if (state === "held") held += p.usd;
1046
+ if (i === 0) book(entry, p, state);
1047
+ else { const e = { ts: entry.ts, host: entry.host }; book(e, p, state); this._spend.log.push(e); }
1048
+ });
1049
+ return Number(held.toFixed(6));
1050
+ }
1051
+ /** Roll back a reservation whose call sent no payment. */
909
1052
  _spendRelease(entry) {
910
1053
  if (!entry) return;
911
1054
  const i = this._spend.log.indexOf(entry);
912
1055
  if (i >= 0) this._spend.log.splice(i, 1);
913
1056
  }
914
1057
 
915
- /** Rolling-24h spend summary (settled paid calls only) - for observability. */
1058
+ /** Rolling-24h spend summary, for observability. `dailyUsd`, `calls` and
1059
+ * `byHost` are settled paid calls; `heldUsd`, `heldCalls` and `heldByHost`
1060
+ * are payments that were sent without a delivered answer, still counted
1061
+ * against the caps because their authorizations may yet be redeemed. */
916
1062
  spendingSummary() {
917
- const cutoff = Date.now() - 24 * 60 * 60 * 1000;
918
- const log = this._spend.log.filter((e) => e.ts >= cutoff && !e.pending);
919
- const byHost = {};
920
- for (const e of log) byHost[e.host] = Number(((byHost[e.host] || 0) + e.usd).toFixed(6));
1063
+ const now = Date.now();
1064
+ const live = this._spend.log.filter((e) => countsAt(e, now));
1065
+ const tally = (entries) => {
1066
+ const byHost = {};
1067
+ for (const e of entries) byHost[e.host] = Number(((byHost[e.host] || 0) + e.usd).toFixed(6));
1068
+ return { usd: Number(entries.reduce((a, e) => a + e.usd, 0).toFixed(6)), calls: entries.length, byHost };
1069
+ };
1070
+ const settled = tally(live.filter((e) => e.state === "settled"));
1071
+ const held = tally(live.filter((e) => e.state === "held"));
921
1072
  return {
922
- dailyUsd: Number(log.reduce((a, e) => a + e.usd, 0).toFixed(6)),
923
- calls: log.length,
924
- byHost,
1073
+ dailyUsd: settled.usd,
1074
+ calls: settled.calls,
1075
+ byHost: settled.byHost,
1076
+ heldUsd: held.usd,
1077
+ heldCalls: held.calls,
1078
+ heldByHost: held.byHost,
925
1079
  limits: { maxPerCallUsd: this._spend.maxPerCall, dailyLimitUsd: this._spend.daily, maxPerHostUsd: this._spend.perHost },
926
1080
  };
927
1081
  }
@@ -989,7 +1143,7 @@ export class GatewayPendingError extends Error {
989
1143
  this.slug = slug;
990
1144
  this.paymentHeader = paymentHeader;
991
1145
  this.status = 402;
992
- this.paid = null;
1146
+ this.paid = "unknown";
993
1147
  this.retryAfterSeconds = Number(body?.retryAfterSeconds) || null;
994
1148
  }
995
1149
  }
@@ -1027,7 +1181,7 @@ export function gatewayAcceptOf(accepts, networks = null) {
1027
1181
  && EVM_ADDR.test(String(a.payTo || "")) && EVM_ADDR.test(String(a.extra.verifyingContract || ""))
1028
1182
  && typeof a.extra.version === "string" && /^[0-9]{1,4}$/.test(a.extra.version)
1029
1183
  && /^[0-9]{1,78}$/.test(String(a.amount ?? "")) && Number(a.maxTimeoutSeconds) > 0 && Number(a.maxTimeoutSeconds) <= GATEWAY_MAX_TIMEOUT_SECONDS
1030
- && acceptUsd(a) != null) || null;
1184
+ && acceptUsd(a, 2) != null) || null;
1031
1185
  }
1032
1186
 
1033
1187
  /**
package/known-assets.js CHANGED
@@ -1,13 +1,16 @@
1
1
  // Known payment assets, and the USD value of an x402 accept priced from them.
2
2
  //
3
- // An x402 "exact" signer signs the accept's raw base-unit `amount` for the
4
- // accept's `asset`. A spending cap therefore has to price that amount with the
5
- // asset's real decimals, and a decimals hint the seller sends beside it (for
6
- // example `extra.decimals`) is not evidence of anything. So the price of an
7
- // accept comes from this table alone: the stablecoins each network settles on
8
- // the Agent402 rails, with their on-chain decimals. An accept whose
9
- // (network, asset) pair is not in the table cannot be priced, and a buyer with
10
- // a cap does not sign it.
3
+ // An x402 "exact" signer signs the accept's raw base-unit amount for the
4
+ // accept's `asset`: `amount` under x402 v2, `maxAmountRequired` under v1. A
5
+ // spending cap therefore has to price the field the signer of that version
6
+ // reads, with the asset's real decimals, and a decimals hint the seller sends
7
+ // beside it (for example `extra.decimals`) is not evidence of anything. So the
8
+ // price of an accept comes from its challenge's version and this table alone:
9
+ // the stablecoins each network settles on the Agent402 rails, with their
10
+ // on-chain decimals. An accept whose (network, asset) pair is not in the table,
11
+ // or that carries both amount fields with different values, cannot be priced,
12
+ // and a buyer with a cap does not sign it. The table is keyed by CAIP-2 ids
13
+ // only: a bare v1 network name (for example "base") is not priced.
11
14
  //
12
15
  // The same file ships in agent402-client, agent402-mcp and agent402-openclaw;
13
16
  // the parent repo's scripts/test-known-assets.js keeps the copies identical and
@@ -85,13 +88,33 @@ export function knownAsset(network, asset) {
85
88
  return list.find((e) => sameAsset(e.asset, asset)) || null;
86
89
  }
87
90
 
88
- /** The USD value of one accept: its base-unit amount (`amount` in x402 v2,
89
- * `maxAmountRequired` in v1) over the KNOWN decimals of its asset. null when
90
- * the asset is not known or the amount is not a whole number of base units. */
91
- export function acceptUsd(accept) {
91
+ /** The base-unit amount the signer of `x402Version` signs for one accept, as
92
+ * a string of digits: `maxAmountRequired` under v1, `amount` under v2. With
93
+ * no version, the one field present (or both, when they agree). null when
94
+ * the field that version signs is missing or not a whole number, when the
95
+ * version is neither 1 nor 2, and whenever the accept carries both fields
96
+ * with different values: such an accept means one price to one reader and
97
+ * another to the next, so it is not priced at all. */
98
+ export function acceptAmount(accept, x402Version = undefined) {
92
99
  if (!accept || typeof accept !== "object") return null;
93
- const raw = accept.amount ?? accept.maxAmountRequired;
94
- if (raw == null || !/^\d+$/.test(String(raw))) return null;
100
+ const has = (x) => x != null && x !== "";
101
+ const v2 = accept.amount, v1 = accept.maxAmountRequired;
102
+ if (has(v2) && has(v1) && String(v2) !== String(v1)) return null;
103
+ let raw;
104
+ if (x402Version === 1) raw = v1;
105
+ else if (x402Version === 2) raw = v2;
106
+ else if (x402Version == null) raw = has(v2) ? v2 : v1;
107
+ else return null;
108
+ if (!has(raw) || !/^\d+$/.test(String(raw))) return null;
109
+ return String(raw);
110
+ }
111
+
112
+ /** The USD value of one accept: the base-unit amount its version's signer
113
+ * signs (acceptAmount) over the KNOWN decimals of its asset. null when the
114
+ * asset is not known or the amount cannot be read. */
115
+ export function acceptUsd(accept, x402Version = undefined) {
116
+ const raw = acceptAmount(accept, x402Version);
117
+ if (raw == null) return null;
95
118
  const entry = knownAsset(accept.network, accept.asset);
96
119
  if (!entry) return null;
97
120
  return Number(raw) / 10 ** entry.decimals;
@@ -99,11 +122,11 @@ export function acceptUsd(accept) {
99
122
 
100
123
  /** The USD values of a 402's accepts: `max` and `min` over the accepts that can
101
124
  * be priced, and `unknown`, the count that cannot. null when there are none. */
102
- export function quoteOf(accepts) {
125
+ export function quoteOf(accepts, x402Version = undefined) {
103
126
  if (!Array.isArray(accepts) || !accepts.length) return null;
104
127
  let max = null, min = null, unknown = 0;
105
128
  for (const a of accepts) {
106
- const usd = acceptUsd(a);
129
+ const usd = acceptUsd(a, x402Version);
107
130
  if (usd == null) { unknown++; continue; }
108
131
  if (max == null || usd > max) max = usd;
109
132
  if (min == null || usd < min) min = usd;
@@ -128,9 +151,9 @@ export function guardSigning(client, { allowance, onSign = null, onRefuse = null
128
151
  const refuse = (detail) => { try { onRefuse?.(detail); } catch { /* the refusal stands */ } return detail; };
129
152
  client.registerPolicy((version, accepts) => {
130
153
  const cap = allowance();
131
- const kept = accepts.filter((a) => { const usd = acceptUsd(a); return usd != null && usd <= cap; });
154
+ const kept = accepts.filter((a) => { const usd = acceptUsd(a, version); return usd != null && usd <= cap; });
132
155
  if (!kept.length) {
133
- const q = quoteOf(accepts);
156
+ const q = quoteOf(accepts, version);
134
157
  const d = refuse({ reason: q && q.min != null ? "over-allowance" : "unknown-asset", priceUsd: q ? q.min : null, allowanceUsd: cap });
135
158
  throw new Error(d.reason === "unknown-asset"
136
159
  ? "spending guard: no payment option is in a known asset, so none can be priced; nothing was signed"
@@ -138,8 +161,8 @@ export function guardSigning(client, { allowance, onSign = null, onRefuse = null
138
161
  }
139
162
  return kept;
140
163
  });
141
- client.onBeforePaymentCreation(async ({ selectedRequirements: r }) => {
142
- const usd = acceptUsd(r);
164
+ client.onBeforePaymentCreation(async ({ paymentRequired, selectedRequirements: r }) => {
165
+ const usd = acceptUsd(r, paymentRequired?.x402Version);
143
166
  const cap = allowance();
144
167
  if (usd == null) {
145
168
  refuse({ reason: "unknown-asset", priceUsd: null, allowanceUsd: cap });
@@ -152,5 +175,97 @@ export function guardSigning(client, { allowance, onSign = null, onRefuse = null
152
175
  onSign?.(usd, r);
153
176
  return undefined;
154
177
  });
178
+ guardSignedAmount(client, { allowance, onRefuse: refuse });
179
+ return client;
180
+ }
181
+
182
+ // The token programs a Solana "exact" payment moves its asset through, and
183
+ // the TransferChecked instruction's tag.
184
+ const SOLANA_TOKEN_PROGRAMS = new Set(["TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"]);
185
+ const TRANSFER_CHECKED = 12;
186
+
187
+ /** The amount a Solana payment transaction moves: its one token-program
188
+ * instruction, which must be a TransferChecked, read as base units and
189
+ * decimals. Decoded with @solana/kit, the peer every Solana x402 signer
190
+ * already loads. null when the transaction cannot be read or does not hold
191
+ * exactly that one transfer. */
192
+ async function solanaTransferOf(transaction) {
193
+ if (typeof transaction !== "string" || !transaction) return null;
194
+ try {
195
+ const kit = await import("@solana/kit");
196
+ const tx = kit.getTransactionDecoder().decode(kit.getBase64Encoder().encode(transaction));
197
+ const msg = kit.getCompiledTransactionMessageDecoder().decode(tx.messageBytes);
198
+ const keys = msg.staticAccounts || [];
199
+ const ixs = msg.version === 1
200
+ ? (msg.instructionHeaders || []).map((h, i) => ({ program: keys[h.programAccountIndex], data: msg.instructionPayloads?.[i]?.instructionData }))
201
+ : (msg.instructions || []).map((ix) => ({ program: keys[ix.programAddressIndex], data: ix.data }));
202
+ const token = ixs.filter((ix) => SOLANA_TOKEN_PROGRAMS.has(String(ix.program)));
203
+ if (token.length !== 1) return null;
204
+ const d = token[0].data;
205
+ if (!d || d.length !== 10 || d[0] !== TRANSFER_CHECKED) return null;
206
+ let amount = 0n;
207
+ for (let i = 8; i >= 1; i--) amount = (amount << 8n) | BigInt(d[i]);
208
+ return { amount: amount.toString(), decimals: d[9] };
209
+ } catch {
210
+ return null;
211
+ }
212
+ }
213
+
214
+ /** The base-unit amount a created x402 payment payload commits the buyer to,
215
+ * read from what was signed: an EVM EIP-3009 `authorization.value`, an EVM
216
+ * Permit2 `permitted.amount`, or a Solana transaction's transfer. Returns
217
+ * `{ amount, decimals? }`, `{ unchecked: true }` for a network whose payload
218
+ * this reader does not decode (neither EVM nor Solana), or null when an EVM or
219
+ * Solana payload holds no amount it can read. */
220
+ export async function signedAmountOf(paymentPayload, network) {
221
+ const p = paymentPayload?.payload;
222
+ const net = String(network || "");
223
+ if (net.startsWith("eip155:")) {
224
+ const vals = [p?.authorization?.value, p?.permit2Authorization?.permitted?.amount].filter((x) => x != null);
225
+ if (vals.length !== 1 || !/^\d+$/.test(String(vals[0]))) return null;
226
+ return { amount: BigInt(String(vals[0])).toString() };
227
+ }
228
+ if (net.startsWith("solana:")) return solanaTransferOf(p?.transaction);
229
+ return { unchecked: true };
230
+ }
231
+
232
+ /** Check a created payment payload against the accept it was built for,
233
+ * before it is sent: the amount that was signed must be the amount the
234
+ * allowance was checked against (acceptAmount under the challenge's
235
+ * version), in the known asset's decimals, and within `cap`. Returns null
236
+ * when it holds, else why not. */
237
+ export async function signedPaymentProblem(paymentPayload, requirements, x402Version, cap = Infinity) {
238
+ const expected = acceptAmount(requirements, x402Version);
239
+ const entry = knownAsset(requirements?.network, requirements?.asset);
240
+ if (expected == null) return "the selected payment option's amount cannot be read";
241
+ const signed = await signedAmountOf(paymentPayload, requirements?.network);
242
+ if (signed == null) return "the signed payment's amount cannot be read";
243
+ if (signed.unchecked) return null;
244
+ if (signed.amount !== BigInt(expected).toString()) return `the signed amount (${signed.amount}) differs from the priced amount (${expected})`;
245
+ if (signed.decimals != null && entry && signed.decimals !== entry.decimals) return "the signed transfer's decimals differ from the known asset's";
246
+ if (Number.isFinite(cap)) {
247
+ if (!entry) return "the selected payment option is not in a known asset";
248
+ const usd = Number(signed.amount) / 10 ** entry.decimals;
249
+ if (usd > cap) return `the signed amount asks ${usd}, above the ${cap} this buyer may sign`;
250
+ }
251
+ return null;
252
+ }
253
+
254
+ /** Refuse to send a payment payload whose signed amount is not the one the
255
+ * allowance passed: registered after payload creation, so the check reads
256
+ * what was actually signed, and a throw here stops the payment-aware fetch
257
+ * before the payload leaves the process. guardSigning registers it; a
258
+ * client with its own selector (and no guardSigning) can register it alone. */
259
+ export function guardSignedAmount(client, { allowance = () => Infinity, onRefuse = null } = {}) {
260
+ if (!client || typeof client.onAfterPaymentCreation !== "function") {
261
+ throw new TypeError("guardSignedAmount needs an @x402/core x402Client (onAfterPaymentCreation)");
262
+ }
263
+ client.onAfterPaymentCreation(async ({ paymentRequired, selectedRequirements: r, paymentPayload }) => {
264
+ const cap = allowance();
265
+ const problem = await signedPaymentProblem(paymentPayload, r, paymentRequired?.x402Version, cap);
266
+ if (!problem) return;
267
+ try { onRefuse?.({ reason: "signed-amount", priceUsd: null, allowanceUsd: cap, detail: problem }); } catch { /* the refusal stands */ }
268
+ throw new Error(`spending guard: ${problem}; the payment was not sent`);
269
+ });
155
270
  return client;
156
271
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent402-client",
3
- "version": "0.8.21",
3
+ "version": "0.8.23",
4
4
  "description": "Agent402 buyer SDK: resolve a task to a tool across 500+ pay-per-call endpoints and call it with payment handled - free via proof-of-work, or over x402 or MPP (a stock mppx fetch works) with a wallet you keep; the buy side of Agentic Finance. Caching, idempotent retries (Idempotency-Key), non-custodial, zero dependencies.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -12,6 +12,7 @@
12
12
  "async-job.js",
13
13
  "known-assets.js",
14
14
  "tls-distrust.js",
15
+ "reset-retry.js",
15
16
  "README.md"
16
17
  ],
17
18
  "engines": {
package/reset-retry.js ADDED
@@ -0,0 +1,47 @@
1
+ // resetRetryFetch(baseFetch) returns a fetch that resends a request ONCE when
2
+ // its connection failed before any response arrived: a kept-alive socket the
3
+ // server's edge already closed answers the next write with ECONNRESET (or
4
+ // undici's "other side closed"), and the request never reached the server.
5
+ //
6
+ // Only a transport error is retried, never an HTTP answer, and only for a
7
+ // body that can be sent again (none, a string, bytes, URLSearchParams, a
8
+ // Blob); a streamed body is not. The resend carries the same headers, so a
9
+ // signed payment is resent as signed: an EIP-3009 authorization's nonce can
10
+ // settle only once, and the server answers a re-sent signed payment from its
11
+ // first answer, so the resend can never pay twice. Wrap it UNDER a payment
12
+ // wrapper (wrapFetchWithPayment(resetRetryFetch(fetch), client)), never over
13
+ // one: over a wrapper, a retry would sign a fresh authorization.
14
+ const RESET_CODES = new Set(["ECONNRESET", "EPIPE", "UND_ERR_SOCKET", "UND_ERR_CLOSED", "ECONNABORTED"]);
15
+
16
+ /** Did this fetch error fail on the connection before any response? */
17
+ export function isConnectionReset(err) {
18
+ let e = err;
19
+ for (let i = 0; i < 5 && e; i++) {
20
+ if (RESET_CODES.has(e.code)) return true;
21
+ if (/other side closed|socket hang up/i.test(String(e.message || ""))) return true;
22
+ e = e.cause;
23
+ }
24
+ return false;
25
+ }
26
+
27
+ function replayableBody(body) {
28
+ if (body === undefined || body === null || typeof body === "string") return true;
29
+ if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) return true;
30
+ if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) return true;
31
+ if (typeof Blob !== "undefined" && body instanceof Blob) return true;
32
+ return false;
33
+ }
34
+
35
+ export function resetRetryFetch(baseFetch = globalThis.fetch, { pauseMs = 300 } = {}) {
36
+ if (typeof baseFetch !== "function") throw new Error("resetRetryFetch needs a fetch function");
37
+ return async function fetchWithResetRetry(input, init) {
38
+ try {
39
+ return await baseFetch(input, init);
40
+ } catch (e) {
41
+ const isRequest = typeof Request !== "undefined" && input instanceof Request;
42
+ if (!isConnectionReset(e) || init?.signal?.aborted || isRequest || !replayableBody(init?.body)) throw e;
43
+ await new Promise((r) => setTimeout(r, pauseMs));
44
+ return baseFetch(input, init);
45
+ }
46
+ };
47
+ }