agent402-client 0.8.22 → 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 +30 -8
- package/index.js +219 -73
- package/known-assets.js +135 -20
- 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
|
-
|
|
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,32 @@ try {
|
|
|
388
389
|
}
|
|
389
390
|
```
|
|
390
391
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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.
|
|
396
418
|
|
|
397
419
|
**Caps are enforced on the payment that is signed.** Pass the `x402Client` your
|
|
398
420
|
`fetch` wraps (or call `a.guardX402Client(client)`) and the client registers a
|
package/index.js
CHANGED
|
@@ -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.
|
|
35
|
+
const VERSION = "0.8.23";
|
|
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,62 @@ 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. 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
|
+
|
|
113
169
|
// A pre-wrapped payment fetch with no x402Client is the one mode where the caps
|
|
114
170
|
// are checked on an unpaid preflight only, not on the payment that is signed.
|
|
115
171
|
// Said once per process, on the first capped paid call in that mode.
|
|
@@ -164,7 +220,7 @@ export class Agent402 {
|
|
|
164
220
|
* @param {function} [opts.onProgress] called with each job document
|
|
165
221
|
* ({ status, stage, progress, etaSeconds, ... }) while a job runs
|
|
166
222
|
*/
|
|
167
|
-
constructor({ baseUrl =
|
|
223
|
+
constructor({ baseUrl = DEFAULT_BASE_URL, fetch: payFetch, cache = true, fetchImpl = undefined, tlsDistrust = true,
|
|
168
224
|
maxPerCallUsd = null, dailyLimitUsd = null, maxPerHostUsd = null, creditsKey = null, x402Client = null, payNetworks = null,
|
|
169
225
|
maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, outputValidator = null,
|
|
170
226
|
respondAsync = "auto", asyncTimeoutMs = DEFAULT_ASYNC_TIMEOUT_MS, onProgress = null, gateway = null, userAgent = null, retryResets = true } = {}) {
|
|
@@ -204,13 +260,16 @@ export class Agent402 {
|
|
|
204
260
|
// failure mode): optional hard ceilings enforced BEFORE any payment is
|
|
205
261
|
// signed. A malicious or misconfigured 402 that quotes an inflated price is
|
|
206
262
|
// refused instead of paid. null = no limit (default - behavior unchanged).
|
|
207
|
-
//
|
|
208
|
-
//
|
|
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.
|
|
209
268
|
this._spend = {
|
|
210
269
|
maxPerCall: numOrNull(maxPerCallUsd),
|
|
211
270
|
daily: numOrNull(dailyLimitUsd),
|
|
212
271
|
perHost: numOrNull(maxPerHostUsd),
|
|
213
|
-
log: [], // [{ ts, host, usd
|
|
272
|
+
log: [], // [{ ts, host, usd, state: "reserved"|"held"|"settled", until? }]
|
|
214
273
|
};
|
|
215
274
|
// The paid call being signed right now, so the signing guard can tell this
|
|
216
275
|
// call's own reservation from every other one (see guardX402Client).
|
|
@@ -254,6 +313,15 @@ export class Agent402 {
|
|
|
254
313
|
if (ctx) ctx.refusal = { ...detail, limit: this._signAllowance().limit };
|
|
255
314
|
},
|
|
256
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
|
+
});
|
|
257
325
|
this._guarded = true;
|
|
258
326
|
return client;
|
|
259
327
|
}
|
|
@@ -263,15 +331,19 @@ export class Agent402 {
|
|
|
263
331
|
* (this call's own one is what is being replaced). */
|
|
264
332
|
_signAllowance() {
|
|
265
333
|
const s = this._spend;
|
|
266
|
-
const
|
|
267
|
-
const
|
|
268
|
-
|
|
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);
|
|
269
341
|
const host = hostOf(this.baseUrl);
|
|
270
342
|
let cap = Infinity, limit = null;
|
|
271
343
|
const tighten = (value, name) => { if (value < cap) { cap = value; limit = name; } };
|
|
272
344
|
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");
|
|
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");
|
|
275
347
|
return { cap: Math.max(0, cap), limit };
|
|
276
348
|
}
|
|
277
349
|
|
|
@@ -586,34 +658,40 @@ export class Agent402 {
|
|
|
586
658
|
const usd = await this._quotedUsd(paidSend, listUsd, { slug, mode: guarded ? "guarded" : "wallet" });
|
|
587
659
|
// Reserve the amount synchronously (before the await) so concurrent calls
|
|
588
660
|
// can't each observe the pre-commit total and collectively blow a rolling
|
|
589
|
-
// cap
|
|
590
|
-
//
|
|
591
|
-
//
|
|
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.
|
|
592
665
|
const reservation = this._spendReserve(host, usd, slug, listUsd, { always: guarded });
|
|
593
|
-
const ctx = { reservation, signedUsd: null, refusal: null };
|
|
594
|
-
const
|
|
666
|
+
const ctx = { reservation, signedUsd: null, refusal: null, sent: [] };
|
|
667
|
+
const sentPayment = () => !guarded || ctx.sent.length > 0;
|
|
595
668
|
let settled = false;
|
|
596
669
|
try {
|
|
597
670
|
const r = await collect(await this._signCtx.run(ctx, () => paidSend(asyncHeaders, this.payFetch)));
|
|
598
671
|
if (!r.ok) throw this._fail(slug, r, await this._failureDetail(slug, r));
|
|
599
|
-
if (
|
|
600
|
-
else this.
|
|
672
|
+
if (!sentPayment()) this._spendRelease(reservation);
|
|
673
|
+
else this._spendHold(reservation, guarded ? ctx.sent : null, { settleLast: true });
|
|
601
674
|
settled = true;
|
|
602
675
|
return this._deliverResponse(slug, r, cacheKey, cache, validator, { maxResponseBytes, paid: true });
|
|
603
676
|
} catch (e) {
|
|
604
677
|
// An invalid or oversized paid HTTP-success body is failed delivery,
|
|
605
|
-
// not a rollback of money that already moved.
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
|
|
610
|
-
|
|
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
|
+
}
|
|
611
687
|
if (ctx.refusal) {
|
|
612
688
|
const d = ctx.refusal;
|
|
613
689
|
throw new SpendingLimitError(d.reason === "unknown-asset"
|
|
614
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`
|
|
615
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)`,
|
|
616
|
-
{ 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 } : {}) });
|
|
617
695
|
}
|
|
618
696
|
throw e;
|
|
619
697
|
}
|
|
@@ -642,7 +720,8 @@ export class Agent402 {
|
|
|
642
720
|
settled = true;
|
|
643
721
|
return this._deliverResponse(slug, r, cacheKey, cache, validator, { maxResponseBytes, paid: true });
|
|
644
722
|
} catch (e) {
|
|
645
|
-
|
|
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));
|
|
646
725
|
else if (!settled) this._spendRelease(reservation);
|
|
647
726
|
throw e;
|
|
648
727
|
}
|
|
@@ -689,8 +768,10 @@ export class Agent402 {
|
|
|
689
768
|
* which would sign the plain accept). Returns NO_GATEWAY_ACCEPT, with
|
|
690
769
|
* nothing reserved or signed, when the answer is not a 402 or offers no
|
|
691
770
|
* batched accept this signer may pay, so the caller falls back. Once the
|
|
692
|
-
* signed header has been sent
|
|
693
|
-
*
|
|
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.
|
|
694
775
|
*/
|
|
695
776
|
async _gatewayCall(slug, tool, { paidSend, asyncHeaders, collect, cacheKey, cache, validator, maxResponseBytes }) {
|
|
696
777
|
const pre = await paidSend();
|
|
@@ -700,14 +781,17 @@ export class Agent402 {
|
|
|
700
781
|
if (!accept) return NO_GATEWAY_ACCEPT;
|
|
701
782
|
const host = hostOf(this.baseUrl);
|
|
702
783
|
const listUsd = parseUsd(tool.price);
|
|
703
|
-
// The amount the accept asks for,
|
|
704
|
-
//
|
|
705
|
-
//
|
|
706
|
-
|
|
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);
|
|
707
791
|
const reservation = this._spendReserve(host, usd, slug, listUsd);
|
|
708
|
-
let header;
|
|
792
|
+
let header, payload;
|
|
709
793
|
try {
|
|
710
|
-
({ header } = await signGatewayPayment(accept, { signer: this.gateway.signer, resource: env?.resource }));
|
|
794
|
+
({ header, payload } = await signGatewayPayment(accept, { signer: this.gateway.signer, resource: env?.resource }));
|
|
711
795
|
} catch (e) {
|
|
712
796
|
// Nothing signed: the reservation is the only thing to undo.
|
|
713
797
|
this._spendRelease(reservation);
|
|
@@ -715,26 +799,35 @@ export class Agent402 {
|
|
|
715
799
|
}
|
|
716
800
|
// From here the signed authorization leaves this process. It is a bearer
|
|
717
801
|
// claim on the deposit until its validBefore whatever the seller answers
|
|
718
|
-
// (a refusal, a failure, a redirect, a job still running), so
|
|
719
|
-
//
|
|
720
|
-
|
|
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);
|
|
721
806
|
const paidHeaders = { "PAYMENT-SIGNATURE": header, ...asyncHeaders };
|
|
722
|
-
let r
|
|
723
|
-
|
|
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));
|
|
807
|
+
let r;
|
|
808
|
+
try {
|
|
734
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;
|
|
735
829
|
}
|
|
736
|
-
|
|
737
|
-
if (!r.ok) throw this._fail(slug, r, await this._failureDetail(slug, r));
|
|
830
|
+
this._spendSettle(reservation);
|
|
738
831
|
return this._deliverResponse(slug, r, cacheKey, cache, validator, { maxResponseBytes, paid: true });
|
|
739
832
|
}
|
|
740
833
|
|
|
@@ -835,6 +928,8 @@ export class Agent402 {
|
|
|
835
928
|
* a caller can see the quote is not the number they budgeted from. */
|
|
836
929
|
_spendCheck(host, usd, slug, listUsd = null) {
|
|
837
930
|
const s = this._spend;
|
|
931
|
+
const now = Date.now();
|
|
932
|
+
s.log = s.log.filter((e) => countsAt(e, now));
|
|
838
933
|
if (s.maxPerCall == null && s.daily == null && s.perHost == null) return;
|
|
839
934
|
const quotedNote = listUsd != null && listUsd !== usd
|
|
840
935
|
? ` (this request is quoted $${usd}; the catalog lists $${listUsd})` : "";
|
|
@@ -843,22 +938,27 @@ export class Agent402 {
|
|
|
843
938
|
`refusing to pay $${usd} for "${slug}" - exceeds maxPerCallUsd $${s.maxPerCall}${quotedNote}`,
|
|
844
939
|
{ limit: "maxPerCallUsd", slug, priceUsd: usd, cap: s.maxPerCall, ...(listUsd != null ? { listPriceUsd: listUsd } : {}) });
|
|
845
940
|
}
|
|
846
|
-
|
|
847
|
-
|
|
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)` : "";
|
|
848
945
|
if (s.daily != null) {
|
|
849
946
|
const spent = s.log.reduce((a, e) => a + e.usd, 0);
|
|
850
947
|
if (spent + usd > s.daily) {
|
|
948
|
+
const held = heldOf(s.log);
|
|
851
949
|
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 });
|
|
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 });
|
|
854
952
|
}
|
|
855
953
|
}
|
|
856
954
|
if (s.perHost != null) {
|
|
857
|
-
const
|
|
955
|
+
const onHost = s.log.filter((e) => e.host === host);
|
|
956
|
+
const spentHost = onHost.reduce((a, e) => a + e.usd, 0);
|
|
858
957
|
if (spentHost + usd > s.perHost) {
|
|
958
|
+
const held = heldOf(onHost);
|
|
859
959
|
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 });
|
|
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 });
|
|
862
962
|
}
|
|
863
963
|
}
|
|
864
964
|
}
|
|
@@ -874,8 +974,9 @@ export class Agent402 {
|
|
|
874
974
|
* protected nothing and lost the part of the message an agent needs to
|
|
875
975
|
* act on. A body that is not JSON contributes nothing but its status. */
|
|
876
976
|
/** A refusal a caller can branch on: `status` (the HTTP status), `slug` and
|
|
877
|
-
* `paid
|
|
878
|
-
*
|
|
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. */
|
|
879
980
|
_fail(slug, r, message) {
|
|
880
981
|
return Object.assign(new Error(message), { status: r?.status, slug, paid: false });
|
|
881
982
|
}
|
|
@@ -907,29 +1008,74 @@ export class Agent402 {
|
|
|
907
1008
|
_spendReserve(host, usd, slug, listUsd = null, { always = false } = {}) {
|
|
908
1009
|
this._spendCheck(host, usd, slug, listUsd);
|
|
909
1010
|
if (!(usd > 0) && !always) return null;
|
|
910
|
-
const entry = { ts: Date.now(), host, usd,
|
|
1011
|
+
const entry = { ts: Date.now(), host, usd, state: "reserved" };
|
|
911
1012
|
this._spend.log.push(entry);
|
|
912
1013
|
return entry;
|
|
913
1014
|
}
|
|
914
|
-
/** Confirm a reservation as settled spend. */
|
|
915
|
-
_spendSettle(entry) { if (entry) entry.
|
|
916
|
-
/**
|
|
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. */
|
|
917
1052
|
_spendRelease(entry) {
|
|
918
1053
|
if (!entry) return;
|
|
919
1054
|
const i = this._spend.log.indexOf(entry);
|
|
920
1055
|
if (i >= 0) this._spend.log.splice(i, 1);
|
|
921
1056
|
}
|
|
922
1057
|
|
|
923
|
-
/** Rolling-24h spend summary
|
|
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. */
|
|
924
1062
|
spendingSummary() {
|
|
925
|
-
const
|
|
926
|
-
const
|
|
927
|
-
const
|
|
928
|
-
|
|
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"));
|
|
929
1072
|
return {
|
|
930
|
-
dailyUsd:
|
|
931
|
-
calls:
|
|
932
|
-
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,
|
|
933
1079
|
limits: { maxPerCallUsd: this._spend.maxPerCall, dailyLimitUsd: this._spend.daily, maxPerHostUsd: this._spend.perHost },
|
|
934
1080
|
};
|
|
935
1081
|
}
|
|
@@ -997,7 +1143,7 @@ export class GatewayPendingError extends Error {
|
|
|
997
1143
|
this.slug = slug;
|
|
998
1144
|
this.paymentHeader = paymentHeader;
|
|
999
1145
|
this.status = 402;
|
|
1000
|
-
this.paid =
|
|
1146
|
+
this.paid = "unknown";
|
|
1001
1147
|
this.retryAfterSeconds = Number(body?.retryAfterSeconds) || null;
|
|
1002
1148
|
}
|
|
1003
1149
|
}
|
|
@@ -1035,7 +1181,7 @@ export function gatewayAcceptOf(accepts, networks = null) {
|
|
|
1035
1181
|
&& EVM_ADDR.test(String(a.payTo || "")) && EVM_ADDR.test(String(a.extra.verifyingContract || ""))
|
|
1036
1182
|
&& typeof a.extra.version === "string" && /^[0-9]{1,4}$/.test(a.extra.version)
|
|
1037
1183
|
&& /^[0-9]{1,78}$/.test(String(a.amount ?? "")) && Number(a.maxTimeoutSeconds) > 0 && Number(a.maxTimeoutSeconds) <= GATEWAY_MAX_TIMEOUT_SECONDS
|
|
1038
|
-
&& acceptUsd(a) != null) || null;
|
|
1184
|
+
&& acceptUsd(a, 2) != null) || null;
|
|
1039
1185
|
}
|
|
1040
1186
|
|
|
1041
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
|
|
4
|
-
// accept's `asset
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
|
89
|
-
* `maxAmountRequired`
|
|
90
|
-
*
|
|
91
|
-
|
|
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
|
|
94
|
-
|
|
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.
|
|
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",
|