@tollxyz/guard 0.2.0 → 0.4.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 +225 -16
- package/dist/check.d.ts +26 -2
- package/dist/check.js +28 -2
- package/dist/index.d.ts +10 -6
- package/dist/index.js +6 -4
- package/dist/policy.d.ts +154 -9
- package/dist/policy.js +399 -14
- package/dist/spend.d.ts +48 -0
- package/dist/spend.js +239 -0
- package/dist/store.d.ts +46 -0
- package/dist/store.js +78 -0
- package/dist/wallets.d.ts +20 -11
- package/dist/wallets.js +269 -61
- package/dist/x402.d.ts +12 -0
- package/dist/x402.js +148 -7
- package/package.json +2 -2
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 { commitSpend, enforce, enforceCalls, gateAuthorization, releaseSpend } 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, {
|
|
@@ -15,6 +15,36 @@ function proxy(target, wrapped) {
|
|
|
15
15
|
},
|
|
16
16
|
});
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* One wrapped call: the check, then the send. With `spend` set, the day-cap reservations the check made are committed
|
|
20
|
+
* after the send and released when the check or the send throws. `confirm` is a preview's confirmId (tollConfirm).
|
|
21
|
+
*/
|
|
22
|
+
async function guarded(o, confirm, check, send) {
|
|
23
|
+
if (!o.spend && confirm === undefined) {
|
|
24
|
+
await check(o);
|
|
25
|
+
return send();
|
|
26
|
+
}
|
|
27
|
+
const col = [];
|
|
28
|
+
const io = { ...o, _collect: col, ...(confirm !== undefined ? { _confirm: confirm } : {}) };
|
|
29
|
+
let out;
|
|
30
|
+
try {
|
|
31
|
+
await check(io);
|
|
32
|
+
out = await send();
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
await releaseSpend(col, o);
|
|
36
|
+
throw e;
|
|
37
|
+
}
|
|
38
|
+
await commitSpend(col, o);
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
/** a preview's confirmId on the request (tollConfirm), and the request without it (what the wallet gets) */
|
|
42
|
+
function takeConfirm(args) {
|
|
43
|
+
if (!args || typeof args !== "object" || !Object.prototype.hasOwnProperty.call(args, "tollConfirm"))
|
|
44
|
+
return [undefined, args];
|
|
45
|
+
const { tollConfirm, ...rest } = args;
|
|
46
|
+
return [typeof tollConfirm === "string" && tollConfirm ? tollConfirm : undefined, rest];
|
|
47
|
+
}
|
|
18
48
|
/** a transaction that carries an EIP-7702 authorizationList (any entry at all) */
|
|
19
49
|
const hasAuthorizations = (tx) => {
|
|
20
50
|
const l = tx?.authorizationList;
|
|
@@ -26,6 +56,32 @@ const addrOf = (account) => {
|
|
|
26
56
|
const a = account?.address;
|
|
27
57
|
return isAddress(a) ? a : undefined;
|
|
28
58
|
};
|
|
59
|
+
/** a chain id as a number when it can be read, else as given (the check then calls it unreadable) */
|
|
60
|
+
const chainOut = (v) => (v === undefined || v === null ? undefined : (chainIdOf(v) ?? v));
|
|
61
|
+
const HEXDIGITS = "0123456789abcdef";
|
|
62
|
+
const bytesHex = (b) => {
|
|
63
|
+
let s = "0x";
|
|
64
|
+
for (const x of b)
|
|
65
|
+
s += HEXDIGITS[x >> 4] + HEXDIGITS[x & 15];
|
|
66
|
+
return s;
|
|
67
|
+
};
|
|
68
|
+
/** a text message as the bytes a wallet signs (UTF-8), hex */
|
|
69
|
+
const utf8Hex = (s) => bytesHex(new TextEncoder().encode(s));
|
|
70
|
+
/** the request without its authorizationList (what is checked when onAuthorization "allow" let the list through) */
|
|
71
|
+
const withoutAuths = (i) => {
|
|
72
|
+
const { authorizationList: _drop, ...rest } = i;
|
|
73
|
+
return rest;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* One transaction: when it carries an authorizationList, onAuthorization decides first ("check": one Toll check of the
|
|
77
|
+
* whole transaction with its list, fail-closed; "block": refused; "allow": the list is let through and the rest is
|
|
78
|
+
* checked as usual).
|
|
79
|
+
*/
|
|
80
|
+
async function gateTx(where, build, auths, o) {
|
|
81
|
+
if (auths && (await gateAuthorization("transaction", where, build, o)))
|
|
82
|
+
return;
|
|
83
|
+
await enforce("transaction", async () => withoutAuths(await build()), o);
|
|
84
|
+
}
|
|
29
85
|
/* ------------------------------------------------------------------ viem */
|
|
30
86
|
let viemEncode = null;
|
|
31
87
|
async function loadViemEncode() {
|
|
@@ -38,11 +94,38 @@ async function loadViemEncode() {
|
|
|
38
94
|
viemEncode = m.encodeFunctionData;
|
|
39
95
|
return viemEncode;
|
|
40
96
|
}
|
|
97
|
+
/** a batch's calls as transactions: {to, data?, value?} or a contract call {to | address, abi, functionName, args} */
|
|
98
|
+
async function callsOf(calls, chainId, from, o) {
|
|
99
|
+
if (!Array.isArray(calls))
|
|
100
|
+
throw new Error("calls must be an array");
|
|
101
|
+
const out = [];
|
|
102
|
+
for (const raw of calls) {
|
|
103
|
+
const k = (raw ?? {});
|
|
104
|
+
let data = typeof k.data === "string" ? k.data : "0x";
|
|
105
|
+
if (k.abi !== undefined && typeof k.functionName === "string") {
|
|
106
|
+
const enc = o.encodeFunctionData ?? (await loadViemEncode());
|
|
107
|
+
data = String(enc({ abi: k.abi, functionName: k.functionName, args: k.args }));
|
|
108
|
+
if (typeof k.dataSuffix === "string")
|
|
109
|
+
data += k.dataSuffix.replace(/^0x/, "");
|
|
110
|
+
}
|
|
111
|
+
out.push({ chainId: chainOut(chainId), from, to: k.to ?? k.address ?? undefined, data, value: k.value ?? 0n });
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
/** a viem / EIP-7702 authorization request as Toll reads it: one entry, the signer, the chain */
|
|
116
|
+
function authInput(a, chainId, from) {
|
|
117
|
+
const address = a?.contractAddress ?? a?.address;
|
|
118
|
+
const entry = { address, chainId: chainOut(a?.chainId ?? chainId) };
|
|
119
|
+
if (a?.nonce !== undefined && a?.nonce !== null)
|
|
120
|
+
entry.nonce = a.nonce;
|
|
121
|
+
return { ...(chainId !== undefined && chainId !== null ? { chainId: chainOut(chainId) } : {}), from, authorizationList: [entry] };
|
|
122
|
+
}
|
|
41
123
|
/**
|
|
42
|
-
* Wraps a viem WalletClient: sendTransaction, writeContract, signTransaction
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
124
|
+
* Wraps a viem WalletClient. Asks Toll first: sendTransaction, writeContract, signTransaction, signTypedData,
|
|
125
|
+
* signMessage, sendCalls (EIP-5792, each call), signAuthorization, and request() for eth_sendTransaction,
|
|
126
|
+
* eth_signTransaction, personal_sign, eth_sign, eth_signTypedData_v4 and wallet_sendCalls. An EIP-7702 authorization
|
|
127
|
+
* goes to onAuthorization (default "check"). Wrap last: `client.extend(...)` on the wrapped client returns an unguarded
|
|
128
|
+
* client, and the account's own sign methods (client.account.sign*) are not wrapped.
|
|
46
129
|
*/
|
|
47
130
|
export function guardViem(client, opts = {}) {
|
|
48
131
|
const c = client;
|
|
@@ -54,54 +137,161 @@ export function guardViem(client, opts = {}) {
|
|
|
54
137
|
to: args?.to ?? undefined,
|
|
55
138
|
data: data ?? "0x",
|
|
56
139
|
value: args?.value ?? 0n,
|
|
140
|
+
...(hasAuthorizations(args) ? { authorizationList: args.authorizationList } : {}),
|
|
57
141
|
});
|
|
142
|
+
const origin = opts.origin !== undefined ? { origin: opts.origin } : {};
|
|
58
143
|
const wrapped = {};
|
|
59
|
-
|
|
60
|
-
|
|
144
|
+
for (const m of ["sendTransaction", "signTransaction"]) {
|
|
145
|
+
if (typeof c[m] !== "function")
|
|
146
|
+
continue;
|
|
147
|
+
wrapped[m] = async (raw, ...rest) => {
|
|
148
|
+
const [confirm, args] = takeConfirm(raw);
|
|
61
149
|
// a deployment (no `to`) goes to the unsupported policy
|
|
62
|
-
|
|
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);
|
|
150
|
+
return guarded(opts, confirm, (o) => gateTx(m, () => txInput(args, args?.data), hasAuthorizations(args), o), () => c[m](args, ...rest));
|
|
74
151
|
};
|
|
75
152
|
}
|
|
76
153
|
if (typeof c.writeContract === "function") {
|
|
77
|
-
wrapped.writeContract = async (
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
await enforce("transaction", async () => {
|
|
154
|
+
wrapped.writeContract = async (raw, ...rest) => {
|
|
155
|
+
const [confirm, args] = takeConfirm(raw);
|
|
156
|
+
const build = async () => {
|
|
81
157
|
const enc = opts.encodeFunctionData ?? (await loadViemEncode());
|
|
82
158
|
let data = String(enc({ abi: args.abi, functionName: args.functionName, args: args.args }));
|
|
83
159
|
if (typeof args.dataSuffix === "string")
|
|
84
160
|
data += args.dataSuffix.replace(/^0x/, "");
|
|
85
161
|
return txInput({ ...args, to: args.address }, data);
|
|
86
|
-
}
|
|
87
|
-
return c.writeContract(args, ...rest);
|
|
162
|
+
};
|
|
163
|
+
return guarded(opts, confirm, (o) => gateTx("writeContract", build, hasAuthorizations(args), o), () => c.writeContract(args, ...rest));
|
|
88
164
|
};
|
|
89
165
|
}
|
|
90
166
|
if (typeof c.signTypedData === "function") {
|
|
91
167
|
wrapped.signTypedData = async (args, ...rest) => {
|
|
92
168
|
const td = { domain: args?.domain ?? {}, types: args?.types ?? {}, primaryType: args?.primaryType, message: args?.message ?? {} };
|
|
93
|
-
|
|
94
|
-
|
|
169
|
+
// the signer (0.3.1): Toll reads what the signature lets someone take from it (a Permit2 message names no owner)
|
|
170
|
+
const from = fromOf(args);
|
|
171
|
+
return guarded(opts, undefined, (o) => enforce("typedData", () => ({ typedData: td, ...(from ? { from } : {}), ...origin }), o), () => c.signTypedData(args, ...rest));
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (typeof c.signMessage === "function") {
|
|
175
|
+
wrapped.signMessage = async (args, ...rest) => guarded(opts, undefined, (o) => enforce("message", () => {
|
|
176
|
+
const m = args?.message;
|
|
177
|
+
const raw = m && typeof m === "object" ? m.raw : undefined;
|
|
178
|
+
const data = typeof m === "string" ? utf8Hex(m) : typeof raw === "string" ? raw : raw instanceof Uint8Array ? bytesHex(raw) : null;
|
|
179
|
+
if (data === null)
|
|
180
|
+
throw new Error("signMessage: message must be a string or {raw}");
|
|
181
|
+
const from = fromOf(args);
|
|
182
|
+
return { message: { kind: "personal_sign", data, ...(from ? { from } : {}) }, ...origin };
|
|
183
|
+
}, o), () => c.signMessage(args, ...rest));
|
|
184
|
+
}
|
|
185
|
+
if (typeof c.sendCalls === "function") {
|
|
186
|
+
wrapped.sendCalls = async (args, ...rest) => {
|
|
187
|
+
return guarded(opts, undefined, (o) => enforceCalls(() => callsOf(args?.calls, chainOf(args), fromOf(args), opts), o), () => c.sendCalls(args, ...rest));
|
|
95
188
|
};
|
|
96
189
|
}
|
|
97
190
|
if (typeof c.signAuthorization === "function") {
|
|
98
|
-
wrapped.signAuthorization = async (...
|
|
99
|
-
|
|
100
|
-
|
|
191
|
+
wrapped.signAuthorization = async (args, ...rest) => {
|
|
192
|
+
return guarded(opts, undefined, (o) => gateAuthorization("transaction", "signAuthorization", () => authInput(args, c.chain?.id, fromOf(args)), o), () => c.signAuthorization(args, ...rest));
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
if (typeof c.request === "function") {
|
|
196
|
+
wrapped.request = async (args, ...rest) => {
|
|
197
|
+
return guarded(opts, undefined, (o) => checkRequest(args, c, fromOf({}), { ...opts, ...o }), () => c.request(args, ...rest));
|
|
101
198
|
};
|
|
102
199
|
}
|
|
103
200
|
return proxy(client, wrapped);
|
|
104
201
|
}
|
|
202
|
+
/** the wallet RPC methods that sign: each asks Toll first; every other method passes through unchecked */
|
|
203
|
+
async function checkRequest(args, c, from, o) {
|
|
204
|
+
const method = args?.method;
|
|
205
|
+
const params = Array.isArray(args?.params) ? args.params : [];
|
|
206
|
+
const origin = o.origin !== undefined ? { origin: o.origin } : {};
|
|
207
|
+
switch (method) {
|
|
208
|
+
case "personal_sign":
|
|
209
|
+
case "eth_sign":
|
|
210
|
+
await enforce("message", () => ({ rpc: { method, params }, ...origin }), o);
|
|
211
|
+
return;
|
|
212
|
+
case "eth_signTypedData_v4":
|
|
213
|
+
await enforce("typedData", () => ({ rpc: { method, params }, ...origin }), o);
|
|
214
|
+
return;
|
|
215
|
+
case "eth_sendTransaction":
|
|
216
|
+
case "eth_signTransaction": {
|
|
217
|
+
const tx = (params[0] ?? {});
|
|
218
|
+
const build = () => ({
|
|
219
|
+
chainId: chainOut(tx.chainId ?? c.chain?.id),
|
|
220
|
+
from: isAddress(tx.from) ? tx.from : from,
|
|
221
|
+
to: tx.to ?? undefined,
|
|
222
|
+
data: typeof tx.data === "string" ? tx.data : typeof tx.input === "string" ? tx.input : "0x",
|
|
223
|
+
value: tx.value ?? 0,
|
|
224
|
+
...(hasAuthorizations(tx) ? { authorizationList: tx.authorizationList } : {}),
|
|
225
|
+
});
|
|
226
|
+
await gateTx(method, build, hasAuthorizations(tx), o);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
case "wallet_sendCalls": {
|
|
230
|
+
const p = (params[0] ?? {});
|
|
231
|
+
await enforceCalls(() => callsOf(p.calls, p.chainId ?? c.chain?.id, isAddress(p.from) ? p.from : from, o), o);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
default:
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/* ------------------------------------------------------------------ ERC-4337 smart accounts (viem account abstraction) */
|
|
239
|
+
/** the user-operation fields passed on to Toll as given (bigints become decimal strings) */
|
|
240
|
+
const USEROP_FIELDS = [
|
|
241
|
+
"factory",
|
|
242
|
+
"factoryData",
|
|
243
|
+
"initCode",
|
|
244
|
+
"paymaster",
|
|
245
|
+
"paymasterData",
|
|
246
|
+
"paymasterAndData",
|
|
247
|
+
"paymasterVerificationGasLimit",
|
|
248
|
+
"paymasterPostOpGasLimit",
|
|
249
|
+
"callGasLimit",
|
|
250
|
+
"verificationGasLimit",
|
|
251
|
+
"preVerificationGas",
|
|
252
|
+
"maxFeePerGas",
|
|
253
|
+
"maxPriorityFeePerGas",
|
|
254
|
+
"signature",
|
|
255
|
+
];
|
|
256
|
+
/**
|
|
257
|
+
* Wraps a viem bundler client (account abstraction): sendUserOperation and prepareUserOperation ask Toll first.
|
|
258
|
+
* `calls` are checked one by one as transactions from the smart account's address; a ready `callData` is sent as a
|
|
259
|
+
* user operation with the account's EntryPoint. An EIP-7702 `authorization` goes to onAuthorization.
|
|
260
|
+
*/
|
|
261
|
+
export function guardSmartAccount(client, sopts = {}) {
|
|
262
|
+
const c = client;
|
|
263
|
+
const check = async (where, args, opts) => {
|
|
264
|
+
const acct = args?.account ?? c.account;
|
|
265
|
+
const sender = addrOf(acct);
|
|
266
|
+
const chainId = args?.chain?.id ?? c.chain?.id;
|
|
267
|
+
if (args?.authorization)
|
|
268
|
+
await gateAuthorization("userOp", where, () => authInput(args.authorization, chainId, sender), opts);
|
|
269
|
+
if (Array.isArray(args?.calls)) {
|
|
270
|
+
await enforceCalls(() => callsOf(args.calls, chainId, sender, opts), opts);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
await enforce("userOp", async () => {
|
|
274
|
+
if (typeof args?.callData !== "string")
|
|
275
|
+
return {};
|
|
276
|
+
let nonce = args.nonce;
|
|
277
|
+
if ((nonce === undefined || nonce === null) && typeof acct?.getNonce === "function")
|
|
278
|
+
nonce = await acct.getNonce();
|
|
279
|
+
const op = { sender: isAddress(args.sender) ? args.sender : sender, nonce: nonce ?? 0n, callData: args.callData };
|
|
280
|
+
for (const k of USEROP_FIELDS)
|
|
281
|
+
if (args[k] !== undefined && args[k] !== null)
|
|
282
|
+
op[k] = args[k];
|
|
283
|
+
const ep = acct?.entryPoint?.address ?? args.entryPointAddress;
|
|
284
|
+
return { userOp: op, ...(isAddress(ep) ? { entryPoint: ep } : {}) };
|
|
285
|
+
}, opts);
|
|
286
|
+
};
|
|
287
|
+
const wrapped = {};
|
|
288
|
+
for (const m of ["sendUserOperation", "prepareUserOperation"]) {
|
|
289
|
+
if (typeof c[m] !== "function")
|
|
290
|
+
continue;
|
|
291
|
+
wrapped[m] = async (args, ...rest) => guarded(sopts, undefined, (o) => check(m, args, { ...sopts, ...o }), () => c[m](args, ...rest));
|
|
292
|
+
}
|
|
293
|
+
return proxy(client, wrapped);
|
|
294
|
+
}
|
|
105
295
|
/* ------------------------------------------------------------------ ethers v6 */
|
|
106
296
|
/** the EIP-712 primary type: the one type no other type refers to (as ethers' TypedDataEncoder.getPrimaryType) */
|
|
107
297
|
export function primaryTypeOf(types) {
|
|
@@ -115,6 +305,10 @@ export function primaryTypeOf(types) {
|
|
|
115
305
|
throw new Error(roots.length ? `ambiguous primary type: ${roots.join(", ")}` : "no primary type");
|
|
116
306
|
return roots[0];
|
|
117
307
|
}
|
|
308
|
+
async function ethersChainId(signer) {
|
|
309
|
+
const net = typeof signer.provider?.getNetwork === "function" ? await signer.provider.getNetwork() : null;
|
|
310
|
+
return net?.chainId ?? undefined;
|
|
311
|
+
}
|
|
118
312
|
async function ethersTx(signer, tx) {
|
|
119
313
|
let to = tx?.to;
|
|
120
314
|
if (to && typeof to === "object" && typeof to.getAddress === "function")
|
|
@@ -127,10 +321,8 @@ async function ethersTx(signer, tx) {
|
|
|
127
321
|
to = r;
|
|
128
322
|
}
|
|
129
323
|
let chainId = tx?.chainId;
|
|
130
|
-
if (chainId === undefined || chainId === null)
|
|
131
|
-
|
|
132
|
-
chainId = net?.chainId ?? undefined;
|
|
133
|
-
}
|
|
324
|
+
if (chainId === undefined || chainId === null)
|
|
325
|
+
chainId = await ethersChainId(signer);
|
|
134
326
|
let from = tx?.from;
|
|
135
327
|
if (from && typeof from === "object" && typeof from.getAddress === "function")
|
|
136
328
|
from = await from.getAddress();
|
|
@@ -142,58 +334,74 @@ async function ethersTx(signer, tx) {
|
|
|
142
334
|
to: (to ?? undefined),
|
|
143
335
|
data: typeof tx?.data === "string" ? tx.data : "0x",
|
|
144
336
|
value: tx?.value ?? 0n,
|
|
337
|
+
...(hasAuthorizations(tx) ? { authorizationList: tx.authorizationList } : {}),
|
|
145
338
|
};
|
|
146
339
|
}
|
|
147
340
|
/**
|
|
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.
|
|
341
|
+
* Wraps an ethers v6 Signer: sendTransaction, signTransaction, signTypedData, signMessage and authorize() ask Toll
|
|
342
|
+
* first. An EIP-7702 authorization (a transaction's authorizationList, or authorize()) goes to onAuthorization
|
|
343
|
+
* (default "check"). `signer.connect(provider)` returns an unguarded signer: wrap the connected one.
|
|
151
344
|
*/
|
|
152
345
|
export function guardEthers(signer, opts = {}) {
|
|
153
346
|
const s = signer;
|
|
347
|
+
const origin = opts.origin !== undefined ? { origin: opts.origin } : {};
|
|
154
348
|
const wrapped = {};
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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);
|
|
349
|
+
for (const m of ["sendTransaction", "signTransaction"]) {
|
|
350
|
+
if (typeof s[m] !== "function")
|
|
351
|
+
continue;
|
|
352
|
+
wrapped[m] = async (raw, ...rest) => {
|
|
353
|
+
const [confirm, tx] = takeConfirm(raw);
|
|
354
|
+
return guarded(opts, confirm, (o) => gateTx(m, () => ethersTx(s, tx), hasAuthorizations(tx), o), () => s[m](tx, ...rest));
|
|
169
355
|
};
|
|
170
356
|
}
|
|
171
357
|
if (typeof s.signTypedData === "function") {
|
|
172
358
|
wrapped.signTypedData = async (domain, types, value, ...rest) => {
|
|
173
|
-
|
|
174
|
-
|
|
359
|
+
return guarded(opts, undefined, (o) => enforce("typedData", async () => {
|
|
360
|
+
// the signer (0.3.1): Toll reads what the signature lets someone take from it (a Permit2 message names no owner)
|
|
361
|
+
const from = typeof s.getAddress === "function" ? await s.getAddress() : undefined;
|
|
362
|
+
return { typedData: { domain: domain ?? {}, types: types ?? {}, primaryType: primaryTypeOf(types ?? {}), message: value ?? {} }, ...(isAddress(from) ? { from } : {}), ...origin };
|
|
363
|
+
}, o), () => s.signTypedData(domain, types, value, ...rest));
|
|
175
364
|
};
|
|
176
365
|
}
|
|
366
|
+
if (typeof s.signMessage === "function") {
|
|
367
|
+
wrapped.signMessage = async (message, ...rest) => guarded(opts, undefined, (o) => enforce("message", async () => {
|
|
368
|
+
// ethers signs a string as its UTF-8 bytes, even one that looks like hex
|
|
369
|
+
const data = typeof message === "string" ? utf8Hex(message) : message instanceof Uint8Array ? bytesHex(message) : null;
|
|
370
|
+
if (data === null)
|
|
371
|
+
throw new Error("signMessage: message must be a string or bytes");
|
|
372
|
+
const from = typeof s.getAddress === "function" ? await s.getAddress() : undefined;
|
|
373
|
+
return { message: { kind: "personal_sign", data, ...(isAddress(from) ? { from } : {}) }, ...origin };
|
|
374
|
+
}, o), () => s.signMessage(message, ...rest));
|
|
375
|
+
}
|
|
177
376
|
if (typeof s.authorize === "function") {
|
|
178
|
-
wrapped.authorize = async (...
|
|
179
|
-
|
|
180
|
-
|
|
377
|
+
wrapped.authorize = async (auth, ...rest) => {
|
|
378
|
+
const build = async () => {
|
|
379
|
+
const chainId = chainOut(await ethersChainId(s));
|
|
380
|
+
const from = typeof s.getAddress === "function" ? await s.getAddress() : undefined;
|
|
381
|
+
return authInput(auth ?? {}, chainId, isAddress(from) ? from : undefined);
|
|
382
|
+
};
|
|
383
|
+
return guarded(opts, undefined, (o) => gateAuthorization("transaction", "authorize", build, o), () => s.authorize(auth, ...rest));
|
|
181
384
|
};
|
|
182
385
|
}
|
|
183
386
|
return proxy(signer, wrapped);
|
|
184
387
|
}
|
|
185
|
-
/* ------------------------------------------------------------------
|
|
388
|
+
/* ------------------------------------------------------------------ any */
|
|
186
389
|
/** true for a viem WalletClient (has `extend` and `request`), false for an ethers Signer */
|
|
187
390
|
export const isViemClient = (x) => !!x && typeof x.extend === "function" && typeof x.request === "function";
|
|
391
|
+
/** true for a viem bundler client (account abstraction): it has sendUserOperation */
|
|
392
|
+
export const isBundlerClient = (x) => !!x && typeof x.sendUserOperation === "function";
|
|
188
393
|
/**
|
|
189
|
-
* Puts Toll's pre-sign check in front of a wallet
|
|
190
|
-
* the same type and methods.
|
|
394
|
+
* Puts Toll's pre-sign check in front of a wallet: a viem bundler client (smart account), a viem WalletClient or an
|
|
395
|
+
* ethers v6 Signer; the wrapped wallet has the same type and methods.
|
|
191
396
|
*/
|
|
192
397
|
export function guard(wallet, opts = {}) {
|
|
398
|
+
// a bundler client is also a viem client: it is detected first
|
|
399
|
+
if (isBundlerClient(wallet))
|
|
400
|
+
return guardSmartAccount(wallet, opts);
|
|
193
401
|
if (isViemClient(wallet))
|
|
194
402
|
return guardViem(wallet, opts);
|
|
195
403
|
if (typeof wallet.getAddress === "function" && (typeof wallet.sendTransaction === "function" || typeof wallet.signTypedData === "function")) {
|
|
196
404
|
return guardEthers(wallet, opts);
|
|
197
405
|
}
|
|
198
|
-
throw new TypeError("guard(): pass a viem WalletClient or an ethers v6 Signer (or use guardViem / guardEthers).");
|
|
406
|
+
throw new TypeError("guard(): pass a viem WalletClient, a viem bundler client or an ethers v6 Signer (or use guardViem / guardSmartAccount / guardEthers).");
|
|
199
407
|
}
|
package/dist/x402.d.ts
CHANGED
|
@@ -14,9 +14,21 @@ export declare function readPaymentForms(res: Response): Promise<{
|
|
|
14
14
|
export declare function readPaymentRequired(res: Response): Promise<PR | null>;
|
|
15
15
|
/** true when the header and the body would pay differently (a payee, amount, asset or scheme in one and not the other) */
|
|
16
16
|
export declare function paymentFormsDiffer(header: PR, body: PR): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* The x402 payment-identifier extension's id in a signed payment header (base64 JSON PaymentPayload):
|
|
19
|
+
* extensions["payment-identifier"].info.id (or .id); null when there is none.
|
|
20
|
+
*/
|
|
21
|
+
export declare function paymentIdOf(header: string | null): string | null;
|
|
17
22
|
export interface X402GuardOptions extends GuardOptions {
|
|
18
23
|
/** called with each 402's report (also passed to onReport) */
|
|
19
24
|
on402?: (report: GuardReport, url: string) => void;
|
|
25
|
+
/**
|
|
26
|
+
* With `spend`: how long a checked 402's cap reservation waits for its paid retry before it is given back
|
|
27
|
+
* (default 120000 ms). The paid retry commits it on a non-402 answer and releases it on a 402 or an error.
|
|
28
|
+
*/
|
|
29
|
+
holdMs?: number;
|
|
30
|
+
/** With `spend`: how long a paid payment-identifier is remembered (default 86400 s) */
|
|
31
|
+
paymentIdTtlS?: number;
|
|
20
32
|
}
|
|
21
33
|
/**
|
|
22
34
|
* Wraps a fetch so every 402 is checked by Toll before an x402 client pays it. A 402 to a request that already
|
package/dist/x402.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { enforce, refuse } from "./policy.js";
|
|
1
|
+
import { commitSpend, enforce, policyRefuse, refuse, releaseSpend, storeOf } from "./policy.js";
|
|
2
2
|
function decodeBase64Json(value) {
|
|
3
3
|
const bin = atob(value.trim());
|
|
4
4
|
return JSON.parse(new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0))));
|
|
@@ -59,19 +59,51 @@ const urlOf = (input) => {
|
|
|
59
59
|
return typeof u === "string" ? u : String(input);
|
|
60
60
|
};
|
|
61
61
|
const PAY_HEADERS = ["payment-signature", "x-payment"];
|
|
62
|
-
|
|
62
|
+
/** the payment header's value on the request (init headers first, then a Request's own), or null */
|
|
63
|
+
const paymentHeader = (input, init) => {
|
|
63
64
|
const read = (h) => {
|
|
64
65
|
if (!h)
|
|
65
|
-
return
|
|
66
|
+
return null;
|
|
66
67
|
try {
|
|
67
68
|
const hs = new Headers(h);
|
|
68
|
-
|
|
69
|
+
for (const n of PAY_HEADERS)
|
|
70
|
+
if (hs.has(n))
|
|
71
|
+
return hs.get(n) ?? "";
|
|
69
72
|
}
|
|
70
73
|
catch {
|
|
71
|
-
|
|
74
|
+
/* unreadable headers */
|
|
72
75
|
}
|
|
76
|
+
return null;
|
|
73
77
|
};
|
|
74
|
-
return read(init?.headers)
|
|
78
|
+
return read(init?.headers) ?? (typeof Request !== "undefined" && input instanceof Request ? read(input.headers) : null);
|
|
79
|
+
};
|
|
80
|
+
const hasPayment = (input, init) => paymentHeader(input, init) !== null;
|
|
81
|
+
/**
|
|
82
|
+
* The x402 payment-identifier extension's id in a signed payment header (base64 JSON PaymentPayload):
|
|
83
|
+
* extensions["payment-identifier"].info.id (or .id); null when there is none.
|
|
84
|
+
*/
|
|
85
|
+
export function paymentIdOf(header) {
|
|
86
|
+
if (!header)
|
|
87
|
+
return null;
|
|
88
|
+
try {
|
|
89
|
+
const p = decodeBase64Json(header);
|
|
90
|
+
const ext = p?.extensions?.["payment-identifier"] ?? p?.payload?.extensions?.["payment-identifier"];
|
|
91
|
+
const id = ext?.info?.id ?? ext?.id ?? p?.paymentId;
|
|
92
|
+
return typeof id === "string" && id ? id : null;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** the request URL without its query: the key a 402's hold waits under for its paid retry */
|
|
99
|
+
const holdKey = (url) => {
|
|
100
|
+
try {
|
|
101
|
+
const u = new URL(url);
|
|
102
|
+
return u.origin + u.pathname;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return url;
|
|
106
|
+
}
|
|
75
107
|
};
|
|
76
108
|
/**
|
|
77
109
|
* Wraps a fetch so every 402 is checked by Toll before an x402 client pays it. A 402 to a request that already
|
|
@@ -79,7 +111,97 @@ const hasPayment = (input, init) => {
|
|
|
79
111
|
*/
|
|
80
112
|
export function guardX402Fetch(fetchLike, opts = {}) {
|
|
81
113
|
const toll = { ...opts, fetch: opts.fetch ?? globalThis.fetch?.bind(globalThis) };
|
|
114
|
+
// spend mode: each checked 402's reservations wait here (oldest first per URL) for the paid retry
|
|
115
|
+
const holds = new Map();
|
|
116
|
+
const takeHold = (key) => {
|
|
117
|
+
const q = holds.get(key);
|
|
118
|
+
const h = q?.shift();
|
|
119
|
+
if (q && !q.length)
|
|
120
|
+
holds.delete(key);
|
|
121
|
+
if (h)
|
|
122
|
+
clearTimeout(h.timer);
|
|
123
|
+
return h ?? null;
|
|
124
|
+
};
|
|
125
|
+
// payment ids being sent right now, and the hold each id keeps across a retry after a network error
|
|
126
|
+
const inFlight = new Set();
|
|
127
|
+
const idHolds = new Map();
|
|
128
|
+
const keepForRetry = (id, reports) => {
|
|
129
|
+
const timer = setTimeout(() => {
|
|
130
|
+
if (idHolds.get(id)?.reports === reports) {
|
|
131
|
+
idHolds.delete(id);
|
|
132
|
+
void releaseSpend(reports, toll);
|
|
133
|
+
}
|
|
134
|
+
}, opts.holdMs ?? 120_000);
|
|
135
|
+
timer.unref?.();
|
|
136
|
+
idHolds.set(id, { reports, timer });
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* A paid request. With an x402 payment-identifier: an id the seller already accepted (non-402 answer) is refused,
|
|
140
|
+
* and so is a second send of an id still in flight; after a network error the same id may be sent again (the
|
|
141
|
+
* seller dedupes it) and it reuses the cap reservation of its 402, so nothing is counted twice.
|
|
142
|
+
*/
|
|
143
|
+
const paidRetry = async (input, init, header) => {
|
|
144
|
+
const url = urlOf(input);
|
|
145
|
+
const id = paymentIdOf(header);
|
|
146
|
+
const store = storeOf(toll);
|
|
147
|
+
if (id !== null) {
|
|
148
|
+
if (await store.paymentSeen(id)) {
|
|
149
|
+
await policyRefuse("x402", { x402: { url } }, "duplicate-payment", `Not paid: payment-identifier ${id} was already paid; the guard never pays it twice.`, toll);
|
|
150
|
+
}
|
|
151
|
+
// checked after the await, right before the mark: no other send of this id can slip in between
|
|
152
|
+
if (inFlight.has(id)) {
|
|
153
|
+
await policyRefuse("x402", { x402: { url } }, "duplicate-payment", `Not paid: payment-identifier ${id} is being sent right now; the guard never sends it twice at once.`, toll);
|
|
154
|
+
}
|
|
155
|
+
inFlight.add(id);
|
|
156
|
+
}
|
|
157
|
+
let hold = null;
|
|
158
|
+
if (id !== null && idHolds.has(id)) {
|
|
159
|
+
const h = idHolds.get(id);
|
|
160
|
+
clearTimeout(h.timer);
|
|
161
|
+
idHolds.delete(id);
|
|
162
|
+
hold = h;
|
|
163
|
+
}
|
|
164
|
+
else
|
|
165
|
+
hold = takeHold(holdKey(url));
|
|
166
|
+
let res;
|
|
167
|
+
try {
|
|
168
|
+
res = await fetchLike(input, init);
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
if (id !== null) {
|
|
172
|
+
// no answer: the seller may not have it; the same id may be retried and keeps this reservation
|
|
173
|
+
inFlight.delete(id);
|
|
174
|
+
if (hold)
|
|
175
|
+
keepForRetry(id, hold.reports);
|
|
176
|
+
}
|
|
177
|
+
else if (hold)
|
|
178
|
+
await releaseSpend(hold.reports, toll);
|
|
179
|
+
throw e;
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
if (res.status === 402) {
|
|
183
|
+
if (hold)
|
|
184
|
+
await releaseSpend(hold.reports, toll);
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
if (hold)
|
|
188
|
+
await commitSpend(hold.reports, toll);
|
|
189
|
+
if (id !== null)
|
|
190
|
+
await store.markPaid(id, opts.paymentIdTtlS ?? 86_400);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
if (id !== null)
|
|
195
|
+
inFlight.delete(id);
|
|
196
|
+
}
|
|
197
|
+
return res;
|
|
198
|
+
};
|
|
82
199
|
const wrapped = async (input, init) => {
|
|
200
|
+
if (toll.spend) {
|
|
201
|
+
const header = paymentHeader(input, init);
|
|
202
|
+
if (header !== null)
|
|
203
|
+
return paidRetry(input, init, header);
|
|
204
|
+
}
|
|
83
205
|
const res = await fetchLike(input, init);
|
|
84
206
|
if (res.status !== 402 || hasPayment(input, init))
|
|
85
207
|
return res;
|
|
@@ -91,13 +213,32 @@ export function guardX402Fetch(fetchLike, opts = {}) {
|
|
|
91
213
|
await refuse("x402", { x402: { ...forms.header, url } }, "x402-forms-differ", "The 402's PAYMENT-REQUIRED header and its JSON body ask for different payments (payee, amount, asset or scheme). A client pays from the one it reads, so it was not paid.", toll);
|
|
92
214
|
}
|
|
93
215
|
const pr = forms.header ?? forms.body;
|
|
216
|
+
const col = [];
|
|
217
|
+
const eo = toll.spend ? { ...toll, _collect: col, _endpoint: url } : toll;
|
|
94
218
|
const r = await enforce("x402", () => {
|
|
95
219
|
if (!pr)
|
|
96
220
|
return {}; // nothing readable: the unsupported policy decides
|
|
97
221
|
const x402 = pr.resource || pr.url ? pr : { ...pr, url };
|
|
98
222
|
const id = typeof opts.agentId === "function" ? opts.agentId(url) : opts.agentId;
|
|
99
223
|
return { x402, ...(id !== undefined && id !== null ? { agentId: id } : {}) };
|
|
100
|
-
},
|
|
224
|
+
}, eo);
|
|
225
|
+
if (col.some((c) => c.spend?.reservations.length)) {
|
|
226
|
+
const key = holdKey(url);
|
|
227
|
+
const entry = { reports: col, timer: setTimeout(() => {
|
|
228
|
+
const q = holds.get(key);
|
|
229
|
+
const i = q ? q.indexOf(entry) : -1;
|
|
230
|
+
if (q && i >= 0) {
|
|
231
|
+
q.splice(i, 1);
|
|
232
|
+
if (!q.length)
|
|
233
|
+
holds.delete(key);
|
|
234
|
+
void releaseSpend(col, toll);
|
|
235
|
+
}
|
|
236
|
+
}, opts.holdMs ?? 120_000) };
|
|
237
|
+
entry.timer.unref?.();
|
|
238
|
+
const q = holds.get(key) ?? [];
|
|
239
|
+
q.push(entry);
|
|
240
|
+
holds.set(key, q);
|
|
241
|
+
}
|
|
101
242
|
opts.on402?.(r, url);
|
|
102
243
|
return res;
|
|
103
244
|
};
|
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.4.0",
|
|
4
|
+
"description": "Toll's live pre-sign check in front of an AI agent's wallet: viem, viem smart-account, ethers and x402 fetch wrappers that ask Toll (ALLOW / WARN / BLOCK, ERC-8265) before anything is signed on Robinhood Chain: transactions, messages, typed data, batches, user operations, EIP-7702 authorizations. Spend caps, allowlists and human approval around the verdict. Zero runtime dependencies.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Toll",
|
|
7
7
|
"type": "module",
|