@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/README.md CHANGED
@@ -1,12 +1,31 @@
1
1
  # @tollxyz/guard
2
2
 
3
+ > **New in 0.4.0: spend caps, allowlists, human approval and preview** (`spend` option). Unset, nothing changes.
4
+ > See [0.4.0: spend caps, allowlists, approval, preview](#040-spend-caps-allowlists-approval-preview).
5
+
6
+ > **0.3.1:** `signTypedData` (viem and ethers) now sends the signing account as `from`, so Toll can say what a Permit2 signature lets someone take.
7
+ >
8
+ > **What changed in 0.3.0: every signing method is checked.**
9
+ > - Messages: viem and ethers `signMessage`, and `request()` for `personal_sign` and `eth_sign`.
10
+ > - Typed data: `request()` for `eth_signTypedData_v4` too, any EIP-712 (orders, permits, sign-ins).
11
+ > - Batches: viem `sendCalls` (EIP-5792) and `request()` for `wallet_sendCalls`, each call checked.
12
+ > - Smart accounts: `guardSmartAccount` wraps a viem bundler client's `sendUserOperation` and `prepareUserOperation`;
13
+ > `guard()` picks it for a bundler client.
14
+ > - EIP-7702: `onAuthorization` now defaults to `"check"`: Toll reads the authorization and decides (a known, verified
15
+ > delegate is WARN medium and goes ahead under `block-high`; an unknown one is BLOCK). `"block"` and `"allow"` stay.
16
+ > An authorization Toll cannot read is blocked.
17
+ > - A signature for another chain is blocked (before 0.3.0 it went through as unsupported); `onUnsupported: "allow"`
18
+ > lets it through.
19
+ > - `eth_sign` always fails closed: it can sign any 32 bytes, a transaction included, so when Toll cannot answer it is
20
+ > blocked whatever `onUnavailable` says.
21
+
3
22
  > **Defaults changed in 0.2.0.** A WARN with any `high` or `critical` warning is now blocked (`onWarn: "block-high"`,
4
23
  > the new default); a WARN with only `medium`/`low` warnings still goes ahead, logged. To keep the 0.1.x behaviour,
5
24
  > pass `onWarn: "allow"`. New `strict: true` preset: WARN, unavailable and unsupported all block. Any option you pass
6
25
  > explicitly still wins over the preset (`{ strict: true, onUnavailable: "allow" }` stays fail-open).
7
26
 
8
- Toll's live pre-sign check in front of an AI agent's wallet. Before the agent signs a transaction, an EIP-712
9
- signature or an x402 payment on Robinhood Chain, the guard asks [Toll](https://t0ll.xyz/agents/x402#presign) and
27
+ Toll's live pre-sign check in front of an AI agent's wallet. Before the agent signs a transaction, a message, an
28
+ EIP-712 signature, a batch, a user operation, an EIP-7702 authorization or an x402 payment on Robinhood Chain, the guard asks [Toll](https://t0ll.xyz/agents/x402#presign) and
10
29
  gets ALLOW / WARN / BLOCK with the reasons. Zero runtime dependencies, ESM only, Node >= 20.19, with types.
11
30
 
12
31
  ```sh
@@ -14,7 +33,7 @@ npm install @tollxyz/guard
14
33
  ```
15
34
 
16
35
  ```ts
17
- const wallet = guard(walletClient); // viem WalletClient or ethers v6 Signer; same methods, checked first
36
+ const wallet = guard(walletClient); // viem WalletClient, viem bundler client or ethers v6 Signer; same methods, checked first
18
37
  ```
19
38
 
20
39
  ```ts
@@ -62,23 +81,43 @@ The answer is an [ERC-8265](https://github.com/ethereum/ERCs/pull/1753) RiskAsse
62
81
  `{ action, warnings: [{ code, severity, message, "x-toll-evidence" }], scanners, "x-toll": { decoded, link, checked_at, note } }`.
63
82
  Critical = BLOCK, high or medium = WARN, low or none = ALLOW.
64
83
 
65
- Robinhood Chain (4663) only for now. A request on another chain, or a contract deployment, is not sent to Toll and
66
- goes through (`onUnsupported: "block"` to stop it instead).
84
+ Robinhood Chain (4663) only for now. A transaction on another chain, or a contract deployment, is not sent to Toll
85
+ and goes through (`onUnsupported: "block"` to stop it instead). A signature for another chain is blocked
86
+ (`onUnsupported: "allow"` lets it through).
67
87
 
68
88
  ## API
69
89
 
70
- ### `guard(wallet, opts?)`, `guardViem(client, opts?)`, `guardEthers(signer, opts?)`
90
+ ### `guard(wallet, opts?)`, `guardViem(client, opts?)`, `guardSmartAccount(bundler, opts?)`, `guardEthers(signer, opts?)`
71
91
 
72
92
  Returns the same wallet behind a Proxy. Checked first:
73
93
 
74
94
  - viem WalletClient: `sendTransaction`, `writeContract` (calldata encoded with your viem's `encodeFunctionData`),
75
- `signTransaction`, `signTypedData`
76
- - ethers v6 Signer: `sendTransaction`, `signTransaction`, `signTypedData`
95
+ `signTransaction`, `signTypedData`, `signMessage`, `sendCalls`, `signAuthorization`, and `request()` for
96
+ `eth_sendTransaction`, `eth_signTransaction`, `personal_sign`, `eth_sign`, `eth_signTypedData_v4` and
97
+ `wallet_sendCalls` (every other method passes through)
98
+ - viem bundler client (account abstraction): `sendUserOperation`, `prepareUserOperation`. `calls` are checked one by
99
+ one as transactions from the smart account's address; a ready `callData` is sent as a user operation with the
100
+ account's EntryPoint
101
+ - ethers v6 Signer: `sendTransaction`, `signTransaction`, `signTypedData`, `signMessage`, `authorize`
102
+
103
+ A batch is checked call by call, in order; the first call blocked stops the whole batch.
104
+
105
+ An EIP-7702 authorization sets the wallet's code to another contract, which can then move everything it holds: a
106
+ transaction with an `authorizationList` on any method above, viem's `signAuthorization`, ethers' `authorize`, a user
107
+ operation's `authorization`. By default (`onAuthorization: "check"`) Toll reads it and decides; if Toll cannot read it
108
+ (unreachable, another chain) it is blocked (TollBlockedError, code `eip7702-authorization`). `"block"` refuses every
109
+ authorization without asking; `"allow"` lets it through unchecked. The account's own sign methods
110
+ (`client.account.sign*`) are not wrapped.
111
+
112
+ ```ts
113
+ const wallet = guard(walletClient, { origin: "https://app.example" }); // origin: the site asking, for sign-ins
114
+ await wallet.signMessage({ message: "Sign in to app.example" }); // checked as personal_sign
115
+ await wallet.request({ method: "personal_sign", params: [hexMessage, address] });
116
+ await wallet.request({ method: "eth_sign", params: [address, hash] }); // blocked when Toll blocks it, or cannot answer
77
117
 
78
- An EIP-7702 authorization sets the wallet's code to another contract, which can then move everything it holds. Toll
79
- does not read one yet, so the guard blocks it itself (TollBlockedError, code `eip7702-authorization`) unless
80
- `onAuthorization: "allow"`: a transaction with an `authorizationList` on any method above, viem's
81
- `signAuthorization` and ethers' `authorize`. The account's own sign methods (`client.account.sign*`) are not wrapped.
118
+ const smart = guard(bundlerClient); // detected by sendUserOperation
119
+ await smart.sendUserOperation({ calls: [{ to: token, abi: erc20Abi, functionName: "transfer", args: [to, amount] }] });
120
+ ```
82
121
 
83
122
  Everything else passes through unchanged. Wrap last: `client.extend(...)` or `signer.connect(...)` on the wrapped
84
123
  wallet returns an unguarded one. viem and ethers are optional peer dependencies, never imported at load time.
@@ -102,8 +141,12 @@ const pay = wrapFetchWithPayment(guardX402Fetch(fetch, { agentId: 123 }), accoun
102
141
 
103
142
  ### `checkTx(input, opts?)`
104
143
 
105
- The raw call. `input`: `{ chainId, from, to, data, value }` for a transaction, `{ typedData }` for an EIP-712
106
- request, `{ x402, agentId?, payment? }` for an x402 payment, or several together. Returns the RiskAssessment. When a
144
+ The raw call. `input`: `{ chainId, from, to, data, value, authorizationList? }` for a transaction,
145
+ `{ chainId, from, authorizationList }` for an authorization alone, `{ typedData }` for an EIP-712 request,
146
+ `{ message: { kind: "personal_sign" | "eth_sign", data, from? } }` for a message, `{ rpc: { method, params } }` for a
147
+ wallet request (`personal_sign`, `eth_sign`, `eth_signTypedData_v4`, `eth_sendTransaction`), `{ userOp, entryPoint? }`
148
+ for an ERC-4337 user operation, `{ x402, agentId?, payment? }` for an x402 payment, or several together; `origin` is
149
+ the site asking. Returns the RiskAssessment. When a
107
150
  coin is still being read, Toll answers WARN `coin-not-read` with `Retry-After`: `checkTx` asks once more if the wait
108
151
  is at most `maxRetryWaitMs` (default 10 s); a coin still not read stays WARN, never ALLOW. Throws
109
152
  `TollUnavailableError` when Toll cannot answer.
@@ -116,17 +159,180 @@ is at most `maxRetryWaitMs` (default 10 s); a coin still not read stays WARN, ne
116
159
  | `onWarn` | `"block-high"` | `"block-high"` (blocked when any warning is `high` or `critical`, else allowed and logged), `"allow"` (every WARN goes ahead, logged; the 0.1.x default), `"block"`, or `(assessment) => boolean` (true lets it through) |
117
160
  | `onBlock` | `"throw"` | `"throw"` (TollBlockedError), or `(assessment) => boolean`, e.g. ask a human; true lets it through |
118
161
  | `onUnavailable` | `"allow"` (`strict`: `"block"`) | Toll unreachable, timed out, 5xx, 429, or a request it could not read: `"allow"` (fail-open, logged) or `"block"` (fail-closed, TollUnavailableError) |
119
- | `onUnsupported` | `"allow"` (`strict`: `"block"`) | another chain, a deployment: `"allow"` or `"block"` |
120
- | `onAuthorization` | `"block"` | an EIP-7702 authorization: `"block"` (TollBlockedError) or `"allow"` (unchecked, logged) |
162
+ | `onUnsupported` | `"allow"` (`strict`: `"block"`) | a transaction on another chain, a deployment: `"allow"` or `"block"`. A signature for another chain is blocked unless this is `"allow"` explicitly |
163
+ | `onAuthorization` | `"check"` | an EIP-7702 authorization: `"check"` (Toll decides; blocked when Toll cannot read it), `"block"` (TollBlockedError, Toll not asked) or `"allow"` (unchecked, logged) |
164
+ | `origin` | none | the site asking for a signature, sent with messages and typed data |
121
165
  | `onReport` | none | `(report) => void`: every decision, with `assessment`, `decision`, `basis`, `paidUrl` |
122
166
  | `logger` | `console.warn` | where WARN and unavailable lines go; `false` = silent |
123
167
  | `timeoutMs` | `15000` | one request to Toll |
124
168
  | `retry`, `maxRetryWaitMs` | `true`, `10000` | the one retry after Retry-After |
125
169
  | `baseUrl`, `fetch` | `https://t0ll.xyz`, global fetch | |
170
+ | `spend` | none | 0.4.0: caps, allowlists, approval ([below](#040-spend-caps-allowlists-approval-preview)); unset = 0.3.x behaviour |
171
+ | `store` | in-process `MemorySpendStore` | where spend counters and paid x402 ids live |
172
+ | `failClosed` | `false` | with `spend`: a request whose spend cannot be read is blocked |
126
173
 
127
174
  Errors: `TollBlockedError` (`.assessment`, `.codes`, message lists each code and message) and
128
175
  `TollUnavailableError` (`.reason`, `.status`, `.paidUrl`).
129
176
 
177
+ ## 0.4.0: spend caps, allowlists, approval, preview
178
+
179
+ Toll's verdict says whether a request looks safe. `spend` adds your own limits on top: how much the agent may spend
180
+ per call and per UTC day, who it may pay, and when a human has to say yes. Without `spend` nothing changes: 0.4.0
181
+ behaves exactly like 0.3.x.
182
+
183
+ The order for every request:
184
+
185
+ 1. **Allowlists** (`allow.to`, `allow.spender`, `allow.payTo`): read from the request itself, before Toll is asked.
186
+ Off the list = blocked, and Toll is not called.
187
+ 2. **Toll's verdict**: ALLOW / WARN / BLOCK with your `onWarn`, `onBlock`, `onUnavailable` as before. A WARN that
188
+ `block-high` blocks is still blocked, however small the amount.
189
+ 3. **Caps** (`assets`, `perEndpoint`): per call, then per day. The day amount is reserved.
190
+ 4. **Approval** (`approveAbove` + `approve`): above the threshold your `approve` function decides.
191
+ 5. **After the send**: the reservation is committed. If the send throws (rejected, nonce too low, RPC down), it is
192
+ released, so failures never use up the day cap.
193
+
194
+ Caps never relax Toll's verdict, and Toll's ALLOW never lifts a cap: a request must pass both.
195
+
196
+ ```ts
197
+ import { guard, NATIVE, USDG, TollPolicyError } from "@tollxyz/guard";
198
+
199
+ const wallet = guard(walletClient, {
200
+ spend: {
201
+ // base units: ETH in wei, USDG with 6 decimals
202
+ assets: {
203
+ [NATIVE]: { perCall: 10n ** 16n, perDay: 5n * 10n ** 16n }, // 0.01 ETH a call, 0.05 ETH a day
204
+ [USDG]: { perCall: 20_000_000n, perDay: 100_000_000n }, // 20 USDG a call, 100 USDG a day
205
+ },
206
+ allow: { spender: ["0xYourRouter..."] }, // approvals only to your router
207
+ approveAbove: { [USDG]: 10_000_000n }, // above 10 USDG a human decides
208
+ approve: async (req) => {
209
+ const ok = await askOnTelegram(`Spend ${req.over[0].amount} of ${req.over[0].asset}?`);
210
+ return { approved: ok, approvalRef: ok ? "tg-msg-1234" : undefined };
211
+ },
212
+ },
213
+ failClosed: true, // a contract call whose spend cannot be read is blocked
214
+ });
215
+
216
+ try {
217
+ await wallet.sendTransaction(tx);
218
+ } catch (e) {
219
+ if (e instanceof TollPolicyError) console.log(e.code, e.amount, e.limit); // "cap-per-day", ...
220
+ else throw e;
221
+ }
222
+ ```
223
+
224
+ **What counts as spend.** Only what leaves the wallet in a spend asset. The defaults are native ETH, WETH and USDG on
225
+ Robinhood Chain, plus every asset you name in `assets` or `approveAbove`. A sell of any other coin is never capped.
226
+
227
+ - Read locally, with no Toll answer needed: the transaction's value, ERC-20 `transfer` / `transferFrom` / `approve` /
228
+ `increaseAllowance` on a spend asset, EIP-2612 permits, Permit2 permits, EIP-3009 transfer authorizations, and an
229
+ x402 402's amount. So caps hold even when Toll is unreachable.
230
+ - Any other contract call (a swap, a router): the amount comes from Toll's simulation (`x-toll.simulation.deltas`).
231
+ When the simulation shows more leaving than the call declares, the larger amount counts.
232
+ - An approval counts against the per-call cap at its full size (an unlimited approval is over any cap) and must fit
233
+ in what is left of the day cap; it is not added to the day total (the later swap is).
234
+ - Unknown spend: a contract call with no simulation (Toll unreachable), a raw user operation, a bare EIP-7702
235
+ authorization. `onUnknownSpend` decides: `"allow"` by default, `"block"` under `failClosed: true`.
236
+
237
+ | `spend` field | |
238
+ | --- | --- |
239
+ | `assets` | `{ [asset]: { perCall?, perDay? } }`, asset = `"native"` or a token address, amounts in base units |
240
+ | `perEndpoint` | x402 caps per URL prefix (origin + path), e.g. `{ "https://api.example.com/v1/": { perDay: 1_000_000n } }` |
241
+ | `allow` | `{ to?, spender?, payTo? }`: every recipient (or called contract), approval spender, x402 payee must be listed |
242
+ | `approveAbove` | `{ [asset]: amount }`: above it, `approve` must say yes |
243
+ | `approve` | `(req) => Promise<{ approved, approvalRef? }>`; no function = denied. `approvalRef` lands in `report.spend.approvalRef` |
244
+ | `onUnknownSpend` | `"allow"` (default) or `"block"` (default under `failClosed`) |
245
+
246
+ New options next to `spend`: `store` (where counters live, below) and `failClosed`.
247
+
248
+ `TollPolicyError` extends `TollBlockedError` (existing `catch` blocks still see a block). `.code` is one of
249
+ `not-allowlisted`, `cap-per-call`, `cap-per-day`, `cap-endpoint`, `approval-denied`, `duplicate-payment`,
250
+ `unknown-spend`; `.asset`, `.amount`, `.limit` when they apply. The report has `basis: "policy"` and `policy: code`.
251
+
252
+ ### Preview, then confirm
253
+
254
+ ```ts
255
+ import { preview } from "@tollxyz/guard";
256
+
257
+ const p = await preview({ chainId: 4663, from, to, data, value }, opts); // same opts as the guard
258
+ // p.decision "allowed" | "blocked", p.basis, p.policy, p.spend, p.needsApproval, p.reason
259
+ if (p.decision === "allowed") await wallet.sendTransaction({ to, data, value, tollConfirm: p.confirmId });
260
+ ```
261
+
262
+ `preview` asks Toll and checks every rule, but reserves nothing, calls no `approve` and no `onReport`. Passing its
263
+ `confirmId` as `tollConfirm` within 30 seconds reuses that Toll answer (one Toll call instead of two). It is used
264
+ once, and only for the identical request; anything else is checked again. The guard strips `tollConfirm` before the
265
+ wallet sees the request (viem `sendTransaction` / `signTransaction` / `writeContract`, ethers `sendTransaction` /
266
+ `signTransaction`).
267
+
268
+ ### x402: payment ids and per-endpoint caps
269
+
270
+ With `spend` set, `guardX402Fetch` also:
271
+
272
+ - counts a 402's amount against `assets` and `perEndpoint` caps when it is checked. The reservation waits for the
273
+ paid retry: committed when the seller answers, released on another 402 or an error, released after `holdMs`
274
+ (default 2 minutes) when no paid retry comes.
275
+ - never pays the same x402 `payment-identifier` twice. An id is marked paid only when the seller accepts the paid
276
+ request (any answer but 402); sending that id again throws `TollPolicyError` `duplicate-payment` (ids are kept
277
+ `paymentIdTtlS`, default 1 day). A second send of an id that is still in flight is refused too. After a network
278
+ error or timeout the same id may be retried (the seller dedupes it): the retry reuses the reservation of its 402,
279
+ so nothing is counted twice. A different id is a new payment.
280
+
281
+ ### Where the counters live
282
+
283
+ The default store is a `MemorySpendStore` in this process, one per `spend` object. **It resets when the process
284
+ restarts and is not shared between processes**: two workers each get the full day cap. For a cap that survives
285
+ restarts or spans workers, pass your own `store` implementing `SpendStore`:
286
+
287
+ ```ts
288
+ interface SpendStore {
289
+ reserve(k: string, amt: bigint, limit: bigint, windowStart: number): Promise<{ ok: boolean; id: string; used: bigint }>;
290
+ commit(id: string): Promise<void>;
291
+ release(id: string): Promise<void>;
292
+ paymentSeen(id: string): Promise<boolean>;
293
+ markPaid(id: string, ttlS: number): Promise<void>;
294
+ }
295
+ ```
296
+
297
+ A Redis store (example only, not shipped; `reserve` must be atomic, hence the Lua script):
298
+
299
+ ```ts
300
+ import Redis from "ioredis";
301
+ import type { SpendStore } from "@tollxyz/guard";
302
+
303
+ const redis = new Redis(process.env.REDIS_URL!);
304
+ const RESERVE = `
305
+ local used = tonumber(redis.call('GET', KEYS[1]) or '0')
306
+ local amt, limit = tonumber(ARGV[1]), tonumber(ARGV[2])
307
+ if used + amt > limit then return {0, tostring(used)} end
308
+ redis.call('INCRBY', KEYS[1], ARGV[1])
309
+ redis.call('EXPIRE', KEYS[1], 172800)
310
+ redis.call('SET', KEYS[2], KEYS[1] .. '|' .. ARGV[1], 'EX', 3600)
311
+ return {1, tostring(used + amt)}`;
312
+ const RELEASE = `
313
+ local v = redis.call('GET', KEYS[1])
314
+ if not v then return 0 end
315
+ local sep = string.find(v, '|', 1, true)
316
+ redis.call('DECRBY', string.sub(v, 1, sep - 1), string.sub(v, sep + 1))
317
+ redis.call('DEL', KEYS[1])
318
+ return 1`;
319
+
320
+ export const redisStore: SpendStore = {
321
+ async reserve(k, amt, limit, windowStart) {
322
+ const id = `toll:res:${crypto.randomUUID()}`;
323
+ // amounts as numbers in Lua: fine below 2^53 base units; use string math for larger caps
324
+ const [ok, used] = (await redis.eval(RESERVE, 2, `toll:cap:${k}:${windowStart}`, id, amt.toString(), limit.toString())) as [number, string];
325
+ return { ok: ok === 1, id: ok === 1 ? id : "", used: BigInt(used) };
326
+ },
327
+ async commit(id) { await redis.del(id); }, // the amount stays in the day counter
328
+ async release(id) { await redis.eval(RELEASE, 1, id); },
329
+ async paymentSeen(id) { return (await redis.exists(`toll:paid:${id}`)) === 1; },
330
+ async markPaid(id, ttlS) { await redis.set(`toll:paid:${id}`, "1", "EX", ttlS); },
331
+ };
332
+
333
+ const wallet = guard(walletClient, { spend: { /* ... */ }, store: redisStore });
334
+ ```
335
+
130
336
  ## Fail-open or fail-closed
131
337
 
132
338
  The default is fail-open: if Toll cannot answer, the signature goes ahead, with a `[toll-guard] ... NOT checked`
@@ -134,6 +340,9 @@ line and an `onReport` call with `basis: "unavailable"`. The agent must never ge
134
340
  free route is rate limited per IP (a busy agent will see 429s). If an unchecked signature is worse for you than a
135
341
  stalled agent, set `onUnavailable: "block"` (or `strict: true`, which also blocks every WARN and unsupported request).
136
342
 
343
+ Two requests fail closed whatever `onUnavailable` says: `eth_sign` (it signs any 32 bytes, a transaction hash
344
+ included, so an unchecked one could be anything) and an EIP-7702 authorization under `onAuthorization: "check"`.
345
+
137
346
  ## Free limit and the paid route
138
347
 
139
348
  The check is free, rate limited per IP. The coin a swap buys is read through the same free coins as Toll's coin
package/dist/check.d.ts CHANGED
@@ -61,8 +61,22 @@ export interface TypedDataLike {
61
61
  primaryType: string;
62
62
  message: Record<string, unknown>;
63
63
  }
64
+ /** A personal_sign / eth_sign message: data is hex (the bytes signed) or text. */
65
+ export interface SignMessageLike {
66
+ kind: "personal_sign" | "eth_sign";
67
+ data: string;
68
+ origin?: string;
69
+ from?: string;
70
+ }
71
+ /** A wallet RPC request Toll reads as the body it stands for: personal_sign, eth_sign, eth_signTypedData_v4, eth_sendTransaction. */
72
+ export interface RpcLike {
73
+ method: string;
74
+ params?: readonly unknown[];
75
+ }
64
76
  /**
65
- * What to check. A transaction ({chainId, from, to, data, value}), an EIP-712 request ({typedData}), an x402 payment
77
+ * What to check. A transaction ({chainId, from, to, data, value}, with an EIP-7702 authorizationList or not), an
78
+ * authorization list alone ({chainId, from, authorizationList}), an EIP-712 request ({typedData}), a message
79
+ * ({message}), a wallet request ({rpc}), an ERC-4337 user operation ({userOp, entryPoint?}), an x402 payment
66
80
  * ({x402, agentId?, payment?}), or several together.
67
81
  */
68
82
  export interface CheckInput {
@@ -71,7 +85,17 @@ export interface CheckInput {
71
85
  to?: string;
72
86
  data?: string;
73
87
  value?: Numberish;
88
+ /** EIP-7702 authorizations: with to/data a type-4 transaction, alone a signAuthorization */
89
+ authorizationList?: readonly unknown[];
74
90
  typedData?: TypedDataLike;
91
+ message?: SignMessageLike;
92
+ rpc?: RpcLike;
93
+ /** an ERC-4337 user operation (v0.6, v0.7 packed or the RPC form) */
94
+ userOp?: Record<string, unknown>;
95
+ /** the user operation's EntryPoint */
96
+ entryPoint?: string;
97
+ /** the site asking for the signature (a URL or host name) */
98
+ origin?: string;
75
99
  /** the 402's PaymentRequired, or one of its accepts entries */
76
100
  x402?: Record<string, unknown>;
77
101
  /** the seller's ERC-8004 agent id, when known */
@@ -97,7 +121,7 @@ export interface CheckOptions {
97
121
  export declare class TollBlockedError extends Error {
98
122
  readonly assessment: RiskAssessment;
99
123
  readonly codes: string[];
100
- constructor(assessment: RiskAssessment, why?: Action);
124
+ constructor(assessment: RiskAssessment, why?: Action | "policy");
101
125
  }
102
126
  export type UnavailableReason = "network" | "timeout" | "rate-limited" | "server-error" | "bad-request" | "bad-answer" | "unsupported" | "encode";
103
127
  export declare class TollUnavailableError extends Error {
package/dist/check.js CHANGED
@@ -19,7 +19,7 @@ export class TollBlockedError extends Error {
19
19
  const top = ws.filter((w) => w.severity !== "low").slice(0, 5);
20
20
  const lines = (top.length ? top : ws.slice(0, 5)).map((w) => `${w.code}: ${w.message}`);
21
21
  const link = assessment["x-toll"]?.link;
22
- super(`Toll ${why}, not signed. ${lines.join(" | ") || "No reason given."}${link ? ` More: ${link}` : ""}`);
22
+ super(`${why === "policy" ? "Spend policy" : `Toll ${why}`}, not signed. ${lines.join(" | ") || "No reason given."}${link ? ` More: ${link}` : ""}`);
23
23
  this.name = "TollBlockedError";
24
24
  this.assessment = assessment;
25
25
  this.codes = ws.map((w) => w.code);
@@ -61,6 +61,7 @@ const trimBase = (b) => b.replace(/\/+$/, "");
61
61
  /** the request body as the route reads it (lib/txcheck.ts parseTxCheckBody): payment rides inside x402 */
62
62
  export function bodyOf(input) {
63
63
  const b = {};
64
+ const auths = input.authorizationList !== undefined && input.authorizationList !== null && input.authorizationList.length > 0;
64
65
  if (input.to !== undefined || input.data !== undefined) {
65
66
  if (input.chainId !== undefined)
66
67
  b.chainId = input.chainId;
@@ -69,9 +70,34 @@ export function bodyOf(input) {
69
70
  b.to = input.to;
70
71
  b.data = input.data ?? "0x";
71
72
  b.value = input.value ?? 0;
73
+ if (auths)
74
+ b.authorizationList = input.authorizationList;
72
75
  }
73
- if (input.typedData)
76
+ else if (auths) {
77
+ // a signAuthorization alone: the list and its signer
78
+ if (input.chainId !== undefined)
79
+ b.chainId = input.chainId;
80
+ if (input.from !== undefined)
81
+ b.from = input.from;
82
+ b.authorizationList = input.authorizationList;
83
+ }
84
+ if (input.typedData) {
74
85
  b.typedData = input.typedData;
86
+ // the signer of typed data alone (0.3.1): Toll reads what the signature lets someone take from it
87
+ if (b.from === undefined && input.from !== undefined)
88
+ b.from = input.from;
89
+ }
90
+ if (input.message)
91
+ b.message = input.message;
92
+ if (input.rpc)
93
+ b.rpc = input.rpc;
94
+ if (input.userOp) {
95
+ b.userOp = input.userOp;
96
+ if (input.entryPoint !== undefined)
97
+ b.entryPoint = input.entryPoint;
98
+ }
99
+ if (input.origin !== undefined)
100
+ b.origin = input.origin;
75
101
  if (input.x402)
76
102
  b.x402 = input.payment ? { ...input.x402, payment: input.payment } : input.x402;
77
103
  if (input.agentId !== undefined && input.agentId !== null)
package/dist/index.d.ts CHANGED
@@ -1,10 +1,14 @@
1
- export { checkTx, chainIdOf, paidUrlOf, isAssessment, TollBlockedError, TollUnavailableError, DEFAULT_BASE_URL, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_RETRY_WAIT_MS, PAID_TX_PATH, PRESIGN_DOCS, TX_CHAIN_ID, } from "./check.js";
2
- export type { Action, Severity, RiskWarning, ScannerVerdict, TollExtra, RiskAssessment, CheckInput, CheckOptions, TypedDataLike, FetchLike, Hex, Numberish, UnavailableReason, } from "./check.js";
3
- export { enforce, unsupportedWhy, refuse, gateAuthorization, resolvePolicy, hasHighWarning } from "./policy.js";
4
- export type { GuardOptions, GuardReport, GuardKind, WarnPolicy, BlockPolicy } from "./policy.js";
5
- export { guard, guardViem, guardEthers, isViemClient, primaryTypeOf } from "./wallets.js";
1
+ export { checkTx, bodyOf, chainIdOf, paidUrlOf, isAssessment, TollBlockedError, TollUnavailableError, DEFAULT_BASE_URL, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_RETRY_WAIT_MS, PAID_TX_PATH, PRESIGN_DOCS, TX_CHAIN_ID, } from "./check.js";
2
+ export type { Action, Severity, RiskWarning, ScannerVerdict, TollExtra, RiskAssessment, SignMessageLike, RpcLike, CheckInput, CheckOptions, TypedDataLike, FetchLike, Hex, Numberish, UnavailableReason, } from "./check.js";
3
+ export { enforce, enforceCalls, unsupportedWhy, refuse, gateAuthorization, resolvePolicy, hasHighWarning, isSignature, isEthSign, TollPolicyError, commitSpend, releaseSpend, preview } from "./policy.js";
4
+ export type { GuardOptions, GuardReport, GuardKind, WarnPolicy, BlockPolicy, SpendPolicy, Caps, ApprovalRequest, PolicyCode, GuardDecision } from "./policy.js";
5
+ export { guard, guardViem, guardEthers, guardSmartAccount, isViemClient, isBundlerClient, primaryTypeOf } from "./wallets.js";
6
6
  export type { ViemGuardOptions } from "./wallets.js";
7
- export { guardX402Fetch, readPaymentRequired, readPaymentForms, paymentFormsDiffer } from "./x402.js";
7
+ export { guardX402Fetch, readPaymentRequired, readPaymentForms, paymentFormsDiffer, paymentIdOf } from "./x402.js";
8
8
  export type { X402GuardOptions } from "./x402.js";
9
9
  export { read8470, latestFinding8470, decodeGetObservation, decodeLatestFinding, encodeGetObservation, encodeLatestByToken, encodeLatestFinding, FINDING_KINDS, NOT_READ, OBS_REGISTRY, OBS_DETECTOR, OBS_CHAIN_ID, RH_RPC, } from "./erc8470.js";
10
10
  export type { Observation8470, Finding8470, LatestFinding8470, Read8470Options, Lean, KeyKind, Unit } from "./erc8470.js";
11
+ export { spendOf, NATIVE, USDG, WETH, DEFAULT_SPEND_ASSETS, UNLIMITED } from "./spend.js";
12
+ export type { Spend, SpendItem } from "./spend.js";
13
+ export { MemorySpendStore, dayStart } from "./store.js";
14
+ export type { SpendStore } from "./store.js";
package/dist/index.js CHANGED
@@ -1,5 +1,7 @@
1
- export { checkTx, chainIdOf, paidUrlOf, isAssessment, TollBlockedError, TollUnavailableError, DEFAULT_BASE_URL, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_RETRY_WAIT_MS, PAID_TX_PATH, PRESIGN_DOCS, TX_CHAIN_ID, } from "./check.js";
2
- export { enforce, unsupportedWhy, refuse, gateAuthorization, resolvePolicy, hasHighWarning } from "./policy.js";
3
- export { guard, guardViem, guardEthers, isViemClient, primaryTypeOf } from "./wallets.js";
4
- export { guardX402Fetch, readPaymentRequired, readPaymentForms, paymentFormsDiffer } from "./x402.js";
1
+ export { checkTx, bodyOf, chainIdOf, paidUrlOf, isAssessment, TollBlockedError, TollUnavailableError, DEFAULT_BASE_URL, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_RETRY_WAIT_MS, PAID_TX_PATH, PRESIGN_DOCS, TX_CHAIN_ID, } from "./check.js";
2
+ export { enforce, enforceCalls, unsupportedWhy, refuse, gateAuthorization, resolvePolicy, hasHighWarning, isSignature, isEthSign, TollPolicyError, commitSpend, releaseSpend, preview } from "./policy.js";
3
+ export { guard, guardViem, guardEthers, guardSmartAccount, isViemClient, isBundlerClient, primaryTypeOf } from "./wallets.js";
4
+ export { guardX402Fetch, readPaymentRequired, readPaymentForms, paymentFormsDiffer, paymentIdOf } from "./x402.js";
5
5
  export { read8470, latestFinding8470, decodeGetObservation, decodeLatestFinding, encodeGetObservation, encodeLatestByToken, encodeLatestFinding, FINDING_KINDS, NOT_READ, OBS_REGISTRY, OBS_DETECTOR, OBS_CHAIN_ID, RH_RPC, } from "./erc8470.js";
6
+ export { spendOf, NATIVE, USDG, WETH, DEFAULT_SPEND_ASSETS, UNLIMITED } from "./spend.js";
7
+ export { MemorySpendStore, dayStart } from "./store.js";
package/dist/policy.d.ts CHANGED
@@ -1,7 +1,9 @@
1
- import { TollUnavailableError, type CheckInput, type CheckOptions, type RiskAssessment } from "./check.js";
1
+ import { TollBlockedError, TollUnavailableError, type CheckInput, type CheckOptions, type RiskAssessment } from "./check.js";
2
+ import { type Spend } from "./spend.js";
3
+ import { type SpendStore } from "./store.js";
2
4
  export type WarnPolicy = "allow" | "block" | "block-high" | ((assessment: RiskAssessment) => boolean | Promise<boolean>);
3
5
  export type BlockPolicy = "throw" | ((assessment: RiskAssessment) => boolean | Promise<boolean>);
4
- export type GuardKind = "transaction" | "typedData" | "x402";
6
+ export type GuardKind = "transaction" | "typedData" | "x402" | "message" | "userOp" | "calls";
5
7
  export interface GuardReport {
6
8
  kind: GuardKind;
7
9
  /** what was sent to Toll (null when nothing was sent) */
@@ -10,11 +12,85 @@ export interface GuardReport {
10
12
  assessment: RiskAssessment | null;
11
13
  /** what the guard did */
12
14
  decision: "allowed" | "blocked";
13
- /** why: Toll's action, "unavailable" (Toll could not answer) or "unsupported" (not a request Toll reads) */
14
- basis: "ALLOW" | "WARN" | "BLOCK" | "unavailable" | "unsupported";
15
+ /**
16
+ * why: Toll's action, "unavailable" (Toll could not answer), "unsupported" (not a request Toll reads) or "policy"
17
+ * (blocked by your own spend rules: allowlist, cap, approval; `policy` says which)
18
+ */
19
+ basis: "ALLOW" | "WARN" | "BLOCK" | "unavailable" | "unsupported" | "policy";
15
20
  /** the paid route (no daily limit) when this caller's free checks are used */
16
21
  paidUrl: string | null;
17
22
  error?: TollUnavailableError;
23
+ /** the spend rule that blocked it (basis "policy") */
24
+ policy?: PolicyCode;
25
+ /** with `spend` set: what the request spends, the day-cap reservations it holds, and the approval reference */
26
+ spend?: {
27
+ spend: Spend;
28
+ reservations: string[];
29
+ approvalRef?: string;
30
+ needsApproval?: boolean;
31
+ };
32
+ }
33
+ export type PolicyCode = "not-allowlisted" | "cap-per-call" | "cap-per-day" | "cap-endpoint" | "approval-denied" | "duplicate-payment" | "unknown-spend";
34
+ export interface Caps {
35
+ /** the most one request may spend, base units */
36
+ perCall?: bigint;
37
+ /** the most all requests together may spend in one UTC day, base units */
38
+ perDay?: bigint;
39
+ }
40
+ export interface ApprovalRequest {
41
+ kind: GuardKind;
42
+ input: CheckInput | null;
43
+ /** Toll's answer (null when Toll could not answer) */
44
+ assessment: RiskAssessment | null;
45
+ spend: Spend;
46
+ /** each asset over its approveAbove threshold */
47
+ over: Array<{
48
+ asset: string;
49
+ amount: bigint;
50
+ threshold: bigint;
51
+ }>;
52
+ }
53
+ /**
54
+ * Your own rules around Toll's verdict. Order: allowlists (before Toll is asked) -> Toll's ALLOW / WARN / BLOCK
55
+ * (unchanged) -> per-call and per-day caps -> human approval -> the day reservation is committed after the send
56
+ * (released when the send throws). Keys are "native" or a token address (any case); amounts are base units.
57
+ */
58
+ export interface SpendPolicy {
59
+ /** caps per spend asset; the spend assets are native, WETH and USDG plus every key here and in approveAbove */
60
+ assets?: Record<string, Caps>;
61
+ /** x402 caps per endpoint: a URL prefix (origin + path), in the payment asset's base units */
62
+ perEndpoint?: Record<string, Caps>;
63
+ /** when a list is given, every recipient / called contract, approval spender or x402 payee must be on it */
64
+ allow?: {
65
+ to?: string[];
66
+ spender?: string[];
67
+ payTo?: string[];
68
+ };
69
+ /** a spend (or allowance) above this amount needs `approve` to say yes */
70
+ approveAbove?: Record<string, bigint>;
71
+ /** asks a human (or another system); no function = denied */
72
+ approve?: (r: ApprovalRequest) => Promise<{
73
+ approved: boolean;
74
+ approvalRef?: string;
75
+ }>;
76
+ /** a request whose spend could not be read (a contract call Toll did not simulate): default "allow", "block" under failClosed */
77
+ onUnknownSpend?: "allow" | "block";
78
+ }
79
+ /** blocked by your own spend rules; a TollBlockedError, so existing catch blocks still see a block */
80
+ export declare class TollPolicyError extends TollBlockedError {
81
+ readonly code: PolicyCode;
82
+ readonly asset?: string;
83
+ readonly amount?: bigint;
84
+ readonly limit?: bigint;
85
+ /** the guard's report of this request */
86
+ readonly report?: GuardReport;
87
+ constructor(code: PolicyCode, message: string, x?: {
88
+ asset?: string;
89
+ amount?: bigint;
90
+ limit?: bigint;
91
+ assessment?: RiskAssessment | null;
92
+ report?: GuardReport;
93
+ });
18
94
  }
19
95
  export interface GuardOptions extends CheckOptions {
20
96
  /**
@@ -44,10 +120,32 @@ export interface GuardOptions extends CheckOptions {
44
120
  agentId?: number | string | ((url: string) => number | string | undefined | null);
45
121
  /**
46
122
  * An EIP-7702 authorization (a transaction's authorizationList, signAuthorization / authorize): it sets the
47
- * wallet's code to another contract, which can then move everything the wallet holds. Toll does not read one yet.
48
- * "block" (default): TollBlockedError, nothing is signed. "allow": let it through unchecked (logged, reported).
123
+ * wallet's code to another contract, which can then move everything the wallet holds.
124
+ * "check" (default since 0.3.0): Toll reads it and decides (a known, verified delegate comes back WARN medium and
125
+ * passes under onWarn "block-high"; an unknown one comes back BLOCK). It fails closed: when Toll cannot read it
126
+ * (unreachable, another chain, a request it refuses) it is blocked, whatever onUnavailable / onUnsupported say.
127
+ * "block": TollBlockedError, nothing is signed, Toll is not asked. "allow": let it through unchecked (logged, reported).
49
128
  */
50
- onAuthorization?: "block" | "allow";
129
+ onAuthorization?: "check" | "block" | "allow";
130
+ /** the site asking for a signature (a URL or host name), sent with messages so Toll can match a sign-in's domain */
131
+ origin?: string;
132
+ /** 0.4.0: spend caps, allowlists and human approval around Toll's verdict; unset = 0.3.x behaviour */
133
+ spend?: SpendPolicy;
134
+ /** where caps and paid x402 ids are counted; default an in-process MemorySpendStore per SpendPolicy object */
135
+ store?: SpendStore;
136
+ /** with `spend`: a request whose spend cannot be read is blocked (onUnknownSpend default "block") */
137
+ failClosed?: boolean;
138
+ }
139
+ /** fields the wrappers pass to enforce; not part of the public options */
140
+ export interface InternalOptions extends GuardOptions {
141
+ /** spend-mode reports of one wrapped call, committed after the send or released when it throws */
142
+ _collect?: GuardReport[];
143
+ /** a preview's confirm id: Toll's answer from that preview is reused once when the request is identical */
144
+ _confirm?: string;
145
+ /** preview: check everything, reserve nothing, never ask for approval */
146
+ _preview?: boolean;
147
+ /** the x402 endpoint URL (per-endpoint caps) */
148
+ _endpoint?: string;
51
149
  }
52
150
  /** the effective policy: explicit options win, then the strict preset, then the defaults */
53
151
  export declare function resolvePolicy(o?: GuardOptions): {
@@ -57,6 +155,10 @@ export declare function resolvePolicy(o?: GuardOptions): {
57
155
  };
58
156
  /** true when any warning is high or critical */
59
157
  export declare const hasHighWarning: (a: RiskAssessment) => boolean;
158
+ /** true for a signature request (typed data, a message, a signing RPC) rather than a transaction or an x402 payment */
159
+ export declare const isSignature: (input: CheckInput | null | undefined) => boolean;
160
+ /** true for eth_sign: it signs any 32 bytes (a transaction hash included), so it fails closed when Toll cannot answer */
161
+ export declare const isEthSign: (input: CheckInput | null | undefined) => boolean;
60
162
  /** why Toll would not read this request, or null when it does */
61
163
  export declare function unsupportedWhy(input: CheckInput): string | null;
62
164
  /**
@@ -70,5 +172,48 @@ export declare function enforce(kind: GuardKind, build: () => CheckInput | Promi
70
172
  * disagree): reported as a BLOCK with one critical warning, then TollBlockedError. Nothing is sent to Toll.
71
173
  */
72
174
  export declare function refuse(kind: GuardKind, input: CheckInput | null, code: string, message: string, o?: GuardOptions): Promise<never>;
73
- /** the onAuthorization policy for an EIP-7702 authorization: resolves when it may go ahead, else throws */
74
- export declare function gateAuthorization(kind: GuardKind, where: string, input: CheckInput | null, o?: GuardOptions): Promise<void>;
175
+ /**
176
+ * The onAuthorization policy for an EIP-7702 authorization. Resolves true when Toll checked the whole request (the
177
+ * "check" default: the caller asks nothing more), false when "allow" let it through unchecked (the caller still checks
178
+ * the rest of the request); throws when it is blocked. `input` is the request with its authorizationList, or a function
179
+ * that builds it; under "check", null is blocked (nothing to send).
180
+ */
181
+ export declare function gateAuthorization(kind: GuardKind, where: string, input: CheckInput | null | (() => CheckInput | Promise<CheckInput>), o?: GuardOptions): Promise<boolean>;
182
+ /**
183
+ * A batch (EIP-5792 sendCalls, wallet_sendCalls, a smart account's calls): each call is checked as its own transaction
184
+ * from the batch's sender, in order; the first one blocked stops the batch. Reports carry kind "calls".
185
+ */
186
+ export declare function enforceCalls(build: () => CheckInput[] | Promise<CheckInput[]>, o?: GuardOptions): Promise<GuardReport[]>;
187
+ /** the store for these options: the one given, else one in-process store per SpendPolicy object */
188
+ export declare function storeOf(o: GuardOptions): SpendStore;
189
+ /** the reservations of these reports, committed: the request was sent */
190
+ export declare function commitSpend(reports: GuardReport | GuardReport[], o: GuardOptions): Promise<void>;
191
+ /** the reservations of these reports, released: the request was refused or the send threw */
192
+ export declare function releaseSpend(reports: GuardReport | GuardReport[], o: GuardOptions): Promise<void>;
193
+ /** a request the spend policy refuses outside enforce (a repeated x402 payment id): reported, then TollPolicyError */
194
+ export declare function policyRefuse(kind: GuardKind, input: CheckInput | null, code: PolicyCode, message: string, o: GuardOptions): Promise<never>;
195
+ export interface GuardDecision {
196
+ decision: "allowed" | "blocked";
197
+ basis: GuardReport["basis"];
198
+ /** Toll's answer (null when Toll was not asked or could not answer) */
199
+ assessment: RiskAssessment | null;
200
+ /** the spend rule that would block it */
201
+ policy?: PolicyCode;
202
+ /** what it would spend (with `spend` set) */
203
+ spend?: Spend;
204
+ /** over an approveAbove threshold: the send will ask `approve` */
205
+ needsApproval?: boolean;
206
+ /** why it would be blocked */
207
+ reason?: string;
208
+ paidUrl: string | null;
209
+ }
210
+ /**
211
+ * What the guard would do with this request, without doing it: Toll is asked and every spend rule is checked, but
212
+ * nothing is reserved, `approve` is not called and onReport is not called. When allowed, `confirmId` passed as
213
+ * `tollConfirm` on the send within 30 s skips the second Toll call (once, and only for the identical request).
214
+ */
215
+ export declare function preview(input: CheckInput, opts?: GuardOptions & {
216
+ kind?: GuardKind;
217
+ }): Promise<GuardDecision & {
218
+ confirmId: string;
219
+ }>;