@tollxyz/guard 0.2.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;
@@ -31,9 +51,49 @@ async function report(o, r) {
31
51
  say(o, `onReport threw: ${e?.message ?? e}`);
32
52
  }
33
53
  }
54
+ const SIGN_RPC = new Set(["personal_sign", "eth_sign", "eth_signTypedData_v4"]);
55
+ /** the typed data a request signs: its typedData, or an eth_signTypedData_v4 request's params[1] (JSON or object) */
56
+ function typedOf(input) {
57
+ if (input.typedData)
58
+ return input.typedData;
59
+ if (input.rpc?.method !== "eth_signTypedData_v4")
60
+ return null;
61
+ let td = input.rpc.params?.[1];
62
+ if (typeof td === "string") {
63
+ try {
64
+ td = JSON.parse(td);
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ return td && typeof td === "object" ? td : null;
71
+ }
72
+ /** true for a signature request (typed data, a message, a signing RPC) rather than a transaction or an x402 payment */
73
+ export const isSignature = (input) => !!input && !input.x402 && (!!input.typedData || !!input.message || SIGN_RPC.has(String(input.rpc?.method)));
74
+ /** true for eth_sign: it signs any 32 bytes (a transaction hash included), so it fails closed when Toll cannot answer */
75
+ export const isEthSign = (input) => input?.message?.kind === "eth_sign" || input?.rpc?.method === "eth_sign";
76
+ /** the other chain a typed-data signature names (its domain chainId), or null (Robinhood Chain, none, or an x402 payment) */
77
+ function otherChainSignature(input) {
78
+ if (input.x402)
79
+ return null;
80
+ const c = typedOf(input)?.domain?.chainId;
81
+ const cid = c === undefined || c === null ? null : chainIdOf(c);
82
+ return cid !== null && cid !== TX_CHAIN_ID ? cid : null;
83
+ }
84
+ /** Toll's 400 for a request on another chain ("... chainId N is not read ...") */
85
+ const OTHER_CHAIN_400 = /chainId \d+ is not read/;
34
86
  /** why Toll would not read this request, or null when it does */
35
87
  export function unsupportedWhy(input) {
36
88
  const tx = input.to !== undefined || input.data !== undefined;
89
+ const auths = !!input.authorizationList && input.authorizationList.length > 0;
90
+ if (!tx && auths) {
91
+ const cid = input.chainId === undefined ? null : chainIdOf(input.chainId);
92
+ if (input.chainId !== undefined && cid === null)
93
+ return "the chain id could not be read";
94
+ if (cid !== null && cid !== TX_CHAIN_ID)
95
+ return `chain ${cid} is not read (Robinhood Chain ${TX_CHAIN_ID} only for now)`;
96
+ }
37
97
  if (tx) {
38
98
  if (input.to === undefined || input.to === null)
39
99
  return "a contract deployment (no `to`) is not read";
@@ -43,13 +103,10 @@ export function unsupportedWhy(input) {
43
103
  if (cid !== null && cid !== TX_CHAIN_ID)
44
104
  return `chain ${cid} is not read (Robinhood Chain ${TX_CHAIN_ID} only for now)`;
45
105
  }
46
- if (input.typedData && !input.x402) {
47
- const c = input.typedData.domain?.chainId;
48
- const cid = c === undefined || c === null ? null : chainIdOf(c);
49
- if (cid !== null && cid !== TX_CHAIN_ID)
50
- return `a signature for chain ${cid} is not read (Robinhood Chain ${TX_CHAIN_ID} only for now)`;
51
- }
52
- if (!tx && !input.typedData && !input.x402)
106
+ const other = otherChainSignature(input);
107
+ if (other !== null)
108
+ return `a signature for chain ${other} is not read (Robinhood Chain ${TX_CHAIN_ID} only for now)`;
109
+ if (!tx && !auths && !input.typedData && !input.x402 && !input.message && !input.rpc && !input.userOp)
53
110
  return "nothing to check";
54
111
  return null;
55
112
  }
@@ -59,6 +116,10 @@ export function unsupportedWhy(input) {
59
116
  * `build` may throw (e.g. calldata that could not be encoded): that counts as unavailable.
60
117
  */
61
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) {
62
123
  let input;
63
124
  try {
64
125
  input = await build();
@@ -68,6 +129,10 @@ export async function enforce(kind, build, o = {}) {
68
129
  return unavailable(kind, null, err, o);
69
130
  }
70
131
  const why = unsupportedWhy(input);
132
+ // a signature for another chain is not "unsupported -> allow": an off-chain signature Toll cannot read is refused
133
+ if (why && isSignature(input) && otherChainSignature(input) !== null && o.onUnsupported !== "allow") {
134
+ return refuse(kind, input, "signature-other-chain", `Not signed: ${why}. A signature Toll cannot read could hand over anything; onUnsupported: "allow" lets it through unchecked.`, o);
135
+ }
71
136
  if (why) {
72
137
  const blocked = resolvePolicy(o).onUnsupported === "block";
73
138
  const r = { kind, input, assessment: null, decision: blocked ? "blocked" : "allowed", basis: "unsupported", paidUrl: null };
@@ -78,10 +143,14 @@ export async function enforce(kind, build, o = {}) {
78
143
  }
79
144
  let a;
80
145
  try {
81
- a = await checkTx(input, o);
146
+ a = takeConfirmed(o, input) ?? (await checkTx(input, o));
82
147
  }
83
148
  catch (e) {
84
149
  const err = e instanceof TollUnavailableError ? e : new TollUnavailableError(`Toll's check failed: ${e?.message ?? e}`, { reason: "network", cause: e });
150
+ // Toll's 400 for a signature on another chain: blocked, never "unsupported -> allow"
151
+ if (err.reason === "bad-request" && isSignature(input) && OTHER_CHAIN_400.test(err.message) && o.onUnsupported !== "allow") {
152
+ return refuse(kind, input, "signature-other-chain", `Not signed: Toll does not read a signature for another chain (${err.message}). onUnsupported: "allow" lets it through unchecked.`, o);
153
+ }
85
154
  return unavailable(kind, input, err, o);
86
155
  }
87
156
  const paidUrl = paidUrlOf(a);
@@ -112,7 +181,8 @@ export async function enforce(kind, build, o = {}) {
112
181
  throw new TollBlockedError(a, "WARN");
113
182
  }
114
183
  async function unavailable(kind, input, err, o) {
115
- const blocked = resolvePolicy(o).onUnavailable === "block";
184
+ // eth_sign signs any 32 bytes (a transaction hash included): it always fails closed
185
+ const blocked = resolvePolicy(o).onUnavailable === "block" || isEthSign(input);
116
186
  const r = { kind, input, assessment: null, decision: blocked ? "blocked" : "allowed", basis: "unavailable", paidUrl: err.paidUrl, error: err };
117
187
  await report(o, r);
118
188
  if (blocked)
@@ -129,12 +199,327 @@ export async function refuse(kind, input, code, message, o = {}) {
129
199
  await report(o, { kind, input, assessment: a, decision: "blocked", basis: "BLOCK", paidUrl: null });
130
200
  throw new TollBlockedError(a, "BLOCK");
131
201
  }
132
- /** the onAuthorization policy for an EIP-7702 authorization: resolves when it may go ahead, else throws */
202
+ /**
203
+ * The onAuthorization policy for an EIP-7702 authorization. Resolves true when Toll checked the whole request (the
204
+ * "check" default: the caller asks nothing more), false when "allow" let it through unchecked (the caller still checks
205
+ * the rest of the request); throws when it is blocked. `input` is the request with its authorizationList, or a function
206
+ * that builds it; under "check", null is blocked (nothing to send).
207
+ */
133
208
  export async function gateAuthorization(kind, where, input, o = {}) {
134
- if (o.onAuthorization === "allow") {
209
+ const p = o.onAuthorization ?? "check";
210
+ const shown = typeof input === "function" ? null : input;
211
+ if (p === "allow") {
135
212
  say(o, `EIP-7702 authorization (${where}) NOT checked, going ahead (onAuthorization "allow")`);
136
- await report(o, { kind, input, assessment: null, decision: "allowed", basis: "unsupported", paidUrl: null });
213
+ await report(o, { kind, input: shown, assessment: null, decision: "allowed", basis: "unsupported", paidUrl: null });
214
+ return false;
215
+ }
216
+ const what = `${where} carries an EIP-7702 authorization, which sets this wallet's code to another contract that can then move everything it holds.`;
217
+ if (p === "block" || input === null) {
218
+ return refuse(kind, shown, "eip7702-authorization", `${what} ${p === "block" ? 'onAuthorization is "block"' : "It could not be read for Toll's check"}; onAuthorization: "allow" lets it through unchecked.`, o);
219
+ }
220
+ const build = typeof input === "function" ? input : () => input;
221
+ try {
222
+ // fail closed: an authorization Toll could not read is not signed
223
+ await enforce(kind, build, { ...o, onUnavailable: "block", onUnsupported: "block" });
224
+ }
225
+ catch (e) {
226
+ if (!(e instanceof TollUnavailableError))
227
+ throw e;
228
+ return refuse(kind, null, "eip7702-authorization", `${what} Toll could not read it (${e.message}), so it is not signed; onAuthorization: "allow" lets it through unchecked.`, o);
229
+ }
230
+ return true;
231
+ }
232
+ /**
233
+ * A batch (EIP-5792 sendCalls, wallet_sendCalls, a smart account's calls): each call is checked as its own transaction
234
+ * from the batch's sender, in order; the first one blocked stops the batch. Reports carry kind "calls".
235
+ */
236
+ export async function enforceCalls(build, o = {}) {
237
+ let calls;
238
+ try {
239
+ calls = await build();
240
+ }
241
+ catch (e) {
242
+ const err = e instanceof TollUnavailableError ? e : new TollUnavailableError(`The batch could not be read for Toll's check: ${e?.message ?? e}`, { reason: "encode", cause: e });
243
+ return [await unavailable("calls", null, err, o)];
244
+ }
245
+ if (!calls.length)
246
+ return [await enforce("calls", () => ({}), o)];
247
+ const out = [];
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
+ }
257
+ return out;
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)
137
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;
138
524
  }
139
- await refuse(kind, input, "eip7702-authorization", `${where} carries an EIP-7702 authorization, which sets this wallet's code to another contract that can then move everything it holds. Toll does not read authorizations yet; onAuthorization: "allow" lets it through unchecked.`, o);
140
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;