@tollxyz/guard 0.1.2 → 0.3.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,7 +1,26 @@
1
1
  # @tollxyz/guard
2
2
 
3
- Toll's live pre-sign check in front of an AI agent's wallet. Before the agent signs a transaction, an EIP-712
4
- signature or an x402 payment on Robinhood Chain, the guard asks [Toll](https://t0ll.xyz/agents/x402#presign) and
3
+ > **What changed in 0.3.0: every signing method is checked.**
4
+ > - Messages: viem and ethers `signMessage`, and `request()` for `personal_sign` and `eth_sign`.
5
+ > - Typed data: `request()` for `eth_signTypedData_v4` too, any EIP-712 (orders, permits, sign-ins).
6
+ > - Batches: viem `sendCalls` (EIP-5792) and `request()` for `wallet_sendCalls`, each call checked.
7
+ > - Smart accounts: `guardSmartAccount` wraps a viem bundler client's `sendUserOperation` and `prepareUserOperation`;
8
+ > `guard()` picks it for a bundler client.
9
+ > - EIP-7702: `onAuthorization` now defaults to `"check"`: Toll reads the authorization and decides (a known, verified
10
+ > delegate is WARN medium and goes ahead under `block-high`; an unknown one is BLOCK). `"block"` and `"allow"` stay.
11
+ > An authorization Toll cannot read is blocked.
12
+ > - A signature for another chain is blocked (before 0.3.0 it went through as unsupported); `onUnsupported: "allow"`
13
+ > lets it through.
14
+ > - `eth_sign` always fails closed: it can sign any 32 bytes, a transaction included, so when Toll cannot answer it is
15
+ > blocked whatever `onUnavailable` says.
16
+
17
+ > **Defaults changed in 0.2.0.** A WARN with any `high` or `critical` warning is now blocked (`onWarn: "block-high"`,
18
+ > the new default); a WARN with only `medium`/`low` warnings still goes ahead, logged. To keep the 0.1.x behaviour,
19
+ > pass `onWarn: "allow"`. New `strict: true` preset: WARN, unavailable and unsupported all block. Any option you pass
20
+ > explicitly still wins over the preset (`{ strict: true, onUnavailable: "allow" }` stays fail-open).
21
+
22
+ Toll's live pre-sign check in front of an AI agent's wallet. Before the agent signs a transaction, a message, an
23
+ 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
5
24
  gets ALLOW / WARN / BLOCK with the reasons. Zero runtime dependencies, ESM only, Node >= 20.19, with types.
6
25
 
7
26
  ```sh
@@ -9,13 +28,13 @@ npm install @tollxyz/guard
9
28
  ```
10
29
 
11
30
  ```ts
12
- const wallet = guard(walletClient); // viem WalletClient or ethers v6 Signer; same methods, checked first
31
+ const wallet = guard(walletClient); // viem WalletClient, viem bundler client or ethers v6 Signer; same methods, checked first
13
32
  ```
14
33
 
15
34
  ```ts
16
35
  import { guard, TollBlockedError } from "@tollxyz/guard";
17
36
 
18
- const wallet = guard(walletClient, { onWarn: "allow", onUnavailable: "allow" });
37
+ const wallet = guard(walletClient, { onWarn: "block-high", onUnavailable: "allow" });
19
38
  try {
20
39
  await wallet.writeContract({ address: token, abi: erc20Abi, functionName: "approve", args: [spender, maxUint256] });
21
40
  } catch (e) {
@@ -57,23 +76,43 @@ The answer is an [ERC-8265](https://github.com/ethereum/ERCs/pull/1753) RiskAsse
57
76
  `{ action, warnings: [{ code, severity, message, "x-toll-evidence" }], scanners, "x-toll": { decoded, link, checked_at, note } }`.
58
77
  Critical = BLOCK, high or medium = WARN, low or none = ALLOW.
59
78
 
60
- Robinhood Chain (4663) only for now. A request on another chain, or a contract deployment, is not sent to Toll and
61
- goes through (`onUnsupported: "block"` to stop it instead).
79
+ Robinhood Chain (4663) only for now. A transaction on another chain, or a contract deployment, is not sent to Toll
80
+ and goes through (`onUnsupported: "block"` to stop it instead). A signature for another chain is blocked
81
+ (`onUnsupported: "allow"` lets it through).
62
82
 
63
83
  ## API
64
84
 
65
- ### `guard(wallet, opts?)`, `guardViem(client, opts?)`, `guardEthers(signer, opts?)`
85
+ ### `guard(wallet, opts?)`, `guardViem(client, opts?)`, `guardSmartAccount(bundler, opts?)`, `guardEthers(signer, opts?)`
66
86
 
67
87
  Returns the same wallet behind a Proxy. Checked first:
68
88
 
69
89
  - viem WalletClient: `sendTransaction`, `writeContract` (calldata encoded with your viem's `encodeFunctionData`),
70
- `signTransaction`, `signTypedData`
71
- - ethers v6 Signer: `sendTransaction`, `signTransaction`, `signTypedData`
90
+ `signTransaction`, `signTypedData`, `signMessage`, `sendCalls`, `signAuthorization`, and `request()` for
91
+ `eth_sendTransaction`, `eth_signTransaction`, `personal_sign`, `eth_sign`, `eth_signTypedData_v4` and
92
+ `wallet_sendCalls` (every other method passes through)
93
+ - viem bundler client (account abstraction): `sendUserOperation`, `prepareUserOperation`. `calls` are checked one by
94
+ one as transactions from the smart account's address; a ready `callData` is sent as a user operation with the
95
+ account's EntryPoint
96
+ - ethers v6 Signer: `sendTransaction`, `signTransaction`, `signTypedData`, `signMessage`, `authorize`
97
+
98
+ A batch is checked call by call, in order; the first call blocked stops the whole batch.
99
+
100
+ An EIP-7702 authorization sets the wallet's code to another contract, which can then move everything it holds: a
101
+ transaction with an `authorizationList` on any method above, viem's `signAuthorization`, ethers' `authorize`, a user
102
+ operation's `authorization`. By default (`onAuthorization: "check"`) Toll reads it and decides; if Toll cannot read it
103
+ (unreachable, another chain) it is blocked (TollBlockedError, code `eip7702-authorization`). `"block"` refuses every
104
+ authorization without asking; `"allow"` lets it through unchecked. The account's own sign methods
105
+ (`client.account.sign*`) are not wrapped.
72
106
 
73
- An EIP-7702 authorization sets the wallet's code to another contract, which can then move everything it holds. Toll
74
- does not read one yet, so the guard blocks it itself (TollBlockedError, code `eip7702-authorization`) unless
75
- `onAuthorization: "allow"`: a transaction with an `authorizationList` on any method above, viem's
76
- `signAuthorization` and ethers' `authorize`. The account's own sign methods (`client.account.sign*`) are not wrapped.
107
+ ```ts
108
+ const wallet = guard(walletClient, { origin: "https://app.example" }); // origin: the site asking, for sign-ins
109
+ await wallet.signMessage({ message: "Sign in to app.example" }); // checked as personal_sign
110
+ await wallet.request({ method: "personal_sign", params: [hexMessage, address] });
111
+ await wallet.request({ method: "eth_sign", params: [address, hash] }); // blocked when Toll blocks it, or cannot answer
112
+
113
+ const smart = guard(bundlerClient); // detected by sendUserOperation
114
+ await smart.sendUserOperation({ calls: [{ to: token, abi: erc20Abi, functionName: "transfer", args: [to, amount] }] });
115
+ ```
77
116
 
78
117
  Everything else passes through unchanged. Wrap last: `client.extend(...)` or `signer.connect(...)` on the wrapped
79
118
  wallet returns an unguarded one. viem and ethers are optional peer dependencies, never imported at load time.
@@ -97,8 +136,12 @@ const pay = wrapFetchWithPayment(guardX402Fetch(fetch, { agentId: 123 }), accoun
97
136
 
98
137
  ### `checkTx(input, opts?)`
99
138
 
100
- The raw call. `input`: `{ chainId, from, to, data, value }` for a transaction, `{ typedData }` for an EIP-712
101
- request, `{ x402, agentId?, payment? }` for an x402 payment, or several together. Returns the RiskAssessment. When a
139
+ The raw call. `input`: `{ chainId, from, to, data, value, authorizationList? }` for a transaction,
140
+ `{ chainId, from, authorizationList }` for an authorization alone, `{ typedData }` for an EIP-712 request,
141
+ `{ message: { kind: "personal_sign" | "eth_sign", data, from? } }` for a message, `{ rpc: { method, params } }` for a
142
+ wallet request (`personal_sign`, `eth_sign`, `eth_signTypedData_v4`, `eth_sendTransaction`), `{ userOp, entryPoint? }`
143
+ for an ERC-4337 user operation, `{ x402, agentId?, payment? }` for an x402 payment, or several together; `origin` is
144
+ the site asking. Returns the RiskAssessment. When a
102
145
  coin is still being read, Toll answers WARN `coin-not-read` with `Retry-After`: `checkTx` asks once more if the wait
103
146
  is at most `maxRetryWaitMs` (default 10 s); a coin still not read stays WARN, never ALLOW. Throws
104
147
  `TollUnavailableError` when Toll cannot answer.
@@ -107,11 +150,13 @@ is at most `maxRetryWaitMs` (default 10 s); a coin still not read stays WARN, ne
107
150
 
108
151
  | Option | Default | |
109
152
  | --- | --- | --- |
110
- | `onWarn` | `"allow"` | `"allow"` (logged and reported), `"block"`, or `(assessment) => boolean` (true lets it through) |
153
+ | `strict` | `false` | preset: the defaults of `onWarn`, `onUnavailable` and `onUnsupported` become `"block"`; each one passed explicitly still wins |
154
+ | `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) |
111
155
  | `onBlock` | `"throw"` | `"throw"` (TollBlockedError), or `(assessment) => boolean`, e.g. ask a human; true lets it through |
112
- | `onUnavailable` | `"allow"` | Toll unreachable, timed out, 5xx, 429, or a request it could not read: `"allow"` (fail-open, logged) or `"block"` (fail-closed, TollUnavailableError) |
113
- | `onUnsupported` | `"allow"` | another chain, a deployment: `"allow"` or `"block"` |
114
- | `onAuthorization` | `"block"` | an EIP-7702 authorization: `"block"` (TollBlockedError) or `"allow"` (unchecked, logged) |
156
+ | `onUnavailable` | `"allow"` (`strict`: `"block"`) | Toll unreachable, timed out, 5xx, 429, or a request it could not read: `"allow"` (fail-open, logged) or `"block"` (fail-closed, TollUnavailableError) |
157
+ | `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 |
158
+ | `onAuthorization` | `"check"` | an EIP-7702 authorization: `"check"` (Toll decides; blocked when Toll cannot read it), `"block"` (TollBlockedError, Toll not asked) or `"allow"` (unchecked, logged) |
159
+ | `origin` | none | the site asking for a signature, sent with messages and typed data |
115
160
  | `onReport` | none | `(report) => void`: every decision, with `assessment`, `decision`, `basis`, `paidUrl` |
116
161
  | `logger` | `console.warn` | where WARN and unavailable lines go; `false` = silent |
117
162
  | `timeoutMs` | `15000` | one request to Toll |
@@ -126,13 +171,16 @@ Errors: `TollBlockedError` (`.assessment`, `.codes`, message lists each code and
126
171
  The default is fail-open: if Toll cannot answer, the signature goes ahead, with a `[toll-guard] ... NOT checked`
127
172
  line and an `onReport` call with `basis: "unavailable"`. The agent must never get stuck because of Toll, and the
128
173
  free route is rate limited per IP (a busy agent will see 429s). If an unchecked signature is worse for you than a
129
- stalled agent, set `onUnavailable: "block"`.
174
+ stalled agent, set `onUnavailable: "block"` (or `strict: true`, which also blocks every WARN and unsupported request).
175
+
176
+ Two requests fail closed whatever `onUnavailable` says: `eth_sign` (it signs any 32 bytes, a transaction hash
177
+ included, so an unchecked one could be anything) and an EIP-7702 authorization under `onAuthorization: "check"`.
130
178
 
131
179
  ## Free limit and the paid route
132
180
 
133
181
  The check is free, rate limited per IP. The coin a swap buys is read through the same free coins as Toll's coin
134
182
  check (10 new coins a day per caller, coins already read stay free); past that the coin is not read and the answer
135
- is WARN `coin-over-quota` naming the paid route, `https://t0ll.xyz/api/x402/tx` (0.01 USDG over x402, no daily
183
+ is WARN `coin-over-quota` naming the paid route, `https://t0ll.xyz/api/x402/tx` (0.005 USDG over x402, no daily
136
184
  limit). The guard never pays: it puts that URL in the log line, in `report.paidUrl` and in `paidUrlOf(assessment)`.
137
185
 
138
186
  ## Privacy
package/dist/check.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  export declare const DEFAULT_BASE_URL = "https://t0ll.xyz";
6
6
  /** Robinhood Chain: the only chain the pre-sign check reads for now */
7
7
  export declare const TX_CHAIN_ID = 4663;
8
- /** the paid route without the daily limit (0.01 USDG over x402); the guard never pays it */
8
+ /** the paid route without the daily limit (0.005 USDG over x402); the guard never pays it */
9
9
  export declare const PAID_TX_PATH = "/api/x402/tx";
10
10
  export declare const PRESIGN_DOCS = "https://t0ll.xyz/agents/x402#presign";
11
11
  export declare const DEFAULT_TIMEOUT_MS = 15000;
@@ -61,8 +61,22 @@ export interface TypedDataLike {
61
61
  primaryType: string;
62
62
  message: Record<string, unknown>;
63
63
  }
64
+ /** A personal_sign / eth_sign message: data is hex (the bytes signed) or text. */
65
+ export interface SignMessageLike {
66
+ kind: "personal_sign" | "eth_sign";
67
+ data: string;
68
+ origin?: string;
69
+ from?: string;
70
+ }
71
+ /** A wallet RPC request Toll reads as the body it stands for: personal_sign, eth_sign, eth_signTypedData_v4, eth_sendTransaction. */
72
+ export interface RpcLike {
73
+ method: string;
74
+ params?: readonly unknown[];
75
+ }
64
76
  /**
65
- * What to check. A transaction ({chainId, from, to, data, value}), an EIP-712 request ({typedData}), an x402 payment
77
+ * What to check. A transaction ({chainId, from, to, data, value}, with an EIP-7702 authorizationList or not), an
78
+ * authorization list alone ({chainId, from, authorizationList}), an EIP-712 request ({typedData}), a message
79
+ * ({message}), a wallet request ({rpc}), an ERC-4337 user operation ({userOp, entryPoint?}), an x402 payment
66
80
  * ({x402, agentId?, payment?}), or several together.
67
81
  */
68
82
  export interface CheckInput {
@@ -71,7 +85,17 @@ export interface CheckInput {
71
85
  to?: string;
72
86
  data?: string;
73
87
  value?: Numberish;
88
+ /** EIP-7702 authorizations: with to/data a type-4 transaction, alone a signAuthorization */
89
+ authorizationList?: readonly unknown[];
74
90
  typedData?: TypedDataLike;
91
+ message?: SignMessageLike;
92
+ rpc?: RpcLike;
93
+ /** an ERC-4337 user operation (v0.6, v0.7 packed or the RPC form) */
94
+ userOp?: Record<string, unknown>;
95
+ /** the user operation's EntryPoint */
96
+ entryPoint?: string;
97
+ /** the site asking for the signature (a URL or host name) */
98
+ origin?: string;
75
99
  /** the 402's PaymentRequired, or one of its accepts entries */
76
100
  x402?: Record<string, unknown>;
77
101
  /** the seller's ERC-8004 agent id, when known */
package/dist/check.js CHANGED
@@ -5,7 +5,7 @@
5
5
  export const DEFAULT_BASE_URL = "https://t0ll.xyz";
6
6
  /** Robinhood Chain: the only chain the pre-sign check reads for now */
7
7
  export const TX_CHAIN_ID = 4663;
8
- /** the paid route without the daily limit (0.01 USDG over x402); the guard never pays it */
8
+ /** the paid route without the daily limit (0.005 USDG over x402); the guard never pays it */
9
9
  export const PAID_TX_PATH = "/api/x402/tx";
10
10
  export const PRESIGN_DOCS = "https://t0ll.xyz/agents/x402#presign";
11
11
  export const DEFAULT_TIMEOUT_MS = 15_000;
@@ -61,6 +61,7 @@ const trimBase = (b) => b.replace(/\/+$/, "");
61
61
  /** the request body as the route reads it (lib/txcheck.ts parseTxCheckBody): payment rides inside x402 */
62
62
  export function bodyOf(input) {
63
63
  const b = {};
64
+ const auths = input.authorizationList !== undefined && input.authorizationList !== null && input.authorizationList.length > 0;
64
65
  if (input.to !== undefined || input.data !== undefined) {
65
66
  if (input.chainId !== undefined)
66
67
  b.chainId = input.chainId;
@@ -69,9 +70,30 @@ export function bodyOf(input) {
69
70
  b.to = input.to;
70
71
  b.data = input.data ?? "0x";
71
72
  b.value = input.value ?? 0;
73
+ if (auths)
74
+ b.authorizationList = input.authorizationList;
75
+ }
76
+ else if (auths) {
77
+ // a signAuthorization alone: the list and its signer
78
+ if (input.chainId !== undefined)
79
+ b.chainId = input.chainId;
80
+ if (input.from !== undefined)
81
+ b.from = input.from;
82
+ b.authorizationList = input.authorizationList;
72
83
  }
73
84
  if (input.typedData)
74
85
  b.typedData = input.typedData;
86
+ if (input.message)
87
+ b.message = input.message;
88
+ if (input.rpc)
89
+ b.rpc = input.rpc;
90
+ if (input.userOp) {
91
+ b.userOp = input.userOp;
92
+ if (input.entryPoint !== undefined)
93
+ b.entryPoint = input.entryPoint;
94
+ }
95
+ if (input.origin !== undefined)
96
+ b.origin = input.origin;
75
97
  if (input.x402)
76
98
  b.x402 = input.payment ? { ...input.x402, payment: input.payment } : input.x402;
77
99
  if (input.agentId !== undefined && input.agentId !== null)
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
- export { checkTx, chainIdOf, paidUrlOf, isAssessment, TollBlockedError, TollUnavailableError, DEFAULT_BASE_URL, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_RETRY_WAIT_MS, PAID_TX_PATH, PRESIGN_DOCS, TX_CHAIN_ID, } from "./check.js";
2
- export type { Action, Severity, RiskWarning, ScannerVerdict, TollExtra, RiskAssessment, CheckInput, CheckOptions, TypedDataLike, FetchLike, Hex, Numberish, UnavailableReason, } from "./check.js";
3
- export { enforce, unsupportedWhy, refuse, gateAuthorization } from "./policy.js";
1
+ export { checkTx, bodyOf, chainIdOf, paidUrlOf, isAssessment, TollBlockedError, TollUnavailableError, DEFAULT_BASE_URL, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_RETRY_WAIT_MS, PAID_TX_PATH, PRESIGN_DOCS, TX_CHAIN_ID, } from "./check.js";
2
+ export type { Action, Severity, RiskWarning, ScannerVerdict, TollExtra, RiskAssessment, SignMessageLike, RpcLike, CheckInput, CheckOptions, TypedDataLike, FetchLike, Hex, Numberish, UnavailableReason, } from "./check.js";
3
+ export { enforce, enforceCalls, unsupportedWhy, refuse, gateAuthorization, resolvePolicy, hasHighWarning, isSignature, isEthSign } from "./policy.js";
4
4
  export type { GuardOptions, GuardReport, GuardKind, WarnPolicy, BlockPolicy } from "./policy.js";
5
- export { guard, guardViem, guardEthers, isViemClient, primaryTypeOf } from "./wallets.js";
5
+ export { guard, guardViem, guardEthers, guardSmartAccount, isViemClient, isBundlerClient, primaryTypeOf } from "./wallets.js";
6
6
  export type { ViemGuardOptions } from "./wallets.js";
7
7
  export { guardX402Fetch, readPaymentRequired, readPaymentForms, paymentFormsDiffer } from "./x402.js";
8
8
  export type { X402GuardOptions } from "./x402.js";
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- export { checkTx, chainIdOf, paidUrlOf, isAssessment, TollBlockedError, TollUnavailableError, DEFAULT_BASE_URL, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_RETRY_WAIT_MS, PAID_TX_PATH, PRESIGN_DOCS, TX_CHAIN_ID, } from "./check.js";
2
- export { enforce, unsupportedWhy, refuse, gateAuthorization } from "./policy.js";
3
- export { guard, guardViem, guardEthers, isViemClient, primaryTypeOf } from "./wallets.js";
1
+ export { checkTx, bodyOf, chainIdOf, paidUrlOf, isAssessment, TollBlockedError, TollUnavailableError, DEFAULT_BASE_URL, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_RETRY_WAIT_MS, PAID_TX_PATH, PRESIGN_DOCS, TX_CHAIN_ID, } from "./check.js";
2
+ export { enforce, enforceCalls, unsupportedWhy, refuse, gateAuthorization, resolvePolicy, hasHighWarning, isSignature, isEthSign } from "./policy.js";
3
+ export { guard, guardViem, guardEthers, guardSmartAccount, isViemClient, isBundlerClient, primaryTypeOf } from "./wallets.js";
4
4
  export { guardX402Fetch, readPaymentRequired, readPaymentForms, paymentFormsDiffer } 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";
package/dist/policy.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { TollUnavailableError, type CheckInput, type CheckOptions, type RiskAssessment } from "./check.js";
2
- export type WarnPolicy = "allow" | "block" | ((assessment: RiskAssessment) => boolean | Promise<boolean>);
2
+ export type WarnPolicy = "allow" | "block" | "block-high" | ((assessment: RiskAssessment) => boolean | Promise<boolean>);
3
3
  export type BlockPolicy = "throw" | ((assessment: RiskAssessment) => boolean | Promise<boolean>);
4
- export type GuardKind = "transaction" | "typedData" | "x402";
4
+ export type GuardKind = "transaction" | "typedData" | "x402" | "message" | "userOp" | "calls";
5
5
  export interface GuardReport {
6
6
  kind: GuardKind;
7
7
  /** what was sent to Toll (null when nothing was sent) */
@@ -17,17 +17,25 @@ export interface GuardReport {
17
17
  error?: TollUnavailableError;
18
18
  }
19
19
  export interface GuardOptions extends CheckOptions {
20
- /** WARN: "allow" (default, reported and logged), "block", or a function; true lets it through */
20
+ /**
21
+ * WARN: "block-high" (default since 0.2.0: blocked when any warning is high or critical, else allowed and logged),
22
+ * "allow" (every WARN goes ahead, logged; the 0.1.x default), "block", or a function (true lets it through)
23
+ */
21
24
  onWarn?: WarnPolicy;
22
25
  /** BLOCK: "throw" (default, TollBlockedError), or a function (e.g. ask a human); true lets it through */
23
26
  onBlock?: BlockPolicy;
24
27
  /**
25
28
  * Toll unreachable, timed out, 5xx, 429, or a request it could not read: "allow" (default, fail-open, logged) or
26
- * "block" (fail-closed, throws TollUnavailableError).
29
+ * "block" (fail-closed, throws TollUnavailableError). `strict` makes "block" the default.
27
30
  */
28
31
  onUnavailable?: "allow" | "block";
29
- /** a request Toll does not read (another chain, a contract deploy): "allow" (default) or "block" */
32
+ /** a request Toll does not read (another chain, a contract deploy): "allow" (default) or "block"; `strict`: "block" */
30
33
  onUnsupported?: "allow" | "block";
34
+ /**
35
+ * The strict preset: the defaults become onWarn "block", onUnavailable "block" and onUnsupported "block".
36
+ * Any of those three passed explicitly (not undefined) still wins over the preset.
37
+ */
38
+ strict?: boolean;
31
39
  /** every decision, with Toll's answer; errors thrown here are caught and logged */
32
40
  onReport?: (report: GuardReport) => void | Promise<void>;
33
41
  /** where WARN / unavailable lines go (default console.warn); false = silent */
@@ -36,11 +44,28 @@ export interface GuardOptions extends CheckOptions {
36
44
  agentId?: number | string | ((url: string) => number | string | undefined | null);
37
45
  /**
38
46
  * An EIP-7702 authorization (a transaction's authorizationList, signAuthorization / authorize): it sets the
39
- * wallet's code to another contract, which can then move everything the wallet holds. Toll does not read one yet.
40
- * "block" (default): TollBlockedError, nothing is signed. "allow": let it through unchecked (logged, reported).
47
+ * wallet's code to another contract, which can then move everything the wallet holds.
48
+ * "check" (default since 0.3.0): Toll reads it and decides (a known, verified delegate comes back WARN medium and
49
+ * passes under onWarn "block-high"; an unknown one comes back BLOCK). It fails closed: when Toll cannot read it
50
+ * (unreachable, another chain, a request it refuses) it is blocked, whatever onUnavailable / onUnsupported say.
51
+ * "block": TollBlockedError, nothing is signed, Toll is not asked. "allow": let it through unchecked (logged, reported).
41
52
  */
42
- onAuthorization?: "block" | "allow";
53
+ onAuthorization?: "check" | "block" | "allow";
54
+ /** the site asking for a signature (a URL or host name), sent with messages so Toll can match a sign-in's domain */
55
+ origin?: string;
43
56
  }
57
+ /** the effective policy: explicit options win, then the strict preset, then the defaults */
58
+ export declare function resolvePolicy(o?: GuardOptions): {
59
+ onWarn: WarnPolicy;
60
+ onUnavailable: "allow" | "block";
61
+ onUnsupported: "allow" | "block";
62
+ };
63
+ /** true when any warning is high or critical */
64
+ export declare const hasHighWarning: (a: RiskAssessment) => boolean;
65
+ /** true for a signature request (typed data, a message, a signing RPC) rather than a transaction or an x402 payment */
66
+ export declare const isSignature: (input: CheckInput | null | undefined) => boolean;
67
+ /** true for eth_sign: it signs any 32 bytes (a transaction hash included), so it fails closed when Toll cannot answer */
68
+ export declare const isEthSign: (input: CheckInput | null | undefined) => boolean;
44
69
  /** why Toll would not read this request, or null when it does */
45
70
  export declare function unsupportedWhy(input: CheckInput): string | null;
46
71
  /**
@@ -54,5 +79,15 @@ export declare function enforce(kind: GuardKind, build: () => CheckInput | Promi
54
79
  * disagree): reported as a BLOCK with one critical warning, then TollBlockedError. Nothing is sent to Toll.
55
80
  */
56
81
  export declare function refuse(kind: GuardKind, input: CheckInput | null, code: string, message: string, o?: GuardOptions): Promise<never>;
57
- /** the onAuthorization policy for an EIP-7702 authorization: resolves when it may go ahead, else throws */
58
- export declare function gateAuthorization(kind: GuardKind, where: string, input: CheckInput | null, o?: GuardOptions): Promise<void>;
82
+ /**
83
+ * The onAuthorization policy for an EIP-7702 authorization. Resolves true when Toll checked the whole request (the
84
+ * "check" default: the caller asks nothing more), false when "allow" let it through unchecked (the caller still checks
85
+ * the rest of the request); throws when it is blocked. `input` is the request with its authorizationList, or a function
86
+ * that builds it; under "check", null is blocked (nothing to send).
87
+ */
88
+ export declare function gateAuthorization(kind: GuardKind, where: string, input: CheckInput | null | (() => CheckInput | Promise<CheckInput>), o?: GuardOptions): Promise<boolean>;
89
+ /**
90
+ * A batch (EIP-5792 sendCalls, wallet_sendCalls, a smart account's calls): each call is checked as its own transaction
91
+ * from the batch's sender, in order; the first one blocked stops the batch. Reports carry kind "calls".
92
+ */
93
+ export declare function enforceCalls(build: () => CheckInput[] | Promise<CheckInput[]>, o?: GuardOptions): Promise<GuardReport[]>;
package/dist/policy.js CHANGED
@@ -1,4 +1,16 @@
1
1
  import { TX_CHAIN_ID, TollBlockedError, TollUnavailableError, checkTx, chainIdOf, paidUrlOf, } from "./check.js";
2
+ /** the effective policy: explicit options win, then the strict preset, then the defaults */
3
+ export function resolvePolicy(o = {}) {
4
+ const strict = o.strict === true;
5
+ return {
6
+ onWarn: o.onWarn ?? (strict ? "block" : "block-high"),
7
+ onUnavailable: o.onUnavailable ?? (strict ? "block" : "allow"),
8
+ onUnsupported: o.onUnsupported ?? (strict ? "block" : "allow"),
9
+ };
10
+ }
11
+ const HIGH = new Set(["high", "critical"]);
12
+ /** true when any warning is high or critical */
13
+ export const hasHighWarning = (a) => (a.warnings ?? []).some((w) => HIGH.has(String(w?.severity).toLowerCase()));
2
14
  const say = (o, msg) => {
3
15
  if (o.logger === false)
4
16
  return;
@@ -19,9 +31,49 @@ async function report(o, r) {
19
31
  say(o, `onReport threw: ${e?.message ?? e}`);
20
32
  }
21
33
  }
34
+ const SIGN_RPC = new Set(["personal_sign", "eth_sign", "eth_signTypedData_v4"]);
35
+ /** the typed data a request signs: its typedData, or an eth_signTypedData_v4 request's params[1] (JSON or object) */
36
+ function typedOf(input) {
37
+ if (input.typedData)
38
+ return input.typedData;
39
+ if (input.rpc?.method !== "eth_signTypedData_v4")
40
+ return null;
41
+ let td = input.rpc.params?.[1];
42
+ if (typeof td === "string") {
43
+ try {
44
+ td = JSON.parse(td);
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ return td && typeof td === "object" ? td : null;
51
+ }
52
+ /** true for a signature request (typed data, a message, a signing RPC) rather than a transaction or an x402 payment */
53
+ export const isSignature = (input) => !!input && !input.x402 && (!!input.typedData || !!input.message || SIGN_RPC.has(String(input.rpc?.method)));
54
+ /** true for eth_sign: it signs any 32 bytes (a transaction hash included), so it fails closed when Toll cannot answer */
55
+ export const isEthSign = (input) => input?.message?.kind === "eth_sign" || input?.rpc?.method === "eth_sign";
56
+ /** the other chain a typed-data signature names (its domain chainId), or null (Robinhood Chain, none, or an x402 payment) */
57
+ function otherChainSignature(input) {
58
+ if (input.x402)
59
+ return null;
60
+ const c = typedOf(input)?.domain?.chainId;
61
+ const cid = c === undefined || c === null ? null : chainIdOf(c);
62
+ return cid !== null && cid !== TX_CHAIN_ID ? cid : null;
63
+ }
64
+ /** Toll's 400 for a request on another chain ("... chainId N is not read ...") */
65
+ const OTHER_CHAIN_400 = /chainId \d+ is not read/;
22
66
  /** why Toll would not read this request, or null when it does */
23
67
  export function unsupportedWhy(input) {
24
68
  const tx = input.to !== undefined || input.data !== undefined;
69
+ const auths = !!input.authorizationList && input.authorizationList.length > 0;
70
+ if (!tx && auths) {
71
+ const cid = input.chainId === undefined ? null : chainIdOf(input.chainId);
72
+ if (input.chainId !== undefined && cid === null)
73
+ return "the chain id could not be read";
74
+ if (cid !== null && cid !== TX_CHAIN_ID)
75
+ return `chain ${cid} is not read (Robinhood Chain ${TX_CHAIN_ID} only for now)`;
76
+ }
25
77
  if (tx) {
26
78
  if (input.to === undefined || input.to === null)
27
79
  return "a contract deployment (no `to`) is not read";
@@ -31,13 +83,10 @@ export function unsupportedWhy(input) {
31
83
  if (cid !== null && cid !== TX_CHAIN_ID)
32
84
  return `chain ${cid} is not read (Robinhood Chain ${TX_CHAIN_ID} only for now)`;
33
85
  }
34
- if (input.typedData && !input.x402) {
35
- const c = input.typedData.domain?.chainId;
36
- const cid = c === undefined || c === null ? null : chainIdOf(c);
37
- if (cid !== null && cid !== TX_CHAIN_ID)
38
- return `a signature for chain ${cid} is not read (Robinhood Chain ${TX_CHAIN_ID} only for now)`;
39
- }
40
- if (!tx && !input.typedData && !input.x402)
86
+ const other = otherChainSignature(input);
87
+ if (other !== null)
88
+ return `a signature for chain ${other} is not read (Robinhood Chain ${TX_CHAIN_ID} only for now)`;
89
+ if (!tx && !auths && !input.typedData && !input.x402 && !input.message && !input.rpc && !input.userOp)
41
90
  return "nothing to check";
42
91
  return null;
43
92
  }
@@ -56,12 +105,16 @@ export async function enforce(kind, build, o = {}) {
56
105
  return unavailable(kind, null, err, o);
57
106
  }
58
107
  const why = unsupportedWhy(input);
108
+ // a signature for another chain is not "unsupported -> allow": an off-chain signature Toll cannot read is refused
109
+ if (why && isSignature(input) && otherChainSignature(input) !== null && o.onUnsupported !== "allow") {
110
+ return refuse(kind, input, "signature-other-chain", `Not signed: ${why}. A signature Toll cannot read could hand over anything; onUnsupported: "allow" lets it through unchecked.`, o);
111
+ }
59
112
  if (why) {
60
- const blocked = o.onUnsupported === "block";
113
+ const blocked = resolvePolicy(o).onUnsupported === "block";
61
114
  const r = { kind, input, assessment: null, decision: blocked ? "blocked" : "allowed", basis: "unsupported", paidUrl: null };
62
115
  await report(o, r);
63
116
  if (blocked)
64
- throw new TollUnavailableError(`Not checked by Toll: ${why}. onUnsupported is "block".`, { reason: "unsupported" });
117
+ throw new TollUnavailableError(`Not checked by Toll: ${why}. onUnsupported is "block"${o.strict === true && o.onUnsupported === undefined ? " (strict)" : ""}.`, { reason: "unsupported" });
65
118
  return r;
66
119
  }
67
120
  let a;
@@ -70,6 +123,10 @@ export async function enforce(kind, build, o = {}) {
70
123
  }
71
124
  catch (e) {
72
125
  const err = e instanceof TollUnavailableError ? e : new TollUnavailableError(`Toll's check failed: ${e?.message ?? e}`, { reason: "network", cause: e });
126
+ // Toll's 400 for a signature on another chain: blocked, never "unsupported -> allow"
127
+ if (err.reason === "bad-request" && isSignature(input) && OTHER_CHAIN_400.test(err.message) && o.onUnsupported !== "allow") {
128
+ return refuse(kind, input, "signature-other-chain", `Not signed: Toll does not read a signature for another chain (${err.message}). onUnsupported: "allow" lets it through unchecked.`, o);
129
+ }
73
130
  return unavailable(kind, input, err, o);
74
131
  }
75
132
  const paidUrl = paidUrlOf(a);
@@ -90,8 +147,8 @@ export async function enforce(kind, build, o = {}) {
90
147
  throw new TollBlockedError(a, "BLOCK");
91
148
  }
92
149
  // WARN, and anything that is not one of the three actions: never treated as an ALLOW
93
- const p = o.onWarn ?? "allow";
94
- const ok = p === "allow" ? true : p === "block" ? false : (await p(a)) === true;
150
+ const p = resolvePolicy(o).onWarn;
151
+ const ok = p === "allow" ? true : p === "block" ? false : p === "block-high" ? !hasHighWarning(a) : (await p(a)) === true;
95
152
  if (ok) {
96
153
  say(o, `WARN (${kind}), going ahead: ${a.warnings.map((w) => `${w.code}: ${w.message}`).join(" | ")}${paidUrl ? ` Paid, no daily limit: ${paidUrl}` : ""}`);
97
154
  return done("allowed", "WARN");
@@ -100,7 +157,8 @@ export async function enforce(kind, build, o = {}) {
100
157
  throw new TollBlockedError(a, "WARN");
101
158
  }
102
159
  async function unavailable(kind, input, err, o) {
103
- const blocked = o.onUnavailable === "block";
160
+ // eth_sign signs any 32 bytes (a transaction hash included): it always fails closed
161
+ const blocked = resolvePolicy(o).onUnavailable === "block" || isEthSign(input);
104
162
  const r = { kind, input, assessment: null, decision: blocked ? "blocked" : "allowed", basis: "unavailable", paidUrl: err.paidUrl, error: err };
105
163
  await report(o, r);
106
164
  if (blocked)
@@ -117,12 +175,53 @@ export async function refuse(kind, input, code, message, o = {}) {
117
175
  await report(o, { kind, input, assessment: a, decision: "blocked", basis: "BLOCK", paidUrl: null });
118
176
  throw new TollBlockedError(a, "BLOCK");
119
177
  }
120
- /** the onAuthorization policy for an EIP-7702 authorization: resolves when it may go ahead, else throws */
178
+ /**
179
+ * The onAuthorization policy for an EIP-7702 authorization. Resolves true when Toll checked the whole request (the
180
+ * "check" default: the caller asks nothing more), false when "allow" let it through unchecked (the caller still checks
181
+ * the rest of the request); throws when it is blocked. `input` is the request with its authorizationList, or a function
182
+ * that builds it; under "check", null is blocked (nothing to send).
183
+ */
121
184
  export async function gateAuthorization(kind, where, input, o = {}) {
122
- if (o.onAuthorization === "allow") {
185
+ const p = o.onAuthorization ?? "check";
186
+ const shown = typeof input === "function" ? null : input;
187
+ if (p === "allow") {
123
188
  say(o, `EIP-7702 authorization (${where}) NOT checked, going ahead (onAuthorization "allow")`);
124
- await report(o, { kind, input, assessment: null, decision: "allowed", basis: "unsupported", paidUrl: null });
125
- return;
189
+ await report(o, { kind, input: shown, assessment: null, decision: "allowed", basis: "unsupported", paidUrl: null });
190
+ return false;
191
+ }
192
+ const what = `${where} carries an EIP-7702 authorization, which sets this wallet's code to another contract that can then move everything it holds.`;
193
+ if (p === "block" || input === null) {
194
+ return refuse(kind, shown, "eip7702-authorization", `${what} ${p === "block" ? 'onAuthorization is "block"' : "It could not be read for Toll's check"}; onAuthorization: "allow" lets it through unchecked.`, o);
195
+ }
196
+ const build = typeof input === "function" ? input : () => input;
197
+ try {
198
+ // fail closed: an authorization Toll could not read is not signed
199
+ await enforce(kind, build, { ...o, onUnavailable: "block", onUnsupported: "block" });
200
+ }
201
+ catch (e) {
202
+ if (!(e instanceof TollUnavailableError))
203
+ throw e;
204
+ return refuse(kind, null, "eip7702-authorization", `${what} Toll could not read it (${e.message}), so it is not signed; onAuthorization: "allow" lets it through unchecked.`, o);
205
+ }
206
+ return true;
207
+ }
208
+ /**
209
+ * A batch (EIP-5792 sendCalls, wallet_sendCalls, a smart account's calls): each call is checked as its own transaction
210
+ * from the batch's sender, in order; the first one blocked stops the batch. Reports carry kind "calls".
211
+ */
212
+ export async function enforceCalls(build, o = {}) {
213
+ let calls;
214
+ try {
215
+ calls = await build();
216
+ }
217
+ catch (e) {
218
+ const err = e instanceof TollUnavailableError ? e : new TollUnavailableError(`The batch could not be read for Toll's check: ${e?.message ?? e}`, { reason: "encode", cause: e });
219
+ return [await unavailable("calls", null, err, o)];
126
220
  }
127
- await refuse(kind, input, "eip7702-authorization", `${where} carries an EIP-7702 authorization, which sets this wallet's code to another contract that can then move everything it holds. Toll does not read authorizations yet; onAuthorization: "allow" lets it through unchecked.`, o);
221
+ if (!calls.length)
222
+ return [await enforce("calls", () => ({}), o)];
223
+ const out = [];
224
+ for (const c of calls)
225
+ out.push(await enforce("calls", () => c, o));
226
+ return out;
128
227
  }
package/dist/wallets.d.ts CHANGED
@@ -6,8 +6,8 @@ import { type Hex } from "./check.js";
6
6
  import { type GuardOptions } from "./policy.js";
7
7
  export interface ViemGuardOptions extends GuardOptions {
8
8
  /**
9
- * Encodes writeContract calldata. Default: viem's own encodeFunctionData, loaded on first use (viem is installed
10
- * wherever a viem WalletClient is).
9
+ * Encodes writeContract (and contract-call batch) calldata. Default: viem's own encodeFunctionData, loaded on first
10
+ * use (viem is installed wherever a viem client is).
11
11
  */
12
12
  encodeFunctionData?: (args: {
13
13
  abi: unknown;
@@ -16,27 +16,36 @@ export interface ViemGuardOptions extends GuardOptions {
16
16
  }) => Hex | string;
17
17
  }
18
18
  /**
19
- * Wraps a viem WalletClient: sendTransaction, writeContract, signTransaction and signTypedData ask Toll first. An
20
- * EIP-7702 authorization (an authorizationList on any of them, or signAuthorization) goes to onAuthorization
21
- * (default: blocked). Wrap last: `client.extend(...)` on the wrapped client returns an unguarded client, and the
22
- * account's own sign methods (client.account.sign*) are not wrapped.
19
+ * Wraps a viem WalletClient. Asks Toll first: sendTransaction, writeContract, signTransaction, signTypedData,
20
+ * signMessage, sendCalls (EIP-5792, each call), signAuthorization, and request() for eth_sendTransaction,
21
+ * eth_signTransaction, personal_sign, eth_sign, eth_signTypedData_v4 and wallet_sendCalls. An EIP-7702 authorization
22
+ * goes to onAuthorization (default "check"). Wrap last: `client.extend(...)` on the wrapped client returns an unguarded
23
+ * client, and the account's own sign methods (client.account.sign*) are not wrapped.
23
24
  */
24
25
  export declare function guardViem<T extends object>(client: T, opts?: ViemGuardOptions): T;
26
+ /**
27
+ * Wraps a viem bundler client (account abstraction): sendUserOperation and prepareUserOperation ask Toll first.
28
+ * `calls` are checked one by one as transactions from the smart account's address; a ready `callData` is sent as a
29
+ * user operation with the account's EntryPoint. An EIP-7702 `authorization` goes to onAuthorization.
30
+ */
31
+ export declare function guardSmartAccount<T extends object>(client: T, opts?: ViemGuardOptions): T;
25
32
  /** the EIP-712 primary type: the one type no other type refers to (as ethers' TypedDataEncoder.getPrimaryType) */
26
33
  export declare function primaryTypeOf(types: Record<string, Array<{
27
34
  name: string;
28
35
  type: string;
29
36
  }>>): string;
30
37
  /**
31
- * Wraps an ethers v6 Signer: sendTransaction, signTransaction and signTypedData ask Toll first. An EIP-7702
32
- * authorization (a transaction's authorizationList, or authorize()) goes to onAuthorization (default: blocked).
33
- * `signer.connect(provider)` returns an unguarded signer: wrap the connected one.
38
+ * Wraps an ethers v6 Signer: sendTransaction, signTransaction, signTypedData, signMessage and authorize() ask Toll
39
+ * first. An EIP-7702 authorization (a transaction's authorizationList, or authorize()) goes to onAuthorization
40
+ * (default "check"). `signer.connect(provider)` returns an unguarded signer: wrap the connected one.
34
41
  */
35
42
  export declare function guardEthers<T extends object>(signer: T, opts?: GuardOptions): T;
36
43
  /** true for a viem WalletClient (has `extend` and `request`), false for an ethers Signer */
37
44
  export declare const isViemClient: (x: unknown) => boolean;
45
+ /** true for a viem bundler client (account abstraction): it has sendUserOperation */
46
+ export declare const isBundlerClient: (x: unknown) => boolean;
38
47
  /**
39
- * Puts Toll's pre-sign check in front of a wallet. A viem WalletClient or an ethers v6 Signer; the wrapped wallet has
40
- * the same type and methods.
48
+ * Puts Toll's pre-sign check in front of a wallet: a viem bundler client (smart account), a viem WalletClient or an
49
+ * ethers v6 Signer; the wrapped wallet has the same type and methods.
41
50
  */
42
51
  export declare function guard<T extends object>(wallet: T, opts?: ViemGuardOptions): T;
package/dist/wallets.js CHANGED
@@ -2,8 +2,8 @@
2
2
  * The wallet wrappers. Duck-typed: viem and ethers are optional peer dependencies, never imported at load time.
3
3
  * The wrapper is a Proxy: every other method and field of the client passes through unchanged.
4
4
  */
5
- import { isAddress } from "./check.js";
6
- import { enforce, gateAuthorization } from "./policy.js";
5
+ import { chainIdOf, isAddress } from "./check.js";
6
+ import { enforce, enforceCalls, gateAuthorization } from "./policy.js";
7
7
  /** wraps an object's methods; everything else passes through, bound to the original */
8
8
  function proxy(target, wrapped) {
9
9
  return new Proxy(target, {
@@ -26,6 +26,32 @@ const addrOf = (account) => {
26
26
  const a = account?.address;
27
27
  return isAddress(a) ? a : undefined;
28
28
  };
29
+ /** a chain id as a number when it can be read, else as given (the check then calls it unreadable) */
30
+ const chainOut = (v) => (v === undefined || v === null ? undefined : (chainIdOf(v) ?? v));
31
+ const HEXDIGITS = "0123456789abcdef";
32
+ const bytesHex = (b) => {
33
+ let s = "0x";
34
+ for (const x of b)
35
+ s += HEXDIGITS[x >> 4] + HEXDIGITS[x & 15];
36
+ return s;
37
+ };
38
+ /** a text message as the bytes a wallet signs (UTF-8), hex */
39
+ const utf8Hex = (s) => bytesHex(new TextEncoder().encode(s));
40
+ /** the request without its authorizationList (what is checked when onAuthorization "allow" let the list through) */
41
+ const withoutAuths = (i) => {
42
+ const { authorizationList: _drop, ...rest } = i;
43
+ return rest;
44
+ };
45
+ /**
46
+ * One transaction: when it carries an authorizationList, onAuthorization decides first ("check": one Toll check of the
47
+ * whole transaction with its list, fail-closed; "block": refused; "allow": the list is let through and the rest is
48
+ * checked as usual).
49
+ */
50
+ async function gateTx(where, build, auths, o) {
51
+ if (auths && (await gateAuthorization("transaction", where, build, o)))
52
+ return;
53
+ await enforce("transaction", async () => withoutAuths(await build()), o);
54
+ }
29
55
  /* ------------------------------------------------------------------ viem */
30
56
  let viemEncode = null;
31
57
  async function loadViemEncode() {
@@ -38,11 +64,38 @@ async function loadViemEncode() {
38
64
  viemEncode = m.encodeFunctionData;
39
65
  return viemEncode;
40
66
  }
67
+ /** a batch's calls as transactions: {to, data?, value?} or a contract call {to | address, abi, functionName, args} */
68
+ async function callsOf(calls, chainId, from, o) {
69
+ if (!Array.isArray(calls))
70
+ throw new Error("calls must be an array");
71
+ const out = [];
72
+ for (const raw of calls) {
73
+ const k = (raw ?? {});
74
+ let data = typeof k.data === "string" ? k.data : "0x";
75
+ if (k.abi !== undefined && typeof k.functionName === "string") {
76
+ const enc = o.encodeFunctionData ?? (await loadViemEncode());
77
+ data = String(enc({ abi: k.abi, functionName: k.functionName, args: k.args }));
78
+ if (typeof k.dataSuffix === "string")
79
+ data += k.dataSuffix.replace(/^0x/, "");
80
+ }
81
+ out.push({ chainId: chainOut(chainId), from, to: k.to ?? k.address ?? undefined, data, value: k.value ?? 0n });
82
+ }
83
+ return out;
84
+ }
85
+ /** a viem / EIP-7702 authorization request as Toll reads it: one entry, the signer, the chain */
86
+ function authInput(a, chainId, from) {
87
+ const address = a?.contractAddress ?? a?.address;
88
+ const entry = { address, chainId: chainOut(a?.chainId ?? chainId) };
89
+ if (a?.nonce !== undefined && a?.nonce !== null)
90
+ entry.nonce = a.nonce;
91
+ return { ...(chainId !== undefined && chainId !== null ? { chainId: chainOut(chainId) } : {}), from, authorizationList: [entry] };
92
+ }
41
93
  /**
42
- * Wraps a viem WalletClient: sendTransaction, writeContract, signTransaction and signTypedData ask Toll first. An
43
- * EIP-7702 authorization (an authorizationList on any of them, or signAuthorization) goes to onAuthorization
44
- * (default: blocked). Wrap last: `client.extend(...)` on the wrapped client returns an unguarded client, and the
45
- * account's own sign methods (client.account.sign*) are not wrapped.
94
+ * Wraps a viem WalletClient. Asks Toll first: sendTransaction, writeContract, signTransaction, signTypedData,
95
+ * signMessage, sendCalls (EIP-5792, each call), signAuthorization, and request() for eth_sendTransaction,
96
+ * eth_signTransaction, personal_sign, eth_sign, eth_signTypedData_v4 and wallet_sendCalls. An EIP-7702 authorization
97
+ * goes to onAuthorization (default "check"). Wrap last: `client.extend(...)` on the wrapped client returns an unguarded
98
+ * client, and the account's own sign methods (client.account.sign*) are not wrapped.
46
99
  */
47
100
  export function guardViem(client, opts = {}) {
48
101
  const c = client;
@@ -54,50 +107,164 @@ export function guardViem(client, opts = {}) {
54
107
  to: args?.to ?? undefined,
55
108
  data: data ?? "0x",
56
109
  value: args?.value ?? 0n,
110
+ ...(hasAuthorizations(args) ? { authorizationList: args.authorizationList } : {}),
57
111
  });
112
+ const origin = opts.origin !== undefined ? { origin: opts.origin } : {};
58
113
  const wrapped = {};
59
- if (typeof c.sendTransaction === "function") {
60
- wrapped.sendTransaction = async (args, ...rest) => {
114
+ for (const m of ["sendTransaction", "signTransaction"]) {
115
+ if (typeof c[m] !== "function")
116
+ continue;
117
+ wrapped[m] = async (args, ...rest) => {
61
118
  // a deployment (no `to`) goes to the unsupported policy
62
- if (hasAuthorizations(args))
63
- await gateAuthorization("transaction", "sendTransaction", txInput(args, args?.data), opts);
64
- await enforce("transaction", () => txInput(args, args?.data), opts);
65
- return c.sendTransaction(args, ...rest);
66
- };
67
- }
68
- if (typeof c.signTransaction === "function") {
69
- wrapped.signTransaction = async (args, ...rest) => {
70
- if (hasAuthorizations(args))
71
- await gateAuthorization("transaction", "signTransaction", txInput(args, args?.data), opts);
72
- await enforce("transaction", () => txInput(args, args?.data), opts);
73
- return c.signTransaction(args, ...rest);
119
+ await gateTx(m, () => txInput(args, args?.data), hasAuthorizations(args), opts);
120
+ return c[m](args, ...rest);
74
121
  };
75
122
  }
76
123
  if (typeof c.writeContract === "function") {
77
124
  wrapped.writeContract = async (args, ...rest) => {
78
- if (hasAuthorizations(args))
79
- await gateAuthorization("transaction", "writeContract", null, opts);
80
- await enforce("transaction", async () => {
125
+ await gateTx("writeContract", async () => {
81
126
  const enc = opts.encodeFunctionData ?? (await loadViemEncode());
82
127
  let data = String(enc({ abi: args.abi, functionName: args.functionName, args: args.args }));
83
128
  if (typeof args.dataSuffix === "string")
84
129
  data += args.dataSuffix.replace(/^0x/, "");
85
130
  return txInput({ ...args, to: args.address }, data);
86
- }, opts);
131
+ }, hasAuthorizations(args), opts);
87
132
  return c.writeContract(args, ...rest);
88
133
  };
89
134
  }
90
135
  if (typeof c.signTypedData === "function") {
91
136
  wrapped.signTypedData = async (args, ...rest) => {
92
137
  const td = { domain: args?.domain ?? {}, types: args?.types ?? {}, primaryType: args?.primaryType, message: args?.message ?? {} };
93
- await enforce("typedData", () => ({ typedData: td }), opts);
138
+ await enforce("typedData", () => ({ typedData: td, ...origin }), opts);
94
139
  return c.signTypedData(args, ...rest);
95
140
  };
96
141
  }
142
+ if (typeof c.signMessage === "function") {
143
+ wrapped.signMessage = async (args, ...rest) => {
144
+ await enforce("message", () => {
145
+ const m = args?.message;
146
+ const raw = m && typeof m === "object" ? m.raw : undefined;
147
+ const data = typeof m === "string" ? utf8Hex(m) : typeof raw === "string" ? raw : raw instanceof Uint8Array ? bytesHex(raw) : null;
148
+ if (data === null)
149
+ throw new Error("signMessage: message must be a string or {raw}");
150
+ const from = fromOf(args);
151
+ return { message: { kind: "personal_sign", data, ...(from ? { from } : {}) }, ...origin };
152
+ }, opts);
153
+ return c.signMessage(args, ...rest);
154
+ };
155
+ }
156
+ if (typeof c.sendCalls === "function") {
157
+ wrapped.sendCalls = async (args, ...rest) => {
158
+ await enforceCalls(() => callsOf(args?.calls, chainOf(args), fromOf(args), opts), opts);
159
+ return c.sendCalls(args, ...rest);
160
+ };
161
+ }
97
162
  if (typeof c.signAuthorization === "function") {
98
- wrapped.signAuthorization = async (...args) => {
99
- await gateAuthorization("transaction", "signAuthorization", null, opts);
100
- return c.signAuthorization(...args);
163
+ wrapped.signAuthorization = async (args, ...rest) => {
164
+ await gateAuthorization("transaction", "signAuthorization", () => authInput(args, c.chain?.id, fromOf(args)), opts);
165
+ return c.signAuthorization(args, ...rest);
166
+ };
167
+ }
168
+ if (typeof c.request === "function") {
169
+ wrapped.request = async (args, ...rest) => {
170
+ await checkRequest(args, c, fromOf({}), opts);
171
+ return c.request(args, ...rest);
172
+ };
173
+ }
174
+ return proxy(client, wrapped);
175
+ }
176
+ /** the wallet RPC methods that sign: each asks Toll first; every other method passes through unchecked */
177
+ async function checkRequest(args, c, from, o) {
178
+ const method = args?.method;
179
+ const params = Array.isArray(args?.params) ? args.params : [];
180
+ const origin = o.origin !== undefined ? { origin: o.origin } : {};
181
+ switch (method) {
182
+ case "personal_sign":
183
+ case "eth_sign":
184
+ await enforce("message", () => ({ rpc: { method, params }, ...origin }), o);
185
+ return;
186
+ case "eth_signTypedData_v4":
187
+ await enforce("typedData", () => ({ rpc: { method, params }, ...origin }), o);
188
+ return;
189
+ case "eth_sendTransaction":
190
+ case "eth_signTransaction": {
191
+ const tx = (params[0] ?? {});
192
+ const build = () => ({
193
+ chainId: chainOut(tx.chainId ?? c.chain?.id),
194
+ from: isAddress(tx.from) ? tx.from : from,
195
+ to: tx.to ?? undefined,
196
+ data: typeof tx.data === "string" ? tx.data : typeof tx.input === "string" ? tx.input : "0x",
197
+ value: tx.value ?? 0,
198
+ ...(hasAuthorizations(tx) ? { authorizationList: tx.authorizationList } : {}),
199
+ });
200
+ await gateTx(method, build, hasAuthorizations(tx), o);
201
+ return;
202
+ }
203
+ case "wallet_sendCalls": {
204
+ const p = (params[0] ?? {});
205
+ await enforceCalls(() => callsOf(p.calls, p.chainId ?? c.chain?.id, isAddress(p.from) ? p.from : from, o), o);
206
+ return;
207
+ }
208
+ default:
209
+ return;
210
+ }
211
+ }
212
+ /* ------------------------------------------------------------------ ERC-4337 smart accounts (viem account abstraction) */
213
+ /** the user-operation fields passed on to Toll as given (bigints become decimal strings) */
214
+ const USEROP_FIELDS = [
215
+ "factory",
216
+ "factoryData",
217
+ "initCode",
218
+ "paymaster",
219
+ "paymasterData",
220
+ "paymasterAndData",
221
+ "paymasterVerificationGasLimit",
222
+ "paymasterPostOpGasLimit",
223
+ "callGasLimit",
224
+ "verificationGasLimit",
225
+ "preVerificationGas",
226
+ "maxFeePerGas",
227
+ "maxPriorityFeePerGas",
228
+ "signature",
229
+ ];
230
+ /**
231
+ * Wraps a viem bundler client (account abstraction): sendUserOperation and prepareUserOperation ask Toll first.
232
+ * `calls` are checked one by one as transactions from the smart account's address; a ready `callData` is sent as a
233
+ * user operation with the account's EntryPoint. An EIP-7702 `authorization` goes to onAuthorization.
234
+ */
235
+ export function guardSmartAccount(client, opts = {}) {
236
+ const c = client;
237
+ const check = async (where, args) => {
238
+ const acct = args?.account ?? c.account;
239
+ const sender = addrOf(acct);
240
+ const chainId = args?.chain?.id ?? c.chain?.id;
241
+ if (args?.authorization)
242
+ await gateAuthorization("userOp", where, () => authInput(args.authorization, chainId, sender), opts);
243
+ if (Array.isArray(args?.calls)) {
244
+ await enforceCalls(() => callsOf(args.calls, chainId, sender, opts), opts);
245
+ return;
246
+ }
247
+ await enforce("userOp", async () => {
248
+ if (typeof args?.callData !== "string")
249
+ return {};
250
+ let nonce = args.nonce;
251
+ if ((nonce === undefined || nonce === null) && typeof acct?.getNonce === "function")
252
+ nonce = await acct.getNonce();
253
+ const op = { sender: isAddress(args.sender) ? args.sender : sender, nonce: nonce ?? 0n, callData: args.callData };
254
+ for (const k of USEROP_FIELDS)
255
+ if (args[k] !== undefined && args[k] !== null)
256
+ op[k] = args[k];
257
+ const ep = acct?.entryPoint?.address ?? args.entryPointAddress;
258
+ return { userOp: op, ...(isAddress(ep) ? { entryPoint: ep } : {}) };
259
+ }, opts);
260
+ };
261
+ const wrapped = {};
262
+ for (const m of ["sendUserOperation", "prepareUserOperation"]) {
263
+ if (typeof c[m] !== "function")
264
+ continue;
265
+ wrapped[m] = async (args, ...rest) => {
266
+ await check(m, args);
267
+ return c[m](args, ...rest);
101
268
  };
102
269
  }
103
270
  return proxy(client, wrapped);
@@ -115,6 +282,10 @@ export function primaryTypeOf(types) {
115
282
  throw new Error(roots.length ? `ambiguous primary type: ${roots.join(", ")}` : "no primary type");
116
283
  return roots[0];
117
284
  }
285
+ async function ethersChainId(signer) {
286
+ const net = typeof signer.provider?.getNetwork === "function" ? await signer.provider.getNetwork() : null;
287
+ return net?.chainId ?? undefined;
288
+ }
118
289
  async function ethersTx(signer, tx) {
119
290
  let to = tx?.to;
120
291
  if (to && typeof to === "object" && typeof to.getAddress === "function")
@@ -127,10 +298,8 @@ async function ethersTx(signer, tx) {
127
298
  to = r;
128
299
  }
129
300
  let chainId = tx?.chainId;
130
- if (chainId === undefined || chainId === null) {
131
- const net = typeof signer.provider?.getNetwork === "function" ? await signer.provider.getNetwork() : null;
132
- chainId = net?.chainId ?? undefined;
133
- }
301
+ if (chainId === undefined || chainId === null)
302
+ chainId = await ethersChainId(signer);
134
303
  let from = tx?.from;
135
304
  if (from && typeof from === "object" && typeof from.getAddress === "function")
136
305
  from = await from.getAddress();
@@ -142,58 +311,74 @@ async function ethersTx(signer, tx) {
142
311
  to: (to ?? undefined),
143
312
  data: typeof tx?.data === "string" ? tx.data : "0x",
144
313
  value: tx?.value ?? 0n,
314
+ ...(hasAuthorizations(tx) ? { authorizationList: tx.authorizationList } : {}),
145
315
  };
146
316
  }
147
317
  /**
148
- * Wraps an ethers v6 Signer: sendTransaction, signTransaction and signTypedData ask Toll first. An EIP-7702
149
- * authorization (a transaction's authorizationList, or authorize()) goes to onAuthorization (default: blocked).
150
- * `signer.connect(provider)` returns an unguarded signer: wrap the connected one.
318
+ * Wraps an ethers v6 Signer: sendTransaction, signTransaction, signTypedData, signMessage and authorize() ask Toll
319
+ * first. An EIP-7702 authorization (a transaction's authorizationList, or authorize()) goes to onAuthorization
320
+ * (default "check"). `signer.connect(provider)` returns an unguarded signer: wrap the connected one.
151
321
  */
152
322
  export function guardEthers(signer, opts = {}) {
153
323
  const s = signer;
324
+ const origin = opts.origin !== undefined ? { origin: opts.origin } : {};
154
325
  const wrapped = {};
155
- if (typeof s.sendTransaction === "function") {
156
- wrapped.sendTransaction = async (tx, ...rest) => {
157
- if (hasAuthorizations(tx))
158
- await gateAuthorization("transaction", "sendTransaction", null, opts);
159
- await enforce("transaction", () => ethersTx(s, tx), opts);
160
- return s.sendTransaction(tx, ...rest);
161
- };
162
- }
163
- if (typeof s.signTransaction === "function") {
164
- wrapped.signTransaction = async (tx, ...rest) => {
165
- if (hasAuthorizations(tx))
166
- await gateAuthorization("transaction", "signTransaction", null, opts);
167
- await enforce("transaction", () => ethersTx(s, tx), opts);
168
- return s.signTransaction(tx, ...rest);
326
+ for (const m of ["sendTransaction", "signTransaction"]) {
327
+ if (typeof s[m] !== "function")
328
+ continue;
329
+ wrapped[m] = async (tx, ...rest) => {
330
+ await gateTx(m, () => ethersTx(s, tx), hasAuthorizations(tx), opts);
331
+ return s[m](tx, ...rest);
169
332
  };
170
333
  }
171
334
  if (typeof s.signTypedData === "function") {
172
335
  wrapped.signTypedData = async (domain, types, value, ...rest) => {
173
- await enforce("typedData", () => ({ typedData: { domain: domain ?? {}, types: types ?? {}, primaryType: primaryTypeOf(types ?? {}), message: value ?? {} } }), opts);
336
+ await enforce("typedData", () => ({ typedData: { domain: domain ?? {}, types: types ?? {}, primaryType: primaryTypeOf(types ?? {}), message: value ?? {} }, ...origin }), opts);
174
337
  return s.signTypedData(domain, types, value, ...rest);
175
338
  };
176
339
  }
340
+ if (typeof s.signMessage === "function") {
341
+ wrapped.signMessage = async (message, ...rest) => {
342
+ await enforce("message", async () => {
343
+ // ethers signs a string as its UTF-8 bytes, even one that looks like hex
344
+ const data = typeof message === "string" ? utf8Hex(message) : message instanceof Uint8Array ? bytesHex(message) : null;
345
+ if (data === null)
346
+ throw new Error("signMessage: message must be a string or bytes");
347
+ const from = typeof s.getAddress === "function" ? await s.getAddress() : undefined;
348
+ return { message: { kind: "personal_sign", data, ...(isAddress(from) ? { from } : {}) }, ...origin };
349
+ }, opts);
350
+ return s.signMessage(message, ...rest);
351
+ };
352
+ }
177
353
  if (typeof s.authorize === "function") {
178
- wrapped.authorize = async (...args) => {
179
- await gateAuthorization("transaction", "authorize", null, opts);
180
- return s.authorize(...args);
354
+ wrapped.authorize = async (auth, ...rest) => {
355
+ await gateAuthorization("transaction", "authorize", async () => {
356
+ const chainId = chainOut(await ethersChainId(s));
357
+ const from = typeof s.getAddress === "function" ? await s.getAddress() : undefined;
358
+ return authInput(auth ?? {}, chainId, isAddress(from) ? from : undefined);
359
+ }, opts);
360
+ return s.authorize(auth, ...rest);
181
361
  };
182
362
  }
183
363
  return proxy(signer, wrapped);
184
364
  }
185
- /* ------------------------------------------------------------------ either */
365
+ /* ------------------------------------------------------------------ any */
186
366
  /** true for a viem WalletClient (has `extend` and `request`), false for an ethers Signer */
187
367
  export const isViemClient = (x) => !!x && typeof x.extend === "function" && typeof x.request === "function";
368
+ /** true for a viem bundler client (account abstraction): it has sendUserOperation */
369
+ export const isBundlerClient = (x) => !!x && typeof x.sendUserOperation === "function";
188
370
  /**
189
- * Puts Toll's pre-sign check in front of a wallet. A viem WalletClient or an ethers v6 Signer; the wrapped wallet has
190
- * the same type and methods.
371
+ * Puts Toll's pre-sign check in front of a wallet: a viem bundler client (smart account), a viem WalletClient or an
372
+ * ethers v6 Signer; the wrapped wallet has the same type and methods.
191
373
  */
192
374
  export function guard(wallet, opts = {}) {
375
+ // a bundler client is also a viem client: it is detected first
376
+ if (isBundlerClient(wallet))
377
+ return guardSmartAccount(wallet, opts);
193
378
  if (isViemClient(wallet))
194
379
  return guardViem(wallet, opts);
195
380
  if (typeof wallet.getAddress === "function" && (typeof wallet.sendTransaction === "function" || typeof wallet.signTypedData === "function")) {
196
381
  return guardEthers(wallet, opts);
197
382
  }
198
- throw new TypeError("guard(): pass a viem WalletClient or an ethers v6 Signer (or use guardViem / guardEthers).");
383
+ throw new TypeError("guard(): pass a viem WalletClient, a viem bundler client or an ethers v6 Signer (or use guardViem / guardSmartAccount / guardEthers).");
199
384
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tollxyz/guard",
3
- "version": "0.1.2",
4
- "description": "Toll's live pre-sign check in front of an AI agent's wallet: viem, ethers and x402 fetch wrappers that ask Toll (ALLOW / WARN / BLOCK, ERC-8265) before anything is signed on Robinhood Chain. Zero runtime dependencies.",
3
+ "version": "0.3.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. Zero runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "Toll",
7
7
  "type": "module",