agent402-client 0.8.14 → 0.8.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -68,7 +68,7 @@ registerExactEvmScheme(client, { signer: privateKeyToAccount(process.env.AGENT_K
68
68
  const payFetch = wrapFetchWithPayment(fetch, client);
69
69
 
70
70
  const a = new Agent402({ fetch: payFetch });
71
- const article = await a.call("extract", { url: "https://example.com/article" });
71
+ const article = await a.call("extract", { url: "https://agent402.tools/guides/x402-in-5-minutes" });
72
72
  ```
73
73
 
74
74
  ## Prepaid credits (keys already issued)
@@ -182,18 +182,49 @@ const { window, asOf, results, totalSellers } = await a.topSellers({ limit: 10 }
182
182
  await a.topSellers({ sort: "calls", include: "all" });
183
183
  ```
184
184
 
185
+ ## Slow routes run as jobs
186
+
187
+ Reports, video and the premium image take from about 40 seconds to a few
188
+ minutes, longer than many HTTP clients wait. Their `/api/pricing` row carries
189
+ `async.recommended` with `expectedSeconds` and `maxSeconds`. For those routes
190
+ `call()` sends the paid request with `Prefer: respond-async`: the server answers
191
+ `202` with a job link at once, and the SDK polls `GET /api/jobs/{id}` (free),
192
+ waiting `Retry-After` seconds between polls, until the job has succeeded or
193
+ failed (it reads the job's `state` and falls back to its `status`, working,
194
+ completed or failed, on a server that sends only that). Payment settles only on
195
+ the delivered answer, so a failed job is a
196
+ failed call and its spend is released.
197
+
198
+ ```js
199
+ const a = new Agent402({
200
+ fetch: payFetch,
201
+ onProgress: (job) => console.log(job.status, job.stage, job.progress, job.etaSeconds),
202
+ asyncTimeoutMs: 10 * 60_000,
203
+ });
204
+ const report = await a.call("research", { query: "x402 adoption" });
205
+ ```
206
+
207
+ A job still running when `asyncTimeoutMs` ends throws `AsyncJobPendingError`
208
+ with `jobUrl`; collect it later with `await a.collectJob(err.jobUrl)`. Its price
209
+ stays counted against your spending caps, because the answer may still be
210
+ delivered and settled. Pass `respondAsync: false` (constructor or call) to wait
211
+ on the connection instead, or `respondAsync: true` to use a job on every route
212
+ that runs as one.
213
+
185
214
  ## API
186
215
 
187
216
  | Method | What |
188
217
  |---|---|
189
- | `new Agent402({ baseUrl?, fetch?, creditsKey?, cache?, fetchImpl?, maxPerCallUsd?, dailyLimitUsd?, maxPerHostUsd?, maxResponseBytes?, outputValidator? })` | `fetch` is your x402- or MPP-wrapped fetch for paid tools (optional); `creditsKey` is a prepaid card-credits key (`a402_...`) used for paid tools when no `fetch` is given; `cache` (default `true`) memoizes deterministic results; the three USD caps set optional spending limits (see below); `maxResponseBytes` (default 32MB, `null` to disable) refuses an oversized response body before it is parsed; `outputValidator` binds a caller-supplied delivery check |
218
+ | `new Agent402({ baseUrl?, fetch?, x402Client?, creditsKey?, cache?, fetchImpl?, maxPerCallUsd?, dailyLimitUsd?, maxPerHostUsd?, maxResponseBytes?, outputValidator?, respondAsync?, asyncTimeoutMs?, onProgress? })` | `fetch` is your x402- or MPP-wrapped fetch for paid tools (optional); `x402Client` is the `@x402/core` client that `fetch` wraps, so the caps are enforced on the payment that is signed (see below); `creditsKey` is a prepaid card-credits key (`a402_...`) used for paid tools when no `fetch` is given; `cache` (default `true`) memoizes deterministic results; the three USD caps set optional spending limits (see below); `maxResponseBytes` (default 32MB, `null` to disable) refuses an oversized response body before it is parsed; `outputValidator` binds a caller-supplied delivery check; `respondAsync` (default `"auto"`), `asyncTimeoutMs` (default 15 minutes) and `onProgress` control slow routes run as jobs (see above) |
190
219
  | `await a.find(task, { k = 5 })` | Resolve a plain-language task to the best-matching tools on **this host** (route, price, schema, example) |
191
220
  | `await a.route(task, { k?, include?, network? })` | Cross-seller Smart Order Router: rank eligible/routable seller rows from the host's current index (free, read-only; opaque results may include server-reported `executeVia`) |
192
221
  | `await a.findWorkflows(task, { k = 2 })` | Resolve a task to matching multi-tool workflow templates (skill packs) |
193
222
  | `await a.getWorkflowPrompt(slug, args)` | Fetch the rendered prompt messages for a skill pack with arguments substituted in |
194
223
  | `await a.topSellers({ limit?, sort?, include? })` | Live x402 leaderboard: which sellers are settling the most USDC (primarily on Base) in the last ~24h (free, no payment) |
195
- | `await a.call(slug, params, { idempotencyKey?, cache?, maxResponseBytes?, outputValidator? })` | Call a tool; auto-pays (PoW for free tools; your payment `fetch` or the credits key for wallet-only); returns the JSON result |
224
+ | `await a.call(slug, params, { idempotencyKey?, cache?, maxResponseBytes?, outputValidator?, respondAsync?, asyncTimeoutMs?, onProgress? })` | Call a tool; auto-pays (PoW for free tools; your payment `fetch` or the credits key for wallet-only); a slow route runs as a job and is collected for you; returns the JSON result |
225
+ | `await a.collectJob(jobUrl, { timeoutMs?, onProgress? })` | Collect a job link (from `AsyncJobPendingError.jobUrl`) until it has the answer |
196
226
  | `Agent402.solvePow(pow)` | Solve a proof-of-work challenge object → an `X-Pow-Solution` value |
227
+ | `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 |
197
228
  | `a.spendingSummary()` | Rolling-24h paid spend so far: `{ dailyUsd, calls, byHost, limits }` |
198
229
  | `a.clearCache()` | Drop the in-memory result cache |
199
230
 
@@ -276,8 +307,13 @@ call that would exceed one is **refused before any payment is signed** (it throw
276
307
  ```js
277
308
  import { Agent402, SpendingLimitError } from "agent402-client";
278
309
 
310
+ const client = new x402Client();
311
+ registerExactEvmScheme(client, { signer });
312
+ const payFetch = wrapFetchWithPayment(fetch, client);
313
+
279
314
  const a = new Agent402({
280
315
  fetch: payFetch,
316
+ x402Client: client, // the caps are enforced on the payment that is signed
281
317
  maxPerCallUsd: 0.05, // reject any single call priced above $0.05
282
318
  dailyLimitUsd: 5, // rolling-24h ceiling across all sellers
283
319
  maxPerHostUsd: 1, // rolling-24h ceiling per seller host
@@ -296,16 +332,33 @@ that later fails byte, JSON, or output validation remains settled spend. Free
296
332
  proof-of-work calls are never counted. Omit a cap (or leave it `null`) for no
297
333
  limit; with none set, behavior is unchanged.
298
334
 
299
- **What the caps check.** When a cap is set, the client preflights the `402` and
300
- checks the ceiling against the **larger** of the advertised price (from the
301
- seller's `/api/pricing`) and the amount the `402` challenge actually quotes - so a
302
- server that under-advertises and then quotes more in the `402` is refused *before*
303
- your wallet fetch signs anything. If the `402` can't be read (FREE_MODE, or an
304
- unrecognized challenge shape) it falls back to the advertised price rather than
305
- block a legitimate payment. Caps hold under **concurrency** too: each call reserves
306
- its amount synchronously, so N simultaneous calls can't each pass against the same
307
- pre-commit total. (The `402` amount is derived assuming stablecoin settlement -
308
- `atomic / 10^decimals ≈ USD` - which matches x402's USDC/USDG rails.)
335
+ **Caps are enforced on the payment that is signed.** Pass the `x402Client` your
336
+ `fetch` wraps (or call `a.guardX402Client(client)`) and the client registers a
337
+ policy on it: every payment option that asks more than the per-call cap, or more
338
+ than is left of the rolling caps, is dropped before the x402 client picks one,
339
+ and the picked one is checked again just before it is signed. A refusal there is
340
+ a `SpendingLimitError` with `atSigning: true`, and nothing is signed. The ledger
341
+ books the amount actually signed.
342
+
343
+ **The one advisory mode.** A pre-wrapped payment `fetch` given without its
344
+ `x402Client` is the only mode where the caps are advisory: they are checked on
345
+ an unpaid preflight of the `402`, before your wallet fetch runs, but not on the
346
+ `402` that fetch then signs. The client logs a one-time warning when caps are
347
+ set in this mode. The adapters built on this client (agent402-agentkit,
348
+ elizaos-plugin-agent402) construct their own x402 client and pass it, so their
349
+ caps hold at signing by default.
350
+
351
+ **How a quote is priced.** An x402 signer signs the option's raw base-unit
352
+ `amount`, so each option is priced from a built-in table of known assets per
353
+ network (`KNOWN_ASSETS`: USDC on each Agent402 rail, USDG on Robinhood Chain,
354
+ with their on-chain decimals), never from a decimals value the seller sends. An
355
+ option in an asset outside the table cannot be priced, and a capped wallet never
356
+ signs it. When a cap is set, the client preflights the `402` and checks the
357
+ ceiling against the **larger** of the advertised price (from the seller's
358
+ `/api/pricing`) and the amount the `402` quotes; if no `402` comes back
359
+ (FREE_MODE) it checks the advertised price. Caps hold under **concurrency** too:
360
+ each call reserves its amount synchronously, so N simultaneous calls can't each
361
+ pass against the same pre-commit total.
309
362
 
310
363
  - **Zero dependencies** for the free/proof-of-work path (uses `node:crypto`).
311
364
  - **Non-custodial:** paid settlement is your `@x402/fetch` / `mppx` fetch + wallet (or a prepaid credits key you bought); this client never sees a private key.
@@ -325,7 +378,7 @@ import { wrapFetchWithPayment } from "@x402/fetch";
325
378
 
326
379
  const client = new x402Client();
327
380
  registerExactEvmScheme(client, { signer });
328
- withNetworkPreference(client, ["robinhood"]); // or ["base","solana"], or ["eip155:4663"]
381
+ withNetworkPreference(client, ["robinhood"]); // or ["base","solana"], or ["eip155:4663"], or ["eip155:5042"] for Arc
329
382
  const payFetch = wrapFetchWithPayment(fetch, client);
330
383
  ```
331
384
 
@@ -351,6 +404,17 @@ const payFetch = wrapFetchWithPayment(fetch, client);
351
404
  Pairs with `maxPerCallUsd` / `dailyLimitUsd` (how much) and
352
405
  `withNetworkPreference` (which chain).
353
406
 
407
+ ## Paid calls never follow redirects
408
+
409
+ A request that can carry a payment or a credits key is sent with
410
+ `redirect: "manual"`. If it is answered with a redirect, the call throws
411
+ `PaymentRedirectError` (`.location` is where it pointed) and nothing more is
412
+ signed, so a redirect can never draw a payment to another host or carry a
413
+ signature to one. A signature already sent before the redirect stays counted
414
+ against your caps. Point `baseUrl` at the address that serves the routes
415
+ directly (`https://agent402.tools`, not `www.` or `http://`); the error names
416
+ the origin to use.
417
+
354
418
  ## Distrusted certificate authorities (`distrustFetch`)
355
419
 
356
420
  Since 0.8.14 the SDK's own requests refuse a TLS chain that passes through one
package/async-job.js ADDED
@@ -0,0 +1,125 @@
1
+ // Collect a paid call that the server answered as a job.
2
+ //
3
+ // A slow Agent402 route (its /api/pricing row carries async.recommended) takes
4
+ // longer than many clients wait. Sent with "Prefer: respond-async", the paid
5
+ // call answers 202 with a job link at once; the server runs it with this
6
+ // call's payment and settles only when the answer is ready. This module polls
7
+ // the job link (free), waiting Retry-After seconds between polls, and turns the
8
+ // final job into an ordinary Response: the route's own status, headers (the
9
+ // payment receipt) and body. A job that is still running when the caller's
10
+ // overall timeout ends throws AsyncJobPendingError carrying the job link, so
11
+ // the answer can still be collected later (the payment, if any, settles only
12
+ // on a delivered result, which may still happen).
13
+ //
14
+ // The job link must be on the same origin as the server: a Location naming
15
+ // another host is refused rather than fetched.
16
+ //
17
+ // Kept in lockstep with mcp/async-job.js (agent402-mcp).
18
+
19
+ export const DEFAULT_ASYNC_TIMEOUT_MS = 15 * 60_000;
20
+ const MIN_WAIT_MS = 1_000;
21
+ const MAX_WAIT_MS = 30_000;
22
+ const MAX_POLL_FAILURES = 5;
23
+
24
+ const sleepMs = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
25
+
26
+ /** True when this call should ask for a job: mode "auto" (default) for routes
27
+ * the catalog marks recommended, true for any route that runs as a job,
28
+ * false never. `advice` is the catalog row's `async` block. */
29
+ export function shouldRequestAsync(advice, mode = "auto") {
30
+ if (!advice || typeof advice !== "object" || mode === false || mode === "off") return false;
31
+ if (mode === true || mode === "always") return true;
32
+ return advice.recommended === true;
33
+ }
34
+
35
+ export class AsyncJobPendingError extends Error {
36
+ constructor(message, { jobUrl, slug, job } = {}) {
37
+ super(message);
38
+ this.name = "AsyncJobPendingError";
39
+ Object.assign(this, { jobUrl, slug, job: job || null, mayBeCharged: true });
40
+ }
41
+ }
42
+
43
+ /** Milliseconds to wait before the next poll, from Retry-After or the body. */
44
+ export function retryAfterMs(headers, doc) {
45
+ const h = headers && typeof headers.get === "function" ? headers.get("retry-after") : null;
46
+ let s = h != null && String(h).trim() !== "" ? Number(h) : NaN;
47
+ if (!Number.isFinite(s)) s = Number(doc?.pollIntervalSeconds);
48
+ if (!Number.isFinite(s) || s < 0) s = 5;
49
+ return Math.max(MIN_WAIT_MS, Math.min(MAX_WAIT_MS, s * 1000));
50
+ }
51
+
52
+ function sameOrigin(url, baseUrl) {
53
+ try { return new URL(url).origin === new URL(baseUrl).origin; } catch { return false; }
54
+ }
55
+
56
+ // The job's lifecycle: `state` (queued | running | succeeded | failed) when
57
+ // the server sends it, else the original `status` (working | completed | failed).
58
+ const STATUS_TO_STATE = { working: "running", completed: "succeeded", failed: "failed", cancelled: "failed" };
59
+ export function jobState(doc) {
60
+ if (typeof doc?.state === "string" && doc.state) return doc.state;
61
+ const s = typeof doc?.status === "string" ? doc.status : "";
62
+ return STATUS_TO_STATE[s] || s || null;
63
+ }
64
+
65
+ /** An ordinary Response for a finished job's answer. */
66
+ export function responseFromJob(doc) {
67
+ const done = jobState(doc) === "succeeded";
68
+ const part = done ? doc.result || {} : doc?.error || {};
69
+ const status = Number(part.httpStatus) || (done ? 200 : 502);
70
+ const body = part.body !== undefined ? part.body : done ? {} : { error: part.message || doc?.message || "The job failed." };
71
+ const headers = { "content-type": "application/json" };
72
+ for (const [k, v] of Object.entries(part.headers || {})) if (typeof v === "string") headers[k.toLowerCase()] = v;
73
+ if (!done && doc?.message) headers["x-agent402-job-message"] = String(doc.message).replace(/[^\x20-\x7e]/g, " ").slice(0, 300);
74
+ return new Response(JSON.stringify(body), { status, headers });
75
+ }
76
+
77
+ /**
78
+ * Turn a 202 job answer into the route's final Response; any other response
79
+ * is returned unchanged.
80
+ * @param {Response} res the answer to the paid call
81
+ * @param {object} o
82
+ * @param {string} o.baseUrl the server's base URL (the job link must share its origin)
83
+ * @param {Function} o.fetchImpl a plain fetch (the job link is free; no payment rides on it)
84
+ * @param {number} [o.timeoutMs] overall wait for the job
85
+ * @param {Function} [o.onProgress] called with each job document
86
+ * @param {string} [o.slug]
87
+ */
88
+ export async function awaitJob(res, { baseUrl, fetchImpl, timeoutMs = DEFAULT_ASYNC_TIMEOUT_MS, onProgress = null, slug = "", sleep = sleepMs, now = () => Date.now() } = {}) {
89
+ if (!res || res.status !== 202) return res;
90
+ let doc = {};
91
+ try { doc = await res.json(); } catch { doc = {}; }
92
+ const loc = (res.headers && typeof res.headers.get === "function" && res.headers.get("location")) || doc.statusUrl;
93
+ if (!loc) throw new AsyncJobPendingError(`"${slug}" answered 202 without a job link`, { slug, job: doc });
94
+ let jobUrl;
95
+ try { jobUrl = new URL(loc, `${String(baseUrl).replace(/\/$/, "")}/`).href; } catch { jobUrl = null; }
96
+ if (!jobUrl || !sameOrigin(jobUrl, baseUrl)) throw new AsyncJobPendingError(`"${slug}" named a job link on another origin; it was not followed`, { slug, job: doc });
97
+ const report = (d) => { try { onProgress?.(d); } catch { /* the caller's callback never breaks the wait */ } };
98
+ report(doc);
99
+ const deadline = now() + Math.max(0, Number(timeoutMs) || DEFAULT_ASYNC_TIMEOUT_MS);
100
+ let wait = retryAfterMs(res.headers, doc);
101
+ let failures = 0;
102
+ for (;;) {
103
+ if (now() + wait > deadline) {
104
+ throw new AsyncJobPendingError(`"${slug}" is still running (${jobState(doc) || "running"}${doc.stage ? `, ${doc.stage}` : ""}); collect it from ${jobUrl} (free) until it expires`, { jobUrl, slug, job: doc });
105
+ }
106
+ await sleep(wait);
107
+ let g;
108
+ try { g = await fetchImpl(jobUrl, { headers: { Accept: "application/json" } }); }
109
+ catch { g = null; }
110
+ if (!g || g.status >= 500 || g.status === 429) {
111
+ if (++failures >= MAX_POLL_FAILURES) throw new AsyncJobPendingError(`the job link for "${slug}" could not be read (${g ? `HTTP ${g.status}` : "network error"}); collect it from ${jobUrl}`, { jobUrl, slug, job: doc });
112
+ wait = retryAfterMs(g?.headers, doc);
113
+ continue;
114
+ }
115
+ if (g.status === 404 || g.status === 410) {
116
+ throw new AsyncJobPendingError(`the job for "${slug}" is ${g.status === 410 ? "expired" : "unknown"} at ${jobUrl}`, { jobUrl, slug, job: doc });
117
+ }
118
+ failures = 0;
119
+ try { doc = await g.json(); } catch { doc = {}; }
120
+ report(doc);
121
+ const st = jobState(doc);
122
+ if (st === "succeeded" || st === "failed") return responseFromJob(doc);
123
+ wait = retryAfterMs(g.headers, doc);
124
+ }
125
+ }
package/index.js CHANGED
@@ -18,14 +18,19 @@
18
18
  // // paid tools: pass an x402-wrapped fetch (your wallet signs)
19
19
  // const a = new Agent402({ fetch: payFetch });
20
20
  import { createHash, randomBytes } from "node:crypto";
21
+ import { AsyncLocalStorage } from "node:async_hooks";
22
+ import { quoteOf, guardSigning } from "./known-assets.js";
23
+ export { KNOWN_ASSETS, knownAsset, acceptUsd } from "./known-assets.js";
21
24
  import { distrustFetch } from "./tls-distrust.js";
22
25
  export { distrustFetch, distrustDispatcher, distrustCheckServerIdentity, DISTRUSTED_SPKI_SHA256 } from "./tls-distrust.js";
26
+ import { awaitJob, shouldRequestAsync, AsyncJobPendingError, DEFAULT_ASYNC_TIMEOUT_MS } from "./async-job.js";
27
+ export { AsyncJobPendingError } from "./async-job.js";
23
28
 
24
29
  // Keep in lockstep with package.json. Every request the SDK issues carries
25
30
  // `User-Agent: agent402-client/<version>` - a standard header, no extra
26
31
  // network calls - so a seller can attribute traffic (and settled payments)
27
32
  // to this SDK. Product token only; nothing about the caller rides along.
28
- const VERSION = "0.8.14";
33
+ const VERSION = "0.8.16";
29
34
  const USER_AGENT = `agent402-client/${VERSION}`;
30
35
  // 32MB: about a hundred times any realistic response from this catalog (the
31
36
  // largest is a base64 image at a few MB), so it cannot break a legitimate
@@ -62,6 +67,16 @@ async function shedRetryWaitMs(r, maxWaitMs = DISCOVERY_MAX_WAIT_MS) {
62
67
 
63
68
  const sleepMs = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
64
69
 
70
+ // A pre-wrapped payment fetch with no x402Client is the one mode where the caps
71
+ // are checked on an unpaid preflight only, not on the payment that is signed.
72
+ // Said once per process, on the first capped paid call in that mode.
73
+ let advisoryCapsWarned = false;
74
+ function warnAdvisoryCaps() {
75
+ if (advisoryCapsWarned) return;
76
+ advisoryCapsWarned = true;
77
+ console.warn("[agent402-client] spending caps are set with a pre-wrapped payment fetch and no x402Client, so they are checked on a preflight of the 402 only, not on the payment that is signed. Pass { x402Client } (the client your fetch wraps) to enforce them at signing.");
78
+ }
79
+
65
80
  export class Agent402 {
66
81
  /**
67
82
  * @param {object} [opts]
@@ -74,6 +89,8 @@ export class Agent402 {
74
89
  * @param {number} [opts.maxPerCallUsd] hard ceiling on a single paid call (USD); over → SpendingLimitError before paying
75
90
  * @param {number} [opts.dailyLimitUsd] hard ceiling on rolling-24h paid spend (USD)
76
91
  * @param {number} [opts.maxPerHostUsd] hard ceiling on rolling-24h paid spend to one seller host (USD)
92
+ * @param {object} [opts.x402Client] the @x402/core x402Client your `fetch` wraps: the caps are then
93
+ * enforced on the payment that is signed (see guardX402Client)
77
94
  * @param {string} [opts.creditsKey] a prepaid card-credits key (a402_...) already issued (new credits are
78
95
  * not on sale) - pays wallet-only tools when no payFetch is given
79
96
  * @param {number|null} [opts.maxResponseBytes=33554432]
@@ -81,10 +98,23 @@ export class Agent402 {
81
98
  * @param {{id:string, validate:function}} [opts.outputValidator]
82
99
  * Optional buyer-owned result validator. `id` namespaces cache entries;
83
100
  * `validate` may return false or throw to reject delivery.
101
+ * @param {"auto"|boolean} [opts.respondAsync="auto"]
102
+ * Slow paid routes (the catalog's `async.recommended`): "auto" sends the
103
+ * paid call with Prefer: respond-async and polls the job link until the
104
+ * answer is ready; true does it on every route that runs as a job; false
105
+ * never (wait on the connection).
106
+ * @param {number} [opts.asyncTimeoutMs=900000] overall wait for a job; past it,
107
+ * AsyncJobPendingError carries the job link to collect later
108
+ * @param {function} [opts.onProgress] called with each job document
109
+ * ({ status, stage, progress, etaSeconds, ... }) while a job runs
84
110
  */
85
111
  constructor({ baseUrl = "https://agent402.tools", fetch: payFetch, cache = true, fetchImpl = undefined, tlsDistrust = true,
86
- maxPerCallUsd = null, dailyLimitUsd = null, maxPerHostUsd = null, creditsKey = null,
87
- maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, outputValidator = null } = {}) {
112
+ maxPerCallUsd = null, dailyLimitUsd = null, maxPerHostUsd = null, creditsKey = null, x402Client = null,
113
+ maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, outputValidator = null,
114
+ respondAsync = "auto", asyncTimeoutMs = DEFAULT_ASYNC_TIMEOUT_MS, onProgress = null } = {}) {
115
+ this.respondAsync = respondAsync;
116
+ this.asyncTimeoutMs = numOrNull(asyncTimeoutMs) ?? DEFAULT_ASYNC_TIMEOUT_MS;
117
+ this.onProgress = typeof onProgress === "function" ? onProgress : null;
88
118
  this.creditsKey = typeof creditsKey === "string" && /^a402_[A-Za-z0-9_-]{32,64}$/.test(creditsKey) ? creditsKey : null;
89
119
  if (fetchImpl === undefined) {
90
120
  fetchImpl = globalThis.fetch;
@@ -120,6 +150,11 @@ export class Agent402 {
120
150
  perHost: numOrNull(maxPerHostUsd),
121
151
  log: [], // [{ ts, host, usd }] - settled paid calls in the last 24h
122
152
  };
153
+ // The paid call being signed right now, so the signing guard can tell this
154
+ // call's own reservation from every other one (see guardX402Client).
155
+ this._signCtx = new AsyncLocalStorage();
156
+ this._guarded = false;
157
+ if (x402Client) this.guardX402Client(x402Client);
123
158
  // A response-size ceiling, enforced before parsing. See _readJson.
124
159
  this.maxResponseBytes = maxResponseBytes === null ? null : (numOrNull(maxResponseBytes) ?? DEFAULT_MAX_RESPONSE_BYTES);
125
160
  // Buyer-owned delivery policy. The callback stays entirely local and may
@@ -127,6 +162,53 @@ export class Agent402 {
127
162
  this.outputValidator = normalizeOutputValidator(outputValidator, "constructor");
128
163
  }
129
164
 
165
+ /**
166
+ * Enforce this client's spending caps on the payment that is signed.
167
+ *
168
+ * Register it on the x402Client your `fetch` wraps (or pass it as the
169
+ * `x402Client` option): every accept that cannot be priced from a known
170
+ * asset, or that asks more than the per-call cap or what is left of the
171
+ * rolling caps, is dropped before the client selects one, and the selected
172
+ * one is checked again just before its payload is built. The preflight read
173
+ * of the 402 still runs for a readable refusal, but the challenge that is
174
+ * signed is the one this guard sees, so the caps hold on the paid request's
175
+ * own 402 whatever the preflight said. Returns the client.
176
+ */
177
+ guardX402Client(client) {
178
+ guardSigning(client, {
179
+ allowance: () => this._signAllowance().cap,
180
+ onSign: (usd) => {
181
+ const ctx = this._signCtx.getStore();
182
+ if (!ctx) return;
183
+ ctx.signedUsd = usd;
184
+ if (ctx.reservation) ctx.reservation.usd = usd;
185
+ },
186
+ onRefuse: (detail) => {
187
+ const ctx = this._signCtx.getStore();
188
+ if (ctx) ctx.refusal = { ...detail, limit: this._signAllowance().limit };
189
+ },
190
+ });
191
+ this._guarded = true;
192
+ return client;
193
+ }
194
+
195
+ /** The most the payment being signed may ask for: the per-call cap and what
196
+ * is left of the rolling-24h caps, net of every other call's reservation
197
+ * (this call's own one is what is being replaced). */
198
+ _signAllowance() {
199
+ const s = this._spend;
200
+ const own = this._signCtx.getStore()?.reservation || null;
201
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
202
+ const others = s.log.filter((e) => e.ts >= cutoff && e !== own);
203
+ const host = hostOf(this.baseUrl);
204
+ let cap = Infinity, limit = null;
205
+ const tighten = (value, name) => { if (value < cap) { cap = value; limit = name; } };
206
+ if (s.maxPerCall != null) tighten(s.maxPerCall, "maxPerCallUsd");
207
+ if (s.daily != null) tighten(s.daily - others.reduce((a, e) => a + e.usd, 0), "dailyLimitUsd");
208
+ if (s.perHost != null) tighten(s.perHost - others.filter((e) => e.host === host).reduce((a, e) => a + e.usd, 0), "maxPerHostUsd");
209
+ return { cap: Math.max(0, cap), limit };
210
+ }
211
+
130
212
  /**
131
213
  * Read a JSON body with a hard byte ceiling.
132
214
  *
@@ -215,7 +297,7 @@ export class Agent402 {
215
297
  const r = await this._discoveryGet("could not load catalog", "/api/pricing");
216
298
  const j = await r.json();
217
299
  const m = new Map();
218
- for (const e of j.endpoints || []) m.set(e.slug, { method: e.method, path: e.path, computePayable: e.computePayable, price: e.price });
300
+ for (const e of j.endpoints || []) m.set(e.slug, { method: e.method, path: e.path, computePayable: e.computePayable, price: e.price, async: e.async && typeof e.async === "object" ? e.async : null });
219
301
  this._catalog = m;
220
302
  return m;
221
303
  }
@@ -341,7 +423,7 @@ export class Agent402 {
341
423
  * Call a tool by slug; pays automatically (PoW for free tools, x402 for
342
424
  * wallet-only) and returns the parsed JSON result.
343
425
  */
344
- async call(slug, params = {}, { idempotencyKey, cache = true, maxResponseBytes, outputValidator } = {}) {
426
+ async call(slug, params = {}, { idempotencyKey, cache = true, maxResponseBytes, outputValidator, respondAsync, asyncTimeoutMs, onProgress } = {}) {
345
427
  // Resolve and validate the buyer-owned contract before catalog fetch,
346
428
  // credential access, payment preflight, or any other network action.
347
429
  const validator = outputValidator == null
@@ -376,12 +458,17 @@ export class Agent402 {
376
458
  const idem = idempotencyKey || (cache
377
459
  ? `a402-${createHash("sha256").update(`${this._idemSalt}:${cacheKey}`).digest("hex").slice(0, 24)}`
378
460
  : `a402-${createHash("sha256").update(`${cacheKey}:${Date.now()}:${Math.random()}`).digest("hex").slice(0, 24)}`);
379
- const send = (extraHeaders = {}, useFetch = this.f) => {
461
+ const send = async (extraHeaders = {}, useFetch = this.f, { paid = false } = {}) => {
380
462
  // UA set here too (not only in the this.f wrapper) so the x402 payFetch
381
463
  // path - the one that settles real payments - always carries it.
382
464
  const headers = { "User-Agent": USER_AGENT, "Idempotency-Key": idem, ...extraHeaders };
383
465
  let url = `${this.baseUrl}${tool.path}`;
384
- const init = { method: tool.method, headers };
466
+ // A request that can carry a payment never follows a redirect: followed,
467
+ // it would answer a 402 from another origin with a signature to that
468
+ // origin's payee, or forward a signature already made to another host,
469
+ // and deliver that host's answer as this route's. The redirect is
470
+ // refused with where it pointed, so an alias baseUrl is easy to fix.
471
+ const init = paid ? { method: tool.method, headers, redirect: "manual" } : { method: tool.method, headers };
385
472
  if (tool.method === "GET") {
386
473
  const qs = new URLSearchParams(Object.entries(params).map(([k, v]) => [k, typeof v === "object" ? JSON.stringify(v) : String(v)])).toString();
387
474
  if (qs) url += `?${qs}`;
@@ -389,8 +476,20 @@ export class Agent402 {
389
476
  headers["Content-Type"] = "application/json";
390
477
  init.body = JSON.stringify(params);
391
478
  }
392
- return useFetch(url, init);
479
+ const r = await useFetch(url, init);
480
+ if (paid && (r?.type === "opaqueredirect" || (r?.status >= 300 && r?.status < 400))) throw new PaymentRedirectError(slug, url, r);
481
+ return r;
393
482
  };
483
+ const paidSend = (extraHeaders = {}, useFetch = this.f) => send(extraHeaders, useFetch, { paid: true });
484
+ // A slow route runs as a job: the paid call carries Prefer: respond-async,
485
+ // answers 202 with a job link, and the answer is collected from it (free)
486
+ // when ready. Payment still settles only on the delivered answer.
487
+ const asyncHeaders = shouldRequestAsync(tool.async, respondAsync ?? this.respondAsync) ? { Prefer: "respond-async" } : {};
488
+ const collect = (r) => awaitJob(r, {
489
+ baseUrl: this.baseUrl, fetchImpl: this.f, slug, sleep: this._sleep,
490
+ timeoutMs: numOrNull(asyncTimeoutMs) ?? this.asyncTimeoutMs,
491
+ onProgress: typeof onProgress === "function" ? onProgress : this.onProgress,
492
+ });
394
493
 
395
494
  // Wallet-only tool → settle in USDC via the provided x402 fetch.
396
495
  if (!tool.computePayable) {
@@ -407,22 +506,40 @@ export class Agent402 {
407
506
  // amount THIS request is quoted. Fail-open: a 402 we cannot read
408
507
  // (FREE_MODE / non-402 / unparseable) falls back to the advertised
409
508
  // price - never block a legitimate payment on a parse miss.
410
- const usd = await this._quotedUsd(send, listUsd);
509
+ const guarded = this._guarded;
510
+ if (!guarded && this._spendCapsConfigured()) warnAdvisoryCaps();
511
+ const usd = await this._quotedUsd(paidSend, listUsd, { slug, mode: guarded ? "guarded" : "wallet" });
411
512
  // Reserve the amount synchronously (before the await) so concurrent calls
412
513
  // can't each observe the pre-commit total and collectively blow a rolling
413
- // cap; release the reservation if the call doesn't settle.
414
- const reservation = this._spendReserve(host, usd, slug, listUsd);
514
+ // cap; release the reservation if the call doesn't settle. With the
515
+ // signing guard on, the reservation is replaced by the amount actually
516
+ // signed, and a call that signed nothing spends nothing.
517
+ const reservation = this._spendReserve(host, usd, slug, listUsd, { always: guarded });
518
+ const ctx = { reservation, signedUsd: null, refusal: null };
519
+ const signedNothing = () => guarded && ctx.signedUsd == null;
415
520
  let settled = false;
416
521
  try {
417
- const r = await send({}, this.payFetch);
522
+ const r = await collect(await this._signCtx.run(ctx, () => paidSend(asyncHeaders, this.payFetch)));
418
523
  if (!r.ok) throw this._fail(slug, r, await this._failureDetail(slug, r));
419
- this._spendSettle(reservation); // confirm the reservation as settled spend
524
+ if (signedNothing()) this._spendRelease(reservation);
525
+ else this._spendSettle(reservation); // confirm the reservation as settled spend
420
526
  settled = true;
421
527
  return this._deliverResponse(slug, r, cacheKey, cache, validator, { maxResponseBytes, paid: true });
422
528
  } catch (e) {
423
529
  // An invalid or oversized paid HTTP-success body is failed delivery,
424
- // not a rollback of money that already moved.
425
- if (!settled) this._spendRelease(reservation);
530
+ // not a rollback of money that already moved. A job still running
531
+ // past the wait may yet settle, so its reservation stands.
532
+ // A redirect after a signature was sent leaves that payment's outcome
533
+ // unknown, so it counts against the caps, like a job still running.
534
+ if ((e instanceof AsyncJobPendingError || e instanceof PaymentRedirectError) && !signedNothing()) this._spendSettle(reservation);
535
+ else if (!settled) this._spendRelease(reservation);
536
+ if (ctx.refusal) {
537
+ const d = ctx.refusal;
538
+ throw new SpendingLimitError(d.reason === "unknown-asset"
539
+ ? `refusing to pay for "${slug}" - no payment option it asked to sign is in a known asset, so none can be priced`
540
+ : `refusing to pay $${d.priceUsd} for "${slug}" - the payment it asked to sign is over ${d.limit || "the cap"} ($${d.allowanceUsd} left to sign)`,
541
+ { limit: d.limit || "signing", slug, priceUsd: d.priceUsd, cap: d.allowanceUsd, atSigning: true, cause: e });
542
+ }
426
543
  throw e;
427
544
  }
428
545
  }
@@ -436,11 +553,11 @@ export class Agent402 {
436
553
  // debit.
437
554
  const host = hostOf(this.baseUrl);
438
555
  const listUsd = parseUsd(tool.price);
439
- const usd = await this._quotedUsd(send, listUsd);
556
+ const usd = await this._quotedUsd(paidSend, listUsd, { slug, mode: "credits" });
440
557
  const reservation = this._spendReserve(host, usd, slug, listUsd);
441
558
  let settled = false;
442
559
  try {
443
- const r = await send({ Authorization: `Bearer ${this.creditsKey}` });
560
+ const r = await collect(await paidSend({ Authorization: `Bearer ${this.creditsKey}`, ...asyncHeaders }));
444
561
  if (r.status === 402) {
445
562
  const body = await r.json().catch(() => ({}));
446
563
  throw this._fail(slug, r, `call "${slug}" refused by credits: ${body.error || "payment required"}${body.balanceUsd != null ? ` (balance $${body.balanceUsd})` : ""} ${body.topup ? ` - top up at ${body.topup}` : body.topupNote ? ` - ${body.topupNote}` : ""}`);
@@ -449,7 +566,11 @@ export class Agent402 {
449
566
  this._spendSettle(reservation);
450
567
  settled = true;
451
568
  return this._deliverResponse(slug, r, cacheKey, cache, validator, { maxResponseBytes, paid: true });
452
- } catch (e) { if (!settled) this._spendRelease(reservation); throw e; }
569
+ } catch (e) {
570
+ if (e instanceof AsyncJobPendingError) this._spendSettle(reservation);
571
+ else if (!settled) this._spendRelease(reservation);
572
+ throw e;
573
+ }
453
574
  }
454
575
  const r = await send(); // no wallet - succeeds only on a FREE_MODE instance
455
576
  if (r.ok) return this._deliverResponse(slug, r, cacheKey, cache, validator, { maxResponseBytes, paid: false });
@@ -468,6 +589,22 @@ export class Agent402 {
468
589
  return this._deliverResponse(slug, r, cacheKey, cache, validator, { maxResponseBytes, paid: false });
469
590
  }
470
591
 
592
+ /**
593
+ * Collect a job link (AsyncJobPendingError.jobUrl, or a 202's Location) until
594
+ * it has the answer, and return the parsed answer. The link is free to poll
595
+ * and must be on this client's baseUrl origin.
596
+ */
597
+ async collectJob(jobUrl, { timeoutMs, onProgress, maxResponseBytes } = {}) {
598
+ const start = new Response(JSON.stringify({ statusUrl: String(jobUrl), pollIntervalSeconds: 1 }), { status: 202, headers: { "content-type": "application/json" } });
599
+ const r = await awaitJob(start, {
600
+ baseUrl: this.baseUrl, fetchImpl: this.f, slug: "job", sleep: this._sleep,
601
+ timeoutMs: numOrNull(timeoutMs) ?? this.asyncTimeoutMs,
602
+ onProgress: typeof onProgress === "function" ? onProgress : this.onProgress,
603
+ });
604
+ if (!r.ok) throw this._fail("job", r, await this._failureDetail("job", r));
605
+ return this._readJson(r, { maxBytes: maxResponseBytes, slug: "job", paid: true });
606
+ }
607
+
471
608
  async _assertOutput(slug, body, validator, { paid = false, cacheHit = false } = {}) {
472
609
  if (!validator) return body;
473
610
  try {
@@ -506,17 +643,38 @@ export class Agent402 {
506
643
  * body. Only preflights when a ceiling is configured (it costs a round trip,
507
644
  * and with no cap the number changes nothing). The preflight send carries no
508
645
  * payment header and no credits key, so it can only ever draw a 402.
509
- * @returns {Promise<number>} the quoted amount, or `listUsd` when unreadable */
510
- async _quotedUsd(send, listUsd) {
646
+ *
647
+ * Each accept is priced from the table of known assets (./known-assets.js),
648
+ * never from a decimals value the seller sends. `mode` says who signs:
649
+ * "guarded" (the signing guard holds every signature to the caps, so the
650
+ * cheapest known accept is what can be paid, and unknown ones are dropped
651
+ * there), "wallet" (no guard: the wallet may sign any accept, so the dearest
652
+ * one counts, and one that cannot be priced is refused here), or "credits"
653
+ * (nothing is signed; the dearest known accept counts).
654
+ * @returns {Promise<number>} the quoted amount, or `listUsd` when there is no readable 402 */
655
+ async _quotedUsd(send, listUsd, { slug = "", mode = "wallet" } = {}) {
511
656
  if (!this._spendCapsConfigured()) return listUsd;
657
+ let q = null;
512
658
  try {
513
659
  const pre = await send();
514
660
  if (pre?.status !== 402) return listUsd;
515
- const quoted = await quoted402Usd(pre);
516
- // Larger of the two: an understated catalog must not lower the ceiling
517
- // the cap is checked against.
518
- return quoted == null ? listUsd : Math.max(listUsd, quoted);
519
- } catch { return listUsd; /* fail-open to the advertised price */ }
661
+ q = quoteOf(await challengeAccepts(pre));
662
+ } catch (e) {
663
+ // A redirect is refused here, before anything is reserved or signed.
664
+ if (e instanceof PaymentRedirectError) throw e;
665
+ return listUsd; /* no readable 402: the advertised price */
666
+ }
667
+ if (!q) return listUsd;
668
+ if (q.max == null || (mode === "wallet" && q.unknown > 0)) {
669
+ if (mode === "credits") return listUsd;
670
+ throw new SpendingLimitError(
671
+ `refusing to pay for "${slug}" - ${q.max == null ? "no" : "a"} payment option it offers is in an asset this client cannot price, and a capped wallet signs only what it can price${mode === "wallet" ? " (pass your x402Client to guard signing and pay the options it can price)" : ""}`,
672
+ { limit: "knownAsset", slug, priceUsd: null, unknownOptions: q.unknown });
673
+ }
674
+ const quoted = mode === "guarded" ? q.min : q.max;
675
+ // Larger of the two: an understated catalog must not lower the ceiling
676
+ // the cap is checked against.
677
+ return Math.max(listUsd, quoted);
520
678
  }
521
679
 
522
680
  /** Throw SpendingLimitError if paying `usd` to `host` now would break a cap.
@@ -594,9 +752,9 @@ export class Agent402 {
594
752
  * concurrent calls account for each other's in-flight reservations instead of
595
753
  * all passing against the same pre-commit total. Returns a reservation handle
596
754
  * (or null for a $0 call). Throws SpendingLimitError before reserving if over. */
597
- _spendReserve(host, usd, slug, listUsd = null) {
755
+ _spendReserve(host, usd, slug, listUsd = null, { always = false } = {}) {
598
756
  this._spendCheck(host, usd, slug, listUsd);
599
- if (!(usd > 0)) return null;
757
+ if (!(usd > 0) && !always) return null;
600
758
  const entry = { ts: Date.now(), host, usd, pending: true };
601
759
  this._spend.log.push(entry);
602
760
  return entry;
@@ -633,6 +791,22 @@ export class Agent402 {
633
791
  * time the body arrives, so a caller must be able to tell "I was charged and
634
792
  * the seller sent something unusable" from "nothing happened". Losing that
635
793
  * distinction would turn a refused response into a silently forgotten spend. */
794
+ /** A request that could carry a payment was answered with a redirect, which
795
+ * is never followed. `location` is where it pointed (null when the runtime
796
+ * hides it, as a browser does for a manual redirect). */
797
+ export class PaymentRedirectError extends Error {
798
+ constructor(slug, url, r) {
799
+ const location = r?.headers?.get?.("location") || null;
800
+ const to = location ? new URL(location, url).href : null;
801
+ super(`refusing to follow a redirect on a paid call to "${slug}": ${url} answered HTTP ${r?.status || "redirect"}${to ? ` pointing to ${to}` : ""}. Paid requests never follow redirects${to ? `; if that is the address you meant, set baseUrl to ${new URL(to).origin}` : ""}.`);
802
+ this.name = "PaymentRedirectError";
803
+ this.slug = slug;
804
+ this.status = r?.status ?? null;
805
+ this.location = to;
806
+ this.paid = false;
807
+ }
808
+ }
809
+
636
810
  export class ResponseTooLargeError extends Error {
637
811
  constructor(size, cap, { slug, paid, source } = {}) {
638
812
  super(`response for "${slug}" is ${size} bytes, over the ${cap}-byte ceiling (${source}) - refused before parsing${paid ? "; this call WAS paid" : ""}`);
@@ -659,23 +833,30 @@ export class SpendingLimitError extends Error {
659
833
  }
660
834
  }
661
835
 
662
- // Parse the USD amount an x402 `402` challenge actually requires - the max across
663
- // the offered rails. x402 is stablecoin-settled (USDC/USDG), so
664
- // atomic / 10^decimals ≈ USD. Returns null if the body isn't a parseable 402
665
- // challenge, so the caller fails open to the advertised catalog price.
666
836
  /** The dearest amount a 402 asks for, in USD, or null when it cannot be read.
837
+ *
838
+ * Each accept is priced from the known-asset table (./known-assets.js): the
839
+ * base-unit amount over that asset's real decimals, never a decimals value
840
+ * the seller sends. One accept that is not a known asset makes the whole
841
+ * quote unreadable (null).
667
842
  *
668
843
  * x402 v2 carries the challenge base64 in the `PAYMENT-REQUIRED` HEADER and
669
844
  * leaves the body `{}`, so a body-only reader learns nothing from a stock v2
670
845
  * seller and silently falls back to the catalog price. Header first, then the
671
846
  * body (v1 sellers, and sellers that serve both). */
672
847
  export async function quoted402Usd(res) {
673
- const header = res?.headers?.get?.("payment-required");
674
- const fromHeader = usdFromAccepts(decodeChallengeHeader(header));
675
- if (fromHeader != null) return fromHeader;
848
+ const q = quoteOf(await challengeAccepts(res));
849
+ // Every accept must be priceable: the wallet picks which one to sign.
850
+ return q && q.unknown === 0 ? q.max : null;
851
+ }
852
+
853
+ /** The accepts array of a 402, from the PAYMENT-REQUIRED header first, then the body. */
854
+ async function challengeAccepts(res) {
855
+ const fromHeader = decodeChallengeHeader(res?.headers?.get?.("payment-required"));
856
+ if (fromHeader) return fromHeader;
676
857
  let body = null;
677
858
  try { body = await res.json(); } catch { body = null; }
678
- return usdFromAccepts(acceptsOf(body));
859
+ return acceptsOf(body);
679
860
  }
680
861
 
681
862
  function decodeChallengeHeader(header) {
@@ -694,29 +875,6 @@ function acceptsOf(obj) {
694
875
  return null;
695
876
  }
696
877
 
697
- /** The MAXIMUM across accepts: the wallet picks which chain to settle on and
698
- * this client cannot know which, so the only sound ceiling is the dearest
699
- * offer. Any entry we cannot price returns null (unreadable, never a guess -
700
- * the caller falls back to the advertised price). */
701
- function usdFromAccepts(accepts) {
702
- if (!Array.isArray(accepts) || !accepts.length) return null;
703
- let maxUsd = null;
704
- for (const a of accepts) {
705
- // v2 names the base-unit amount `amount`; x402 v1 called it `maxAmountRequired`.
706
- const atomic = Number(a && (a.amount ?? a.maxAmountRequired));
707
- if (!Number.isFinite(atomic) || atomic < 0) return null;
708
- // USDC is 6 decimals on every chain these rails settle; an accept may say
709
- // otherwise, and a wrong exponent is a 1000x pricing error, so a
710
- // non-integer or out-of-range hint makes the whole quote unreadable.
711
- const declared = (a && a.extra && a.extra.decimals) ?? (a && a.decimals);
712
- const decimals = declared == null ? 6 : Number(declared);
713
- if (!Number.isInteger(decimals) || decimals < 0 || decimals > 30) return null;
714
- const usd = atomic / 10 ** decimals;
715
- if (maxUsd == null || usd > maxUsd) maxUsd = usd;
716
- }
717
- return maxUsd;
718
- }
719
-
720
878
  function numOrNull(v) { if (v == null) return null; const n = Number(v); return Number.isFinite(n) && n >= 0 ? n : null; }
721
879
  function parseUsd(price) {
722
880
  if (typeof price === "number") return Number.isFinite(price) ? price : 0;
@@ -796,6 +954,7 @@ export const NETWORK_CAIP2 = {
796
954
  arbitrum: "eip155:42161",
797
955
  "base-sepolia": "eip155:84532",
798
956
  robinhood: "eip155:4663",
957
+ arc: "eip155:5042",
799
958
  solana: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
800
959
  "solana-devnet": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
801
960
  };
@@ -0,0 +1,120 @@
1
+ // Known payment assets, and the USD value of an x402 accept priced from them.
2
+ //
3
+ // An x402 "exact" signer signs the accept's raw base-unit `amount` for the
4
+ // accept's `asset`. A spending cap therefore has to price that amount with the
5
+ // asset's real decimals, and a decimals hint the seller sends beside it (for
6
+ // example `extra.decimals`) is not evidence of anything. So the price of an
7
+ // accept comes from this table alone: the stablecoins each network settles on
8
+ // the Agent402 rails, with their on-chain decimals. An accept whose
9
+ // (network, asset) pair is not in the table cannot be priced, and a buyer with
10
+ // a cap does not sign it.
11
+ //
12
+ // The same file ships in agent402-client, agent402-mcp and agent402-openclaw;
13
+ // the parent repo's scripts/test-known-assets.js keeps the copies identical and
14
+ // checks the table against the assets the server offers.
15
+
16
+ /** network (CAIP-2) -> the assets a buyer may price there. */
17
+ export const KNOWN_ASSETS = Object.freeze({
18
+ // EVM: USDC (native Circle) unless named otherwise.
19
+ "eip155:8453": [{ asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", decimals: 6, symbol: "USDC" }], // Base
20
+ "eip155:84532": [{ asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", decimals: 6, symbol: "USDC" }], // Base Sepolia
21
+ "eip155:137": [{ asset: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", decimals: 6, symbol: "USDC" }], // Polygon
22
+ "eip155:42161": [{ asset: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", decimals: 6, symbol: "USDC" }], // Arbitrum
23
+ "eip155:143": [{ asset: "0x754704Bc059F8C67012fEd69BC8A327a5aafb603", decimals: 6, symbol: "USDC" }], // Monad
24
+ "eip155:42220": [{ asset: "0xcebA9300f2b948710d2653dD7B07f33A8B32118C", decimals: 6, symbol: "USDC" }], // Celo
25
+ "eip155:43114": [{ asset: "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", decimals: 6, symbol: "USDC" }], // Avalanche
26
+ "eip155:1329": [{ asset: "0xe15fC38F6D8c56aF07bbCBe3BAf5708A2Bf42392", decimals: 6, symbol: "USDC" }], // Sei
27
+ "eip155:10": [{ asset: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", decimals: 6, symbol: "USDC" }], // Optimism
28
+ "eip155:5042": [{ asset: "0x3600000000000000000000000000000000000000", decimals: 6, symbol: "USDC" }], // Arc
29
+ "eip155:4663": [{ asset: "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168", decimals: 6, symbol: "USDG" }], // Robinhood Chain
30
+ // Solana: the USDC mint.
31
+ "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": [{ asset: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", decimals: 6, symbol: "USDC" }],
32
+ "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": [{ asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", decimals: 6, symbol: "USDC" }], // devnet
33
+ // Stellar: the USDC Stellar Asset Contract (7 decimals on Stellar).
34
+ "stellar:pubnet": [{ asset: "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", decimals: 7, symbol: "USDC" }],
35
+ // Algorand: USDC is ASA 31566704.
36
+ "algorand:wGHE2Pwdvd7S12BL5FaOP20EGYesN73ktiC1qzkkit8=": [{ asset: "31566704", decimals: 6, symbol: "USDC" }],
37
+ });
38
+
39
+ const EVM_ADDRESS = /^0x[0-9a-fA-F]{40}$/;
40
+ // EVM addresses compare without case (checksum casing is presentation);
41
+ // base58, Stellar and Algorand identifiers compare exactly.
42
+ const sameAsset = (a, b) => (EVM_ADDRESS.test(a) && EVM_ADDRESS.test(b) ? a.toLowerCase() === b.toLowerCase() : a === b);
43
+
44
+ /** The table entry for (network, asset), or null when it is not a known asset. */
45
+ export function knownAsset(network, asset) {
46
+ if (typeof network !== "string" || typeof asset !== "string" || !asset) return null;
47
+ const list = KNOWN_ASSETS[network];
48
+ if (!list) return null;
49
+ return list.find((e) => sameAsset(e.asset, asset)) || null;
50
+ }
51
+
52
+ /** The USD value of one accept: its base-unit amount (`amount` in x402 v2,
53
+ * `maxAmountRequired` in v1) over the KNOWN decimals of its asset. null when
54
+ * the asset is not known or the amount is not a whole number of base units. */
55
+ export function acceptUsd(accept) {
56
+ if (!accept || typeof accept !== "object") return null;
57
+ const raw = accept.amount ?? accept.maxAmountRequired;
58
+ if (raw == null || !/^\d+$/.test(String(raw))) return null;
59
+ const entry = knownAsset(accept.network, accept.asset);
60
+ if (!entry) return null;
61
+ return Number(raw) / 10 ** entry.decimals;
62
+ }
63
+
64
+ /** The USD values of a 402's accepts: `max` and `min` over the accepts that can
65
+ * be priced, and `unknown`, the count that cannot. null when there are none. */
66
+ export function quoteOf(accepts) {
67
+ if (!Array.isArray(accepts) || !accepts.length) return null;
68
+ let max = null, min = null, unknown = 0;
69
+ for (const a of accepts) {
70
+ const usd = acceptUsd(a);
71
+ if (usd == null) { unknown++; continue; }
72
+ if (max == null || usd > max) max = usd;
73
+ if (min == null || usd < min) min = usd;
74
+ }
75
+ return { max, min, unknown, total: accepts.length };
76
+ }
77
+
78
+ /** Enforce a USD allowance at signing on an @x402/core x402Client.
79
+ *
80
+ * `allowance()` is the most this buyer may sign for the payment being built
81
+ * (a per-call cap, a remaining budget, or the smaller of the two; Infinity
82
+ * for none). A registered policy drops every accept that cannot be priced or
83
+ * is over the allowance, so the client selects among the ones that fit, and a
84
+ * hook before payload creation checks the selected accept again and passes
85
+ * its USD value to `onSign`. A refusal throws (from the policy) or aborts
86
+ * (from the hook) before any signature exists; `onRefuse` hears why first. */
87
+ export function guardSigning(client, { allowance, onSign = null, onRefuse = null }) {
88
+ if (!client || typeof client.registerPolicy !== "function" || typeof client.onBeforePaymentCreation !== "function") {
89
+ throw new TypeError("guardSigning needs an @x402/core x402Client (registerPolicy and onBeforePaymentCreation)");
90
+ }
91
+ if (typeof allowance !== "function") throw new TypeError("guardSigning needs an allowance() function");
92
+ const refuse = (detail) => { try { onRefuse?.(detail); } catch { /* the refusal stands */ } return detail; };
93
+ client.registerPolicy((version, accepts) => {
94
+ const cap = allowance();
95
+ const kept = accepts.filter((a) => { const usd = acceptUsd(a); return usd != null && usd <= cap; });
96
+ if (!kept.length) {
97
+ const q = quoteOf(accepts);
98
+ const d = refuse({ reason: q && q.min != null ? "over-allowance" : "unknown-asset", priceUsd: q ? q.min : null, allowanceUsd: cap });
99
+ throw new Error(d.reason === "unknown-asset"
100
+ ? "spending guard: no payment option is in a known asset, so none can be priced; nothing was signed"
101
+ : `spending guard: the cheapest payment option asks $${d.priceUsd}, above the $${cap} this buyer may sign; nothing was signed`);
102
+ }
103
+ return kept;
104
+ });
105
+ client.onBeforePaymentCreation(async ({ selectedRequirements: r }) => {
106
+ const usd = acceptUsd(r);
107
+ const cap = allowance();
108
+ if (usd == null) {
109
+ refuse({ reason: "unknown-asset", priceUsd: null, allowanceUsd: cap });
110
+ return { abort: true, reason: "spending guard: the selected payment option is not in a known asset; nothing was signed" };
111
+ }
112
+ if (usd > cap) {
113
+ refuse({ reason: "over-allowance", priceUsd: usd, allowanceUsd: cap });
114
+ return { abort: true, reason: `spending guard: the selected payment option asks $${usd}, above the $${cap} this buyer may sign; nothing was signed` };
115
+ }
116
+ onSign?.(usd, r);
117
+ return undefined;
118
+ });
119
+ return client;
120
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent402-client",
3
- "version": "0.8.14",
3
+ "version": "0.8.16",
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",
@@ -9,6 +9,8 @@
9
9
  },
10
10
  "files": [
11
11
  "index.js",
12
+ "async-job.js",
13
+ "known-assets.js",
12
14
  "tls-distrust.js",
13
15
  "README.md"
14
16
  ],
@@ -41,6 +43,7 @@
41
43
  "tool-discovery",
42
44
  "smart-order-router",
43
45
  "robinhood-chain",
46
+ "arc",
44
47
  "usdg",
45
48
  "multi-chain",
46
49
  "agentic-finance",