@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 +58 -16
- package/dist/check.d.ts +25 -1
- package/dist/check.js +22 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -3
- package/dist/policy.d.ts +25 -6
- package/dist/policy.js +100 -13
- package/dist/wallets.d.ts +20 -11
- package/dist/wallets.js +242 -57
- package/package.json +2 -2
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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,
|
|
106
|
-
|
|
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` | `"
|
|
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}
|
|
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.
|
|
48
|
-
* "
|
|
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
|
-
/**
|
|
74
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
10
|
-
* wherever a viem
|
|
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
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
32
|
-
* authorization (a transaction's authorizationList, or authorize()) goes to onAuthorization
|
|
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
|
|
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
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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 (...
|
|
99
|
-
await gateAuthorization("transaction", "signAuthorization",
|
|
100
|
-
return c.signAuthorization(...
|
|
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
|
-
|
|
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
|
|
149
|
-
* authorization (a transaction's authorizationList, or authorize()) goes to onAuthorization
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
await
|
|
160
|
-
return s
|
|
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 (...
|
|
179
|
-
await gateAuthorization("transaction", "authorize",
|
|
180
|
-
|
|
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
|
-
/* ------------------------------------------------------------------
|
|
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
|
|
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.
|
|
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",
|