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