agent402-client 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +12 -3
  3. package/index.js +76 -11
  4. package/package.json +2 -2
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Havok Holdings LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -117,9 +117,18 @@ try {
117
117
 
118
118
  Only **settled** paid calls count against the rolling window — a blocked or failed
119
119
  call never consumes budget. Free proof-of-work calls are never counted. Omit a cap
120
- (or leave it `null`) for no limit; with none set, behavior is unchanged. This is a
121
- buyer-side circuit breaker against a malicious or misconfigured `402` that quotes an
122
- inflated price.
120
+ (or leave it `null`) for no limit; with none set, behavior is unchanged.
121
+
122
+ **What the caps check.** When a cap is set, the client preflights the `402` and
123
+ checks the ceiling against the **larger** of the advertised price (from the
124
+ seller's `/api/pricing`) and the amount the `402` challenge actually quotes — so a
125
+ server that under-advertises and then quotes more in the `402` is refused *before*
126
+ your wallet fetch signs anything. If the `402` can't be read (FREE_MODE, or an
127
+ unrecognized challenge shape) it falls back to the advertised price rather than
128
+ block a legitimate payment. Caps hold under **concurrency** too: each call reserves
129
+ its amount synchronously, so N simultaneous calls can't each pass against the same
130
+ pre-commit total. (The `402` amount is derived assuming stablecoin settlement —
131
+ `atomic / 10^decimals ≈ USD` — which matches x402's USDC/USDG rails.)
123
132
 
124
133
  - **Zero dependencies** for the free/proof-of-work path (uses `node:crypto`).
125
134
  - **Non-custodial:** paid settlement is your `@x402/fetch` + wallet; this client never sees your key.
package/index.js CHANGED
@@ -183,15 +183,38 @@ export class Agent402 {
183
183
  // Wallet-only tool → settle in USDC via the provided x402 fetch.
184
184
  if (!tool.computePayable) {
185
185
  if (this.payFetch) {
186
- // Spending policy: refuse to pay BEFORE signing if the quoted price
187
- // breaks a configured ceiling (per-call / rolling-24h / per-host).
188
- const usd = parseUsd(tool.price);
186
+ // Spending policy: refuse to pay BEFORE signing if the price breaks a
187
+ // configured ceiling (per-call / rolling-24h / per-host).
189
188
  const host = hostOf(this.baseUrl);
190
- this._spendCheck(host, usd, slug);
191
- const r = await send({}, this.payFetch);
192
- if (!r.ok) throw new Error(`call "${slug}" failed: HTTP ${r.status}`);
193
- this._spendCommit(host, usd); // record spend only once the call actually settled
194
- return this._store(cacheKey, await r.json(), cache);
189
+ let usd = parseUsd(tool.price);
190
+ // The catalog price is seller-ADVERTISED — a hostile server could under-
191
+ // state it and then quote more in the 402. When a cap is set, preflight
192
+ // the 402 to learn the price the wallet will actually be asked to sign and
193
+ // check the cap against the larger of the two. Fail-open: if the 402 can't
194
+ // be read (FREE_MODE / non-402 / unparseable), fall back to the advertised
195
+ // price — never block a legitimate payment on a parse miss.
196
+ if (this._spendCapsConfigured()) {
197
+ try {
198
+ const pre = await send();
199
+ if (pre.status === 402) {
200
+ const quoted = parse402Usd(await pre.json().catch(() => null));
201
+ if (quoted != null) usd = Math.max(usd, quoted);
202
+ }
203
+ } catch { /* fail-open to the advertised price */ }
204
+ }
205
+ // Reserve the amount synchronously (before the await) so concurrent calls
206
+ // can't each observe the pre-commit total and collectively blow a rolling
207
+ // cap; release the reservation if the call doesn't settle.
208
+ const reservation = this._spendReserve(host, usd, slug);
209
+ try {
210
+ const r = await send({}, this.payFetch);
211
+ if (!r.ok) throw new Error(`call "${slug}" failed: HTTP ${r.status}`);
212
+ this._spendSettle(reservation); // confirm the reservation as settled spend
213
+ return this._store(cacheKey, await r.json(), cache);
214
+ } catch (e) {
215
+ this._spendRelease(reservation); // roll back — nothing settled
216
+ throw e;
217
+ }
195
218
  }
196
219
  const r = await send(); // no wallet — succeeds only on a FREE_MODE instance
197
220
  if (r.ok) return this._store(cacheKey, await r.json(), cache);
@@ -249,13 +272,36 @@ export class Agent402 {
249
272
  }
250
273
  }
251
274
 
252
- /** Record a settled paid call against the rolling budget. */
253
- _spendCommit(host, usd) { if (usd > 0) this._spend.log.push({ ts: Date.now(), host, usd }); }
275
+ /** True if any spending ceiling is configured (worth preflighting the 402). */
276
+ _spendCapsConfigured() {
277
+ const s = this._spend;
278
+ return s.maxPerCall != null || s.daily != null || s.perHost != null;
279
+ }
280
+
281
+ /** Check caps AND reserve the amount atomically (no await in between), so
282
+ * concurrent calls account for each other's in-flight reservations instead of
283
+ * all passing against the same pre-commit total. Returns a reservation handle
284
+ * (or null for a $0 call). Throws SpendingLimitError before reserving if over. */
285
+ _spendReserve(host, usd, slug) {
286
+ this._spendCheck(host, usd, slug);
287
+ if (!(usd > 0)) return null;
288
+ const entry = { ts: Date.now(), host, usd, pending: true };
289
+ this._spend.log.push(entry);
290
+ return entry;
291
+ }
292
+ /** Confirm a reservation as settled spend. */
293
+ _spendSettle(entry) { if (entry) entry.pending = false; }
294
+ /** Roll back a reservation whose call did not settle (failed / errored). */
295
+ _spendRelease(entry) {
296
+ if (!entry) return;
297
+ const i = this._spend.log.indexOf(entry);
298
+ if (i >= 0) this._spend.log.splice(i, 1);
299
+ }
254
300
 
255
301
  /** Rolling-24h spend summary (settled paid calls only) — for observability. */
256
302
  spendingSummary() {
257
303
  const cutoff = Date.now() - 24 * 60 * 60 * 1000;
258
- const log = this._spend.log.filter((e) => e.ts >= cutoff);
304
+ const log = this._spend.log.filter((e) => e.ts >= cutoff && !e.pending);
259
305
  const byHost = {};
260
306
  for (const e of log) byHost[e.host] = Number(((byHost[e.host] || 0) + e.usd).toFixed(6));
261
307
  return {
@@ -277,6 +323,25 @@ export class SpendingLimitError extends Error {
277
323
  }
278
324
  }
279
325
 
326
+ // Parse the USD amount an x402 `402` challenge actually requires — the max across
327
+ // the offered rails. x402 is stablecoin-settled (USDC/USDG), so
328
+ // atomic / 10^decimals ≈ USD. Returns null if the body isn't a parseable 402
329
+ // challenge, so the caller fails open to the advertised catalog price.
330
+ function parse402Usd(body) {
331
+ const accepts = body && body.accepts;
332
+ if (!Array.isArray(accepts) || !accepts.length) return null;
333
+ let maxUsd = 0;
334
+ for (const a of accepts) {
335
+ const atomic = Number(a && a.maxAmountRequired);
336
+ if (!Number.isFinite(atomic) || atomic < 0) return null;
337
+ const decimals = Number((a && a.extra && a.extra.decimals) ?? (a && a.decimals) ?? 6);
338
+ if (!Number.isFinite(decimals) || decimals < 0 || decimals > 30) return null;
339
+ const usd = atomic / 10 ** decimals;
340
+ if (usd > maxUsd) maxUsd = usd;
341
+ }
342
+ return maxUsd;
343
+ }
344
+
280
345
  function numOrNull(v) { if (v == null) return null; const n = Number(v); return Number.isFinite(n) && n >= 0 ? n : null; }
281
346
  function parseUsd(price) {
282
347
  if (typeof price === "number") return Number.isFinite(price) ? price : 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent402-client",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Buyer-side SDK for Agent402.Tools (x402): resolve a task to a tool across 1,407 pay-per-call endpoints and call it with payment handled for you — free tools settle via built-in proof-of-work (no wallet), wallet-only tools via your x402 fetch — with a withNetworkPreference helper to pick the settlement chain (USDC on Base/Solana/Polygon/Arbitrum/Stellar or USDG on Robinhood Chain). Caching, idempotent retries, cross-seller Smart Order Router.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -41,7 +41,7 @@
41
41
  "usdg",
42
42
  "multi-chain"
43
43
  ],
44
- "author": "Mike Petrillo <mike@agent402.tools> (https://github.com/MikeyPetrillo)",
44
+ "author": "Havok Holdings LLC <mike@agent402.tools> (https://agent402.tools)",
45
45
  "license": "MIT",
46
46
  "repository": {
47
47
  "type": "git",