agent402-client 0.8.22 → 0.8.24

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.
Files changed (4) hide show
  1. package/README.md +33 -8
  2. package/index.js +225 -75
  3. package/known-assets.js +195 -22
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -254,8 +254,9 @@ minutes, longer than many HTTP clients wait. Their `/api/pricing` row carries
254
254
  waiting `Retry-After` seconds between polls, until the job has succeeded or
255
255
  failed (it reads the job's `state` and falls back to its `status`, working,
256
256
  completed or failed, on a server that sends only that). Payment settles only on
257
- the delivered answer, so a failed job is a
258
- 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.
259
260
 
260
261
  ```js
261
262
  const a = new Agent402({
@@ -287,7 +288,7 @@ that runs as one.
287
288
  | `await a.collectJob(jobUrl, { timeoutMs?, onProgress? })` | Collect a job link (from `AsyncJobPendingError.jobUrl`) until it has the answer |
288
289
  | `Agent402.solvePow(pow)` | Solve a proof-of-work challenge object → an `X-Pow-Solution` value |
289
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 |
290
- | `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 |
291
292
  | `a.clearCache()` | Drop the in-memory result cache |
292
293
 
293
294
  ## Response size ceiling
@@ -388,11 +389,35 @@ try {
388
389
  }
389
390
  ```
390
391
 
391
- Only **settled** paid calls count against the rolling window. A call refused or
392
- failed before settlement never consumes budget. A paid HTTP-success response
393
- that later fails byte, JSON, or output validation remains settled spend. Free
394
- proof-of-work calls are never counted. Omit a cap (or leave it `null`) for no
395
- 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). With an `x402Client`, an accept asking to stay valid for
402
+ more than a day (`MAX_VALIDITY_SECONDS`) is refused before anything is signed,
403
+ and a payload signed for longer is refused before it is sent, so no hold
404
+ outlives a day after the send (seven days and an hour for a batched payment). Pass your `x402Client` so the client sees exactly which
405
+ payments were signed; through a pre-wrapped `fetch` alone it cannot tell, so any
406
+ failed paid call is held. A paid HTTP-success response that later fails byte,
407
+ JSON, or output validation remains settled spend. Free proof-of-work calls are
408
+ never counted. Omit a cap (or leave it `null`) for no limit; with none set,
409
+ behavior is unchanged.
410
+
411
+ **Held payments.** `heldUsd` is money this client sent a signed payment for
412
+ without getting a delivered answer. It is held, not spent: a seller that kept
413
+ the authorization could still redeem it, so the caps count it until it can no
414
+ longer settle. When the authorization expires unused, the held amount is
415
+ released and was never charged. On the built-in default host
416
+ (`https://agent402.tools`, matched by exact origin), an error status (400 or
417
+ above) is never settled, so a hold for one ends at the authorization's
418
+ `validBefore` plus 10 minutes, with no 24-hour floor, or 15 minutes after the
419
+ answer when the client cannot read `validBefore` (no `x402Client`). Every other
420
+ `baseUrl` keeps the rule above.
396
421
 
397
422
  **Caps are enforced on the payment that is signed.** Pass the `x402Client` your
398
423
  `fetch` wraps (or call `a.guardX402Client(client)`) and the client registers a
package/index.js CHANGED
@@ -19,8 +19,8 @@
19
19
  // const a = new Agent402({ fetch: payFetch });
20
20
  import { createHash, randomBytes } from "node:crypto";
21
21
  import { AsyncLocalStorage } from "node:async_hooks";
22
- import { quoteOf, guardSigning, networkIdsOf, acceptUsd } from "./known-assets.js";
23
- export { KNOWN_ASSETS, NETWORK_NAMES, knownAsset, acceptUsd, networkIdsOf } from "./known-assets.js";
22
+ import { quoteOf, guardSigning, networkIdsOf, acceptUsd, signedExpirySeconds, MAX_VALIDITY_SECONDS } from "./known-assets.js";
23
+ export { KNOWN_ASSETS, NETWORK_NAMES, knownAsset, acceptUsd, networkIdsOf, MAX_VALIDITY_SECONDS } 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
26
  export { resetRetryFetch, isConnectionReset } from "./reset-retry.js";
@@ -32,7 +32,7 @@ export { AsyncJobPendingError } from "./async-job.js";
32
32
  // `User-Agent: agent402-client/<version>` - a standard header, no extra
33
33
  // network calls - so a seller can attribute traffic (and settled payments)
34
34
  // to this SDK. Product token only; nothing about the caller rides along.
35
- const VERSION = "0.8.22";
35
+ const VERSION = "0.8.24";
36
36
  const USER_AGENT = `agent402-client/${VERSION}`;
37
37
  // A package built on this SDK (an adapter) names itself first: its own
38
38
  // product token, then this one. Anything that is not one well-formed token
@@ -110,6 +110,64 @@ export const GATEWAY_MAX_TIMEOUT_SECONDS = 7 * 24 * 3600 + 3600;
110
110
  const GATEWAY_DOCS_URL = "https://github.com/MikeyPetrillo/Agent402/wiki/Circle-Gateway";
111
111
  const EVM_ADDR = /^0x[0-9a-fA-F]{40}$/;
112
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, never later than `maxSeconds` after `nowMs` (the
123
+ * longest window this client signs: MAX_VALIDITY_SECONDS, or
124
+ * GATEWAY_MAX_TIMEOUT_SECONDS for a batched payment), so no hold outlives
125
+ * the ceiling a payload must pass to be sent. null when the payload names no
126
+ * expiry this reads (a Solana transaction, an unknown scheme). */
127
+ export function signedValidUntilMs(paymentPayload, { maxSeconds = MAX_VALIDITY_SECONDS, nowMs = Date.now() } = {}) {
128
+ const v = signedExpirySeconds(paymentPayload);
129
+ if (v == null || !Number.isFinite(v)) return null;
130
+ return Math.min(v * 1000, nowMs + maxSeconds * 1000) + HOLD_SLACK_MS;
131
+ }
132
+
133
+ /** A spend-log entry the caps still count at `now`: anything recorded inside
134
+ * the rolling window, and a held payment whose authorization can still
135
+ * settle, however long ago it was signed. */
136
+ const countsAt = (e, now) => e.state === "held" && e.ownHost
137
+ ? e.until > now
138
+ : e.ts >= now - SPEND_WINDOW_MS || (e.state === "held" && e.until != null && e.until > now);
139
+
140
+ // The client's built-in seller. It settles a payment only on an answer below
141
+ // 400 (the handler runs first), so a sent payment it answered with an error
142
+ // status was not settled and can be redeemed only until its authorization
143
+ // expires: such a hold ends at that expiry plus HOLD_SLACK_MS, with no 24 h
144
+ // floor, or DEFAULT_HOST_BLIND_HOLD_MS after the answer when the expiry is not
145
+ // readable (its challenges expire in about five minutes). Matched by exact
146
+ // origin; every other seller keeps the rolling-window rule.
147
+ export const DEFAULT_BASE_URL = "https://agent402.tools";
148
+ const DEFAULT_ORIGIN = new URL(DEFAULT_BASE_URL).origin;
149
+ export const DEFAULT_HOST_BLIND_HOLD_MS = 15 * 60 * 1000;
150
+ function isDefaultOrigin(url) { try { return new URL(url).origin === DEFAULT_ORIGIN; } catch { return false; } }
151
+
152
+ /** Mark an error thrown after a payment header was sent: `paid: "unknown"`
153
+ * (the seller holds a signed authorization it may still redeem) and
154
+ * `heldUsd`, the amount held against the caps for it. An Agent402 refusal
155
+ * also says so in its message. */
156
+ function markOutcomeUnknown(e, slug, heldUsd, { seen = true } = {}) {
157
+ if (!e || typeof e !== "object") return e;
158
+ try {
159
+ const own = e.paid === false && e.slug === slug;
160
+ e.paid = "unknown";
161
+ e.heldUsd = heldUsd;
162
+ if (own && typeof e.message === "string" && !/spending caps/.test(e.message)) {
163
+ e.message += seen
164
+ ? `. A payment was sent, so its outcome is unknown: its signed amount stays held against this client's spending caps (spendingSummary().heldUsd).`
165
+ : `. 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).`;
166
+ }
167
+ } catch { /* a frozen error keeps what it says */ }
168
+ return e;
169
+ }
170
+
113
171
  // A pre-wrapped payment fetch with no x402Client is the one mode where the caps
114
172
  // are checked on an unpaid preflight only, not on the payment that is signed.
115
173
  // Said once per process, on the first capped paid call in that mode.
@@ -164,7 +222,7 @@ export class Agent402 {
164
222
  * @param {function} [opts.onProgress] called with each job document
165
223
  * ({ status, stage, progress, etaSeconds, ... }) while a job runs
166
224
  */
167
- constructor({ baseUrl = "https://agent402.tools", fetch: payFetch, cache = true, fetchImpl = undefined, tlsDistrust = true,
225
+ constructor({ baseUrl = DEFAULT_BASE_URL, fetch: payFetch, cache = true, fetchImpl = undefined, tlsDistrust = true,
168
226
  maxPerCallUsd = null, dailyLimitUsd = null, maxPerHostUsd = null, creditsKey = null, x402Client = null, payNetworks = null,
169
227
  maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, outputValidator = null,
170
228
  respondAsync = "auto", asyncTimeoutMs = DEFAULT_ASYNC_TIMEOUT_MS, onProgress = null, gateway = null, userAgent = null, retryResets = true } = {}) {
@@ -204,13 +262,16 @@ export class Agent402 {
204
262
  // failure mode): optional hard ceilings enforced BEFORE any payment is
205
263
  // signed. A malicious or misconfigured 402 that quotes an inflated price is
206
264
  // refused instead of paid. null = no limit (default - behavior unchanged).
207
- // Amounts commit to the rolling window only on a settled paid call, so a
208
- // failed/blocked call never counts against the budget.
265
+ // A call refused before any payment left this process never counts. Once
266
+ // a payment header has been sent, its signed amount counts whatever the
267
+ // seller answers: "settled" on a delivered answer, otherwise "held" (the
268
+ // authorization can still be redeemed) until it can no longer settle and
269
+ // the rolling window has passed it.
209
270
  this._spend = {
210
271
  maxPerCall: numOrNull(maxPerCallUsd),
211
272
  daily: numOrNull(dailyLimitUsd),
212
273
  perHost: numOrNull(maxPerHostUsd),
213
- log: [], // [{ ts, host, usd }] - settled paid calls in the last 24h
274
+ log: [], // [{ ts, host, usd, state: "reserved"|"held"|"settled", until? }]
214
275
  };
215
276
  // The paid call being signed right now, so the signing guard can tell this
216
277
  // call's own reservation from every other one (see guardX402Client).
@@ -254,6 +315,15 @@ export class Agent402 {
254
315
  if (ctx) ctx.refusal = { ...detail, limit: this._signAllowance().limit };
255
316
  },
256
317
  });
318
+ // Registered after the guard's own check of the signed amount, so it runs
319
+ // only for a payload that check let through: the payload the payment
320
+ // fetch sends next. Each one is recorded with its amount and expiry (a
321
+ // fetch that retries with a fresh payload sends two).
322
+ client.onAfterPaymentCreation(async ({ paymentPayload }) => {
323
+ const ctx = this._signCtx.getStore();
324
+ if (!ctx || ctx.signedUsd == null) return;
325
+ ctx.sent.push({ usd: ctx.signedUsd, until: signedValidUntilMs(paymentPayload) });
326
+ });
257
327
  this._guarded = true;
258
328
  return client;
259
329
  }
@@ -263,15 +333,19 @@ export class Agent402 {
263
333
  * (this call's own one is what is being replaced). */
264
334
  _signAllowance() {
265
335
  const s = this._spend;
266
- const own = this._signCtx.getStore()?.reservation || null;
267
- const cutoff = Date.now() - 24 * 60 * 60 * 1000;
268
- const others = s.log.filter((e) => e.ts >= cutoff && e !== own);
336
+ const ctx = this._signCtx.getStore();
337
+ const own = ctx?.reservation || null;
338
+ // A payload this call already sent (a fetch that retries with a fresh
339
+ // one) is outstanding too, beside every other call's entry.
340
+ const ownSent = (ctx?.sent || []).reduce((a, p) => a + p.usd, 0);
341
+ const now = Date.now();
342
+ const others = s.log.filter((e) => countsAt(e, now) && e !== own);
269
343
  const host = hostOf(this.baseUrl);
270
344
  let cap = Infinity, limit = null;
271
345
  const tighten = (value, name) => { if (value < cap) { cap = value; limit = name; } };
272
346
  if (s.maxPerCall != null) tighten(s.maxPerCall, "maxPerCallUsd");
273
- if (s.daily != null) tighten(s.daily - others.reduce((a, e) => a + e.usd, 0), "dailyLimitUsd");
274
- if (s.perHost != null) tighten(s.perHost - others.filter((e) => e.host === host).reduce((a, e) => a + e.usd, 0), "maxPerHostUsd");
347
+ if (s.daily != null) tighten(s.daily - ownSent - others.reduce((a, e) => a + e.usd, 0), "dailyLimitUsd");
348
+ if (s.perHost != null) tighten(s.perHost - ownSent - others.filter((e) => e.host === host).reduce((a, e) => a + e.usd, 0), "maxPerHostUsd");
275
349
  return { cap: Math.max(0, cap), limit };
276
350
  }
277
351
 
@@ -586,34 +660,42 @@ export class Agent402 {
586
660
  const usd = await this._quotedUsd(paidSend, listUsd, { slug, mode: guarded ? "guarded" : "wallet" });
587
661
  // Reserve the amount synchronously (before the await) so concurrent calls
588
662
  // can't each observe the pre-commit total and collectively blow a rolling
589
- // cap; release the reservation if the call doesn't settle. With the
590
- // signing guard on, the reservation is replaced by the amount actually
591
- // signed, and a call that signed nothing spends nothing.
663
+ // cap. With the signing guard on, the reservation is replaced by the
664
+ // amount actually signed, and each payload the guard let through is
665
+ // recorded as sent (ctx.sent). Without it the payment fetch is opaque:
666
+ // any request it made may have carried a signature.
592
667
  const reservation = this._spendReserve(host, usd, slug, listUsd, { always: guarded });
593
- const ctx = { reservation, signedUsd: null, refusal: null };
594
- const signedNothing = () => guarded && ctx.signedUsd == null;
668
+ const ctx = { reservation, signedUsd: null, refusal: null, sent: [] };
669
+ const sentPayment = () => !guarded || ctx.sent.length > 0;
595
670
  let settled = false;
596
671
  try {
597
672
  const r = await collect(await this._signCtx.run(ctx, () => paidSend(asyncHeaders, this.payFetch)));
598
673
  if (!r.ok) throw this._fail(slug, r, await this._failureDetail(slug, r));
599
- if (signedNothing()) this._spendRelease(reservation);
600
- else this._spendSettle(reservation); // confirm the reservation as settled spend
674
+ if (!sentPayment()) this._spendRelease(reservation);
675
+ else this._spendHold(reservation, guarded ? ctx.sent : null, { settleLast: true });
601
676
  settled = true;
602
677
  return this._deliverResponse(slug, r, cacheKey, cache, validator, { maxResponseBytes, paid: true });
603
678
  } catch (e) {
604
679
  // An invalid or oversized paid HTTP-success body is failed delivery,
605
- // not a rollback of money that already moved. A job still running
606
- // past the wait may yet settle, so its reservation stands.
607
- // A redirect after a signature was sent leaves that payment's outcome
608
- // unknown, so it counts against the caps, like a job still running.
609
- if ((e instanceof AsyncJobPendingError || e instanceof PaymentRedirectError) && !signedNothing()) this._spendSettle(reservation);
610
- else if (!settled) this._spendRelease(reservation);
680
+ // not a rollback of money that already moved. Any other failure once
681
+ // a payment header was sent (an error status, a dropped connection,
682
+ // a redirect, a job still running) leaves an authorization the seller
683
+ // can still redeem, so its signed amount is held, not released. Only
684
+ // a call that sent no payment releases its reservation.
685
+ if (!settled) {
686
+ if (sentPayment()) markOutcomeUnknown(e, slug, this._spendHold(reservation, guarded ? ctx.sent : null, { ownHost: this._unsettledAnswer(e) }), { seen: guarded });
687
+ else this._spendRelease(reservation);
688
+ }
611
689
  if (ctx.refusal) {
612
690
  const d = ctx.refusal;
613
691
  throw new SpendingLimitError(d.reason === "unknown-asset"
614
692
  ? `refusing to pay for "${slug}" - no payment option it asked to sign is in a known asset, so none can be priced`
693
+ : d.reason === "signed-amount" || d.reason === "signed-validity"
694
+ ? `refusing to pay for "${slug}" - ${d.detail}, so the payment was not sent`
695
+ : d.reason === "validity"
696
+ ? `refusing to pay for "${slug}" - ${d.detail}, so nothing was signed`
615
697
  : `refusing to pay $${d.priceUsd} for "${slug}" - the payment it asked to sign is over ${d.limit || "the cap"} ($${d.allowanceUsd} left to sign)`,
616
- { limit: d.limit || "signing", slug, priceUsd: d.priceUsd, cap: d.allowanceUsd, atSigning: true, cause: e });
698
+ { limit: d.limit || "signing", slug, priceUsd: d.priceUsd, cap: d.allowanceUsd, atSigning: true, cause: e, ...(e?.paid === "unknown" ? { paid: "unknown", heldUsd: e.heldUsd } : {}) });
617
699
  }
618
700
  throw e;
619
701
  }
@@ -642,7 +724,8 @@ export class Agent402 {
642
724
  settled = true;
643
725
  return this._deliverResponse(slug, r, cacheKey, cache, validator, { maxResponseBytes, paid: true });
644
726
  } catch (e) {
645
- if (e instanceof AsyncJobPendingError) this._spendSettle(reservation);
727
+ // A job still running may yet be debited, so its amount is held.
728
+ if (e instanceof AsyncJobPendingError) markOutcomeUnknown(e, slug, this._spendHold(reservation));
646
729
  else if (!settled) this._spendRelease(reservation);
647
730
  throw e;
648
731
  }
@@ -689,8 +772,10 @@ export class Agent402 {
689
772
  * which would sign the plain accept). Returns NO_GATEWAY_ACCEPT, with
690
773
  * nothing reserved or signed, when the answer is not a 402 or offers no
691
774
  * batched accept this signer may pay, so the caller falls back. Once the
692
- * signed header has been sent the amount is spent for the caps whatever
693
- * comes back; only a signer that throws releases the reservation.
775
+ * signed header has been sent its amount is held against the caps until a
776
+ * delivered answer settles it, or until the authorization can no longer
777
+ * settle and the rolling window has passed it; only a signer that throws
778
+ * releases the reservation.
694
779
  */
695
780
  async _gatewayCall(slug, tool, { paidSend, asyncHeaders, collect, cacheKey, cache, validator, maxResponseBytes }) {
696
781
  const pre = await paidSend();
@@ -700,14 +785,17 @@ export class Agent402 {
700
785
  if (!accept) return NO_GATEWAY_ACCEPT;
701
786
  const host = hostOf(this.baseUrl);
702
787
  const listUsd = parseUsd(tool.price);
703
- // The amount the accept asks for, priced from the known-asset table
704
- // (gatewayAcceptOf keeps only an accept that table can price); the
705
- // catalog's figure never lowers what the caps are checked against.
706
- const usd = Math.max(listUsd, acceptUsd(accept));
788
+ // The amount the accept asks for, read from the field the challenge's
789
+ // version names and priced from the known-asset table; an accept that
790
+ // version does not price is not paid here. The catalog's figure never
791
+ // lowers what the caps are checked against.
792
+ const acceptedUsd = acceptUsd(accept, Number(env?.x402Version));
793
+ if (acceptedUsd == null) return NO_GATEWAY_ACCEPT;
794
+ const usd = Math.max(listUsd, acceptedUsd);
707
795
  const reservation = this._spendReserve(host, usd, slug, listUsd);
708
- let header;
796
+ let header, payload;
709
797
  try {
710
- ({ header } = await signGatewayPayment(accept, { signer: this.gateway.signer, resource: env?.resource }));
798
+ ({ header, payload } = await signGatewayPayment(accept, { signer: this.gateway.signer, resource: env?.resource }));
711
799
  } catch (e) {
712
800
  // Nothing signed: the reservation is the only thing to undo.
713
801
  this._spendRelease(reservation);
@@ -715,26 +803,35 @@ export class Agent402 {
715
803
  }
716
804
  // From here the signed authorization leaves this process. It is a bearer
717
805
  // claim on the deposit until its validBefore whatever the seller answers
718
- // (a refusal, a failure, a redirect, a job still running), so it counts
719
- // as spent against the caps from the first send.
720
- this._spendSettle(reservation);
806
+ // (a refusal, a failure, a redirect, a job still running), so its signed
807
+ // amount is held against the caps from the first send.
808
+ const sent = [{ usd: acceptedUsd, until: signedValidUntilMs(payload, { maxSeconds: GATEWAY_MAX_TIMEOUT_SECONDS }) }];
809
+ const heldUsd = this._spendHold(reservation, sent);
721
810
  const paidHeaders = { "PAYMENT-SIGNATURE": header, ...asyncHeaders };
722
- let r = await collect(await paidSend(paidHeaders));
723
- let body = null;
724
- for (let resend = 0; ; resend++) {
725
- if (r.status !== 402) break;
726
- body = await readJsonQuietly(r);
727
- const pending = body && typeof body === "object" && (body.reason === "settlement-pending" || body.retry === "same-payment");
728
- if (!pending) break;
729
- // Gateway may yet settle it: the header is handed back so the same
730
- // payment can be resent later.
731
- if (resend >= GATEWAY_PENDING_RESENDS) throw new GatewayPendingError(slug, header, body);
732
- const retryAfterSec = Number(r.headers?.get?.("retry-after")) || Number(body.retryAfterSeconds) || 10;
733
- await this._sleep(Math.min(Math.max(0, retryAfterSec) * 1000, GATEWAY_PENDING_MAX_WAIT_MS));
811
+ let r;
812
+ try {
734
813
  r = await collect(await paidSend(paidHeaders));
814
+ let body = null;
815
+ for (let resend = 0; ; resend++) {
816
+ if (r.status !== 402) break;
817
+ body = await readJsonQuietly(r);
818
+ const pending = body && typeof body === "object" && (body.reason === "settlement-pending" || body.retry === "same-payment");
819
+ if (!pending) break;
820
+ // Gateway may yet settle it: the header is handed back so the same
821
+ // payment can be resent later.
822
+ if (resend >= GATEWAY_PENDING_RESENDS) throw new GatewayPendingError(slug, header, body);
823
+ const retryAfterSec = Number(r.headers?.get?.("retry-after")) || Number(body.retryAfterSeconds) || 10;
824
+ await this._sleep(Math.min(Math.max(0, retryAfterSec) * 1000, GATEWAY_PENDING_MAX_WAIT_MS));
825
+ r = await collect(await paidSend(paidHeaders));
826
+ }
827
+ if (r.status === 402) throw this._fail(slug, r, gatewayRefusalMessage(slug, body, accept, this.gateway.signer.address));
828
+ if (!r.ok) throw this._fail(slug, r, await this._failureDetail(slug, r));
829
+ } catch (e) {
830
+ if (this._unsettledAnswer(e)) this._spendHold(reservation, sent, { ownHost: true });
831
+ markOutcomeUnknown(e, slug, heldUsd);
832
+ throw e;
735
833
  }
736
- if (r.status === 402) throw this._fail(slug, r, gatewayRefusalMessage(slug, body, accept, this.gateway.signer.address));
737
- if (!r.ok) throw this._fail(slug, r, await this._failureDetail(slug, r));
834
+ this._spendSettle(reservation);
738
835
  return this._deliverResponse(slug, r, cacheKey, cache, validator, { maxResponseBytes, paid: true });
739
836
  }
740
837
 
@@ -835,6 +932,8 @@ export class Agent402 {
835
932
  * a caller can see the quote is not the number they budgeted from. */
836
933
  _spendCheck(host, usd, slug, listUsd = null) {
837
934
  const s = this._spend;
935
+ const now = Date.now();
936
+ s.log = s.log.filter((e) => countsAt(e, now));
838
937
  if (s.maxPerCall == null && s.daily == null && s.perHost == null) return;
839
938
  const quotedNote = listUsd != null && listUsd !== usd
840
939
  ? ` (this request is quoted $${usd}; the catalog lists $${listUsd})` : "";
@@ -843,22 +942,27 @@ export class Agent402 {
843
942
  `refusing to pay $${usd} for "${slug}" - exceeds maxPerCallUsd $${s.maxPerCall}${quotedNote}`,
844
943
  { limit: "maxPerCallUsd", slug, priceUsd: usd, cap: s.maxPerCall, ...(listUsd != null ? { listPriceUsd: listUsd } : {}) });
845
944
  }
846
- const cutoff = Date.now() - 24 * 60 * 60 * 1000;
847
- s.log = s.log.filter((e) => e.ts >= cutoff);
945
+ // Held payments count: each is a signed authorization that was sent and
946
+ // may still be redeemed. The refusal says how much of the total they are.
947
+ const heldOf = (entries) => entries.filter((e) => e.state === "held").reduce((a, e) => a + e.usd, 0);
948
+ const heldNote = (held) => held > 0 ? ` (including $${Number(held.toFixed(6))} held for sent payments whose outcome is unknown)` : "";
848
949
  if (s.daily != null) {
849
950
  const spent = s.log.reduce((a, e) => a + e.usd, 0);
850
951
  if (spent + usd > s.daily) {
952
+ const held = heldOf(s.log);
851
953
  throw new SpendingLimitError(
852
- `refusing to pay $${usd} for "${slug}" - would bring 24h spend to $${(spent + usd).toFixed(6)}, over dailyLimitUsd $${s.daily}`,
853
- { limit: "dailyLimitUsd", slug, priceUsd: usd, spent, cap: s.daily });
954
+ `refusing to pay $${usd} for "${slug}" - would bring 24h spend to $${(spent + usd).toFixed(6)}${heldNote(held)}, over dailyLimitUsd $${s.daily}`,
955
+ { limit: "dailyLimitUsd", slug, priceUsd: usd, spent, heldUsd: Number(held.toFixed(6)), cap: s.daily });
854
956
  }
855
957
  }
856
958
  if (s.perHost != null) {
857
- const spentHost = s.log.filter((e) => e.host === host).reduce((a, e) => a + e.usd, 0);
959
+ const onHost = s.log.filter((e) => e.host === host);
960
+ const spentHost = onHost.reduce((a, e) => a + e.usd, 0);
858
961
  if (spentHost + usd > s.perHost) {
962
+ const held = heldOf(onHost);
859
963
  throw new SpendingLimitError(
860
- `refusing to pay $${usd} for "${slug}" - would bring 24h spend to ${host} to $${(spentHost + usd).toFixed(6)}, over maxPerHostUsd $${s.perHost}`,
861
- { limit: "maxPerHostUsd", slug, host, priceUsd: usd, spent: spentHost, cap: s.perHost });
964
+ `refusing to pay $${usd} for "${slug}" - would bring 24h spend to ${host} to $${(spentHost + usd).toFixed(6)}${heldNote(held)}, over maxPerHostUsd $${s.perHost}`,
965
+ { limit: "maxPerHostUsd", slug, host, priceUsd: usd, spent: spentHost, heldUsd: Number(held.toFixed(6)), cap: s.perHost });
862
966
  }
863
967
  }
864
968
  }
@@ -874,8 +978,9 @@ export class Agent402 {
874
978
  * protected nothing and lost the part of the message an agent needs to
875
979
  * act on. A body that is not JSON contributes nothing but its status. */
876
980
  /** A refusal a caller can branch on: `status` (the HTTP status), `slug` and
877
- * `paid: false` ride on the Error, so a 402 is not something to regex out of
878
- * the message. Nothing was spent on any path that reaches this. */
981
+ * `paid` ride on the Error, so a 402 is not something to regex out of the
982
+ * message. `paid: false` here; a failure after a payment header was sent
983
+ * is re-marked `paid: "unknown"` (markOutcomeUnknown) by the paid path. */
879
984
  _fail(slug, r, message) {
880
985
  return Object.assign(new Error(message), { status: r?.status, slug, paid: false });
881
986
  }
@@ -907,29 +1012,74 @@ export class Agent402 {
907
1012
  _spendReserve(host, usd, slug, listUsd = null, { always = false } = {}) {
908
1013
  this._spendCheck(host, usd, slug, listUsd);
909
1014
  if (!(usd > 0) && !always) return null;
910
- const entry = { ts: Date.now(), host, usd, pending: true };
1015
+ const entry = { ts: Date.now(), host, usd, state: "reserved" };
911
1016
  this._spend.log.push(entry);
912
1017
  return entry;
913
1018
  }
914
- /** Confirm a reservation as settled spend. */
915
- _spendSettle(entry) { if (entry) entry.pending = false; }
916
- /** Roll back a reservation whose call did not settle (failed / errored). */
1019
+ /** Confirm a reservation (or a hold) as settled spend. */
1020
+ _spendSettle(entry) { if (entry) { entry.state = "settled"; entry.until = null; entry.ownHost = false; } }
1021
+ /** True when a paid call failed on an answer the built-in seller does not
1022
+ * settle: an error status (400 or above) from DEFAULT_BASE_URL's exact
1023
+ * origin. A Gateway payment still pending, a job still running, a redirect
1024
+ * or a dropped connection may have settled, so none of them counts. */
1025
+ _unsettledAnswer(e) {
1026
+ return isDefaultOrigin(this.baseUrl) && e instanceof Error && e.paid === false
1027
+ && Number.isInteger(e.status) && e.status >= 400 && !(e instanceof GatewayPendingError);
1028
+ }
1029
+ /** Hold a reservation whose payment header was sent: it counts against the
1030
+ * caps until its authorization can no longer settle (`until`) and the
1031
+ * rolling window has passed it. `sent` lists each payload that left the
1032
+ * process ({ usd, until }); the reservation holds the first, and each
1033
+ * further one gets its own entry. With `settleLast`, the last payload is
1034
+ * the one a delivered answer settled, so it is booked settled instead.
1035
+ * null `sent` holds the reservation at its own amount, expiry unknown.
1036
+ * Returns the total now held. */
1037
+ _spendHold(entry, sent = null, { settleLast = false, ownHost = false } = {}) {
1038
+ if (!entry) return 0;
1039
+ const list = sent && sent.length ? sent : [{ usd: entry.usd, until: null }];
1040
+ const blindUntil = Date.now() + DEFAULT_HOST_BLIND_HOLD_MS;
1041
+ const book = (e, p, state) => {
1042
+ e.usd = p.usd; e.state = state;
1043
+ e.ownHost = state === "held" && ownHost;
1044
+ e.until = state !== "held" ? null : p.until ?? (e.ownHost ? blindUntil : null);
1045
+ };
1046
+ let held = 0;
1047
+ list.forEach((p, i) => {
1048
+ const state = settleLast && i === list.length - 1 ? "settled" : "held";
1049
+ if (state === "held") held += p.usd;
1050
+ if (i === 0) book(entry, p, state);
1051
+ else { const e = { ts: entry.ts, host: entry.host }; book(e, p, state); this._spend.log.push(e); }
1052
+ });
1053
+ return Number(held.toFixed(6));
1054
+ }
1055
+ /** Roll back a reservation whose call sent no payment. */
917
1056
  _spendRelease(entry) {
918
1057
  if (!entry) return;
919
1058
  const i = this._spend.log.indexOf(entry);
920
1059
  if (i >= 0) this._spend.log.splice(i, 1);
921
1060
  }
922
1061
 
923
- /** Rolling-24h spend summary (settled paid calls only) - for observability. */
1062
+ /** Rolling-24h spend summary, for observability. `dailyUsd`, `calls` and
1063
+ * `byHost` are settled paid calls; `heldUsd`, `heldCalls` and `heldByHost`
1064
+ * are payments that were sent without a delivered answer, still counted
1065
+ * against the caps because their authorizations may yet be redeemed. */
924
1066
  spendingSummary() {
925
- const cutoff = Date.now() - 24 * 60 * 60 * 1000;
926
- const log = this._spend.log.filter((e) => e.ts >= cutoff && !e.pending);
927
- const byHost = {};
928
- for (const e of log) byHost[e.host] = Number(((byHost[e.host] || 0) + e.usd).toFixed(6));
1067
+ const now = Date.now();
1068
+ const live = this._spend.log.filter((e) => countsAt(e, now));
1069
+ const tally = (entries) => {
1070
+ const byHost = {};
1071
+ for (const e of entries) byHost[e.host] = Number(((byHost[e.host] || 0) + e.usd).toFixed(6));
1072
+ return { usd: Number(entries.reduce((a, e) => a + e.usd, 0).toFixed(6)), calls: entries.length, byHost };
1073
+ };
1074
+ const settled = tally(live.filter((e) => e.state === "settled"));
1075
+ const held = tally(live.filter((e) => e.state === "held"));
929
1076
  return {
930
- dailyUsd: Number(log.reduce((a, e) => a + e.usd, 0).toFixed(6)),
931
- calls: log.length,
932
- byHost,
1077
+ dailyUsd: settled.usd,
1078
+ calls: settled.calls,
1079
+ byHost: settled.byHost,
1080
+ heldUsd: held.usd,
1081
+ heldCalls: held.calls,
1082
+ heldByHost: held.byHost,
933
1083
  limits: { maxPerCallUsd: this._spend.maxPerCall, dailyLimitUsd: this._spend.daily, maxPerHostUsd: this._spend.perHost },
934
1084
  };
935
1085
  }
@@ -997,7 +1147,7 @@ export class GatewayPendingError extends Error {
997
1147
  this.slug = slug;
998
1148
  this.paymentHeader = paymentHeader;
999
1149
  this.status = 402;
1000
- this.paid = null;
1150
+ this.paid = "unknown";
1001
1151
  this.retryAfterSeconds = Number(body?.retryAfterSeconds) || null;
1002
1152
  }
1003
1153
  }
@@ -1035,7 +1185,7 @@ export function gatewayAcceptOf(accepts, networks = null) {
1035
1185
  && EVM_ADDR.test(String(a.payTo || "")) && EVM_ADDR.test(String(a.extra.verifyingContract || ""))
1036
1186
  && typeof a.extra.version === "string" && /^[0-9]{1,4}$/.test(a.extra.version)
1037
1187
  && /^[0-9]{1,78}$/.test(String(a.amount ?? "")) && Number(a.maxTimeoutSeconds) > 0 && Number(a.maxTimeoutSeconds) <= GATEWAY_MAX_TIMEOUT_SECONDS
1038
- && acceptUsd(a) != null) || null;
1188
+ && acceptUsd(a, 2) != null) || null;
1039
1189
  }
1040
1190
 
1041
1191
  /**
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;
@@ -111,6 +134,51 @@ export function quoteOf(accepts) {
111
134
  return { max, min, unknown, total: accepts.length };
112
135
  }
113
136
 
137
+ // The longest a signed payment may stay redeemable. An EVM "exact" signer
138
+ // sets validBefore (EIP-3009) or deadline (Permit2) to the moment of signing
139
+ // plus the accept's maxTimeoutSeconds, and the signed payload is a bearer
140
+ // claim until then, so a seller that quotes years gets an authorization it
141
+ // could redeem for years and a held amount that never stops counting. Honest
142
+ // accepts ask for minutes (Agent402's own ask 300 s); a day admits every
143
+ // one of them. An accept that asks for longer is dropped before selection,
144
+ // and a payload signed past it is refused before it is sent.
145
+ export const MAX_VALIDITY_SECONDS = 24 * 3600;
146
+ // Signing reads the clock a moment before the check after it does.
147
+ const VALIDITY_CLOCK_SLACK_SECONDS = 60;
148
+
149
+ /** True when an accept asks for a validity window past MAX_VALIDITY_SECONDS.
150
+ * An accept with no readable maxTimeoutSeconds is left to the check of what
151
+ * was signed (signedValidityProblem). */
152
+ export function acceptValidityTooLong(accept, maxSeconds = MAX_VALIDITY_SECONDS) {
153
+ const raw = accept?.maxTimeoutSeconds;
154
+ if (raw == null || raw === "") return false;
155
+ const n = Number(raw);
156
+ return !Number.isFinite(n) || n > maxSeconds;
157
+ }
158
+
159
+ /** The expiry a signed payload carries, in unix seconds: an EIP-3009
160
+ * `authorization.validBefore` or a Permit2 `deadline`. null when the payload
161
+ * names none (a Solana transaction, an unknown scheme). NaN when it names
162
+ * one that is not a whole number of seconds. */
163
+ export function signedExpirySeconds(paymentPayload) {
164
+ const p = paymentPayload?.payload;
165
+ const v = p?.authorization?.validBefore ?? p?.permit2Authorization?.deadline;
166
+ if (v == null) return null;
167
+ return /^\d{1,15}$/.test(String(v)) ? Number(v) : NaN;
168
+ }
169
+
170
+ /** Why a created payment payload stays redeemable for too long, or null: its
171
+ * expiry is past `nowMs` plus `maxSeconds` (and a minute of clock slack), or
172
+ * it names an expiry that cannot be read. */
173
+ export function signedValidityProblem(paymentPayload, { maxSeconds = MAX_VALIDITY_SECONDS, nowMs = Date.now() } = {}) {
174
+ const exp = signedExpirySeconds(paymentPayload);
175
+ if (exp == null) return null;
176
+ if (!Number.isFinite(exp)) return "the signed authorization's expiry cannot be read";
177
+ const left = exp - Math.floor(nowMs / 1000);
178
+ if (left > maxSeconds + VALIDITY_CLOCK_SLACK_SECONDS) return `the signed authorization stays valid for ${left} seconds, past the ${maxSeconds}-second ceiling`;
179
+ return null;
180
+ }
181
+
114
182
  /** Enforce a USD allowance at signing on an @x402/core x402Client.
115
183
  *
116
184
  * `allowance()` is the most this buyer may sign for the payment being built
@@ -118,8 +186,10 @@ export function quoteOf(accepts) {
118
186
  * for none). A registered policy drops every accept that cannot be priced or
119
187
  * is over the allowance, so the client selects among the ones that fit, and a
120
188
  * hook before payload creation checks the selected accept again and passes
121
- * its USD value to `onSign`. A refusal throws (from the policy) or aborts
122
- * (from the hook) before any signature exists; `onRefuse` hears why first. */
189
+ * its USD value to `onSign`. An accept asking for a validity window past
190
+ * MAX_VALIDITY_SECONDS is dropped and refused the same way. A refusal throws
191
+ * (from the policy) or aborts (from the hook) before any signature exists;
192
+ * `onRefuse` hears why first. */
123
193
  export function guardSigning(client, { allowance, onSign = null, onRefuse = null }) {
124
194
  if (!client || typeof client.registerPolicy !== "function" || typeof client.onBeforePaymentCreation !== "function") {
125
195
  throw new TypeError("guardSigning needs an @x402/core x402Client (registerPolicy and onBeforePaymentCreation)");
@@ -128,9 +198,14 @@ export function guardSigning(client, { allowance, onSign = null, onRefuse = null
128
198
  const refuse = (detail) => { try { onRefuse?.(detail); } catch { /* the refusal stands */ } return detail; };
129
199
  client.registerPolicy((version, accepts) => {
130
200
  const cap = allowance();
131
- const kept = accepts.filter((a) => { const usd = acceptUsd(a); return usd != null && usd <= cap; });
201
+ const priced = accepts.filter((a) => { const usd = acceptUsd(a, version); return usd != null && usd <= cap; });
202
+ const kept = priced.filter((a) => !acceptValidityTooLong(a));
203
+ if (!kept.length && priced.length) {
204
+ const d = refuse({ reason: "validity", priceUsd: null, allowanceUsd: cap, detail: `every payment option it may sign asks to stay valid for more than ${MAX_VALIDITY_SECONDS} seconds` });
205
+ throw new Error(`spending guard: ${d.detail}; nothing was signed`);
206
+ }
132
207
  if (!kept.length) {
133
- const q = quoteOf(accepts);
208
+ const q = quoteOf(accepts, version);
134
209
  const d = refuse({ reason: q && q.min != null ? "over-allowance" : "unknown-asset", priceUsd: q ? q.min : null, allowanceUsd: cap });
135
210
  throw new Error(d.reason === "unknown-asset"
136
211
  ? "spending guard: no payment option is in a known asset, so none can be priced; nothing was signed"
@@ -138,8 +213,8 @@ export function guardSigning(client, { allowance, onSign = null, onRefuse = null
138
213
  }
139
214
  return kept;
140
215
  });
141
- client.onBeforePaymentCreation(async ({ selectedRequirements: r }) => {
142
- const usd = acceptUsd(r);
216
+ client.onBeforePaymentCreation(async ({ paymentRequired, selectedRequirements: r }) => {
217
+ const usd = acceptUsd(r, paymentRequired?.x402Version);
143
218
  const cap = allowance();
144
219
  if (usd == null) {
145
220
  refuse({ reason: "unknown-asset", priceUsd: null, allowanceUsd: cap });
@@ -149,8 +224,106 @@ export function guardSigning(client, { allowance, onSign = null, onRefuse = null
149
224
  refuse({ reason: "over-allowance", priceUsd: usd, allowanceUsd: cap });
150
225
  return { abort: true, reason: `spending guard: the selected payment option asks $${usd}, above the $${cap} this buyer may sign; nothing was signed` };
151
226
  }
227
+ if (acceptValidityTooLong(r)) {
228
+ const d = refuse({ reason: "validity", priceUsd: usd, allowanceUsd: cap, detail: `the selected payment option asks to stay valid for ${r.maxTimeoutSeconds} seconds, past the ${MAX_VALIDITY_SECONDS}-second ceiling` });
229
+ return { abort: true, reason: `spending guard: ${d.detail}; nothing was signed` };
230
+ }
152
231
  onSign?.(usd, r);
153
232
  return undefined;
154
233
  });
234
+ guardSignedAmount(client, { allowance, onRefuse: refuse });
235
+ return client;
236
+ }
237
+
238
+ // The token programs a Solana "exact" payment moves its asset through, and
239
+ // the TransferChecked instruction's tag.
240
+ const SOLANA_TOKEN_PROGRAMS = new Set(["TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"]);
241
+ const TRANSFER_CHECKED = 12;
242
+
243
+ /** The amount a Solana payment transaction moves: its one token-program
244
+ * instruction, which must be a TransferChecked, read as base units and
245
+ * decimals. Decoded with @solana/kit, the peer every Solana x402 signer
246
+ * already loads. null when the transaction cannot be read or does not hold
247
+ * exactly that one transfer. */
248
+ async function solanaTransferOf(transaction) {
249
+ if (typeof transaction !== "string" || !transaction) return null;
250
+ try {
251
+ const kit = await import("@solana/kit");
252
+ const tx = kit.getTransactionDecoder().decode(kit.getBase64Encoder().encode(transaction));
253
+ const msg = kit.getCompiledTransactionMessageDecoder().decode(tx.messageBytes);
254
+ const keys = msg.staticAccounts || [];
255
+ const ixs = msg.version === 1
256
+ ? (msg.instructionHeaders || []).map((h, i) => ({ program: keys[h.programAccountIndex], data: msg.instructionPayloads?.[i]?.instructionData }))
257
+ : (msg.instructions || []).map((ix) => ({ program: keys[ix.programAddressIndex], data: ix.data }));
258
+ const token = ixs.filter((ix) => SOLANA_TOKEN_PROGRAMS.has(String(ix.program)));
259
+ if (token.length !== 1) return null;
260
+ const d = token[0].data;
261
+ if (!d || d.length !== 10 || d[0] !== TRANSFER_CHECKED) return null;
262
+ let amount = 0n;
263
+ for (let i = 8; i >= 1; i--) amount = (amount << 8n) | BigInt(d[i]);
264
+ return { amount: amount.toString(), decimals: d[9] };
265
+ } catch {
266
+ return null;
267
+ }
268
+ }
269
+
270
+ /** The base-unit amount a created x402 payment payload commits the buyer to,
271
+ * read from what was signed: an EVM EIP-3009 `authorization.value`, an EVM
272
+ * Permit2 `permitted.amount`, or a Solana transaction's transfer. Returns
273
+ * `{ amount, decimals? }`, `{ unchecked: true }` for a network whose payload
274
+ * this reader does not decode (neither EVM nor Solana), or null when an EVM or
275
+ * Solana payload holds no amount it can read. */
276
+ export async function signedAmountOf(paymentPayload, network) {
277
+ const p = paymentPayload?.payload;
278
+ const net = String(network || "");
279
+ if (net.startsWith("eip155:")) {
280
+ const vals = [p?.authorization?.value, p?.permit2Authorization?.permitted?.amount].filter((x) => x != null);
281
+ if (vals.length !== 1 || !/^\d+$/.test(String(vals[0]))) return null;
282
+ return { amount: BigInt(String(vals[0])).toString() };
283
+ }
284
+ if (net.startsWith("solana:")) return solanaTransferOf(p?.transaction);
285
+ return { unchecked: true };
286
+ }
287
+
288
+ /** Check a created payment payload against the accept it was built for,
289
+ * before it is sent: the amount that was signed must be the amount the
290
+ * allowance was checked against (acceptAmount under the challenge's
291
+ * version), in the known asset's decimals, and within `cap`. Returns null
292
+ * when it holds, else why not. */
293
+ export async function signedPaymentProblem(paymentPayload, requirements, x402Version, cap = Infinity) {
294
+ const expected = acceptAmount(requirements, x402Version);
295
+ const entry = knownAsset(requirements?.network, requirements?.asset);
296
+ if (expected == null) return "the selected payment option's amount cannot be read";
297
+ const signed = await signedAmountOf(paymentPayload, requirements?.network);
298
+ if (signed == null) return "the signed payment's amount cannot be read";
299
+ if (signed.unchecked) return null;
300
+ if (signed.amount !== BigInt(expected).toString()) return `the signed amount (${signed.amount}) differs from the priced amount (${expected})`;
301
+ if (signed.decimals != null && entry && signed.decimals !== entry.decimals) return "the signed transfer's decimals differ from the known asset's";
302
+ if (Number.isFinite(cap)) {
303
+ if (!entry) return "the selected payment option is not in a known asset";
304
+ const usd = Number(signed.amount) / 10 ** entry.decimals;
305
+ if (usd > cap) return `the signed amount asks ${usd}, above the ${cap} this buyer may sign`;
306
+ }
307
+ return null;
308
+ }
309
+
310
+ /** Refuse to send a payment payload whose signed amount is not the one the
311
+ * allowance passed, or whose expiry is past MAX_VALIDITY_SECONDS from now
312
+ * (signedValidityProblem): registered after payload creation, so the check
313
+ * reads what was actually signed, and a throw here stops the payment-aware
314
+ * fetch before the payload leaves the process. guardSigning registers it; a
315
+ * client with its own selector (and no guardSigning) can register it alone. */
316
+ export function guardSignedAmount(client, { allowance = () => Infinity, onRefuse = null } = {}) {
317
+ if (!client || typeof client.onAfterPaymentCreation !== "function") {
318
+ throw new TypeError("guardSignedAmount needs an @x402/core x402Client (onAfterPaymentCreation)");
319
+ }
320
+ client.onAfterPaymentCreation(async ({ paymentRequired, selectedRequirements: r, paymentPayload }) => {
321
+ const cap = allowance();
322
+ const amountProblem = await signedPaymentProblem(paymentPayload, r, paymentRequired?.x402Version, cap);
323
+ const problem = amountProblem || signedValidityProblem(paymentPayload);
324
+ if (!problem) return;
325
+ try { onRefuse?.({ reason: amountProblem ? "signed-amount" : "signed-validity", priceUsd: null, allowanceUsd: cap, detail: problem }); } catch { /* the refusal stands */ }
326
+ throw new Error(`spending guard: ${problem}; the payment was not sent`);
327
+ });
155
328
  return client;
156
329
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent402-client",
3
- "version": "0.8.22",
3
+ "version": "0.8.24",
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",