@tollxyz/guard 0.6.1 → 0.7.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,495 +1,517 @@
1
- # @tollxyz/guard
2
-
3
- > **New in 0.4.1: ALLOW tickets** (`ticket` option, `appendTicketSuffix`): a single-use, signed ticket for one exact
4
- > call, for contracts that require one. See [ALLOW tickets](#allow-tickets).
5
-
6
- > **New in 0.4.0: spend caps, allowlists, human approval and preview** (`spend` option). Unset, nothing changes.
7
- > See [0.4.0: spend caps, allowlists, approval, preview](#040-spend-caps-allowlists-approval-preview).
8
-
9
- > **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.
10
- >
11
- > **What changed in 0.3.0: every signing method is checked.**
12
- > - Messages: viem and ethers `signMessage`, and `request()` for `personal_sign` and `eth_sign`.
13
- > - Typed data: `request()` for `eth_signTypedData_v4` too, any EIP-712 (orders, permits, sign-ins).
14
- > - Batches: viem `sendCalls` (EIP-5792) and `request()` for `wallet_sendCalls`, each call checked.
15
- > - Smart accounts: `guardSmartAccount` wraps a viem bundler client's `sendUserOperation` and `prepareUserOperation`;
16
- > `guard()` picks it for a bundler client.
17
- > - EIP-7702: `onAuthorization` now defaults to `"check"`: Toll reads the authorization and decides (a known, verified
18
- > delegate is WARN medium and goes ahead under `block-high`; an unknown one is BLOCK). `"block"` and `"allow"` stay.
19
- > An authorization Toll cannot read is blocked.
20
- > - A signature for another chain is blocked (before 0.3.0 it went through as unsupported); `onUnsupported: "allow"`
21
- > lets it through.
22
- > - `eth_sign` always fails closed: it can sign any 32 bytes, a transaction included, so when Toll cannot answer it is
23
- > blocked whatever `onUnavailable` says.
24
-
25
- > **Default changed in 0.6.0: the guard fails closed.** When Toll cannot or will not answer (unreachable, timeout,
26
- > 429, 5xx, HTTP 400 or 413, an answer that is not a RiskAssessment) nothing is signed: `TollUnavailableError`, with
27
- > "Not signed" in the message. Before, such a request went ahead unchecked, so whoever built the request could
28
- > switch the check off (a request padded past Toll's 64 KB limit answers 413). To keep the old behaviour pass
29
- > `failOpen: true` (or `onUnavailable: "allow"`), and know that it lets anyone who can shape the request skip the check.
30
-
31
- > **Also in 0.6.0.** With a `spend` policy, a request whose spend cannot be read (a contract call Toll did not simulate,
32
- > an x402 amount such as `"+5000000"` or a value that does not parse) is refused by default: `onUnknownSpend: "allow"`
33
- > opts out. A WARN that says the coin was not read yet (`coin-not-read`) is refused under the default `block-high`.
34
- > Fixed: `transferFrom`'s recipient was read from the amount word, so an allowlist refused every `transferFrom`.
35
-
36
- > **Defaults changed in 0.2.0.** A WARN with any `high` or `critical` warning is now blocked (`onWarn: "block-high"`,
37
- > the new default); a WARN with only `medium`/`low` warnings still goes ahead, logged. To keep the 0.1.x behaviour,
38
- > pass `onWarn: "allow"`. New `strict: true` preset: WARN, unavailable and unsupported all block. Any option you pass
39
- > explicitly still wins over the preset (`{ strict: true, onUnavailable: "allow" }` stays fail-open).
40
-
41
- Toll's live pre-sign check in front of an AI agent's wallet. Before the agent signs a transaction, a message, an
42
- 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
43
- gets ALLOW / WARN / BLOCK with the reasons. Zero runtime dependencies, ESM only, Node >= 20.19, with types.
44
-
45
- ```sh
46
- npm install @tollxyz/guard
47
- ```
48
-
49
- ```ts
50
- const wallet = guard(walletClient); // viem WalletClient, viem bundler client or ethers v6 Signer; same methods, checked first
51
- ```
52
-
53
- ```ts
54
- import { guard, TollBlockedError } from "@tollxyz/guard";
55
-
56
- const wallet = guard(walletClient, { onWarn: "block-high", onUnavailable: "allow" });
57
- try {
58
- await wallet.writeContract({ address: token, abi: erc20Abi, functionName: "approve", args: [spender, maxUint256] });
59
- } catch (e) {
60
- if (e instanceof TollBlockedError) console.log(e.codes, e.assessment); // not signed
61
- else throw e;
62
- }
63
- ```
64
-
65
- Run it as an ES module (an `.mjs`/`.mts` file, or `"type": "module"` in package.json).
66
-
67
- Toll states facts, never says safe: ALLOW means none of the checks Toll runs found a reason to stop, nothing more.
68
- Information only, not financial advice.
69
-
70
- ## Gate a buy in one call
71
-
72
- Before your agent even builds the swap, ask for one word with `@tollxyz/sdk`:
73
-
74
- ```ts
75
- import { Toll } from "@tollxyz/sdk";
76
- const toll = new Toll();
77
- const r = await toll.preBuyCheck(token); // Decision: BLOCK / WARN / ALLOW / WAIT, up to 3 reasons
78
- if (r.decision === "BLOCK") throw new Error(r.text); // the owner decides, never the agent
79
- if (r.decision === "WAIT") await sleep(r.pending!.retryAfterS * 1000); // part of the coin is still being read
80
- ```
81
-
82
- It runs the same calibrated rules as the guard's swap check (BLOCK = a finding whose coins rugged at 40% or more
83
- within 24 h, or sells that fail), so the gate and the guard never disagree. Then let `guardViem` / `guardEthers`
84
- check the transaction itself when it is signed.
85
-
86
- ## What is checked
87
-
88
- Every check runs on Toll's server (`POST https://t0ll.xyz/api/tx/check`), so new checks reach you without a package
89
- update. The full list is at <https://t0ll.xyz/agents/safety>: what the calldata or signature does (approvals,
90
- Permit / Permit2, transfers, swaps), unlimited approvals and approvals to a wallet instead of a contract, spenders and
91
- recipients Toll has flagged, a swap that pays someone else, the coin a swap buys (Toll's coin check: who is behind
92
- it), the transaction simulated at the current block, what a signature lets someone take (the pull simulated on the
93
- wallet's real balances, in USD where Toll has a price), instructions aimed at AI agents in token text and in the text
94
- fields of EIP-712 typed data, a single-pool Uniswap v4 swap quoted on the coin's main pool (`swap-route-worse` when
95
- that pays more than 3% more), Uniswap v4 position manager calls (`lp-take-to-other`, `lp-mint-to-other`), and for x402
96
- the payee against the seller's registered ERC-8004 agentWallet (including `extra.erc8004`), an `upto` payment's
97
- signed maximum and an `auth-capture` payment's escrow, token collector and hold.
98
-
99
- The answer is an [ERC-8265](https://github.com/ethereum/ERCs/pull/1753) RiskAssessment:
100
- `{ action, warnings: [{ code, severity, message, "x-toll-evidence" }], scanners, "x-toll": { decoded, link, checked_at, note } }`.
101
- Critical = BLOCK, high or medium = WARN, low or none = ALLOW.
102
-
103
- Robinhood Chain (4663) only for now. A transaction on another chain, or a contract deployment, is not sent to Toll
104
- and goes through (`onUnsupported: "block"` to stop it instead). A signature for another chain is blocked
105
- (`onUnsupported: "allow"` lets it through).
106
-
107
- ## Building on Hookr
108
-
109
- A wallet behind the guard that signs a call built by [Hookr](https://hookr.fun)'s MCP server
110
- (`https://hookr.fun/api/mcp`: `hookr_build_swap`, `hookr_build_launch`, `hookr_build_claim`, `hookr_build_agent_link`,
111
- `hookr_build_fill`, the liquidity builders) has that call checked like any other: the guard sends it to Toll with the
112
- wallet as `from` before anything is signed. Toll reads HookrRouter swaps, HookrLauncher launches (the token, the
113
- pools, who holds the liquidity, a dev buy), a launch's own withdrawal and fee collection, a pool core's claim, the
114
- Agent gate link and the order book fill. Each is named as Hookr's contract, not audited (`hookr-contract`, low); an
115
- unknown hook, rules contract or add-on is a warning, and a payout to anyone but `from` is `hookr-pays-other` (high).
116
- Any other function of those contracts is `calldata-undecoded`: WARN, never ALLOW. Nothing to configure: the checks are
117
- on Toll's server. Steps: <https://t0ll.xyz/hooks/hookr#agents>
118
-
119
- ## API
120
-
121
- ### `guard(wallet, opts?)`, `guardViem(client, opts?)`, `guardSmartAccount(bundler, opts?)`, `guardEthers(signer, opts?)`
122
-
123
- Returns the same wallet behind a Proxy. Checked first:
124
-
125
- - viem WalletClient: `sendTransaction`, `writeContract` (calldata encoded with your viem's `encodeFunctionData`),
126
- `signTransaction`, `signTypedData`, `signMessage`, `sendCalls`, `signAuthorization`, and `request()` for
127
- `eth_sendTransaction`, `eth_signTransaction`, `personal_sign`, `eth_sign`, `eth_signTypedData_v4` and
128
- `wallet_sendCalls` (every other method passes through)
129
- - viem bundler client (account abstraction): `sendUserOperation`, `prepareUserOperation`. `calls` are checked one by
130
- one as transactions from the smart account's address; a ready `callData` is sent as a user operation with the
131
- account's EntryPoint
132
- - ethers v6 Signer: `sendTransaction`, `signTransaction`, `signTypedData`, `signMessage`, `authorize`
133
-
134
- A batch is checked call by call, in order; the first call blocked stops the whole batch.
135
-
136
- An EIP-7702 authorization sets the wallet's code to another contract, which can then move everything it holds: a
137
- transaction with an `authorizationList` on any method above, viem's `signAuthorization`, ethers' `authorize`, a user
138
- operation's `authorization`. By default (`onAuthorization: "check"`) Toll reads it and decides; if Toll cannot read it
139
- (unreachable, another chain) it is blocked (TollBlockedError, code `eip7702-authorization`). `"block"` refuses every
140
- authorization without asking; `"allow"` lets it through unchecked. The account's own sign methods
141
- (`client.account.sign*`) are not wrapped.
142
-
143
- ```ts
144
- const wallet = guard(walletClient, { origin: "https://app.example" }); // origin: the site asking, for sign-ins
145
- await wallet.signMessage({ message: "Sign in to app.example" }); // checked as personal_sign
146
- await wallet.request({ method: "personal_sign", params: [hexMessage, address] });
147
- await wallet.request({ method: "eth_sign", params: [address, hash] }); // blocked when Toll blocks it, or cannot answer
148
-
149
- const smart = guard(bundlerClient); // detected by sendUserOperation
150
- await smart.sendUserOperation({ calls: [{ to: token, abi: erc20Abi, functionName: "transfer", args: [to, amount] }] });
151
- ```
152
-
153
- Everything else passes through unchanged. Wrap last: `client.extend(...)` or `signer.connect(...)` on the wrapped
154
- wallet returns an unguarded one. viem and ethers are optional peer dependencies, never imported at load time.
155
-
156
- ### `guardX402Fetch(fetch, opts?)`
157
-
158
- Wraps the fetch your x402 client uses. When a 402 arrives, its payment requirement goes to Toll before the client
159
- pays; on BLOCK the 402 never reaches the payer (TollBlockedError is thrown). The paid retry (a request with a
160
- `PAYMENT-SIGNATURE` or `X-PAYMENT` header) and every non-402 answer pass through.
161
-
162
- A 402 can carry its requirement in the `PAYMENT-REQUIRED` header and in the JSON body, and a client pays from the one
163
- it reads (`x402-fetch` v1 reads the body). The guard reads both: when they ask for different payments (payee, amount,
164
- asset or scheme), it blocks without asking Toll (code `x402-forms-differ`).
165
-
166
- ```ts
167
- import { wrapFetchWithPayment } from "x402-fetch";
168
- const pay = wrapFetchWithPayment(guardX402Fetch(fetch, { agentId: 123 }), account);
169
- ```
170
-
171
- `agentId` (the seller's ERC-8004 id, or a function of the URL) is optional.
172
-
173
- ### `checkTx(input, opts?)`
174
-
175
- The raw call. `input`: `{ chainId, from, to, data, value, authorizationList? }` for a transaction,
176
- `{ chainId, from, authorizationList }` for an authorization alone, `{ typedData }` for an EIP-712 request,
177
- `{ message: { kind: "personal_sign" | "eth_sign", data, from? } }` for a message, `{ rpc: { method, params } }` for a
178
- wallet request (`personal_sign`, `eth_sign`, `eth_signTypedData_v4`, `eth_sendTransaction`), `{ userOp, entryPoint? }`
179
- for an ERC-4337 user operation, `{ x402, agentId?, payment? }` for an x402 payment, or several together; `origin` is
180
- the site asking. Returns the RiskAssessment. When a
181
- coin is still being read, Toll answers WARN `coin-not-read` with `Retry-After`: `checkTx` asks once more if the wait
182
- is at most `maxRetryWaitMs` (default 10 s); a coin still not read stays WARN, never ALLOW. Throws
183
- `TollUnavailableError` when Toll cannot answer.
184
-
185
- ### Options
186
-
187
- | Option | Default | |
188
- | --- | --- | --- |
189
- | `strict` | `false` | preset: the defaults of `onWarn`, `onUnavailable` and `onUnsupported` become `"block"`; each one passed explicitly still wins |
190
- | `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) |
191
- | `onBlock` | `"throw"` | `"throw"` (TollBlockedError), or `(assessment) => boolean`, e.g. ask a human; true lets it through |
192
- | `onUnavailable` | `"block"` (since 0.6.0) | Toll unreachable, timed out, 5xx, 429, any other non-2xx (400, 413) or an answer that is not a RiskAssessment: `"block"` (fail-closed, TollUnavailableError) or `"allow"` (fail-open, logged). Passed explicitly it wins over `failOpen` and `strict` |
193
- | `failOpen` | `false` | 0.6.0: `true` = the old default: when Toll cannot or will not answer, the request goes ahead unchecked (logged). `strict` and an explicit `onUnavailable` win over it |
194
- | `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 |
195
- | `onAuthorization` | `"check"` | an EIP-7702 authorization: `"check"` (Toll decides; blocked when Toll cannot read it), `"block"` (TollBlockedError, Toll not asked) or `"allow"` (unchecked, logged) |
196
- | `origin` | none | the site asking for a signature, sent with messages and typed data |
197
- | `onReport` | none | `(report) => void`: every decision, with `assessment`, `decision`, `basis`, `paidUrl` |
198
- | `logger` | `console.warn` | where WARN and unavailable lines go; `false` = silent |
199
- | `timeoutMs` | `15000` | one request to Toll |
200
- | `retry`, `maxRetryWaitMs` | `true`, `10000` | the one retry after Retry-After |
201
- | `baseUrl`, `fetch` | `https://t0ll.xyz`, global fetch | |
202
- | `spend` | none | 0.4.0: caps, allowlists, approval ([below](#040-spend-caps-allowlists-approval-preview)); unset = 0.3.x behaviour |
203
- | `store` | in-process `MemorySpendStore` | where spend counters and paid x402 ids live |
204
- | `failClosed` | `false` | with `spend`: a request whose spend cannot be read is blocked (the default since 0.6.0; `onUnknownSpend: "allow"` opts out) |
205
- | `ticket` | none | 0.4.1: `true` or `{consumer?, scope?}`: ask Toll for a single-use ALLOW ticket for this exact call; it comes back as `report.ticket` ([below](#allow-tickets)) |
206
-
207
- Errors: `TollBlockedError` (`.assessment`, `.codes`, message lists each code and message) and
208
- `TollUnavailableError` (`.reason`, `.status`, `.paidUrl`).
209
-
210
- ## 0.4.0: spend caps, allowlists, approval, preview
211
-
212
- Toll's verdict says whether a request looks safe. `spend` adds your own limits on top: how much the agent may spend
213
- 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
214
- behaves exactly like 0.3.x.
215
-
216
- The order for every request:
217
-
218
- 1. **Allowlists** (`allow.to`, `allow.spender`, `allow.payTo`): read from the request itself, before Toll is asked.
219
- Off the list = blocked, and Toll is not called.
220
- 2. **Toll's verdict**: ALLOW / WARN / BLOCK with your `onWarn`, `onBlock`, `onUnavailable` as before. A WARN that
221
- `block-high` blocks is still blocked, however small the amount.
222
- 3. **Caps** (`assets`, `perEndpoint`): per call, then per day. The day amount is reserved.
223
- 4. **Approval** (`approveAbove` + `approve`): above the threshold your `approve` function decides.
224
- 5. **After the send**: the reservation is committed. If the send throws (rejected, nonce too low, RPC down), it is
225
- released, so failures never use up the day cap.
226
-
227
- Caps never relax Toll's verdict, and Toll's ALLOW never lifts a cap: a request must pass both.
228
-
229
- ```ts
230
- import { guard, NATIVE, USDG, TollPolicyError } from "@tollxyz/guard";
231
-
232
- const wallet = guard(walletClient, {
233
- spend: {
234
- // base units: ETH in wei, USDG with 6 decimals
235
- assets: {
236
- [NATIVE]: { perCall: 10n ** 16n, perDay: 5n * 10n ** 16n }, // 0.01 ETH a call, 0.05 ETH a day
237
- [USDG]: { perCall: 20_000_000n, perDay: 100_000_000n }, // 20 USDG a call, 100 USDG a day
238
- },
239
- allow: { spender: ["0xYourRouter..."] }, // approvals only to your router
240
- approveAbove: { [USDG]: 10_000_000n }, // above 10 USDG a human decides
241
- approve: async (req) => {
242
- const ok = await askOnTelegram(`Spend ${req.over[0].amount} of ${req.over[0].asset}?`);
243
- return { approved: ok, approvalRef: ok ? "tg-msg-1234" : undefined };
244
- },
245
- },
246
- failClosed: true, // a contract call whose spend cannot be read is blocked
247
- });
248
-
249
- try {
250
- await wallet.sendTransaction(tx);
251
- } catch (e) {
252
- if (e instanceof TollPolicyError) console.log(e.code, e.amount, e.limit); // "cap-per-day", ...
253
- else throw e;
254
- }
255
- ```
256
-
257
- **What counts as spend.** Only what leaves the wallet in a spend asset. The defaults are native ETH, WETH and USDG on
258
- Robinhood Chain, plus every asset you name in `assets` or `approveAbove`. A sell of any other coin is never capped.
259
-
260
- - Read locally, with no Toll answer needed: the transaction's value, ERC-20 `transfer` / `transferFrom` / `approve` /
261
- `increaseAllowance` on a spend asset, EIP-2612 permits, Permit2 permits, EIP-3009 transfer authorizations, and an
262
- x402 402's amount. So caps hold even when Toll is unreachable.
263
- - Any other contract call (a swap, a router): the amount comes from Toll's simulation (`x-toll.simulation.deltas`).
264
- When the simulation shows more leaving than the call declares, the larger amount counts.
265
- - An approval counts against the per-call cap at its full size (an unlimited approval is over any cap) and must fit
266
- in what is left of the day cap; it is not added to the day total (the later swap is).
267
- - Unknown spend: a contract call with no simulation (Toll unreachable), a raw user operation, a bare EIP-7702
268
- authorization. `onUnknownSpend` decides: `"block"` by default since 0.6.0 (caps that bind only on plain transfers are not caps), `"allow"` to opt out. An x402 amount or a transaction value the guard cannot parse is an unknown spend too.
269
-
270
- | `spend` field | |
271
- | --- | --- |
272
- | `assets` | `{ [asset]: { perCall?, perDay? } }`, asset = `"native"` or a token address, amounts in base units |
273
- | `perEndpoint` | x402 caps per URL prefix (origin + path), e.g. `{ "https://api.example.com/v1/": { perDay: 1_000_000n } }` |
274
- | `allow` | `{ to?, spender?, payTo? }`: every recipient (or called contract), approval spender, x402 payee must be listed |
275
- | `approveAbove` | `{ [asset]: amount }`: above it, `approve` must say yes |
276
- | `approve` | `(req) => Promise<{ approved, approvalRef? }>`; no function = denied. `approvalRef` lands in `report.spend.approvalRef` |
277
- | `onUnknownSpend` | `"block"` (default since 0.6.0) or `"allow"` |
278
-
279
- New options next to `spend`: `store` (where counters live, below) and `failClosed`.
280
-
281
- `TollPolicyError` extends `TollBlockedError` (existing `catch` blocks still see a block). `.code` is one of
282
- `not-allowlisted`, `cap-per-call`, `cap-per-day`, `cap-endpoint`, `approval-denied`, `duplicate-payment`,
283
- `unknown-spend`; `.asset`, `.amount`, `.limit` when they apply. The report has `basis: "policy"` and `policy: code`.
284
-
285
- ### Preview, then confirm
286
-
287
- ```ts
288
- import { preview } from "@tollxyz/guard";
289
-
290
- const p = await preview({ chainId: 4663, from, to, data, value }, opts); // same opts as the guard
291
- // p.decision "allowed" | "blocked", p.basis, p.policy, p.spend, p.needsApproval, p.reason
292
- if (p.decision === "allowed") await wallet.sendTransaction({ to, data, value, tollConfirm: p.confirmId });
293
- ```
294
-
295
- `preview` asks Toll and checks every rule, but reserves nothing, calls no `approve` and no `onReport`. Passing its
296
- `confirmId` as `tollConfirm` within 30 seconds reuses that Toll answer (one Toll call instead of two). It is used
297
- once, and only for the identical request; anything else is checked again. The guard strips `tollConfirm` before the
298
- wallet sees the request (viem `sendTransaction` / `signTransaction` / `writeContract`, ethers `sendTransaction` /
299
- `signTransaction`).
300
-
301
- ### x402: payment ids and per-endpoint caps
302
-
303
- With `spend` set, `guardX402Fetch` also:
304
-
305
- - counts a 402's amount against `assets` and `perEndpoint` caps when it is checked. The reservation waits for the
306
- paid retry: committed when the seller answers, released on another 402 or an error, released after `holdMs`
307
- (default 2 minutes) when no paid retry comes.
308
- - never pays the same x402 `payment-identifier` twice. An id is marked paid only when the seller accepts the paid
309
- request (any answer but 402); sending that id again throws `TollPolicyError` `duplicate-payment` (ids are kept
310
- `paymentIdTtlS`, default 1 day). A second send of an id that is still in flight is refused too. After a network
311
- error or timeout the same id may be retried (the seller dedupes it): the retry reuses the reservation of its 402,
312
- so nothing is counted twice. A different id is a new payment.
313
-
314
- ### Where the counters live
315
-
316
- The default store is a `MemorySpendStore` in this process, one per `spend` object. **It resets when the process
317
- restarts and is not shared between processes**: two workers each get the full day cap. For a cap that survives
318
- restarts or spans workers, pass your own `store` implementing `SpendStore`:
319
-
320
- ```ts
321
- interface SpendStore {
322
- reserve(k: string, amt: bigint, limit: bigint, windowStart: number): Promise<{ ok: boolean; id: string; used: bigint }>;
323
- commit(id: string): Promise<void>;
324
- release(id: string): Promise<void>;
325
- paymentSeen(id: string): Promise<boolean>;
326
- markPaid(id: string, ttlS: number): Promise<void>;
327
- }
328
- ```
329
-
330
- A Redis store (example only, not shipped; `reserve` must be atomic, hence the Lua script):
331
-
332
- ```ts
333
- import Redis from "ioredis";
334
- import type { SpendStore } from "@tollxyz/guard";
335
-
336
- const redis = new Redis(process.env.REDIS_URL!);
337
- const RESERVE = `
338
- local used = tonumber(redis.call('GET', KEYS[1]) or '0')
339
- local amt, limit = tonumber(ARGV[1]), tonumber(ARGV[2])
340
- if used + amt > limit then return {0, tostring(used)} end
341
- redis.call('INCRBY', KEYS[1], ARGV[1])
342
- redis.call('EXPIRE', KEYS[1], 172800)
343
- redis.call('SET', KEYS[2], KEYS[1] .. '|' .. ARGV[1], 'EX', 3600)
344
- return {1, tostring(used + amt)}`;
345
- const RELEASE = `
346
- local v = redis.call('GET', KEYS[1])
347
- if not v then return 0 end
348
- local sep = string.find(v, '|', 1, true)
349
- redis.call('DECRBY', string.sub(v, 1, sep - 1), string.sub(v, sep + 1))
350
- redis.call('DEL', KEYS[1])
351
- return 1`;
352
-
353
- export const redisStore: SpendStore = {
354
- async reserve(k, amt, limit, windowStart) {
355
- const id = `toll:res:${crypto.randomUUID()}`;
356
- // amounts as numbers in Lua: fine below 2^53 base units; use string math for larger caps
357
- const [ok, used] = (await redis.eval(RESERVE, 2, `toll:cap:${k}:${windowStart}`, id, amt.toString(), limit.toString())) as [number, string];
358
- return { ok: ok === 1, id: ok === 1 ? id : "", used: BigInt(used) };
359
- },
360
- async commit(id) { await redis.del(id); }, // the amount stays in the day counter
361
- async release(id) { await redis.eval(RELEASE, 1, id); },
362
- async paymentSeen(id) { return (await redis.exists(`toll:paid:${id}`)) === 1; },
363
- async markPaid(id, ttlS) { await redis.set(`toll:paid:${id}`, "1", "EX", ttlS); },
364
- };
365
-
366
- const wallet = guard(walletClient, { spend: { /* ... */ }, store: redisStore });
367
- ```
368
-
369
- ## 0.5.0: mirror the caps into the facilitator budget
370
-
371
- guard's caps live in the agent's process. Toll's x402 facilitator (https://x402.t0ll.xyz) has per-payer budgets (live since 2026-11-11): the
372
- payer wallet signs its limits once and the facilitator refuses any payment past them where it settles, so a second
373
- process, a key used elsewhere or a bug in the agent cannot spend past them through it. If budgets are ever switched
374
- off the facilitator answers `404 budgets_off`. `guardBudget` builds that budget from your `spend`
375
- policy; guard itself behaves exactly as before.
376
-
377
- ```ts
378
- import { guardBudget, USDG } from "@tollxyz/guard";
379
- import { Toll } from "@tollxyz/sdk"; // 0.7.0 or later
380
-
381
- const spend = {
382
- assets: { [USDG]: { perCall: 100_000n, perDay: 1_000_000n } }, // 0.10 / 1.00 USDG
383
- perEndpoint: { "https://api.example/quotes": { perDay: 250_000n } },
384
- allow: { payTo: ["0xSellerA...", "0xSellerB..."] },
385
- };
386
- const { budget, typedData, notMirrored } = guardBudget(spend, {
387
- payer: account.address,
388
- nonce: BigInt(Date.now()), // newer wins
389
- endpointPayTo: { "https://api.example/quotes": "0xSellerA..." }, // the facilitator sees a payTo, not a URL
390
- });
391
- console.log(notMirrored); // guard rules the facilitator cannot hold (they stay enforced by guard)
392
- await new Toll().setBudget({ budget, signature: await account.signTypedData(typedData) });
393
- ```
394
-
395
- | guard | facilitator budget |
396
- | --- | --- |
397
- | `assets[USDG].perCall` | `perPayment` (required: pass `perPayment` if the policy has none; 0 refuses everything) |
398
- | `assets[USDG].perDay` | `perDay` (required, same rule) |
399
- | `perEndpoint[prefix].perDay` | `payToCaps` entry for that endpoint's payTo (`endpointPayTo`, or a key that is an address); two endpoints on one payTo take the stricter cap |
400
- | `allow.payTo` | `allowedPayTos` |
401
- | `perEndpoint[prefix].perCall`, other assets, `allow.to`, `allow.spender`, `approveAbove` | not mirrored, listed in `notMirrored` |
402
-
403
- `perPayTo` (one day cap for every payTo) and `validUntil` (unix seconds) are options. Days are UTC on both sides. The
404
- typed data is the facilitator's EIP-712 `Budget` (domain `Toll Facilitator Budget` / `1` / chain 4663, no
405
- verifyingContract); `BUDGET_DOMAIN` and `BUDGET_TYPES` are exported. guard has no runtime dependencies: posting it is
406
- `@tollxyz/sdk`'s `setBudget` (or `POST https://x402.t0ll.xyz/budget` with `{ budget, signature }`, amounts as decimal
407
- strings).
408
-
409
- ## ALLOW tickets
410
-
411
- A contract can require a Toll ALLOW ticket before a sensitive call (a router, a module, a Uniswap v4 hook, a
412
- delegation caveat). The Solidity side is [toll-allow](https://github.com/t0ll-xyz/toll-allow); the full guide is
413
- https://t0ll.xyz/agents/allow.
414
-
415
- - A ticket is an EIP-712 verdict signed by Toll for **one exact call** (target, value, data), one account (`from`),
416
- an optional consumer contract and a 60 second window. It is single use.
417
- - A BLOCK is never signed. A WARN is signed only when no warning is high or critical (as verdict 1). No ticket
418
- without `from`, or for a transaction with an EIP-7702 authorization list. When none is issued, `report.ticket` is
419
- `{ why }`.
420
- - Toll's production signer on Robinhood Chain (4663) is `0x4A2C93066d94F1E69f3EAd5438c4efDdf80a46ba`
421
- (`TOLL_ALLOW_SIGNER`). Pin it in your contract, or a `TollSignerSet` you control that includes it.
422
-
423
- ```ts
424
- import { enforce, appendTicketSuffix } from "@tollxyz/guard";
425
-
426
- // 1. check the inner call (what the router will call), bound to your router as the consumer
427
- const r = await enforce("transaction", () => ({ chainId: 4663, from: me, to: target, data, value }), {
428
- ticket: { consumer: ROUTER },
429
- });
430
- if (!r.ticket || !("signature" in r.ticket)) throw new Error(r.ticket?.why ?? "no ticket");
431
-
432
- // 2. put the ticket on the calldata: data ‖ abi.encode(ticket, signature) ‖ uint32 len ‖ 0x900bc3e1
433
- const withTicket = appendTicketSuffix(data, r.ticket.ticket, r.ticket.signature);
434
- // 3. call the router within 60 s: router.exec(target, withTicket) with the same value, from the same account
435
- ```
436
-
437
- `appendTicketSuffix` is byte for byte `TollAllow.encodeSuffix` (tested against toll-allow's vectors). For a v4 hook,
438
- ask with `scope: "v4swap"` and pass `abi.encode(ticket, signature)` as `hookData`. The same option works on
439
- `checkTx(input, { ticket })`, where the ticket is in `assessment["x-toll"].ticket`.
440
-
441
- A ticket is necessary, never sufficient: the contract keeps its own checks (slippage, limits, access control), and
442
- should keep an owner bypass behind a timelock in case Toll is unreachable.
443
-
444
- ## Fail-open or fail-closed
445
-
446
- The default is fail-closed since 0.6.0: if Toll cannot or will not answer, nothing is signed. The guard throws
447
- `TollUnavailableError` (its `reason` says why: `network`, `timeout`, `rate-limited`, `server-error`, `bad-request`,
448
- `bad-answer`) and calls `onReport` with `decision: "blocked"`, `basis: "unavailable"`. The reason for the default: a
449
- refusal can be caused on purpose. A request padded past Toll's size limit answers HTTP 413, and a guard that let
450
- "not checked" through would sign it.
451
-
452
- The free route is rate limited per IP (a busy agent will see 429s): catch `TollUnavailableError`, wait and ask again,
453
- or use the paid route the error names in `paidUrl`. If a stalled agent is worse for you than an unchecked signature,
454
- pass `failOpen: true`: the request then goes ahead with a `[toll-guard] ... NOT checked` line and an `onReport` call
455
- with `decision: "allowed"`, `basis: "unavailable"`. `strict: true` also blocks every WARN and unsupported request.
456
-
457
- Two requests fail closed whatever `onUnavailable` and `failOpen` say: `eth_sign` (it signs any 32 bytes, a transaction hash
458
- included, so an unchecked one could be anything) and an EIP-7702 authorization under `onAuthorization: "check"`.
459
-
460
- ## Free limit and the paid route
461
-
462
- The check is free, rate limited per IP. The coin a swap buys is read through the same free coins as Toll's coin
463
- check (10 new coins a day per caller, coins already read stay free); past that the coin is not read and the answer
464
- is WARN `coin-over-quota` naming the paid route, `https://t0ll.xyz/api/x402/tx` (0.005 USDG over x402, no daily
465
- limit). The guard never pays: it puts that URL in the log line, in `report.paidUrl` and in `paidUrlOf(assessment)`.
466
-
467
- ## Privacy
468
-
469
- What is sent to Toll: the transaction, EIP-712 request or x402 payment requirement you are about to sign (to, data,
470
- value, chain id, your address as `from`), nothing else. No key, no account, no signature.
471
-
472
- ## ERC-8470 observations
473
-
474
- `read8470(token, opts?)` reads Toll's latest on-chain [ERC-8470](https://github.com/ethereum/ERCs/pull/2121) Launch
475
- Observation of a token on Robinhood Chain (registry `0x730e3c356299c9203cb042f6c0a9274aeabf9af3`, Toll's detector
476
- `0x02593AbFf31f4E8eC1D5c2F297a347C51ef06E5c`) with a plain `eth_call` to `https://rpc.mainnet.chain.robinhood.com`
477
- (`rpcUrl` to change). Returns the observation and its findings, or null when Toll has none for that token. A finding
478
- not read (`type(uint256).max` on chain) is `value: null, notRead: true`, never 0.
479
- `latestFinding8470(token, kind, param?)` reads one finding (kind by name, e.g. `"first-block-share"`, or bytes32).
480
-
481
- ## Specs
482
-
483
- - Pre-sign check: <https://t0ll.xyz/agents/x402#presign>
484
- - ERC-8470 Launch Observations: <https://github.com/ethereum/ERCs/pull/2121>
485
- - x402 Payee Verification: <https://github.com/ethereum/ERCs/pull/2132>
486
- - ERC-8265 RiskAssessment: <https://github.com/ethereum/ERCs/pull/1753>
487
-
488
- ## Develop
489
-
490
- ```sh
491
- npm run build && npm test # unit tests, no network
492
- node scripts/smoke.mjs # one live call with a harmless request (a 0-value transfer)
493
- ```
494
-
495
- MIT.
1
+ # @tollxyz/guard
2
+
3
+ > **New in 0.4.1: ALLOW tickets** (`ticket` option, `appendTicketSuffix`): a single-use, signed ticket for one exact
4
+ > call, for contracts that require one. See [ALLOW tickets](#allow-tickets).
5
+
6
+ > **New in 0.4.0: spend caps, allowlists, human approval and preview** (`spend` option). Unset, nothing changes.
7
+ > See [0.4.0: spend caps, allowlists, approval, preview](#040-spend-caps-allowlists-approval-preview).
8
+
9
+ > **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.
10
+ >
11
+ > **What changed in 0.3.0: every signing method is checked.**
12
+ > - Messages: viem and ethers `signMessage`, and `request()` for `personal_sign` and `eth_sign`.
13
+ > - Typed data: `request()` for `eth_signTypedData_v4` too, any EIP-712 (orders, permits, sign-ins).
14
+ > - Batches: viem `sendCalls` (EIP-5792) and `request()` for `wallet_sendCalls`, each call checked.
15
+ > - Smart accounts: `guardSmartAccount` wraps a viem bundler client's `sendUserOperation` and `prepareUserOperation`;
16
+ > `guard()` picks it for a bundler client.
17
+ > - EIP-7702: `onAuthorization` now defaults to `"check"`: Toll reads the authorization and decides (a known, verified
18
+ > delegate is WARN medium and goes ahead under `block-high`; an unknown one is BLOCK). `"block"` and `"allow"` stay.
19
+ > An authorization Toll cannot read is blocked.
20
+ > - A signature for another chain is blocked (before 0.3.0 it went through as unsupported); `onUnsupported: "allow"`
21
+ > lets it through.
22
+ > - `eth_sign` always fails closed: it can sign any 32 bytes, a transaction included, so when Toll cannot answer it is
23
+ > blocked whatever `onUnavailable` says.
24
+
25
+ > **Default changed in 0.6.0: the guard fails closed.** When Toll cannot or will not answer (unreachable, timeout,
26
+ > 429, 5xx, HTTP 400 or 413, an answer that is not a RiskAssessment) nothing is signed: `TollUnavailableError`, with
27
+ > "Not signed" in the message. Before, such a request went ahead unchecked, so whoever built the request could
28
+ > switch the check off (a request padded past Toll's 64 KB limit answers 413). To keep the old behaviour pass
29
+ > `failOpen: true` (or `onUnavailable: "allow"`), and know that it lets anyone who can shape the request skip the check.
30
+
31
+ > **Also in 0.6.0.** With a `spend` policy, a request whose spend cannot be read (a contract call Toll did not simulate,
32
+ > an x402 amount such as `"+5000000"` or a value that does not parse) is refused by default: `onUnknownSpend: "allow"`
33
+ > opts out. A WARN that says the coin was not read yet (`coin-not-read`) is refused under the default `block-high`.
34
+ > Fixed: `transferFrom`'s recipient was read from the amount word, so an allowlist refused every `transferFrom`.
35
+
36
+ > **Defaults changed in 0.2.0.** A WARN with any `high` or `critical` warning is now blocked (`onWarn: "block-high"`,
37
+ > the new default); a WARN with only `medium`/`low` warnings still goes ahead, logged. To keep the 0.1.x behaviour,
38
+ > pass `onWarn: "allow"`. New `strict: true` preset: WARN, unavailable and unsupported all block. Any option you pass
39
+ > explicitly still wins over the preset (`{ strict: true, onUnavailable: "allow" }` stays fail-open).
40
+
41
+ Toll's live pre-sign check in front of an AI agent's wallet. Before the agent signs a transaction, a message, an
42
+ 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
43
+ gets ALLOW / WARN / BLOCK with the reasons. Zero runtime dependencies, ESM only, Node >= 20.19, with types.
44
+
45
+ ```sh
46
+ npm install @tollxyz/guard
47
+ ```
48
+
49
+ ```ts
50
+ const wallet = guard(walletClient); // viem WalletClient, viem bundler client or ethers v6 Signer; same methods, checked first
51
+ ```
52
+
53
+ ```ts
54
+ import { guard, TollBlockedError } from "@tollxyz/guard";
55
+
56
+ const wallet = guard(walletClient, { onWarn: "block-high", onUnavailable: "allow" });
57
+ try {
58
+ await wallet.writeContract({ address: token, abi: erc20Abi, functionName: "approve", args: [spender, maxUint256] });
59
+ } catch (e) {
60
+ if (e instanceof TollBlockedError) console.log(e.codes, e.assessment); // not signed
61
+ else throw e;
62
+ }
63
+ ```
64
+
65
+ Run it as an ES module (an `.mjs`/`.mts` file, or `"type": "module"` in package.json).
66
+
67
+ Toll states facts, never says safe: ALLOW means none of the checks Toll runs found a reason to stop, nothing more.
68
+ Information only, not financial advice.
69
+
70
+ ## Gate a buy in one call
71
+
72
+ Before your agent even builds the swap, ask for one word with `@tollxyz/sdk`:
73
+
74
+ ```ts
75
+ import { Toll } from "@tollxyz/sdk";
76
+ const toll = new Toll();
77
+ const r = await toll.preBuyCheck(token); // Decision: BLOCK / WARN / ALLOW / WAIT, up to 3 reasons
78
+ if (r.decision === "BLOCK") throw new Error(r.text); // the owner decides, never the agent
79
+ if (r.decision === "WAIT") await sleep(r.pending!.retryAfterS * 1000); // part of the coin is still being read
80
+ ```
81
+
82
+ It runs the same calibrated rules as the guard's swap check (BLOCK = a finding whose coins rugged at 40% or more
83
+ within 24 h, or sells that fail), so the gate and the guard never disagree. Then let `guardViem` / `guardEthers`
84
+ check the transaction itself when it is signed.
85
+
86
+ ## What is checked
87
+
88
+ Every check runs on Toll's server (`POST https://t0ll.xyz/api/tx/check`), so new checks reach you without a package
89
+ update. The full list is at <https://t0ll.xyz/agents/safety>: what the calldata or signature does (approvals,
90
+ Permit / Permit2, transfers, swaps), unlimited approvals and approvals to a wallet instead of a contract, spenders and
91
+ recipients Toll has flagged, a swap that pays someone else, the coin a swap buys (Toll's coin check: who is behind
92
+ it), the transaction simulated at the current block, what a signature lets someone take (the pull simulated on the
93
+ wallet's real balances, in USD where Toll has a price), instructions aimed at AI agents in token text and in the text
94
+ fields of EIP-712 typed data, a single-pool Uniswap v4 swap quoted on the coin's main pool (`swap-route-worse` when
95
+ that pays more than 3% more), Uniswap v4 position manager calls (`lp-take-to-other`, `lp-mint-to-other`), and for x402
96
+ the payee against the seller's registered ERC-8004 agentWallet (including `extra.erc8004`), an `upto` payment's
97
+ signed maximum and an `auth-capture` payment's escrow, token collector and hold.
98
+
99
+ The answer is an [ERC-8265](https://github.com/ethereum/ERCs/pull/1753) RiskAssessment:
100
+ `{ action, warnings: [{ code, severity, message, "x-toll-evidence" }], scanners, "x-toll": { decoded, link, checked_at, note } }`.
101
+ Critical = BLOCK, high or medium = WARN, low or none = ALLOW.
102
+
103
+ Robinhood Chain (4663) only for now. A transaction on another chain, or a contract deployment, is not sent to Toll
104
+ and goes through (`onUnsupported: "block"` to stop it instead). A signature for another chain is blocked
105
+ (`onUnsupported: "allow"` lets it through).
106
+
107
+ ## Building on Hookr
108
+
109
+ A wallet behind the guard that signs a call built by [Hookr](https://hookr.fun)'s MCP server
110
+ (`https://hookr.fun/api/mcp`: `hookr_build_swap`, `hookr_build_launch`, `hookr_build_claim`, `hookr_build_agent_link`,
111
+ `hookr_build_fill`, the liquidity builders) has that call checked like any other: the guard sends it to Toll with the
112
+ wallet as `from` before anything is signed. Toll reads HookrRouter swaps, HookrLauncher launches (the token, the
113
+ pools, who holds the liquidity, a dev buy), a launch's own withdrawal and fee collection, a pool core's claim, the
114
+ Agent gate link and the order book fill. Each is named as Hookr's contract, not audited (`hookr-contract`, low); an
115
+ unknown hook, rules contract or add-on is a warning, and a payout to anyone but `from` is `hookr-pays-other` (high).
116
+ Any other function of those contracts is `calldata-undecoded`: WARN, never ALLOW. Nothing to configure: the checks are
117
+ on Toll's server. Steps: <https://t0ll.xyz/hooks/hookr#agents>
118
+
119
+ ### `guardHookr(sdk, opts?)`: the check on every call Hookr's SDK builds
120
+
121
+ Without a guarded wallet, wrap the builders instead. Every `build*` function of
122
+ [hookr-sdk](https://www.npmjs.com/package/hookr-sdk) then sends its unsigned call to Toll before returning it:
123
+
124
+ ```ts
125
+ import * as hookr from "hookr-sdk";
126
+ import { guardHookr } from "@tollxyz/guard";
127
+
128
+ const checked = guardHookr(hookr, { from: account });
129
+ const swapCall = await checked.buildSwap(manifest, swap); // the one change: await
130
+ await wallet.sendTransaction({ account, ...swapCall });
131
+ ```
132
+
133
+ - BLOCK throws `TollBlockedError` and the call is never returned. WARN, Toll unreachable and the other policies are
134
+ the same options as `guard()`.
135
+ - `buildQuote` is not wrapped: its call is read, never signed. Reads, ABIs and constants pass through untouched.
136
+ - A builder that returns several calls (approvals) has each checked in order; the first refusal stops the rest.
137
+ - Calls from Hookr's MCP (`hookr_build_*` answers `{ calls: [...] }`): `await checkHookrCalls(result, { from })`.
138
+ - Toll reads Robinhood Chain only. For a call built for another chain pass `chainId`: it is reported as not checked,
139
+ and `onUnsupported: "block"` or `strict` refuses it.
140
+
141
+ ## API
142
+
143
+ ### `guard(wallet, opts?)`, `guardViem(client, opts?)`, `guardSmartAccount(bundler, opts?)`, `guardEthers(signer, opts?)`
144
+
145
+ Returns the same wallet behind a Proxy. Checked first:
146
+
147
+ - viem WalletClient: `sendTransaction`, `writeContract` (calldata encoded with your viem's `encodeFunctionData`),
148
+ `signTransaction`, `signTypedData`, `signMessage`, `sendCalls`, `signAuthorization`, and `request()` for
149
+ `eth_sendTransaction`, `eth_signTransaction`, `personal_sign`, `eth_sign`, `eth_signTypedData_v4` and
150
+ `wallet_sendCalls` (every other method passes through)
151
+ - viem bundler client (account abstraction): `sendUserOperation`, `prepareUserOperation`. `calls` are checked one by
152
+ one as transactions from the smart account's address; a ready `callData` is sent as a user operation with the
153
+ account's EntryPoint
154
+ - ethers v6 Signer: `sendTransaction`, `signTransaction`, `signTypedData`, `signMessage`, `authorize`
155
+
156
+ A batch is checked call by call, in order; the first call blocked stops the whole batch.
157
+
158
+ An EIP-7702 authorization sets the wallet's code to another contract, which can then move everything it holds: a
159
+ transaction with an `authorizationList` on any method above, viem's `signAuthorization`, ethers' `authorize`, a user
160
+ operation's `authorization`. By default (`onAuthorization: "check"`) Toll reads it and decides; if Toll cannot read it
161
+ (unreachable, another chain) it is blocked (TollBlockedError, code `eip7702-authorization`). `"block"` refuses every
162
+ authorization without asking; `"allow"` lets it through unchecked. The account's own sign methods
163
+ (`client.account.sign*`) are not wrapped.
164
+
165
+ ```ts
166
+ const wallet = guard(walletClient, { origin: "https://app.example" }); // origin: the site asking, for sign-ins
167
+ await wallet.signMessage({ message: "Sign in to app.example" }); // checked as personal_sign
168
+ await wallet.request({ method: "personal_sign", params: [hexMessage, address] });
169
+ await wallet.request({ method: "eth_sign", params: [address, hash] }); // blocked when Toll blocks it, or cannot answer
170
+
171
+ const smart = guard(bundlerClient); // detected by sendUserOperation
172
+ await smart.sendUserOperation({ calls: [{ to: token, abi: erc20Abi, functionName: "transfer", args: [to, amount] }] });
173
+ ```
174
+
175
+ Everything else passes through unchanged. Wrap last: `client.extend(...)` or `signer.connect(...)` on the wrapped
176
+ wallet returns an unguarded one. viem and ethers are optional peer dependencies, never imported at load time.
177
+
178
+ ### `guardX402Fetch(fetch, opts?)`
179
+
180
+ Wraps the fetch your x402 client uses. When a 402 arrives, its payment requirement goes to Toll before the client
181
+ pays; on BLOCK the 402 never reaches the payer (TollBlockedError is thrown). The paid retry (a request with a
182
+ `PAYMENT-SIGNATURE` or `X-PAYMENT` header) and every non-402 answer pass through.
183
+
184
+ A 402 can carry its requirement in the `PAYMENT-REQUIRED` header and in the JSON body, and a client pays from the one
185
+ it reads (`x402-fetch` v1 reads the body). The guard reads both: when they ask for different payments (payee, amount,
186
+ asset or scheme), it blocks without asking Toll (code `x402-forms-differ`).
187
+
188
+ ```ts
189
+ import { wrapFetchWithPayment } from "x402-fetch";
190
+ const pay = wrapFetchWithPayment(guardX402Fetch(fetch, { agentId: 123 }), account);
191
+ ```
192
+
193
+ `agentId` (the seller's ERC-8004 id, or a function of the URL) is optional.
194
+
195
+ ### `checkTx(input, opts?)`
196
+
197
+ The raw call. `input`: `{ chainId, from, to, data, value, authorizationList? }` for a transaction,
198
+ `{ chainId, from, authorizationList }` for an authorization alone, `{ typedData }` for an EIP-712 request,
199
+ `{ message: { kind: "personal_sign" | "eth_sign", data, from? } }` for a message, `{ rpc: { method, params } }` for a
200
+ wallet request (`personal_sign`, `eth_sign`, `eth_signTypedData_v4`, `eth_sendTransaction`), `{ userOp, entryPoint? }`
201
+ for an ERC-4337 user operation, `{ x402, agentId?, payment? }` for an x402 payment, or several together; `origin` is
202
+ the site asking. Returns the RiskAssessment. When a
203
+ coin is still being read, Toll answers WARN `coin-not-read` with `Retry-After`: `checkTx` asks once more if the wait
204
+ is at most `maxRetryWaitMs` (default 10 s); a coin still not read stays WARN, never ALLOW. Throws
205
+ `TollUnavailableError` when Toll cannot answer.
206
+
207
+ ### Options
208
+
209
+ | Option | Default | |
210
+ | --- | --- | --- |
211
+ | `strict` | `false` | preset: the defaults of `onWarn`, `onUnavailable` and `onUnsupported` become `"block"`; each one passed explicitly still wins |
212
+ | `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) |
213
+ | `onBlock` | `"throw"` | `"throw"` (TollBlockedError), or `(assessment) => boolean`, e.g. ask a human; true lets it through |
214
+ | `onUnavailable` | `"block"` (since 0.6.0) | Toll unreachable, timed out, 5xx, 429, any other non-2xx (400, 413) or an answer that is not a RiskAssessment: `"block"` (fail-closed, TollUnavailableError) or `"allow"` (fail-open, logged). Passed explicitly it wins over `failOpen` and `strict` |
215
+ | `failOpen` | `false` | 0.6.0: `true` = the old default: when Toll cannot or will not answer, the request goes ahead unchecked (logged). `strict` and an explicit `onUnavailable` win over it |
216
+ | `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 |
217
+ | `onAuthorization` | `"check"` | an EIP-7702 authorization: `"check"` (Toll decides; blocked when Toll cannot read it), `"block"` (TollBlockedError, Toll not asked) or `"allow"` (unchecked, logged) |
218
+ | `origin` | none | the site asking for a signature, sent with messages and typed data |
219
+ | `onReport` | none | `(report) => void`: every decision, with `assessment`, `decision`, `basis`, `paidUrl` |
220
+ | `logger` | `console.warn` | where WARN and unavailable lines go; `false` = silent |
221
+ | `timeoutMs` | `15000` | one request to Toll |
222
+ | `retry`, `maxRetryWaitMs` | `true`, `10000` | the one retry after Retry-After |
223
+ | `baseUrl`, `fetch` | `https://t0ll.xyz`, global fetch | |
224
+ | `spend` | none | 0.4.0: caps, allowlists, approval ([below](#040-spend-caps-allowlists-approval-preview)); unset = 0.3.x behaviour |
225
+ | `store` | in-process `MemorySpendStore` | where spend counters and paid x402 ids live |
226
+ | `failClosed` | `false` | with `spend`: a request whose spend cannot be read is blocked (the default since 0.6.0; `onUnknownSpend: "allow"` opts out) |
227
+ | `ticket` | none | 0.4.1: `true` or `{consumer?, scope?}`: ask Toll for a single-use ALLOW ticket for this exact call; it comes back as `report.ticket` ([below](#allow-tickets)) |
228
+
229
+ Errors: `TollBlockedError` (`.assessment`, `.codes`, message lists each code and message) and
230
+ `TollUnavailableError` (`.reason`, `.status`, `.paidUrl`).
231
+
232
+ ## 0.4.0: spend caps, allowlists, approval, preview
233
+
234
+ Toll's verdict says whether a request looks safe. `spend` adds your own limits on top: how much the agent may spend
235
+ 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
236
+ behaves exactly like 0.3.x.
237
+
238
+ The order for every request:
239
+
240
+ 1. **Allowlists** (`allow.to`, `allow.spender`, `allow.payTo`): read from the request itself, before Toll is asked.
241
+ Off the list = blocked, and Toll is not called.
242
+ 2. **Toll's verdict**: ALLOW / WARN / BLOCK with your `onWarn`, `onBlock`, `onUnavailable` as before. A WARN that
243
+ `block-high` blocks is still blocked, however small the amount.
244
+ 3. **Caps** (`assets`, `perEndpoint`): per call, then per day. The day amount is reserved.
245
+ 4. **Approval** (`approveAbove` + `approve`): above the threshold your `approve` function decides.
246
+ 5. **After the send**: the reservation is committed. If the send throws (rejected, nonce too low, RPC down), it is
247
+ released, so failures never use up the day cap.
248
+
249
+ Caps never relax Toll's verdict, and Toll's ALLOW never lifts a cap: a request must pass both.
250
+
251
+ ```ts
252
+ import { guard, NATIVE, USDG, TollPolicyError } from "@tollxyz/guard";
253
+
254
+ const wallet = guard(walletClient, {
255
+ spend: {
256
+ // base units: ETH in wei, USDG with 6 decimals
257
+ assets: {
258
+ [NATIVE]: { perCall: 10n ** 16n, perDay: 5n * 10n ** 16n }, // 0.01 ETH a call, 0.05 ETH a day
259
+ [USDG]: { perCall: 20_000_000n, perDay: 100_000_000n }, // 20 USDG a call, 100 USDG a day
260
+ },
261
+ allow: { spender: ["0xYourRouter..."] }, // approvals only to your router
262
+ approveAbove: { [USDG]: 10_000_000n }, // above 10 USDG a human decides
263
+ approve: async (req) => {
264
+ const ok = await askOnTelegram(`Spend ${req.over[0].amount} of ${req.over[0].asset}?`);
265
+ return { approved: ok, approvalRef: ok ? "tg-msg-1234" : undefined };
266
+ },
267
+ },
268
+ failClosed: true, // a contract call whose spend cannot be read is blocked
269
+ });
270
+
271
+ try {
272
+ await wallet.sendTransaction(tx);
273
+ } catch (e) {
274
+ if (e instanceof TollPolicyError) console.log(e.code, e.amount, e.limit); // "cap-per-day", ...
275
+ else throw e;
276
+ }
277
+ ```
278
+
279
+ **What counts as spend.** Only what leaves the wallet in a spend asset. The defaults are native ETH, WETH and USDG on
280
+ Robinhood Chain, plus every asset you name in `assets` or `approveAbove`. A sell of any other coin is never capped.
281
+
282
+ - Read locally, with no Toll answer needed: the transaction's value, ERC-20 `transfer` / `transferFrom` / `approve` /
283
+ `increaseAllowance` on a spend asset, EIP-2612 permits, Permit2 permits, EIP-3009 transfer authorizations, and an
284
+ x402 402's amount. So caps hold even when Toll is unreachable.
285
+ - Any other contract call (a swap, a router): the amount comes from Toll's simulation (`x-toll.simulation.deltas`).
286
+ When the simulation shows more leaving than the call declares, the larger amount counts.
287
+ - An approval counts against the per-call cap at its full size (an unlimited approval is over any cap) and must fit
288
+ in what is left of the day cap; it is not added to the day total (the later swap is).
289
+ - Unknown spend: a contract call with no simulation (Toll unreachable), a raw user operation, a bare EIP-7702
290
+ authorization. `onUnknownSpend` decides: `"block"` by default since 0.6.0 (caps that bind only on plain transfers are not caps), `"allow"` to opt out. An x402 amount or a transaction value the guard cannot parse is an unknown spend too.
291
+
292
+ | `spend` field | |
293
+ | --- | --- |
294
+ | `assets` | `{ [asset]: { perCall?, perDay? } }`, asset = `"native"` or a token address, amounts in base units |
295
+ | `perEndpoint` | x402 caps per URL prefix (origin + path), e.g. `{ "https://api.example.com/v1/": { perDay: 1_000_000n } }` |
296
+ | `allow` | `{ to?, spender?, payTo? }`: every recipient (or called contract), approval spender, x402 payee must be listed |
297
+ | `approveAbove` | `{ [asset]: amount }`: above it, `approve` must say yes |
298
+ | `approve` | `(req) => Promise<{ approved, approvalRef? }>`; no function = denied. `approvalRef` lands in `report.spend.approvalRef` |
299
+ | `onUnknownSpend` | `"block"` (default since 0.6.0) or `"allow"` |
300
+
301
+ New options next to `spend`: `store` (where counters live, below) and `failClosed`.
302
+
303
+ `TollPolicyError` extends `TollBlockedError` (existing `catch` blocks still see a block). `.code` is one of
304
+ `not-allowlisted`, `cap-per-call`, `cap-per-day`, `cap-endpoint`, `approval-denied`, `duplicate-payment`,
305
+ `unknown-spend`; `.asset`, `.amount`, `.limit` when they apply. The report has `basis: "policy"` and `policy: code`.
306
+
307
+ ### Preview, then confirm
308
+
309
+ ```ts
310
+ import { preview } from "@tollxyz/guard";
311
+
312
+ const p = await preview({ chainId: 4663, from, to, data, value }, opts); // same opts as the guard
313
+ // p.decision "allowed" | "blocked", p.basis, p.policy, p.spend, p.needsApproval, p.reason
314
+ if (p.decision === "allowed") await wallet.sendTransaction({ to, data, value, tollConfirm: p.confirmId });
315
+ ```
316
+
317
+ `preview` asks Toll and checks every rule, but reserves nothing, calls no `approve` and no `onReport`. Passing its
318
+ `confirmId` as `tollConfirm` within 30 seconds reuses that Toll answer (one Toll call instead of two). It is used
319
+ once, and only for the identical request; anything else is checked again. The guard strips `tollConfirm` before the
320
+ wallet sees the request (viem `sendTransaction` / `signTransaction` / `writeContract`, ethers `sendTransaction` /
321
+ `signTransaction`).
322
+
323
+ ### x402: payment ids and per-endpoint caps
324
+
325
+ With `spend` set, `guardX402Fetch` also:
326
+
327
+ - counts a 402's amount against `assets` and `perEndpoint` caps when it is checked. The reservation waits for the
328
+ paid retry: committed when the seller answers, released on another 402 or an error, released after `holdMs`
329
+ (default 2 minutes) when no paid retry comes.
330
+ - never pays the same x402 `payment-identifier` twice. An id is marked paid only when the seller accepts the paid
331
+ request (any answer but 402); sending that id again throws `TollPolicyError` `duplicate-payment` (ids are kept
332
+ `paymentIdTtlS`, default 1 day). A second send of an id that is still in flight is refused too. After a network
333
+ error or timeout the same id may be retried (the seller dedupes it): the retry reuses the reservation of its 402,
334
+ so nothing is counted twice. A different id is a new payment.
335
+
336
+ ### Where the counters live
337
+
338
+ The default store is a `MemorySpendStore` in this process, one per `spend` object. **It resets when the process
339
+ restarts and is not shared between processes**: two workers each get the full day cap. For a cap that survives
340
+ restarts or spans workers, pass your own `store` implementing `SpendStore`:
341
+
342
+ ```ts
343
+ interface SpendStore {
344
+ reserve(k: string, amt: bigint, limit: bigint, windowStart: number): Promise<{ ok: boolean; id: string; used: bigint }>;
345
+ commit(id: string): Promise<void>;
346
+ release(id: string): Promise<void>;
347
+ paymentSeen(id: string): Promise<boolean>;
348
+ markPaid(id: string, ttlS: number): Promise<void>;
349
+ }
350
+ ```
351
+
352
+ A Redis store (example only, not shipped; `reserve` must be atomic, hence the Lua script):
353
+
354
+ ```ts
355
+ import Redis from "ioredis";
356
+ import type { SpendStore } from "@tollxyz/guard";
357
+
358
+ const redis = new Redis(process.env.REDIS_URL!);
359
+ const RESERVE = `
360
+ local used = tonumber(redis.call('GET', KEYS[1]) or '0')
361
+ local amt, limit = tonumber(ARGV[1]), tonumber(ARGV[2])
362
+ if used + amt > limit then return {0, tostring(used)} end
363
+ redis.call('INCRBY', KEYS[1], ARGV[1])
364
+ redis.call('EXPIRE', KEYS[1], 172800)
365
+ redis.call('SET', KEYS[2], KEYS[1] .. '|' .. ARGV[1], 'EX', 3600)
366
+ return {1, tostring(used + amt)}`;
367
+ const RELEASE = `
368
+ local v = redis.call('GET', KEYS[1])
369
+ if not v then return 0 end
370
+ local sep = string.find(v, '|', 1, true)
371
+ redis.call('DECRBY', string.sub(v, 1, sep - 1), string.sub(v, sep + 1))
372
+ redis.call('DEL', KEYS[1])
373
+ return 1`;
374
+
375
+ export const redisStore: SpendStore = {
376
+ async reserve(k, amt, limit, windowStart) {
377
+ const id = `toll:res:${crypto.randomUUID()}`;
378
+ // amounts as numbers in Lua: fine below 2^53 base units; use string math for larger caps
379
+ const [ok, used] = (await redis.eval(RESERVE, 2, `toll:cap:${k}:${windowStart}`, id, amt.toString(), limit.toString())) as [number, string];
380
+ return { ok: ok === 1, id: ok === 1 ? id : "", used: BigInt(used) };
381
+ },
382
+ async commit(id) { await redis.del(id); }, // the amount stays in the day counter
383
+ async release(id) { await redis.eval(RELEASE, 1, id); },
384
+ async paymentSeen(id) { return (await redis.exists(`toll:paid:${id}`)) === 1; },
385
+ async markPaid(id, ttlS) { await redis.set(`toll:paid:${id}`, "1", "EX", ttlS); },
386
+ };
387
+
388
+ const wallet = guard(walletClient, { spend: { /* ... */ }, store: redisStore });
389
+ ```
390
+
391
+ ## 0.5.0: mirror the caps into the facilitator budget
392
+
393
+ guard's caps live in the agent's process. Toll's x402 facilitator (https://x402.t0ll.xyz) has per-payer budgets (live since 2026-11-11): the
394
+ payer wallet signs its limits once and the facilitator refuses any payment past them where it settles, so a second
395
+ process, a key used elsewhere or a bug in the agent cannot spend past them through it. If budgets are ever switched
396
+ off the facilitator answers `404 budgets_off`. `guardBudget` builds that budget from your `spend`
397
+ policy; guard itself behaves exactly as before.
398
+
399
+ ```ts
400
+ import { guardBudget, USDG } from "@tollxyz/guard";
401
+ import { Toll } from "@tollxyz/sdk"; // 0.7.0 or later
402
+
403
+ const spend = {
404
+ assets: { [USDG]: { perCall: 100_000n, perDay: 1_000_000n } }, // 0.10 / 1.00 USDG
405
+ perEndpoint: { "https://api.example/quotes": { perDay: 250_000n } },
406
+ allow: { payTo: ["0xSellerA...", "0xSellerB..."] },
407
+ };
408
+ const { budget, typedData, notMirrored } = guardBudget(spend, {
409
+ payer: account.address,
410
+ nonce: BigInt(Date.now()), // newer wins
411
+ endpointPayTo: { "https://api.example/quotes": "0xSellerA..." }, // the facilitator sees a payTo, not a URL
412
+ });
413
+ console.log(notMirrored); // guard rules the facilitator cannot hold (they stay enforced by guard)
414
+ await new Toll().setBudget({ budget, signature: await account.signTypedData(typedData) });
415
+ ```
416
+
417
+ | guard | facilitator budget |
418
+ | --- | --- |
419
+ | `assets[USDG].perCall` | `perPayment` (required: pass `perPayment` if the policy has none; 0 refuses everything) |
420
+ | `assets[USDG].perDay` | `perDay` (required, same rule) |
421
+ | `perEndpoint[prefix].perDay` | `payToCaps` entry for that endpoint's payTo (`endpointPayTo`, or a key that is an address); two endpoints on one payTo take the stricter cap |
422
+ | `allow.payTo` | `allowedPayTos` |
423
+ | `perEndpoint[prefix].perCall`, other assets, `allow.to`, `allow.spender`, `approveAbove` | not mirrored, listed in `notMirrored` |
424
+
425
+ `perPayTo` (one day cap for every payTo) and `validUntil` (unix seconds) are options. Days are UTC on both sides. The
426
+ typed data is the facilitator's EIP-712 `Budget` (domain `Toll Facilitator Budget` / `1` / chain 4663, no
427
+ verifyingContract); `BUDGET_DOMAIN` and `BUDGET_TYPES` are exported. guard has no runtime dependencies: posting it is
428
+ `@tollxyz/sdk`'s `setBudget` (or `POST https://x402.t0ll.xyz/budget` with `{ budget, signature }`, amounts as decimal
429
+ strings).
430
+
431
+ ## ALLOW tickets
432
+
433
+ A contract can require a Toll ALLOW ticket before a sensitive call (a router, a module, a Uniswap v4 hook, a
434
+ delegation caveat). The Solidity side is [toll-allow](https://github.com/t0ll-xyz/toll-allow); the full guide is
435
+ https://t0ll.xyz/agents/allow.
436
+
437
+ - A ticket is an EIP-712 verdict signed by Toll for **one exact call** (target, value, data), one account (`from`),
438
+ an optional consumer contract and a 60 second window. It is single use.
439
+ - A BLOCK is never signed. A WARN is signed only when no warning is high or critical (as verdict 1). No ticket
440
+ without `from`, or for a transaction with an EIP-7702 authorization list. When none is issued, `report.ticket` is
441
+ `{ why }`.
442
+ - Toll's production signer on Robinhood Chain (4663) is `0x4A2C93066d94F1E69f3EAd5438c4efDdf80a46ba`
443
+ (`TOLL_ALLOW_SIGNER`). Pin it in your contract, or a `TollSignerSet` you control that includes it.
444
+
445
+ ```ts
446
+ import { enforce, appendTicketSuffix } from "@tollxyz/guard";
447
+
448
+ // 1. check the inner call (what the router will call), bound to your router as the consumer
449
+ const r = await enforce("transaction", () => ({ chainId: 4663, from: me, to: target, data, value }), {
450
+ ticket: { consumer: ROUTER },
451
+ });
452
+ if (!r.ticket || !("signature" in r.ticket)) throw new Error(r.ticket?.why ?? "no ticket");
453
+
454
+ // 2. put the ticket on the calldata: data ‖ abi.encode(ticket, signature) ‖ uint32 len ‖ 0x900bc3e1
455
+ const withTicket = appendTicketSuffix(data, r.ticket.ticket, r.ticket.signature);
456
+ // 3. call the router within 60 s: router.exec(target, withTicket) with the same value, from the same account
457
+ ```
458
+
459
+ `appendTicketSuffix` is byte for byte `TollAllow.encodeSuffix` (tested against toll-allow's vectors). For a v4 hook,
460
+ ask with `scope: "v4swap"` and pass `abi.encode(ticket, signature)` as `hookData`. The same option works on
461
+ `checkTx(input, { ticket })`, where the ticket is in `assessment["x-toll"].ticket`.
462
+
463
+ A ticket is necessary, never sufficient: the contract keeps its own checks (slippage, limits, access control), and
464
+ should keep an owner bypass behind a timelock in case Toll is unreachable.
465
+
466
+ ## Fail-open or fail-closed
467
+
468
+ The default is fail-closed since 0.6.0: if Toll cannot or will not answer, nothing is signed. The guard throws
469
+ `TollUnavailableError` (its `reason` says why: `network`, `timeout`, `rate-limited`, `server-error`, `bad-request`,
470
+ `bad-answer`) and calls `onReport` with `decision: "blocked"`, `basis: "unavailable"`. The reason for the default: a
471
+ refusal can be caused on purpose. A request padded past Toll's size limit answers HTTP 413, and a guard that let
472
+ "not checked" through would sign it.
473
+
474
+ The free route is rate limited per IP (a busy agent will see 429s): catch `TollUnavailableError`, wait and ask again,
475
+ or use the paid route the error names in `paidUrl`. If a stalled agent is worse for you than an unchecked signature,
476
+ pass `failOpen: true`: the request then goes ahead with a `[toll-guard] ... NOT checked` line and an `onReport` call
477
+ with `decision: "allowed"`, `basis: "unavailable"`. `strict: true` also blocks every WARN and unsupported request.
478
+
479
+ Two requests fail closed whatever `onUnavailable` and `failOpen` say: `eth_sign` (it signs any 32 bytes, a transaction hash
480
+ included, so an unchecked one could be anything) and an EIP-7702 authorization under `onAuthorization: "check"`.
481
+
482
+ ## Free limit and the paid route
483
+
484
+ The check is free, rate limited per IP. The coin a swap buys is read through the same free coins as Toll's coin
485
+ check (10 new coins a day per caller, coins already read stay free); past that the coin is not read and the answer
486
+ is WARN `coin-over-quota` naming the paid route, `https://t0ll.xyz/api/x402/tx` (0.005 USDG over x402, no daily
487
+ limit). The guard never pays: it puts that URL in the log line, in `report.paidUrl` and in `paidUrlOf(assessment)`.
488
+
489
+ ## Privacy
490
+
491
+ What is sent to Toll: the transaction, EIP-712 request or x402 payment requirement you are about to sign (to, data,
492
+ value, chain id, your address as `from`), nothing else. No key, no account, no signature.
493
+
494
+ ## ERC-8470 observations
495
+
496
+ `read8470(token, opts?)` reads Toll's latest on-chain [ERC-8470](https://github.com/ethereum/ERCs/pull/2121) Launch
497
+ Observation of a token on Robinhood Chain (registry `0x730e3c356299c9203cb042f6c0a9274aeabf9af3`, Toll's detector
498
+ `0x02593AbFf31f4E8eC1D5c2F297a347C51ef06E5c`) with a plain `eth_call` to `https://rpc.mainnet.chain.robinhood.com`
499
+ (`rpcUrl` to change). Returns the observation and its findings, or null when Toll has none for that token. A finding
500
+ not read (`type(uint256).max` on chain) is `value: null, notRead: true`, never 0.
501
+ `latestFinding8470(token, kind, param?)` reads one finding (kind by name, e.g. `"first-block-share"`, or bytes32).
502
+
503
+ ## Specs
504
+
505
+ - Pre-sign check: <https://t0ll.xyz/agents/x402#presign>
506
+ - ERC-8470 Launch Observations: <https://github.com/ethereum/ERCs/pull/2121>
507
+ - x402 Payee Verification: <https://github.com/ethereum/ERCs/pull/2132>
508
+ - ERC-8265 RiskAssessment: <https://github.com/ethereum/ERCs/pull/1753>
509
+
510
+ ## Develop
511
+
512
+ ```sh
513
+ npm run build && npm test # unit tests, no network
514
+ node scripts/smoke.mjs # one live call with a harmless request (a 0-value transfer)
515
+ ```
516
+
517
+ MIT.