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.
- package/README.md +33 -8
- package/index.js +225 -75
- package/known-assets.js +195 -22
- 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,35 @@ 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). 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.
|
|
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 =
|
|
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
|
-
//
|
|
208
|
-
//
|
|
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
|
|
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
|
|
267
|
-
const
|
|
268
|
-
|
|
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
|
|
590
|
-
//
|
|
591
|
-
//
|
|
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
|
|
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 (
|
|
600
|
-
else this.
|
|
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.
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
|
|
610
|
-
|
|
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
|
-
|
|
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
|
|
693
|
-
*
|
|
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,
|
|
704
|
-
//
|
|
705
|
-
//
|
|
706
|
-
|
|
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
|
|
719
|
-
//
|
|
720
|
-
|
|
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
|
|
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));
|
|
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
|
-
|
|
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
|
-
|
|
847
|
-
|
|
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
|
|
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
|
|
878
|
-
*
|
|
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,
|
|
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.
|
|
916
|
-
/**
|
|
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
|
|
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
|
|
926
|
-
const
|
|
927
|
-
const
|
|
928
|
-
|
|
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:
|
|
931
|
-
calls:
|
|
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 =
|
|
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
|
|
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;
|
|
@@ -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`.
|
|
122
|
-
*
|
|
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
|
|
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.
|
|
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",
|