@tollxyz/guard 0.3.0 → 0.4.1

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,5 +1,13 @@
1
1
  # @tollxyz/guard
2
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
+ >
3
11
  > **What changed in 0.3.0: every signing method is checked.**
4
12
  > - Messages: viem and ethers `signMessage`, and `request()` for `personal_sign` and `eth_sign`.
5
13
  > - Typed data: `request()` for `eth_signTypedData_v4` too, any EIP-712 (orders, permits, sign-ins).
@@ -162,10 +170,208 @@ is at most `maxRetryWaitMs` (default 10 s); a coin still not read stays WARN, ne
162
170
  | `timeoutMs` | `15000` | one request to Toll |
163
171
  | `retry`, `maxRetryWaitMs` | `true`, `10000` | the one retry after Retry-After |
164
172
  | `baseUrl`, `fetch` | `https://t0ll.xyz`, global fetch | |
173
+ | `spend` | none | 0.4.0: caps, allowlists, approval ([below](#040-spend-caps-allowlists-approval-preview)); unset = 0.3.x behaviour |
174
+ | `store` | in-process `MemorySpendStore` | where spend counters and paid x402 ids live |
175
+ | `failClosed` | `false` | with `spend`: a request whose spend cannot be read is blocked |
176
+ | `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)) |
165
177
 
166
178
  Errors: `TollBlockedError` (`.assessment`, `.codes`, message lists each code and message) and
167
179
  `TollUnavailableError` (`.reason`, `.status`, `.paidUrl`).
168
180
 
181
+ ## 0.4.0: spend caps, allowlists, approval, preview
182
+
183
+ Toll's verdict says whether a request looks safe. `spend` adds your own limits on top: how much the agent may spend
184
+ 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
185
+ behaves exactly like 0.3.x.
186
+
187
+ The order for every request:
188
+
189
+ 1. **Allowlists** (`allow.to`, `allow.spender`, `allow.payTo`): read from the request itself, before Toll is asked.
190
+ Off the list = blocked, and Toll is not called.
191
+ 2. **Toll's verdict**: ALLOW / WARN / BLOCK with your `onWarn`, `onBlock`, `onUnavailable` as before. A WARN that
192
+ `block-high` blocks is still blocked, however small the amount.
193
+ 3. **Caps** (`assets`, `perEndpoint`): per call, then per day. The day amount is reserved.
194
+ 4. **Approval** (`approveAbove` + `approve`): above the threshold your `approve` function decides.
195
+ 5. **After the send**: the reservation is committed. If the send throws (rejected, nonce too low, RPC down), it is
196
+ released, so failures never use up the day cap.
197
+
198
+ Caps never relax Toll's verdict, and Toll's ALLOW never lifts a cap: a request must pass both.
199
+
200
+ ```ts
201
+ import { guard, NATIVE, USDG, TollPolicyError } from "@tollxyz/guard";
202
+
203
+ const wallet = guard(walletClient, {
204
+ spend: {
205
+ // base units: ETH in wei, USDG with 6 decimals
206
+ assets: {
207
+ [NATIVE]: { perCall: 10n ** 16n, perDay: 5n * 10n ** 16n }, // 0.01 ETH a call, 0.05 ETH a day
208
+ [USDG]: { perCall: 20_000_000n, perDay: 100_000_000n }, // 20 USDG a call, 100 USDG a day
209
+ },
210
+ allow: { spender: ["0xYourRouter..."] }, // approvals only to your router
211
+ approveAbove: { [USDG]: 10_000_000n }, // above 10 USDG a human decides
212
+ approve: async (req) => {
213
+ const ok = await askOnTelegram(`Spend ${req.over[0].amount} of ${req.over[0].asset}?`);
214
+ return { approved: ok, approvalRef: ok ? "tg-msg-1234" : undefined };
215
+ },
216
+ },
217
+ failClosed: true, // a contract call whose spend cannot be read is blocked
218
+ });
219
+
220
+ try {
221
+ await wallet.sendTransaction(tx);
222
+ } catch (e) {
223
+ if (e instanceof TollPolicyError) console.log(e.code, e.amount, e.limit); // "cap-per-day", ...
224
+ else throw e;
225
+ }
226
+ ```
227
+
228
+ **What counts as spend.** Only what leaves the wallet in a spend asset. The defaults are native ETH, WETH and USDG on
229
+ Robinhood Chain, plus every asset you name in `assets` or `approveAbove`. A sell of any other coin is never capped.
230
+
231
+ - Read locally, with no Toll answer needed: the transaction's value, ERC-20 `transfer` / `transferFrom` / `approve` /
232
+ `increaseAllowance` on a spend asset, EIP-2612 permits, Permit2 permits, EIP-3009 transfer authorizations, and an
233
+ x402 402's amount. So caps hold even when Toll is unreachable.
234
+ - Any other contract call (a swap, a router): the amount comes from Toll's simulation (`x-toll.simulation.deltas`).
235
+ When the simulation shows more leaving than the call declares, the larger amount counts.
236
+ - An approval counts against the per-call cap at its full size (an unlimited approval is over any cap) and must fit
237
+ in what is left of the day cap; it is not added to the day total (the later swap is).
238
+ - Unknown spend: a contract call with no simulation (Toll unreachable), a raw user operation, a bare EIP-7702
239
+ authorization. `onUnknownSpend` decides: `"allow"` by default, `"block"` under `failClosed: true`.
240
+
241
+ | `spend` field | |
242
+ | --- | --- |
243
+ | `assets` | `{ [asset]: { perCall?, perDay? } }`, asset = `"native"` or a token address, amounts in base units |
244
+ | `perEndpoint` | x402 caps per URL prefix (origin + path), e.g. `{ "https://api.example.com/v1/": { perDay: 1_000_000n } }` |
245
+ | `allow` | `{ to?, spender?, payTo? }`: every recipient (or called contract), approval spender, x402 payee must be listed |
246
+ | `approveAbove` | `{ [asset]: amount }`: above it, `approve` must say yes |
247
+ | `approve` | `(req) => Promise<{ approved, approvalRef? }>`; no function = denied. `approvalRef` lands in `report.spend.approvalRef` |
248
+ | `onUnknownSpend` | `"allow"` (default) or `"block"` (default under `failClosed`) |
249
+
250
+ New options next to `spend`: `store` (where counters live, below) and `failClosed`.
251
+
252
+ `TollPolicyError` extends `TollBlockedError` (existing `catch` blocks still see a block). `.code` is one of
253
+ `not-allowlisted`, `cap-per-call`, `cap-per-day`, `cap-endpoint`, `approval-denied`, `duplicate-payment`,
254
+ `unknown-spend`; `.asset`, `.amount`, `.limit` when they apply. The report has `basis: "policy"` and `policy: code`.
255
+
256
+ ### Preview, then confirm
257
+
258
+ ```ts
259
+ import { preview } from "@tollxyz/guard";
260
+
261
+ const p = await preview({ chainId: 4663, from, to, data, value }, opts); // same opts as the guard
262
+ // p.decision "allowed" | "blocked", p.basis, p.policy, p.spend, p.needsApproval, p.reason
263
+ if (p.decision === "allowed") await wallet.sendTransaction({ to, data, value, tollConfirm: p.confirmId });
264
+ ```
265
+
266
+ `preview` asks Toll and checks every rule, but reserves nothing, calls no `approve` and no `onReport`. Passing its
267
+ `confirmId` as `tollConfirm` within 30 seconds reuses that Toll answer (one Toll call instead of two). It is used
268
+ once, and only for the identical request; anything else is checked again. The guard strips `tollConfirm` before the
269
+ wallet sees the request (viem `sendTransaction` / `signTransaction` / `writeContract`, ethers `sendTransaction` /
270
+ `signTransaction`).
271
+
272
+ ### x402: payment ids and per-endpoint caps
273
+
274
+ With `spend` set, `guardX402Fetch` also:
275
+
276
+ - counts a 402's amount against `assets` and `perEndpoint` caps when it is checked. The reservation waits for the
277
+ paid retry: committed when the seller answers, released on another 402 or an error, released after `holdMs`
278
+ (default 2 minutes) when no paid retry comes.
279
+ - never pays the same x402 `payment-identifier` twice. An id is marked paid only when the seller accepts the paid
280
+ request (any answer but 402); sending that id again throws `TollPolicyError` `duplicate-payment` (ids are kept
281
+ `paymentIdTtlS`, default 1 day). A second send of an id that is still in flight is refused too. After a network
282
+ error or timeout the same id may be retried (the seller dedupes it): the retry reuses the reservation of its 402,
283
+ so nothing is counted twice. A different id is a new payment.
284
+
285
+ ### Where the counters live
286
+
287
+ The default store is a `MemorySpendStore` in this process, one per `spend` object. **It resets when the process
288
+ restarts and is not shared between processes**: two workers each get the full day cap. For a cap that survives
289
+ restarts or spans workers, pass your own `store` implementing `SpendStore`:
290
+
291
+ ```ts
292
+ interface SpendStore {
293
+ reserve(k: string, amt: bigint, limit: bigint, windowStart: number): Promise<{ ok: boolean; id: string; used: bigint }>;
294
+ commit(id: string): Promise<void>;
295
+ release(id: string): Promise<void>;
296
+ paymentSeen(id: string): Promise<boolean>;
297
+ markPaid(id: string, ttlS: number): Promise<void>;
298
+ }
299
+ ```
300
+
301
+ A Redis store (example only, not shipped; `reserve` must be atomic, hence the Lua script):
302
+
303
+ ```ts
304
+ import Redis from "ioredis";
305
+ import type { SpendStore } from "@tollxyz/guard";
306
+
307
+ const redis = new Redis(process.env.REDIS_URL!);
308
+ const RESERVE = `
309
+ local used = tonumber(redis.call('GET', KEYS[1]) or '0')
310
+ local amt, limit = tonumber(ARGV[1]), tonumber(ARGV[2])
311
+ if used + amt > limit then return {0, tostring(used)} end
312
+ redis.call('INCRBY', KEYS[1], ARGV[1])
313
+ redis.call('EXPIRE', KEYS[1], 172800)
314
+ redis.call('SET', KEYS[2], KEYS[1] .. '|' .. ARGV[1], 'EX', 3600)
315
+ return {1, tostring(used + amt)}`;
316
+ const RELEASE = `
317
+ local v = redis.call('GET', KEYS[1])
318
+ if not v then return 0 end
319
+ local sep = string.find(v, '|', 1, true)
320
+ redis.call('DECRBY', string.sub(v, 1, sep - 1), string.sub(v, sep + 1))
321
+ redis.call('DEL', KEYS[1])
322
+ return 1`;
323
+
324
+ export const redisStore: SpendStore = {
325
+ async reserve(k, amt, limit, windowStart) {
326
+ const id = `toll:res:${crypto.randomUUID()}`;
327
+ // amounts as numbers in Lua: fine below 2^53 base units; use string math for larger caps
328
+ const [ok, used] = (await redis.eval(RESERVE, 2, `toll:cap:${k}:${windowStart}`, id, amt.toString(), limit.toString())) as [number, string];
329
+ return { ok: ok === 1, id: ok === 1 ? id : "", used: BigInt(used) };
330
+ },
331
+ async commit(id) { await redis.del(id); }, // the amount stays in the day counter
332
+ async release(id) { await redis.eval(RELEASE, 1, id); },
333
+ async paymentSeen(id) { return (await redis.exists(`toll:paid:${id}`)) === 1; },
334
+ async markPaid(id, ttlS) { await redis.set(`toll:paid:${id}`, "1", "EX", ttlS); },
335
+ };
336
+
337
+ const wallet = guard(walletClient, { spend: { /* ... */ }, store: redisStore });
338
+ ```
339
+
340
+ ## ALLOW tickets
341
+
342
+ A contract can require a Toll ALLOW ticket before a sensitive call (a router, a module, a Uniswap v4 hook, a
343
+ delegation caveat). The Solidity side is [toll-allow](https://github.com/t0ll-xyz/toll-allow); the full guide is
344
+ https://t0ll.xyz/agents/allow.
345
+
346
+ - A ticket is an EIP-712 verdict signed by Toll for **one exact call** (target, value, data), one account (`from`),
347
+ an optional consumer contract and a 60 second window. It is single use.
348
+ - A BLOCK is never signed. A WARN is signed only when no warning is high or critical (as verdict 1). No ticket
349
+ without `from`, or for a transaction with an EIP-7702 authorization list. When none is issued, `report.ticket` is
350
+ `{ why }`.
351
+ - Toll's production signer on Robinhood Chain (4663) is `0x4A2C93066d94F1E69f3EAd5438c4efDdf80a46ba`
352
+ (`TOLL_ALLOW_SIGNER`). Pin it in your contract, or a `TollSignerSet` you control that includes it.
353
+
354
+ ```ts
355
+ import { enforce, appendTicketSuffix } from "@tollxyz/guard";
356
+
357
+ // 1. check the inner call (what the router will call), bound to your router as the consumer
358
+ const r = await enforce("transaction", () => ({ chainId: 4663, from: me, to: target, data, value }), {
359
+ ticket: { consumer: ROUTER },
360
+ });
361
+ if (!r.ticket || !("signature" in r.ticket)) throw new Error(r.ticket?.why ?? "no ticket");
362
+
363
+ // 2. put the ticket on the calldata: data ‖ abi.encode(ticket, signature) ‖ uint32 len ‖ 0x900bc3e1
364
+ const withTicket = appendTicketSuffix(data, r.ticket.ticket, r.ticket.signature);
365
+ // 3. call the router within 60 s: router.exec(target, withTicket) with the same value, from the same account
366
+ ```
367
+
368
+ `appendTicketSuffix` is byte for byte `TollAllow.encodeSuffix` (tested against toll-allow's vectors). For a v4 hook,
369
+ ask with `scope: "v4swap"` and pass `abi.encode(ticket, signature)` as `hookData`. The same option works on
370
+ `checkTx(input, { ticket })`, where the ticket is in `assessment["x-toll"].ticket`.
371
+
372
+ A ticket is necessary, never sufficient: the contract keeps its own checks (slippage, limits, access control), and
373
+ should keep an owner bypass behind a timelock in case Toll is unreachable.
374
+
169
375
  ## Fail-open or fail-closed
170
376
 
171
377
  The default is fail-open: if Toll cannot answer, the signature goes ahead, with a `[toll-guard] ... NOT checked`
package/dist/check.d.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  * The one call to Toll: POST https://t0ll.xyz/api/tx/check. Every check runs on Toll's server, so a new check reaches
3
3
  * every agent without a package update. This file only sends the request and reads the answer.
4
4
  */
5
+ import { type TicketAsk } from "./ticket.js";
5
6
  export declare const DEFAULT_BASE_URL = "https://t0ll.xyz";
6
7
  /** Robinhood Chain: the only chain the pre-sign check reads for now */
7
8
  export declare const TX_CHAIN_ID = 4663;
@@ -117,11 +118,16 @@ export interface CheckOptions {
117
118
  maxRetryWaitMs?: number;
118
119
  /** for tests */
119
120
  sleep?: (ms: number) => Promise<void>;
121
+ /**
122
+ * 0.4.1: ask Toll for a single-use ALLOW ticket for this exact call (`true`, or {consumer?, scope?}). It comes back
123
+ * in x-toll.ticket (and on the GuardReport): signed, or with why none was issued. A BLOCK is never signed.
124
+ */
125
+ ticket?: boolean | TicketAsk;
120
126
  }
121
127
  export declare class TollBlockedError extends Error {
122
128
  readonly assessment: RiskAssessment;
123
129
  readonly codes: string[];
124
- constructor(assessment: RiskAssessment, why?: Action);
130
+ constructor(assessment: RiskAssessment, why?: Action | "policy");
125
131
  }
126
132
  export type UnavailableReason = "network" | "timeout" | "rate-limited" | "server-error" | "bad-request" | "bad-answer" | "unsupported" | "encode";
127
133
  export declare class TollUnavailableError extends Error {
package/dist/check.js CHANGED
@@ -2,6 +2,7 @@
2
2
  * The one call to Toll: POST https://t0ll.xyz/api/tx/check. Every check runs on Toll's server, so a new check reaches
3
3
  * every agent without a package update. This file only sends the request and reads the answer.
4
4
  */
5
+ import { ticketAskOf } from "./ticket.js";
5
6
  export const DEFAULT_BASE_URL = "https://t0ll.xyz";
6
7
  /** Robinhood Chain: the only chain the pre-sign check reads for now */
7
8
  export const TX_CHAIN_ID = 4663;
@@ -19,7 +20,7 @@ export class TollBlockedError extends Error {
19
20
  const top = ws.filter((w) => w.severity !== "low").slice(0, 5);
20
21
  const lines = (top.length ? top : ws.slice(0, 5)).map((w) => `${w.code}: ${w.message}`);
21
22
  const link = assessment["x-toll"]?.link;
22
- super(`Toll ${why}, not signed. ${lines.join(" | ") || "No reason given."}${link ? ` More: ${link}` : ""}`);
23
+ super(`${why === "policy" ? "Spend policy" : `Toll ${why}`}, not signed. ${lines.join(" | ") || "No reason given."}${link ? ` More: ${link}` : ""}`);
23
24
  this.name = "TollBlockedError";
24
25
  this.assessment = assessment;
25
26
  this.codes = ws.map((w) => w.code);
@@ -81,8 +82,12 @@ export function bodyOf(input) {
81
82
  b.from = input.from;
82
83
  b.authorizationList = input.authorizationList;
83
84
  }
84
- if (input.typedData)
85
+ if (input.typedData) {
85
86
  b.typedData = input.typedData;
87
+ // the signer of typed data alone (0.3.1): Toll reads what the signature lets someone take from it
88
+ if (b.from === undefined && input.from !== undefined)
89
+ b.from = input.from;
90
+ }
86
91
  if (input.message)
87
92
  b.message = input.message;
88
93
  if (input.rpc)
@@ -179,7 +184,8 @@ export async function checkTx(input, opts = {}) {
179
184
  const base = trimBase(opts.baseUrl ?? DEFAULT_BASE_URL);
180
185
  const url = `${base}/api/tx/check`;
181
186
  const paid = `${base}${PAID_TX_PATH}`;
182
- const body = toJson(bodyOf(input));
187
+ const ask = ticketAskOf(opts.ticket);
188
+ const body = toJson(ask ? { ...bodyOf(input), ticket: ask } : bodyOf(input));
183
189
  const first = await once(url, body, opts, f, paid);
184
190
  if (first.retryS === null || opts.retry === false)
185
191
  return first.a;
package/dist/index.d.ts CHANGED
@@ -1,10 +1,16 @@
1
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
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 } from "./policy.js";
4
- export type { GuardOptions, GuardReport, GuardKind, WarnPolicy, BlockPolicy } from "./policy.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
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";
15
+ export { appendTicketSuffix, encodeTicketAndSig, ticketAskOf, ticketOfAnswer, TICKET_SUFFIX_MAGIC, TOLL_ALLOW_SIGNER, ALLOW_DOCS } from "./ticket.js";
16
+ export type { TicketAsk, AllowTicket, TicketResult } from "./ticket.js";
package/dist/index.js CHANGED
@@ -1,5 +1,8 @@
1
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 } from "./policy.js";
2
+ export { enforce, enforceCalls, unsupportedWhy, refuse, gateAuthorization, resolvePolicy, hasHighWarning, isSignature, isEthSign, TollPolicyError, commitSpend, releaseSpend, preview } from "./policy.js";
3
3
  export { guard, guardViem, guardEthers, guardSmartAccount, isViemClient, isBundlerClient, primaryTypeOf } from "./wallets.js";
4
- export { guardX402Fetch, readPaymentRequired, readPaymentForms, paymentFormsDiffer } from "./x402.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";
8
+ export { appendTicketSuffix, encodeTicketAndSig, ticketAskOf, ticketOfAnswer, TICKET_SUFFIX_MAGIC, TOLL_ALLOW_SIGNER, ALLOW_DOCS } from "./ticket.js";
package/dist/policy.d.ts CHANGED
@@ -1,4 +1,7 @@
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 TicketResult } from "./ticket.js";
3
+ import { type Spend } from "./spend.js";
4
+ import { type SpendStore } from "./store.js";
2
5
  export type WarnPolicy = "allow" | "block" | "block-high" | ((assessment: RiskAssessment) => boolean | Promise<boolean>);
3
6
  export type BlockPolicy = "throw" | ((assessment: RiskAssessment) => boolean | Promise<boolean>);
4
7
  export type GuardKind = "transaction" | "typedData" | "x402" | "message" | "userOp" | "calls";
@@ -10,11 +13,90 @@ export interface GuardReport {
10
13
  assessment: RiskAssessment | null;
11
14
  /** what the guard did */
12
15
  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";
16
+ /**
17
+ * why: Toll's action, "unavailable" (Toll could not answer), "unsupported" (not a request Toll reads) or "policy"
18
+ * (blocked by your own spend rules: allowlist, cap, approval; `policy` says which)
19
+ */
20
+ basis: "ALLOW" | "WARN" | "BLOCK" | "unavailable" | "unsupported" | "policy";
15
21
  /** the paid route (no daily limit) when this caller's free checks are used */
16
22
  paidUrl: string | null;
17
23
  error?: TollUnavailableError;
24
+ /** the spend rule that blocked it (basis "policy") */
25
+ policy?: PolicyCode;
26
+ /** with `spend` set: what the request spends, the day-cap reservations it holds, and the approval reference */
27
+ spend?: {
28
+ spend: Spend;
29
+ reservations: string[];
30
+ approvalRef?: string;
31
+ needsApproval?: boolean;
32
+ };
33
+ /**
34
+ * 0.4.1, with the `ticket` option: Toll's single-use ALLOW ticket for this exact call ({ticket, signature, signer}),
35
+ * or {why} none was issued. Put it on the call with appendTicketSuffix (routers) or pass abi.encode(ticket, signature).
36
+ */
37
+ ticket?: TicketResult;
38
+ }
39
+ export type PolicyCode = "not-allowlisted" | "cap-per-call" | "cap-per-day" | "cap-endpoint" | "approval-denied" | "duplicate-payment" | "unknown-spend";
40
+ export interface Caps {
41
+ /** the most one request may spend, base units */
42
+ perCall?: bigint;
43
+ /** the most all requests together may spend in one UTC day, base units */
44
+ perDay?: bigint;
45
+ }
46
+ export interface ApprovalRequest {
47
+ kind: GuardKind;
48
+ input: CheckInput | null;
49
+ /** Toll's answer (null when Toll could not answer) */
50
+ assessment: RiskAssessment | null;
51
+ spend: Spend;
52
+ /** each asset over its approveAbove threshold */
53
+ over: Array<{
54
+ asset: string;
55
+ amount: bigint;
56
+ threshold: bigint;
57
+ }>;
58
+ }
59
+ /**
60
+ * Your own rules around Toll's verdict. Order: allowlists (before Toll is asked) -> Toll's ALLOW / WARN / BLOCK
61
+ * (unchanged) -> per-call and per-day caps -> human approval -> the day reservation is committed after the send
62
+ * (released when the send throws). Keys are "native" or a token address (any case); amounts are base units.
63
+ */
64
+ export interface SpendPolicy {
65
+ /** caps per spend asset; the spend assets are native, WETH and USDG plus every key here and in approveAbove */
66
+ assets?: Record<string, Caps>;
67
+ /** x402 caps per endpoint: a URL prefix (origin + path), in the payment asset's base units */
68
+ perEndpoint?: Record<string, Caps>;
69
+ /** when a list is given, every recipient / called contract, approval spender or x402 payee must be on it */
70
+ allow?: {
71
+ to?: string[];
72
+ spender?: string[];
73
+ payTo?: string[];
74
+ };
75
+ /** a spend (or allowance) above this amount needs `approve` to say yes */
76
+ approveAbove?: Record<string, bigint>;
77
+ /** asks a human (or another system); no function = denied */
78
+ approve?: (r: ApprovalRequest) => Promise<{
79
+ approved: boolean;
80
+ approvalRef?: string;
81
+ }>;
82
+ /** a request whose spend could not be read (a contract call Toll did not simulate): default "allow", "block" under failClosed */
83
+ onUnknownSpend?: "allow" | "block";
84
+ }
85
+ /** blocked by your own spend rules; a TollBlockedError, so existing catch blocks still see a block */
86
+ export declare class TollPolicyError extends TollBlockedError {
87
+ readonly code: PolicyCode;
88
+ readonly asset?: string;
89
+ readonly amount?: bigint;
90
+ readonly limit?: bigint;
91
+ /** the guard's report of this request */
92
+ readonly report?: GuardReport;
93
+ constructor(code: PolicyCode, message: string, x?: {
94
+ asset?: string;
95
+ amount?: bigint;
96
+ limit?: bigint;
97
+ assessment?: RiskAssessment | null;
98
+ report?: GuardReport;
99
+ });
18
100
  }
19
101
  export interface GuardOptions extends CheckOptions {
20
102
  /**
@@ -53,6 +135,23 @@ export interface GuardOptions extends CheckOptions {
53
135
  onAuthorization?: "check" | "block" | "allow";
54
136
  /** the site asking for a signature (a URL or host name), sent with messages so Toll can match a sign-in's domain */
55
137
  origin?: string;
138
+ /** 0.4.0: spend caps, allowlists and human approval around Toll's verdict; unset = 0.3.x behaviour */
139
+ spend?: SpendPolicy;
140
+ /** where caps and paid x402 ids are counted; default an in-process MemorySpendStore per SpendPolicy object */
141
+ store?: SpendStore;
142
+ /** with `spend`: a request whose spend cannot be read is blocked (onUnknownSpend default "block") */
143
+ failClosed?: boolean;
144
+ }
145
+ /** fields the wrappers pass to enforce; not part of the public options */
146
+ export interface InternalOptions extends GuardOptions {
147
+ /** spend-mode reports of one wrapped call, committed after the send or released when it throws */
148
+ _collect?: GuardReport[];
149
+ /** a preview's confirm id: Toll's answer from that preview is reused once when the request is identical */
150
+ _confirm?: string;
151
+ /** preview: check everything, reserve nothing, never ask for approval */
152
+ _preview?: boolean;
153
+ /** the x402 endpoint URL (per-endpoint caps) */
154
+ _endpoint?: string;
56
155
  }
57
156
  /** the effective policy: explicit options win, then the strict preset, then the defaults */
58
157
  export declare function resolvePolicy(o?: GuardOptions): {
@@ -91,3 +190,36 @@ export declare function gateAuthorization(kind: GuardKind, where: string, input:
91
190
  * from the batch's sender, in order; the first one blocked stops the batch. Reports carry kind "calls".
92
191
  */
93
192
  export declare function enforceCalls(build: () => CheckInput[] | Promise<CheckInput[]>, o?: GuardOptions): Promise<GuardReport[]>;
193
+ /** the store for these options: the one given, else one in-process store per SpendPolicy object */
194
+ export declare function storeOf(o: GuardOptions): SpendStore;
195
+ /** the reservations of these reports, committed: the request was sent */
196
+ export declare function commitSpend(reports: GuardReport | GuardReport[], o: GuardOptions): Promise<void>;
197
+ /** the reservations of these reports, released: the request was refused or the send threw */
198
+ export declare function releaseSpend(reports: GuardReport | GuardReport[], o: GuardOptions): Promise<void>;
199
+ /** a request the spend policy refuses outside enforce (a repeated x402 payment id): reported, then TollPolicyError */
200
+ export declare function policyRefuse(kind: GuardKind, input: CheckInput | null, code: PolicyCode, message: string, o: GuardOptions): Promise<never>;
201
+ export interface GuardDecision {
202
+ decision: "allowed" | "blocked";
203
+ basis: GuardReport["basis"];
204
+ /** Toll's answer (null when Toll was not asked or could not answer) */
205
+ assessment: RiskAssessment | null;
206
+ /** the spend rule that would block it */
207
+ policy?: PolicyCode;
208
+ /** what it would spend (with `spend` set) */
209
+ spend?: Spend;
210
+ /** over an approveAbove threshold: the send will ask `approve` */
211
+ needsApproval?: boolean;
212
+ /** why it would be blocked */
213
+ reason?: string;
214
+ paidUrl: string | null;
215
+ }
216
+ /**
217
+ * What the guard would do with this request, without doing it: Toll is asked and every spend rule is checked, but
218
+ * nothing is reserved, `approve` is not called and onReport is not called. When allowed, `confirmId` passed as
219
+ * `tollConfirm` on the send within 30 s skips the second Toll call (once, and only for the identical request).
220
+ */
221
+ export declare function preview(input: CheckInput, opts?: GuardOptions & {
222
+ kind?: GuardKind;
223
+ }): Promise<GuardDecision & {
224
+ confirmId: string;
225
+ }>;