@tollxyz/guard 0.3.0 → 0.4.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.
package/dist/policy.js CHANGED
@@ -1,4 +1,24 @@
1
- import { TX_CHAIN_ID, TollBlockedError, TollUnavailableError, checkTx, chainIdOf, paidUrlOf, } from "./check.js";
1
+ import { TX_CHAIN_ID, TollBlockedError, TollUnavailableError, checkTx, chainIdOf, paidUrlOf, bodyOf, toJson, } from "./check.js";
2
+ import { DEFAULT_SPEND_ASSETS, spendOf } from "./spend.js";
3
+ import { MemorySpendStore, dayStart } from "./store.js";
4
+ /** blocked by your own spend rules; a TollBlockedError, so existing catch blocks still see a block */
5
+ export class TollPolicyError extends TollBlockedError {
6
+ code;
7
+ asset;
8
+ amount;
9
+ limit;
10
+ /** the guard's report of this request */
11
+ report;
12
+ constructor(code, message, x = {}) {
13
+ super({ action: "BLOCK", warnings: [{ code: `policy-${code}`, severity: "critical", message }], ...(x.assessment?.["x-toll"] ? { "x-toll": x.assessment["x-toll"] } : {}) }, "policy");
14
+ this.name = "TollPolicyError";
15
+ this.code = code;
16
+ this.asset = x.asset;
17
+ this.amount = x.amount;
18
+ this.limit = x.limit;
19
+ this.report = x.report;
20
+ }
21
+ }
2
22
  /** the effective policy: explicit options win, then the strict preset, then the defaults */
3
23
  export function resolvePolicy(o = {}) {
4
24
  const strict = o.strict === true;
@@ -96,6 +116,10 @@ export function unsupportedWhy(input) {
96
116
  * `build` may throw (e.g. calldata that could not be encoded): that counts as unavailable.
97
117
  */
98
118
  export async function enforce(kind, build, o = {}) {
119
+ return o.spend ? enforceSpend(kind, build, o) : enforceVerdict(kind, build, o);
120
+ }
121
+ /** Toll's verdict and the 0.3.x policy (onWarn, onBlock, onUnavailable, onUnsupported) */
122
+ async function enforceVerdict(kind, build, o) {
99
123
  let input;
100
124
  try {
101
125
  input = await build();
@@ -119,7 +143,7 @@ export async function enforce(kind, build, o = {}) {
119
143
  }
120
144
  let a;
121
145
  try {
122
- a = await checkTx(input, o);
146
+ a = takeConfirmed(o, input) ?? (await checkTx(input, o));
123
147
  }
124
148
  catch (e) {
125
149
  const err = e instanceof TollUnavailableError ? e : new TollUnavailableError(`Toll's check failed: ${e?.message ?? e}`, { reason: "network", cause: e });
@@ -221,7 +245,281 @@ export async function enforceCalls(build, o = {}) {
221
245
  if (!calls.length)
222
246
  return [await enforce("calls", () => ({}), o)];
223
247
  const out = [];
224
- for (const c of calls)
225
- out.push(await enforce("calls", () => c, o));
248
+ try {
249
+ for (const c of calls)
250
+ out.push(await enforce("calls", () => c, o));
251
+ }
252
+ catch (e) {
253
+ // the batch is not sent: the calls already passed give their day reservations back
254
+ await releaseSpend(out, o);
255
+ throw e;
256
+ }
226
257
  return out;
227
258
  }
259
+ /* ------------------------------------------------------------------ spend mode */
260
+ const lc = (x) => String(x ?? "").toLowerCase();
261
+ const fmt = (a) => (a === "native" ? "native ETH" : a);
262
+ const stores = new WeakMap();
263
+ /** the store for these options: the one given, else one in-process store per SpendPolicy object */
264
+ export function storeOf(o) {
265
+ if (o.store)
266
+ return o.store;
267
+ const sp = o.spend ?? {};
268
+ let s = stores.get(sp);
269
+ if (!s) {
270
+ s = new MemorySpendStore();
271
+ stores.set(sp, s);
272
+ }
273
+ return s;
274
+ }
275
+ const lower = (r) => Object.fromEntries(Object.entries(r ?? {}).map(([k, v]) => [lc(k), v]));
276
+ function assetsOf(sp) {
277
+ return [...new Set([...DEFAULT_SPEND_ASSETS, ...Object.keys(sp.assets ?? {}), ...Object.keys(sp.approveAbove ?? {})].map(lc))];
278
+ }
279
+ /** the first counterparty off an allowlist, or null */
280
+ function offList(sp, s) {
281
+ const check = (list, got, what) => {
282
+ if (!list)
283
+ return null;
284
+ const ok = new Set(list.map(lc));
285
+ const bad = got.find((x) => !ok.has(x));
286
+ return bad ? `${what} ${bad} is not on the allowlist` : null;
287
+ };
288
+ return check(sp.allow?.to, s.to, "recipient") ?? check(sp.allow?.spender, s.spender, "spender") ?? check(sp.allow?.payTo, s.payTo, "x402 payee");
289
+ }
290
+ /* previews: Toll's answer kept 30 s for one confirmed send of the identical request */
291
+ const CONFIRM_TTL_MS = 30_000;
292
+ const confirmed = new Map();
293
+ let confirmSeq = 0;
294
+ function rememberConfirm(input, assessment) {
295
+ const t = Date.now();
296
+ for (const [k, v] of confirmed)
297
+ if (v.until <= t)
298
+ confirmed.delete(k);
299
+ const id = `c${t.toString(36)}${(++confirmSeq).toString(36)}${Math.random().toString(36).slice(2, 8)}`;
300
+ confirmed.set(id, { body: toJson(bodyOf(input)), assessment, until: t + CONFIRM_TTL_MS });
301
+ return id;
302
+ }
303
+ function takeConfirmed(o, input) {
304
+ const id = o._confirm;
305
+ if (!id)
306
+ return null;
307
+ const c = confirmed.get(id);
308
+ if (!c || c.until <= Date.now() || c.body !== toJson(bodyOf(input)))
309
+ return null;
310
+ confirmed.delete(id); // once
311
+ return c.assessment;
312
+ }
313
+ /** the reservations of these reports, committed: the request was sent */
314
+ export async function commitSpend(reports, o) {
315
+ if (!o.spend)
316
+ return;
317
+ const store = storeOf(o);
318
+ for (const r of [].concat(reports))
319
+ for (const id of r.spend?.reservations ?? [])
320
+ await store.commit(id);
321
+ }
322
+ /** the reservations of these reports, released: the request was refused or the send threw */
323
+ export async function releaseSpend(reports, o) {
324
+ if (!o.spend)
325
+ return;
326
+ const store = storeOf(o);
327
+ for (const r of [].concat(reports))
328
+ for (const id of r.spend?.reservations ?? [])
329
+ await store.release(id);
330
+ }
331
+ /** the per-endpoint caps whose prefix the URL starts with */
332
+ function endpointCaps(sp, url) {
333
+ if (!url || !sp.perEndpoint)
334
+ return [];
335
+ let u;
336
+ try {
337
+ const x = new URL(url);
338
+ u = x.origin + x.pathname;
339
+ }
340
+ catch {
341
+ u = url;
342
+ }
343
+ return Object.entries(sp.perEndpoint).filter(([p]) => u.toLowerCase().startsWith(p.toLowerCase()));
344
+ }
345
+ /**
346
+ * Spend mode: allowlists -> Toll's verdict (the 0.3.x policy, unchanged) -> caps -> approval. Resolves with the report
347
+ * holding its day reservations (commitSpend after the send, releaseSpend when it throws); throws TollPolicyError for a
348
+ * spend rule, TollBlockedError / TollUnavailableError as before.
349
+ */
350
+ async function enforceSpend(kind, build, o) {
351
+ const io = o;
352
+ const sp = o.spend;
353
+ const store = storeOf(o);
354
+ const assets = assetsOf(sp);
355
+ const preview = io._preview === true;
356
+ let input = null;
357
+ let buildErr = null;
358
+ try {
359
+ input = await build();
360
+ }
361
+ catch (e) {
362
+ buildErr = e;
363
+ }
364
+ const blockWith = async (base, code, message, s, x = {}) => {
365
+ const r = { ...base, decision: "blocked", basis: "policy", policy: code, ...(s ? { spend: { spend: s, reservations: [] } } : {}) };
366
+ if (!preview)
367
+ await report(o, r);
368
+ throw new TollPolicyError(code, message, { ...x, assessment: base.assessment, report: r });
369
+ };
370
+ // 1. allowlists, from the local read, before Toll is asked
371
+ if (input) {
372
+ const local = spendOf(input, null, assets);
373
+ const why = offList(sp, local);
374
+ if (why)
375
+ return blockWith({ kind, input, assessment: null, decision: "blocked", basis: "policy", paidUrl: null }, "not-allowlisted", `Not signed: ${why}.`, local);
376
+ }
377
+ // 2. Toll's verdict (the 0.3.x policy), reported once at the end
378
+ let verdict = null;
379
+ const vo = { ...o, onReport: (r) => void (verdict = r) };
380
+ let base;
381
+ try {
382
+ base = await enforceVerdict(kind, buildErr ? () => Promise.reject(buildErr) : () => input, vo);
383
+ }
384
+ catch (e) {
385
+ if (verdict && !preview)
386
+ await report(o, verdict);
387
+ if (preview && verdict && e instanceof Error)
388
+ e.report = verdict;
389
+ throw e;
390
+ }
391
+ // 3. what it spends, now with Toll's simulation when there is one
392
+ const s = input ? spendOf(input, base.assessment, assets) : { items: [], outflow: {}, approvals: {}, to: [], spender: [], payTo: [], unknown: true, simulated: false };
393
+ const why = offList(sp, s);
394
+ if (why)
395
+ return blockWith(base, "not-allowlisted", `Not signed: ${why}.`, s);
396
+ if (s.unknown && (sp.onUnknownSpend ?? (o.failClosed ? "block" : "allow")) === "block") {
397
+ return blockWith(base, "unknown-spend", 'Not signed: what this request spends could not be read (no simulation), and onUnknownSpend is "block".', s);
398
+ }
399
+ const caps = lower(sp.assets);
400
+ const touched = [...new Set([...Object.keys(s.outflow), ...Object.keys(s.approvals)])];
401
+ const amountOf = (a) => {
402
+ const out = s.outflow[a] ?? 0n;
403
+ const ap = s.approvals[a] ?? 0n;
404
+ return out > ap ? out : ap;
405
+ };
406
+ // 4. per-call caps
407
+ for (const a of touched) {
408
+ const c = caps[a]?.perCall;
409
+ const amt = amountOf(a);
410
+ if (c !== undefined && amt > c)
411
+ return blockWith(base, "cap-per-call", `Not signed: ${amt} of ${fmt(a)} is over the per-call cap of ${c}.`, s, { asset: a, amount: amt, limit: c });
412
+ }
413
+ const x402url = typeof input?.x402?.url === "string" ? input.x402.url : typeof input?.x402?.resource === "string" ? input.x402.resource : undefined;
414
+ const eps = input?.x402 ? endpointCaps(sp, io._endpoint ?? x402url) : [];
415
+ const epAmount = Object.values(s.outflow).reduce((m, v) => (v > m ? v : m), 0n);
416
+ for (const [p, c] of eps) {
417
+ if (c.perCall !== undefined && epAmount > c.perCall)
418
+ return blockWith(base, "cap-endpoint", `Not paid: ${epAmount} is over the per-call cap of ${c.perCall} for ${p}.`, s, { amount: epAmount, limit: c.perCall });
419
+ }
420
+ // 5. day caps: outflows are reserved; an allowance must fit in what is left today (checked, not reserved)
421
+ const held = [];
422
+ const day = dayStart(Date.now());
423
+ const giveBack = async () => {
424
+ for (const id of held)
425
+ await store.release(id);
426
+ held.length = 0;
427
+ };
428
+ const reserve = async (key, amt, limit, keep, code, msg, asset) => {
429
+ const r = await store.reserve(key, amt, limit, day);
430
+ if (!r.ok) {
431
+ await giveBack();
432
+ await blockWith(base, code, `${msg} (${r.used} of ${limit} used today).`, s, { asset, amount: amt, limit });
433
+ }
434
+ if (keep && !preview)
435
+ held.push(r.id);
436
+ else
437
+ await store.release(r.id);
438
+ };
439
+ for (const a of touched) {
440
+ const c = caps[a]?.perDay;
441
+ if (c === undefined)
442
+ continue;
443
+ const out = s.outflow[a] ?? 0n;
444
+ if (out > 0n)
445
+ await reserve(`day:${a}`, out, c, true, "cap-per-day", `Not signed: ${out} more of ${fmt(a)} is over the day cap of ${c}`, a);
446
+ const ap = s.approvals[a] ?? 0n;
447
+ if (ap > 0n)
448
+ await reserve(`day:${a}`, ap, c, false, "cap-per-day", `Not signed: an allowance of ${ap} ${fmt(a)} is over what is left of the day cap of ${c}`, a);
449
+ }
450
+ for (const [p, c] of eps) {
451
+ if (c.perDay !== undefined && epAmount > 0n)
452
+ await reserve(`endpoint:${p}`, epAmount, c.perDay, true, "cap-endpoint", `Not paid: ${epAmount} more is over the day cap of ${c.perDay} for ${p}`);
453
+ }
454
+ // 6. human approval above a threshold
455
+ const above = lower(sp.approveAbove);
456
+ const over = touched.filter((a) => above[a] !== undefined && amountOf(a) > above[a]).map((a) => ({ asset: a, amount: amountOf(a), threshold: above[a] }));
457
+ let approvalRef;
458
+ let needsApproval = false;
459
+ if (over.length && preview)
460
+ needsApproval = true;
461
+ else if (over.length) {
462
+ let ok = false;
463
+ try {
464
+ const res = sp.approve ? await sp.approve({ kind, input, assessment: base.assessment, spend: s, over }) : { approved: false };
465
+ ok = res?.approved === true;
466
+ approvalRef = res?.approvalRef;
467
+ }
468
+ catch (e) {
469
+ say(o, `approve threw: ${e?.message ?? e}`);
470
+ }
471
+ if (!ok) {
472
+ await giveBack();
473
+ const top = over[0];
474
+ return blockWith(base, "approval-denied", `Not signed: ${top.amount} of ${fmt(top.asset)} is over ${top.threshold} and was not approved${sp.approve ? "" : " (no approve function)"}.`, s, { asset: top.asset, amount: top.amount, limit: top.threshold });
475
+ }
476
+ }
477
+ const r = { ...base, spend: { spend: s, reservations: [...held], ...(approvalRef !== undefined ? { approvalRef } : {}), ...(needsApproval ? { needsApproval } : {}) } };
478
+ if (!preview) {
479
+ await report(o, r);
480
+ io._collect?.push(r);
481
+ }
482
+ return r;
483
+ }
484
+ /** a request the spend policy refuses outside enforce (a repeated x402 payment id): reported, then TollPolicyError */
485
+ export async function policyRefuse(kind, input, code, message, o) {
486
+ const r = { kind, input, assessment: null, decision: "blocked", basis: "policy", policy: code, paidUrl: null };
487
+ await report(o, r);
488
+ throw new TollPolicyError(code, message, { report: r });
489
+ }
490
+ const kindOf = (i) => i.x402 ? "x402" : i.userOp ? "userOp" : i.typedData || i.rpc?.method === "eth_signTypedData_v4" ? "typedData" : i.message || i.rpc ? "message" : "transaction";
491
+ /**
492
+ * What the guard would do with this request, without doing it: Toll is asked and every spend rule is checked, but
493
+ * nothing is reserved, `approve` is not called and onReport is not called. When allowed, `confirmId` passed as
494
+ * `tollConfirm` on the send within 30 s skips the second Toll call (once, and only for the identical request).
495
+ */
496
+ export async function preview(input, opts = {}) {
497
+ const { kind: k, ...rest } = opts;
498
+ const kind = k ?? kindOf(input);
499
+ const io = { ...rest, onReport: undefined, _preview: true };
500
+ try {
501
+ const r = await enforce(kind, () => input, io);
502
+ return {
503
+ decision: "allowed",
504
+ basis: r.basis,
505
+ assessment: r.assessment,
506
+ ...(r.spend ? { spend: r.spend.spend } : {}),
507
+ ...(r.spend?.needsApproval ? { needsApproval: true } : {}),
508
+ paidUrl: r.paidUrl,
509
+ confirmId: r.assessment ? rememberConfirm(input, r.assessment) : "",
510
+ };
511
+ }
512
+ catch (e) {
513
+ const rep = e.report;
514
+ if (e instanceof TollPolicyError) {
515
+ return { decision: "blocked", basis: "policy", policy: e.code, assessment: rep?.assessment ?? null, ...(rep?.spend ? { spend: rep.spend.spend } : {}), reason: e.message, paidUrl: rep?.paidUrl ?? null, confirmId: "" };
516
+ }
517
+ if (e instanceof TollBlockedError) {
518
+ return { decision: "blocked", basis: e.assessment.action, assessment: e.assessment, reason: e.message, paidUrl: paidUrlOf(e.assessment), confirmId: "" };
519
+ }
520
+ if (e instanceof TollUnavailableError) {
521
+ return { decision: "blocked", basis: e.reason === "unsupported" ? "unsupported" : "unavailable", assessment: null, reason: e.message, paidUrl: e.paidUrl, confirmId: "" };
522
+ }
523
+ throw e;
524
+ }
525
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * What a request spends, read locally (calldata, typed data, an x402 requirement) and, when Toll's answer carries one,
3
+ * from its simulation (x-toll.simulation). Only outflows of the spend assets count (default: native ETH, WETH and USDG
4
+ * on Robinhood Chain), so selling a coin that is not a spend asset is never capped.
5
+ */
6
+ import { type CheckInput, type RiskAssessment } from "./check.js";
7
+ /** native ETH as a spend-asset key */
8
+ export declare const NATIVE = "native";
9
+ /** USDG on Robinhood Chain */
10
+ export declare const USDG = "0x5fc5360d0400a0fd4f2af552add042d716f1d168";
11
+ /** WETH on Robinhood Chain */
12
+ export declare const WETH = "0x0bd7d308f8e1639fab988df18a8011f41eacad73";
13
+ /** the spend assets when none are given */
14
+ export declare const DEFAULT_SPEND_ASSETS: readonly string[];
15
+ /** an unlimited allowance (type(uint256).max): larger than any cap */
16
+ export declare const UNLIMITED: bigint;
17
+ export interface SpendItem {
18
+ kind: "value" | "transfer" | "approve" | "x402" | "simulation";
19
+ /** "native" or a lowercase token address */
20
+ asset: string;
21
+ /** base units; UNLIMITED for an unlimited allowance */
22
+ amount: bigint;
23
+ to?: string;
24
+ spender?: string;
25
+ payTo?: string;
26
+ }
27
+ export interface Spend {
28
+ items: SpendItem[];
29
+ /** per asset, base units leaving the signer: the larger of what the call declares and what the simulation shows */
30
+ outflow: Record<string, bigint>;
31
+ /** per asset, the largest allowance granted (UNLIMITED = infinite) */
32
+ approvals: Record<string, bigint>;
33
+ /** who the request pays or calls (a token transfer's recipient, else the transaction's `to`), lowercase */
34
+ to: string[];
35
+ /** spenders an approval or permit names, lowercase */
36
+ spender: string[];
37
+ /** x402 payees, lowercase */
38
+ payTo: string[];
39
+ /** true when what leaves the wallet could not be read (a contract call with no simulation, a raw user operation) */
40
+ unknown: boolean;
41
+ /** true when Toll's simulation was read */
42
+ simulated: boolean;
43
+ }
44
+ /**
45
+ * What a request spends. `assessment` is Toll's answer (null when Toll was not asked or could not answer): its
46
+ * simulation, when read, beats the declared amounts upward. `assets` are the spend assets ("native" or token addresses).
47
+ */
48
+ export declare function spendOf(input: CheckInput, assessment: RiskAssessment | null | undefined, assets?: readonly string[]): Spend;
package/dist/spend.js ADDED
@@ -0,0 +1,239 @@
1
+ /**
2
+ * What a request spends, read locally (calldata, typed data, an x402 requirement) and, when Toll's answer carries one,
3
+ * from its simulation (x-toll.simulation). Only outflows of the spend assets count (default: native ETH, WETH and USDG
4
+ * on Robinhood Chain), so selling a coin that is not a spend asset is never capped.
5
+ */
6
+ import { isAddress } from "./check.js";
7
+ /** native ETH as a spend-asset key */
8
+ export const NATIVE = "native";
9
+ /** USDG on Robinhood Chain */
10
+ export const USDG = "0x5fc5360d0400a0fd4f2af552add042d716f1d168";
11
+ /** WETH on Robinhood Chain */
12
+ export const WETH = "0x0bd7d308f8e1639fab988df18a8011f41eacad73";
13
+ /** the spend assets when none are given */
14
+ export const DEFAULT_SPEND_ASSETS = [NATIVE, WETH, USDG];
15
+ /** an unlimited allowance (type(uint256).max): larger than any cap */
16
+ export const UNLIMITED = (1n << 256n) - 1n;
17
+ /** Permit2 (same address on every chain) */
18
+ const PERMIT2 = "0x000000000022d473030f116ddee9f6b43ac78ba3";
19
+ /** native ETH as the simulation names it */
20
+ const SIM_NATIVE = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
21
+ const lc = (x) => String(x ?? "").toLowerCase();
22
+ const big = (x) => {
23
+ try {
24
+ if (typeof x === "bigint")
25
+ return x;
26
+ if (typeof x === "number")
27
+ return Number.isSafeInteger(x) ? BigInt(x) : null;
28
+ if (typeof x === "string" && /^(0x[0-9a-fA-F]+|\d+)$/.test(x.trim()))
29
+ return BigInt(x.trim());
30
+ }
31
+ catch {
32
+ /* unreadable */
33
+ }
34
+ return null;
35
+ };
36
+ const SEL = {
37
+ transfer: "0xa9059cbb",
38
+ transferFrom: "0x23b872dd",
39
+ approve: "0x095ea7b3",
40
+ increaseAllowance: "0x39509351",
41
+ deposit: "0xd0e30db0",
42
+ withdraw: "0x2e1a7d4d",
43
+ };
44
+ function words(data) {
45
+ const body = data.slice(10);
46
+ const out = [];
47
+ for (let i = 0; i + 64 <= body.length; i += 64)
48
+ out.push(body.slice(i, i + 64));
49
+ return out;
50
+ }
51
+ const wAddr = (w) => (w ? "0x" + w.slice(24).toLowerCase() : "");
52
+ const wInt = (w) => (w ? BigInt("0x" + w) : 0n);
53
+ class Acc {
54
+ assets;
55
+ items = [];
56
+ declared = {};
57
+ simOut = {};
58
+ approvals = {};
59
+ to = new Set();
60
+ spender = new Set();
61
+ payTo = new Set();
62
+ unknown = false;
63
+ simulated = false;
64
+ constructor(assets) {
65
+ this.assets = assets;
66
+ }
67
+ out(asset, amount, item, max = false) {
68
+ if (!this.assets.has(asset) || amount <= 0n)
69
+ return;
70
+ const cur = this.declared[asset] ?? 0n;
71
+ this.declared[asset] = max ? (amount > cur ? amount : cur) : cur + amount;
72
+ this.items.push({ ...item, asset, amount });
73
+ }
74
+ approve(asset, amount, spender) {
75
+ if (spender)
76
+ this.spender.add(spender);
77
+ if (!this.assets.has(asset))
78
+ return;
79
+ const cur = this.approvals[asset] ?? 0n;
80
+ if (amount > cur)
81
+ this.approvals[asset] = amount;
82
+ this.items.push({ kind: "approve", asset, amount, spender });
83
+ }
84
+ done() {
85
+ const outflow = {};
86
+ for (const a of new Set([...Object.keys(this.declared), ...Object.keys(this.simOut)])) {
87
+ const d = this.declared[a] ?? 0n;
88
+ const s = this.simOut[a] ?? 0n;
89
+ outflow[a] = s > d ? s : d;
90
+ }
91
+ return { items: this.items, outflow, approvals: this.approvals, to: [...this.to], spender: [...this.spender], payTo: [...this.payTo], unknown: this.unknown && !this.simulated, simulated: this.simulated };
92
+ }
93
+ }
94
+ function decodeTx(i, acc) {
95
+ const from = isAddress(i.from) ? lc(i.from) : null;
96
+ const target = lc(i.to);
97
+ const value = big(i.value ?? 0) ?? 0n;
98
+ const data = typeof i.data === "string" ? i.data : "0x";
99
+ if (value > 0n)
100
+ acc.out(NATIVE, value, { kind: "value", to: target });
101
+ if (data.length < 10) {
102
+ if (target)
103
+ acc.to.add(target);
104
+ return;
105
+ }
106
+ const sel = data.slice(0, 10).toLowerCase();
107
+ const w = words(data);
108
+ if (sel === SEL.transfer && w.length >= 2) {
109
+ const to = wAddr(w[0]);
110
+ acc.to.add(to);
111
+ acc.out(target, wInt(w[1]), { kind: "transfer", to });
112
+ return;
113
+ }
114
+ if (sel === SEL.transferFrom && w.length >= 3) {
115
+ const src = wAddr(w[0]);
116
+ const to = wAddr(w[2]);
117
+ acc.to.add(to);
118
+ // only the signer's own tokens are its spend
119
+ if (!from || src === from)
120
+ acc.out(target, wInt(w[2]), { kind: "transfer", to });
121
+ return;
122
+ }
123
+ if ((sel === SEL.approve || sel === SEL.increaseAllowance) && w.length >= 2) {
124
+ acc.approve(target, wInt(w[1]), wAddr(w[0]));
125
+ return;
126
+ }
127
+ acc.to.add(target);
128
+ // WETH deposit (the value is already counted) and withdraw (WETH back to ETH): no new spend
129
+ if (target === WETH && (sel === SEL.deposit || sel === SEL.withdraw))
130
+ return;
131
+ acc.unknown = true;
132
+ }
133
+ function decodeTyped(td, acc, signer) {
134
+ const vc = lc(td.domain?.verifyingContract);
135
+ const m = (td.message ?? {});
136
+ const p = String(td.primaryType ?? "");
137
+ if (vc === PERMIT2) {
138
+ const spender = lc(m.spender);
139
+ if (p === "PermitSingle")
140
+ acc.approve(lc(m.details?.token), big(m.details?.amount) ?? UNLIMITED, spender);
141
+ else if (p === "PermitBatch")
142
+ for (const d of Array.isArray(m.details) ? m.details : [])
143
+ acc.approve(lc(d?.token), big(d?.amount) ?? UNLIMITED, spender);
144
+ else if (p === "PermitTransferFrom" || p === "PermitWitnessTransferFrom")
145
+ acc.approve(lc(m.permitted?.token), big(m.permitted?.amount) ?? UNLIMITED, spender);
146
+ else if (p === "PermitBatchTransferFrom" || p === "PermitBatchWitnessTransferFrom")
147
+ for (const d of Array.isArray(m.permitted) ? m.permitted : [])
148
+ acc.approve(lc(d?.token), big(d?.amount) ?? UNLIMITED, spender);
149
+ return;
150
+ }
151
+ if (p === "Permit" && m.spender !== undefined) {
152
+ acc.approve(vc, big(m.value) ?? UNLIMITED, lc(m.spender));
153
+ return;
154
+ }
155
+ if ((p === "TransferWithAuthorization" || p === "ReceiveWithAuthorization") && (!signer || lc(m.from) === signer || m.from === undefined)) {
156
+ const to = lc(m.to);
157
+ acc.to.add(to);
158
+ acc.out(vc, big(m.value) ?? 0n, { kind: "transfer", to });
159
+ }
160
+ }
161
+ function typedOf(i) {
162
+ if (i.typedData)
163
+ return i.typedData;
164
+ if (i.rpc?.method !== "eth_signTypedData_v4")
165
+ return null;
166
+ let td = i.rpc.params?.[1];
167
+ if (typeof td === "string") {
168
+ try {
169
+ td = JSON.parse(td);
170
+ }
171
+ catch {
172
+ return null;
173
+ }
174
+ }
175
+ return td && typeof td === "object" ? td : null;
176
+ }
177
+ function decodeX402(x, acc) {
178
+ const entries = Array.isArray(x.accepts) ? x.accepts : [x];
179
+ for (const raw of entries) {
180
+ const e = (raw && typeof raw === "object" ? raw : {});
181
+ const payTo = lc(e.payTo);
182
+ if (payTo)
183
+ acc.payTo.add(payTo);
184
+ const amt = big(e.amount ?? e.maxAmountRequired);
185
+ // the client pays one entry: the largest per asset counts
186
+ if (amt !== null)
187
+ acc.out(lc(e.asset), amt, { kind: "x402", payTo }, true);
188
+ }
189
+ }
190
+ function readSim(a, from, acc) {
191
+ const s = a?.["x-toll"]?.simulation;
192
+ if (!s || !from)
193
+ return;
194
+ const eff = s.status === "success" || s.status === "reverted" ? s : s.status === "insufficient-eth" ? s.effects : null;
195
+ if (!eff || !Array.isArray(eff.deltas))
196
+ return;
197
+ acc.simulated = true;
198
+ for (const d of eff.deltas) {
199
+ if (lc(d?.holder) !== from)
200
+ continue;
201
+ const delta = big(String(d?.delta ?? "").replace(/^-/, ""));
202
+ if (delta === null || !String(d?.delta).startsWith("-"))
203
+ continue;
204
+ const tok = lc(d?.token);
205
+ const asset = tok === SIM_NATIVE || tok === "0x0000000000000000000000000000000000000000" ? NATIVE : tok;
206
+ if (!acc.assets.has(asset))
207
+ continue;
208
+ acc.simOut[asset] = (acc.simOut[asset] ?? 0n) + delta;
209
+ acc.items.push({ kind: "simulation", asset, amount: delta });
210
+ }
211
+ for (const ap of Array.isArray(eff.approvals) ? eff.approvals : []) {
212
+ if (lc(ap?.owner) !== from || ap?.kind === "nft-all")
213
+ continue;
214
+ acc.approve(lc(ap?.token), ap?.amount === null || ap?.amount === undefined ? UNLIMITED : (big(ap.amount) ?? UNLIMITED), lc(ap?.spender));
215
+ }
216
+ }
217
+ /**
218
+ * What a request spends. `assessment` is Toll's answer (null when Toll was not asked or could not answer): its
219
+ * simulation, when read, beats the declared amounts upward. `assets` are the spend assets ("native" or token addresses).
220
+ */
221
+ export function spendOf(input, assessment, assets = DEFAULT_SPEND_ASSETS) {
222
+ const acc = new Acc(new Set(assets.map(lc)));
223
+ const isTx = input.to !== undefined || input.data !== undefined;
224
+ const signer = isAddress(input.from) ? lc(input.from) : isAddress(input.message?.from) ? lc(input.message?.from) : null;
225
+ if (isTx)
226
+ decodeTx(input, acc);
227
+ else if (input.authorizationList && input.authorizationList.length)
228
+ acc.unknown = true; // a delegation can move anything
229
+ if (input.userOp)
230
+ acc.unknown = true; // raw callData is not decoded here
231
+ const td = typedOf(input);
232
+ if (td)
233
+ decodeTyped(td, acc, signer);
234
+ if (input.x402)
235
+ decodeX402(input.x402, acc);
236
+ if (isTx)
237
+ readSim(assessment, signer, acc);
238
+ return acc.done();
239
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Where the guard keeps its spend counters and the x402 payment ids it has paid. The default MemorySpendStore lives in
3
+ * this process: it resets on restart and is not shared between processes. Pass your own SpendStore (Redis, a database)
4
+ * for counters that survive restarts or span several workers.
5
+ */
6
+ export interface SpendStore {
7
+ /**
8
+ * Reserves `amt` under key `k` in the window starting at `windowStart` (ms, UTC). ok when committed + pending + amt
9
+ * stays within `limit`; `used` is committed + pending after the call. Must be atomic per key.
10
+ */
11
+ reserve(k: string, amt: bigint, limit: bigint, windowStart: number): Promise<{
12
+ ok: boolean;
13
+ id: string;
14
+ used: bigint;
15
+ }>;
16
+ /** the reservation was spent: it stays counted */
17
+ commit(id: string): Promise<void>;
18
+ /** the reservation was not spent (refused, the send threw): it no longer counts */
19
+ release(id: string): Promise<void>;
20
+ /** true when this x402 payment id was paid within its TTL */
21
+ paymentSeen(id: string): Promise<boolean>;
22
+ markPaid(id: string, ttlS: number): Promise<void>;
23
+ }
24
+ /** the start of the UTC day holding `t` (ms) */
25
+ export declare const dayStart: (t: number) => number;
26
+ /** an in-process SpendStore: resets on restart, not shared between processes */
27
+ export declare class MemorySpendStore implements SpendStore {
28
+ private readonly buckets;
29
+ private readonly where;
30
+ private readonly paid;
31
+ private readonly now;
32
+ private seq;
33
+ constructor(o?: {
34
+ now?: () => number;
35
+ });
36
+ private prune;
37
+ reserve(k: string, amt: bigint, limit: bigint, windowStart: number): Promise<{
38
+ ok: boolean;
39
+ id: string;
40
+ used: bigint;
41
+ }>;
42
+ commit(id: string): Promise<void>;
43
+ release(id: string): Promise<void>;
44
+ paymentSeen(id: string): Promise<boolean>;
45
+ markPaid(id: string, ttlS: number): Promise<void>;
46
+ }