@tollxyz/guard 0.4.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,8 @@
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
+
3
6
  > **New in 0.4.0: spend caps, allowlists, human approval and preview** (`spend` option). Unset, nothing changes.
4
7
  > See [0.4.0: spend caps, allowlists, approval, preview](#040-spend-caps-allowlists-approval-preview).
5
8
 
@@ -170,6 +173,7 @@ is at most `maxRetryWaitMs` (default 10 s); a coin still not read stays WARN, ne
170
173
  | `spend` | none | 0.4.0: caps, allowlists, approval ([below](#040-spend-caps-allowlists-approval-preview)); unset = 0.3.x behaviour |
171
174
  | `store` | in-process `MemorySpendStore` | where spend counters and paid x402 ids live |
172
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)) |
173
177
 
174
178
  Errors: `TollBlockedError` (`.assessment`, `.codes`, message lists each code and message) and
175
179
  `TollUnavailableError` (`.reason`, `.status`, `.paidUrl`).
@@ -333,6 +337,41 @@ export const redisStore: SpendStore = {
333
337
  const wallet = guard(walletClient, { spend: { /* ... */ }, store: redisStore });
334
338
  ```
335
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
+
336
375
  ## Fail-open or fail-closed
337
376
 
338
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,6 +118,11 @@ 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;
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;
@@ -183,7 +184,8 @@ export async function checkTx(input, opts = {}) {
183
184
  const base = trimBase(opts.baseUrl ?? DEFAULT_BASE_URL);
184
185
  const url = `${base}/api/tx/check`;
185
186
  const paid = `${base}${PAID_TX_PATH}`;
186
- const body = toJson(bodyOf(input));
187
+ const ask = ticketAskOf(opts.ticket);
188
+ const body = toJson(ask ? { ...bodyOf(input), ticket: ask } : bodyOf(input));
187
189
  const first = await once(url, body, opts, f, paid);
188
190
  if (first.retryS === null || opts.retry === false)
189
191
  return first.a;
package/dist/index.d.ts CHANGED
@@ -12,3 +12,5 @@ export { spendOf, NATIVE, USDG, WETH, DEFAULT_SPEND_ASSETS, UNLIMITED } from "./
12
12
  export type { Spend, SpendItem } from "./spend.js";
13
13
  export { MemorySpendStore, dayStart } from "./store.js";
14
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
@@ -5,3 +5,4 @@ export { guardX402Fetch, readPaymentRequired, readPaymentForms, paymentFormsDiff
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
6
  export { spendOf, NATIVE, USDG, WETH, DEFAULT_SPEND_ASSETS, UNLIMITED } from "./spend.js";
7
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,5 @@
1
1
  import { TollBlockedError, TollUnavailableError, type CheckInput, type CheckOptions, type RiskAssessment } from "./check.js";
2
+ import { type TicketResult } from "./ticket.js";
2
3
  import { type Spend } from "./spend.js";
3
4
  import { type SpendStore } from "./store.js";
4
5
  export type WarnPolicy = "allow" | "block" | "block-high" | ((assessment: RiskAssessment) => boolean | Promise<boolean>);
@@ -29,6 +30,11 @@ export interface GuardReport {
29
30
  approvalRef?: string;
30
31
  needsApproval?: boolean;
31
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;
32
38
  }
33
39
  export type PolicyCode = "not-allowlisted" | "cap-per-call" | "cap-per-day" | "cap-endpoint" | "approval-denied" | "duplicate-payment" | "unknown-spend";
34
40
  export interface Caps {
package/dist/policy.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { TX_CHAIN_ID, TollBlockedError, TollUnavailableError, checkTx, chainIdOf, paidUrlOf, bodyOf, toJson, } from "./check.js";
2
+ import { ticketOfAnswer } from "./ticket.js";
2
3
  import { DEFAULT_SPEND_ASSETS, spendOf } from "./spend.js";
3
4
  import { MemorySpendStore, dayStart } from "./store.js";
4
5
  /** blocked by your own spend rules; a TollBlockedError, so existing catch blocks still see a block */
@@ -155,7 +156,8 @@ async function enforceVerdict(kind, build, o) {
155
156
  }
156
157
  const paidUrl = paidUrlOf(a);
157
158
  const done = async (decision, basis) => {
158
- const r = { kind, input, assessment: a, decision, basis, paidUrl };
159
+ const tk = ticketOfAnswer(a);
160
+ const r = { kind, input, assessment: a, decision, basis, paidUrl, ...(tk ? { ticket: tk } : {}) };
159
161
  await report(o, r);
160
162
  return r;
161
163
  };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * 0.4.1: Toll's single-use ALLOW ticket. Ask for one with the `ticket` option; Toll signs a compact EIP-712 verdict
3
+ * bound to one exact call, one account, an optional consumer contract and a 60 second window. A contract built on
4
+ * toll-allow (https://github.com/t0ll-xyz/toll-allow) accepts the call only with a valid, unused ticket.
5
+ * A BLOCK is never signed. No ticket without `from`, or for a transaction with an EIP-7702 authorization list.
6
+ *
7
+ * appendTicketSuffix MUST match toll-allow's TollAllow.encodeSuffix / splitSuffix byte for byte:
8
+ * body ‖ abi.encode((uint8,uint8,address,address,bytes32,uint64,uint64,bytes32,bytes32), bytes) ‖ uint32 len ‖ 0x900bc3e1
9
+ * where len is the byte length of the abi.encode part and 0x900bc3e1 = bytes4(keccak256("TollAllow.v1")).
10
+ */
11
+ import type { Hex } from "./check.js";
12
+ /** what to ask Toll for: `true` = scope "call", any consumer */
13
+ export interface TicketAsk {
14
+ /** the contract that will check the ticket (its `consumer` field); unset = any contract */
15
+ consumer?: string;
16
+ /** "call" (default): bound to (target, value, keccak256(data)); "v4swap": bound to the swap a HookrRouter call makes */
17
+ scope?: "call" | "v4swap";
18
+ }
19
+ /** the ticket as Toll returns it (x-toll.ticket.ticket); uint64 times are unix seconds */
20
+ export interface AllowTicket {
21
+ scope: number;
22
+ verdict: number;
23
+ account: string;
24
+ consumer: string;
25
+ subject: string;
26
+ validAfter: number | bigint | string;
27
+ validUntil: number | bigint | string;
28
+ nonce: string;
29
+ reportHash: string;
30
+ }
31
+ /** x-toll.ticket: a signed ticket, or why none was issued */
32
+ export type TicketResult = {
33
+ ticket: AllowTicket;
34
+ signature: Hex;
35
+ signer: string;
36
+ } | {
37
+ why: string;
38
+ };
39
+ /** bytes4(keccak256("TollAllow.v1")) */
40
+ export declare const TICKET_SUFFIX_MAGIC = "0x900bc3e1";
41
+ /** Toll's production ALLOW signer on Robinhood Chain (4663): pin it, or a TollSignerSet you control that includes it */
42
+ export declare const TOLL_ALLOW_SIGNER = "0x4A2C93066d94F1E69f3EAd5438c4efDdf80a46ba";
43
+ export declare const ALLOW_DOCS = "https://t0ll.xyz/agents/allow";
44
+ /** the ask as the route reads it (`ticket: {consumer?, scope?}`), or null for none */
45
+ export declare function ticketAskOf(t: boolean | TicketAsk | undefined | null): TicketAsk | null;
46
+ /** the ticket in an answer, when the answer carries one (signed or with its why) */
47
+ export declare function ticketOfAnswer(a: {
48
+ "x-toll"?: Record<string, unknown>;
49
+ } | null | undefined): TicketResult | undefined;
50
+ /** abi.encode(Ticket, bytes) as hex without 0x: the static tuple inline (9 words), then the offset and the bytes */
51
+ export declare function encodeTicketAndSig(t: AllowTicket, signature: string): string;
52
+ /**
53
+ * The calldata a ticket-checking router reads: `data ‖ abi.encode(ticket, signature) ‖ uint32 len ‖ 0x900bc3e1`.
54
+ * The ticket's subject is over `data` (the body) only. Identical to TollAllow.encodeSuffix in toll-allow.
55
+ */
56
+ export declare function appendTicketSuffix(data: Hex, ticket: AllowTicket, signature: Hex): Hex;
package/dist/ticket.js ADDED
@@ -0,0 +1,90 @@
1
+ /** bytes4(keccak256("TollAllow.v1")) */
2
+ export const TICKET_SUFFIX_MAGIC = "0x900bc3e1";
3
+ /** Toll's production ALLOW signer on Robinhood Chain (4663): pin it, or a TollSignerSet you control that includes it */
4
+ export const TOLL_ALLOW_SIGNER = "0x4A2C93066d94F1E69f3EAd5438c4efDdf80a46ba";
5
+ export const ALLOW_DOCS = "https://t0ll.xyz/agents/allow";
6
+ /** the ask as the route reads it (`ticket: {consumer?, scope?}`), or null for none */
7
+ export function ticketAskOf(t) {
8
+ if (t === undefined || t === null || t === false)
9
+ return null;
10
+ if (t === true)
11
+ return {};
12
+ const o = {};
13
+ if (t.consumer !== undefined)
14
+ o.consumer = t.consumer;
15
+ if (t.scope !== undefined)
16
+ o.scope = t.scope;
17
+ return o;
18
+ }
19
+ /** the ticket in an answer, when the answer carries one (signed or with its why) */
20
+ export function ticketOfAnswer(a) {
21
+ const t = a?.["x-toll"]?.ticket;
22
+ if (!t || typeof t !== "object")
23
+ return undefined;
24
+ if (typeof t.why === "string")
25
+ return { why: t.why };
26
+ if (t.ticket && typeof t.ticket === "object" && typeof t.signature === "string" && typeof t.signer === "string") {
27
+ return { ticket: t.ticket, signature: t.signature, signer: t.signer };
28
+ }
29
+ return undefined;
30
+ }
31
+ /* ------------------------------------------------------------------ ABI encoding (no dependencies) */
32
+ const hexBody = (h, what) => {
33
+ if (typeof h !== "string" || !/^0x([0-9a-fA-F]{2})*$/.test(h))
34
+ throw new TypeError(`${what} must be 0x hex with whole bytes`);
35
+ return h.slice(2).toLowerCase();
36
+ };
37
+ const word = (n, bits, what) => {
38
+ if (n < 0n || n >= 1n << BigInt(bits))
39
+ throw new RangeError(`${what} does not fit in uint${bits}`);
40
+ return n.toString(16).padStart(64, "0");
41
+ };
42
+ const uint = (v, bits, what) => {
43
+ let n;
44
+ try {
45
+ n = BigInt(v);
46
+ }
47
+ catch {
48
+ throw new TypeError(`${what} must be a whole number`);
49
+ }
50
+ return word(n, bits, what);
51
+ };
52
+ const addr = (a, what) => {
53
+ if (typeof a !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(a))
54
+ throw new TypeError(`${what} must be an address`);
55
+ return a.slice(2).toLowerCase().padStart(64, "0");
56
+ };
57
+ const b32 = (h, what) => {
58
+ if (typeof h !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(h))
59
+ throw new TypeError(`${what} must be 32 bytes`);
60
+ return h.slice(2).toLowerCase();
61
+ };
62
+ /** abi.encode(Ticket, bytes) as hex without 0x: the static tuple inline (9 words), then the offset and the bytes */
63
+ export function encodeTicketAndSig(t, signature) {
64
+ const sig = hexBody(signature, "signature");
65
+ const head = uint(t.scope, 8, "ticket.scope") +
66
+ uint(t.verdict, 8, "ticket.verdict") +
67
+ addr(t.account, "ticket.account") +
68
+ addr(t.consumer, "ticket.consumer") +
69
+ b32(t.subject, "ticket.subject") +
70
+ uint(t.validAfter, 64, "ticket.validAfter") +
71
+ uint(t.validUntil, 64, "ticket.validUntil") +
72
+ b32(t.nonce, "ticket.nonce") +
73
+ b32(t.reportHash, "ticket.reportHash");
74
+ const offset = word(10n * 32n, 256, "offset"); // 9 tuple words + this offset word
75
+ const len = word(BigInt(sig.length / 2), 256, "signature length");
76
+ const padded = sig + "0".repeat((64 - (sig.length % 64)) % 64);
77
+ return head + offset + len + padded;
78
+ }
79
+ /**
80
+ * The calldata a ticket-checking router reads: `data ‖ abi.encode(ticket, signature) ‖ uint32 len ‖ 0x900bc3e1`.
81
+ * The ticket's subject is over `data` (the body) only. Identical to TollAllow.encodeSuffix in toll-allow.
82
+ */
83
+ export function appendTicketSuffix(data, ticket, signature) {
84
+ const body = hexBody(data, "data");
85
+ const enc = encodeTicketAndSig(ticket, signature);
86
+ const n = enc.length / 2;
87
+ if (n >= 2 ** 32)
88
+ throw new RangeError("ticket encoding too long");
89
+ return `0x${body}${enc}${n.toString(16).padStart(8, "0")}${TICKET_SUFFIX_MAGIC.slice(2)}`;
90
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tollxyz/guard",
3
- "version": "0.4.0",
4
- "description": "Toll's live pre-sign check in front of an AI agent's wallet: viem, viem smart-account, ethers and x402 fetch wrappers that ask Toll (ALLOW / WARN / BLOCK, ERC-8265) before anything is signed on Robinhood Chain: transactions, messages, typed data, batches, user operations, EIP-7702 authorizations. Spend caps, allowlists and human approval around the verdict. Zero runtime dependencies.",
3
+ "version": "0.4.1",
4
+ "description": "Toll's live pre-sign check in front of an AI agent's wallet: viem, viem smart-account, ethers and x402 fetch wrappers that ask Toll (ALLOW / WARN / BLOCK, ERC-8265) before anything is signed on Robinhood Chain: transactions, messages, typed data, batches, user operations, EIP-7702 authorizations. Spend caps, allowlists and human approval around the verdict; single-use ALLOW tickets for contracts that require one. Zero runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "Toll",
7
7
  "type": "module",
@@ -37,6 +37,7 @@
37
37
  "erc-8004",
38
38
  "erc-8470",
39
39
  "erc-8265",
40
+ "allow-ticket",
40
41
  "robinhood-chain",
41
42
  "viem",
42
43
  "ethers",