@tollxyz/guard 0.2.0 → 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,12 +1,26 @@
1
1
  # @tollxyz/guard
2
2
 
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
+
3
17
  > **Defaults changed in 0.2.0.** A WARN with any `high` or `critical` warning is now blocked (`onWarn: "block-high"`,
4
18
  > the new default); a WARN with only `medium`/`low` warnings still goes ahead, logged. To keep the 0.1.x behaviour,
5
19
  > pass `onWarn: "allow"`. New `strict: true` preset: WARN, unavailable and unsupported all block. Any option you pass
6
20
  > explicitly still wins over the preset (`{ strict: true, onUnavailable: "allow" }` stays fail-open).
7
21
 
8
- Toll's live pre-sign check in front of an AI agent's wallet. Before the agent signs a transaction, an EIP-712
9
- signature or an x402 payment on Robinhood Chain, the guard asks [Toll](https://t0ll.xyz/agents/x402#presign) and
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
10
24
  gets ALLOW / WARN / BLOCK with the reasons. Zero runtime dependencies, ESM only, Node >= 20.19, with types.
11
25
 
12
26
  ```sh
@@ -14,7 +28,7 @@ npm install @tollxyz/guard
14
28
  ```
15
29
 
16
30
  ```ts
17
- 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
18
32
  ```
19
33
 
20
34
  ```ts
@@ -62,23 +76,43 @@ The answer is an [ERC-8265](https://github.com/ethereum/ERCs/pull/1753) RiskAsse
62
76
  `{ action, warnings: [{ code, severity, message, "x-toll-evidence" }], scanners, "x-toll": { decoded, link, checked_at, note } }`.
63
77
  Critical = BLOCK, high or medium = WARN, low or none = ALLOW.
64
78
 
65
- Robinhood Chain (4663) only for now. A request on another chain, or a contract deployment, is not sent to Toll and
66
- goes through (`onUnsupported: "block"` to stop it instead).
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).
67
82
 
68
83
  ## API
69
84
 
70
- ### `guard(wallet, opts?)`, `guardViem(client, opts?)`, `guardEthers(signer, opts?)`
85
+ ### `guard(wallet, opts?)`, `guardViem(client, opts?)`, `guardSmartAccount(bundler, opts?)`, `guardEthers(signer, opts?)`
71
86
 
72
87
  Returns the same wallet behind a Proxy. Checked first:
73
88
 
74
89
  - viem WalletClient: `sendTransaction`, `writeContract` (calldata encoded with your viem's `encodeFunctionData`),
75
- `signTransaction`, `signTypedData`
76
- - 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.
77
106
 
78
- An EIP-7702 authorization sets the wallet's code to another contract, which can then move everything it holds. Toll
79
- does not read one yet, so the guard blocks it itself (TollBlockedError, code `eip7702-authorization`) unless
80
- `onAuthorization: "allow"`: a transaction with an `authorizationList` on any method above, viem's
81
- `signAuthorization` and ethers' `authorize`. The account's own sign methods (`client.account.sign*`) are not wrapped.
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
+ ```
82
116
 
83
117
  Everything else passes through unchanged. Wrap last: `client.extend(...)` or `signer.connect(...)` on the wrapped
84
118
  wallet returns an unguarded one. viem and ethers are optional peer dependencies, never imported at load time.
@@ -102,8 +136,12 @@ const pay = wrapFetchWithPayment(guardX402Fetch(fetch, { agentId: 123 }), accoun
102
136
 
103
137
  ### `checkTx(input, opts?)`
104
138
 
105
- The raw call. `input`: `{ chainId, from, to, data, value }` for a transaction, `{ typedData }` for an EIP-712
106
- request, `{ x402, agentId?, payment? }` for an x402 payment, or several together. Returns the RiskAssessment. When a
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
107
145
  coin is still being read, Toll answers WARN `coin-not-read` with `Retry-After`: `checkTx` asks once more if the wait
108
146
  is at most `maxRetryWaitMs` (default 10 s); a coin still not read stays WARN, never ALLOW. Throws
109
147
  `TollUnavailableError` when Toll cannot answer.
@@ -116,8 +154,9 @@ is at most `maxRetryWaitMs` (default 10 s); a coin still not read stays WARN, ne
116
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) |
117
155
  | `onBlock` | `"throw"` | `"throw"` (TollBlockedError), or `(assessment) => boolean`, e.g. ask a human; true lets it through |
118
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) |
119
- | `onUnsupported` | `"allow"` (`strict`: `"block"`) | another chain, a deployment: `"allow"` or `"block"` |
120
- | `onAuthorization` | `"block"` | an EIP-7702 authorization: `"block"` (TollBlockedError) or `"allow"` (unchecked, logged) |
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 |
121
160
  | `onReport` | none | `(report) => void`: every decision, with `assessment`, `decision`, `basis`, `paidUrl` |
122
161
  | `logger` | `console.warn` | where WARN and unavailable lines go; `false` = silent |
123
162
  | `timeoutMs` | `15000` | one request to Toll |
@@ -134,6 +173,9 @@ line and an `onReport` call with `basis: "unavailable"`. The agent must never ge
134
173
  free route is rate limited per IP (a busy agent will see 429s). If an unchecked signature is worse for you than a
135
174
  stalled agent, set `onUnavailable: "block"` (or `strict: true`, which also blocks every WARN and unsupported request).
136
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"`.
178
+
137
179
  ## Free limit and the paid route
138
180
 
139
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
package/dist/check.d.ts CHANGED
@@ -61,8 +61,22 @@ export interface TypedDataLike {
61
61
  primaryType: string;
62
62
  message: Record<string, unknown>;
63
63
  }
64
+ /** A personal_sign / eth_sign message: data is hex (the bytes signed) or text. */
65
+ export interface SignMessageLike {
66
+ kind: "personal_sign" | "eth_sign";
67
+ data: string;
68
+ origin?: string;
69
+ from?: string;
70
+ }
71
+ /** A wallet RPC request Toll reads as the body it stands for: personal_sign, eth_sign, eth_signTypedData_v4, eth_sendTransaction. */
72
+ export interface RpcLike {
73
+ method: string;
74
+ params?: readonly unknown[];
75
+ }
64
76
  /**
65
- * What to check. A transaction ({chainId, from, to, data, value}), an EIP-712 request ({typedData}), an x402 payment
77
+ * What to check. A transaction ({chainId, from, to, data, value}, with an EIP-7702 authorizationList or not), an
78
+ * authorization list alone ({chainId, from, authorizationList}), an EIP-712 request ({typedData}), a message
79
+ * ({message}), a wallet request ({rpc}), an ERC-4337 user operation ({userOp, entryPoint?}), an x402 payment
66
80
  * ({x402, agentId?, payment?}), or several together.
67
81
  */
68
82
  export interface CheckInput {
@@ -71,7 +85,17 @@ export interface CheckInput {
71
85
  to?: string;
72
86
  data?: string;
73
87
  value?: Numberish;
88
+ /** EIP-7702 authorizations: with to/data a type-4 transaction, alone a signAuthorization */
89
+ authorizationList?: readonly unknown[];
74
90
  typedData?: TypedDataLike;
91
+ message?: SignMessageLike;
92
+ rpc?: RpcLike;
93
+ /** an ERC-4337 user operation (v0.6, v0.7 packed or the RPC form) */
94
+ userOp?: Record<string, unknown>;
95
+ /** the user operation's EntryPoint */
96
+ entryPoint?: string;
97
+ /** the site asking for the signature (a URL or host name) */
98
+ origin?: string;
75
99
  /** the 402's PaymentRequired, or one of its accepts entries */
76
100
  x402?: Record<string, unknown>;
77
101
  /** the seller's ERC-8004 agent id, when known */
package/dist/check.js CHANGED
@@ -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, resolvePolicy, hasHighWarning } 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, resolvePolicy, hasHighWarning } 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
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) */
@@ -44,10 +44,15 @@ export interface GuardOptions extends CheckOptions {
44
44
  agentId?: number | string | ((url: string) => number | string | undefined | null);
45
45
  /**
46
46
  * An EIP-7702 authorization (a transaction's authorizationList, signAuthorization / authorize): it sets the
47
- * wallet's code to another contract, which can then move everything the wallet holds. Toll does not read one yet.
48
- * "block" (default): TollBlockedError, nothing is signed. "allow": let it through unchecked (logged, reported).
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).
49
52
  */
50
- 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;
51
56
  }
52
57
  /** the effective policy: explicit options win, then the strict preset, then the defaults */
53
58
  export declare function resolvePolicy(o?: GuardOptions): {
@@ -57,6 +62,10 @@ export declare function resolvePolicy(o?: GuardOptions): {
57
62
  };
58
63
  /** true when any warning is high or critical */
59
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;
60
69
  /** why Toll would not read this request, or null when it does */
61
70
  export declare function unsupportedWhy(input: CheckInput): string | null;
62
71
  /**
@@ -70,5 +79,15 @@ export declare function enforce(kind: GuardKind, build: () => CheckInput | Promi
70
79
  * disagree): reported as a BLOCK with one critical warning, then TollBlockedError. Nothing is sent to Toll.
71
80
  */
72
81
  export declare function refuse(kind: GuardKind, input: CheckInput | null, code: string, message: string, o?: GuardOptions): Promise<never>;
73
- /** the onAuthorization policy for an EIP-7702 authorization: resolves when it may go ahead, else throws */
74
- export declare function gateAuthorization(kind: GuardKind, where: string, input: CheckInput | null, o?: GuardOptions): Promise<void>;
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
@@ -31,9 +31,49 @@ async function report(o, r) {
31
31
  say(o, `onReport threw: ${e?.message ?? e}`);
32
32
  }
33
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/;
34
66
  /** why Toll would not read this request, or null when it does */
35
67
  export function unsupportedWhy(input) {
36
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
+ }
37
77
  if (tx) {
38
78
  if (input.to === undefined || input.to === null)
39
79
  return "a contract deployment (no `to`) is not read";
@@ -43,13 +83,10 @@ export function unsupportedWhy(input) {
43
83
  if (cid !== null && cid !== TX_CHAIN_ID)
44
84
  return `chain ${cid} is not read (Robinhood Chain ${TX_CHAIN_ID} only for now)`;
45
85
  }
46
- if (input.typedData && !input.x402) {
47
- const c = input.typedData.domain?.chainId;
48
- const cid = c === undefined || c === null ? null : chainIdOf(c);
49
- if (cid !== null && cid !== TX_CHAIN_ID)
50
- return `a signature for chain ${cid} is not read (Robinhood Chain ${TX_CHAIN_ID} only for now)`;
51
- }
52
- 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)
53
90
  return "nothing to check";
54
91
  return null;
55
92
  }
@@ -68,6 +105,10 @@ export async function enforce(kind, build, o = {}) {
68
105
  return unavailable(kind, null, err, o);
69
106
  }
70
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
+ }
71
112
  if (why) {
72
113
  const blocked = resolvePolicy(o).onUnsupported === "block";
73
114
  const r = { kind, input, assessment: null, decision: blocked ? "blocked" : "allowed", basis: "unsupported", paidUrl: null };
@@ -82,6 +123,10 @@ export async function enforce(kind, build, o = {}) {
82
123
  }
83
124
  catch (e) {
84
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
+ }
85
130
  return unavailable(kind, input, err, o);
86
131
  }
87
132
  const paidUrl = paidUrlOf(a);
@@ -112,7 +157,8 @@ export async function enforce(kind, build, o = {}) {
112
157
  throw new TollBlockedError(a, "WARN");
113
158
  }
114
159
  async function unavailable(kind, input, err, o) {
115
- const blocked = resolvePolicy(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);
116
162
  const r = { kind, input, assessment: null, decision: blocked ? "blocked" : "allowed", basis: "unavailable", paidUrl: err.paidUrl, error: err };
117
163
  await report(o, r);
118
164
  if (blocked)
@@ -129,12 +175,53 @@ export async function refuse(kind, input, code, message, o = {}) {
129
175
  await report(o, { kind, input, assessment: a, decision: "blocked", basis: "BLOCK", paidUrl: null });
130
176
  throw new TollBlockedError(a, "BLOCK");
131
177
  }
132
- /** 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
+ */
133
184
  export async function gateAuthorization(kind, where, input, o = {}) {
134
- if (o.onAuthorization === "allow") {
185
+ const p = o.onAuthorization ?? "check";
186
+ const shown = typeof input === "function" ? null : input;
187
+ if (p === "allow") {
135
188
  say(o, `EIP-7702 authorization (${where}) NOT checked, going ahead (onAuthorization "allow")`);
136
- await report(o, { kind, input, assessment: null, decision: "allowed", basis: "unsupported", paidUrl: null });
137
- 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)];
138
220
  }
139
- 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;
140
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.2.0",
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",