@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/policy.js
CHANGED
|
@@ -1,4 +1,24 @@
|
|
|
1
|
-
import { TX_CHAIN_ID, TollBlockedError, TollUnavailableError, checkTx, chainIdOf, paidUrlOf, } from "./check.js";
|
|
1
|
+
import { TX_CHAIN_ID, TollBlockedError, TollUnavailableError, checkTx, chainIdOf, paidUrlOf, bodyOf, toJson, } from "./check.js";
|
|
2
|
+
import { DEFAULT_SPEND_ASSETS, spendOf } from "./spend.js";
|
|
3
|
+
import { MemorySpendStore, dayStart } from "./store.js";
|
|
4
|
+
/** blocked by your own spend rules; a TollBlockedError, so existing catch blocks still see a block */
|
|
5
|
+
export class TollPolicyError extends TollBlockedError {
|
|
6
|
+
code;
|
|
7
|
+
asset;
|
|
8
|
+
amount;
|
|
9
|
+
limit;
|
|
10
|
+
/** the guard's report of this request */
|
|
11
|
+
report;
|
|
12
|
+
constructor(code, message, x = {}) {
|
|
13
|
+
super({ action: "BLOCK", warnings: [{ code: `policy-${code}`, severity: "critical", message }], ...(x.assessment?.["x-toll"] ? { "x-toll": x.assessment["x-toll"] } : {}) }, "policy");
|
|
14
|
+
this.name = "TollPolicyError";
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.asset = x.asset;
|
|
17
|
+
this.amount = x.amount;
|
|
18
|
+
this.limit = x.limit;
|
|
19
|
+
this.report = x.report;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
2
22
|
/** the effective policy: explicit options win, then the strict preset, then the defaults */
|
|
3
23
|
export function resolvePolicy(o = {}) {
|
|
4
24
|
const strict = o.strict === true;
|
|
@@ -31,9 +51,49 @@ async function report(o, r) {
|
|
|
31
51
|
say(o, `onReport threw: ${e?.message ?? e}`);
|
|
32
52
|
}
|
|
33
53
|
}
|
|
54
|
+
const SIGN_RPC = new Set(["personal_sign", "eth_sign", "eth_signTypedData_v4"]);
|
|
55
|
+
/** the typed data a request signs: its typedData, or an eth_signTypedData_v4 request's params[1] (JSON or object) */
|
|
56
|
+
function typedOf(input) {
|
|
57
|
+
if (input.typedData)
|
|
58
|
+
return input.typedData;
|
|
59
|
+
if (input.rpc?.method !== "eth_signTypedData_v4")
|
|
60
|
+
return null;
|
|
61
|
+
let td = input.rpc.params?.[1];
|
|
62
|
+
if (typeof td === "string") {
|
|
63
|
+
try {
|
|
64
|
+
td = JSON.parse(td);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return td && typeof td === "object" ? td : null;
|
|
71
|
+
}
|
|
72
|
+
/** true for a signature request (typed data, a message, a signing RPC) rather than a transaction or an x402 payment */
|
|
73
|
+
export const isSignature = (input) => !!input && !input.x402 && (!!input.typedData || !!input.message || SIGN_RPC.has(String(input.rpc?.method)));
|
|
74
|
+
/** true for eth_sign: it signs any 32 bytes (a transaction hash included), so it fails closed when Toll cannot answer */
|
|
75
|
+
export const isEthSign = (input) => input?.message?.kind === "eth_sign" || input?.rpc?.method === "eth_sign";
|
|
76
|
+
/** the other chain a typed-data signature names (its domain chainId), or null (Robinhood Chain, none, or an x402 payment) */
|
|
77
|
+
function otherChainSignature(input) {
|
|
78
|
+
if (input.x402)
|
|
79
|
+
return null;
|
|
80
|
+
const c = typedOf(input)?.domain?.chainId;
|
|
81
|
+
const cid = c === undefined || c === null ? null : chainIdOf(c);
|
|
82
|
+
return cid !== null && cid !== TX_CHAIN_ID ? cid : null;
|
|
83
|
+
}
|
|
84
|
+
/** Toll's 400 for a request on another chain ("... chainId N is not read ...") */
|
|
85
|
+
const OTHER_CHAIN_400 = /chainId \d+ is not read/;
|
|
34
86
|
/** why Toll would not read this request, or null when it does */
|
|
35
87
|
export function unsupportedWhy(input) {
|
|
36
88
|
const tx = input.to !== undefined || input.data !== undefined;
|
|
89
|
+
const auths = !!input.authorizationList && input.authorizationList.length > 0;
|
|
90
|
+
if (!tx && auths) {
|
|
91
|
+
const cid = input.chainId === undefined ? null : chainIdOf(input.chainId);
|
|
92
|
+
if (input.chainId !== undefined && cid === null)
|
|
93
|
+
return "the chain id could not be read";
|
|
94
|
+
if (cid !== null && cid !== TX_CHAIN_ID)
|
|
95
|
+
return `chain ${cid} is not read (Robinhood Chain ${TX_CHAIN_ID} only for now)`;
|
|
96
|
+
}
|
|
37
97
|
if (tx) {
|
|
38
98
|
if (input.to === undefined || input.to === null)
|
|
39
99
|
return "a contract deployment (no `to`) is not read";
|
|
@@ -43,13 +103,10 @@ export function unsupportedWhy(input) {
|
|
|
43
103
|
if (cid !== null && cid !== TX_CHAIN_ID)
|
|
44
104
|
return `chain ${cid} is not read (Robinhood Chain ${TX_CHAIN_ID} only for now)`;
|
|
45
105
|
}
|
|
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)
|
|
106
|
+
const other = otherChainSignature(input);
|
|
107
|
+
if (other !== null)
|
|
108
|
+
return `a signature for chain ${other} is not read (Robinhood Chain ${TX_CHAIN_ID} only for now)`;
|
|
109
|
+
if (!tx && !auths && !input.typedData && !input.x402 && !input.message && !input.rpc && !input.userOp)
|
|
53
110
|
return "nothing to check";
|
|
54
111
|
return null;
|
|
55
112
|
}
|
|
@@ -59,6 +116,10 @@ export function unsupportedWhy(input) {
|
|
|
59
116
|
* `build` may throw (e.g. calldata that could not be encoded): that counts as unavailable.
|
|
60
117
|
*/
|
|
61
118
|
export async function enforce(kind, build, o = {}) {
|
|
119
|
+
return o.spend ? enforceSpend(kind, build, o) : enforceVerdict(kind, build, o);
|
|
120
|
+
}
|
|
121
|
+
/** Toll's verdict and the 0.3.x policy (onWarn, onBlock, onUnavailable, onUnsupported) */
|
|
122
|
+
async function enforceVerdict(kind, build, o) {
|
|
62
123
|
let input;
|
|
63
124
|
try {
|
|
64
125
|
input = await build();
|
|
@@ -68,6 +129,10 @@ export async function enforce(kind, build, o = {}) {
|
|
|
68
129
|
return unavailable(kind, null, err, o);
|
|
69
130
|
}
|
|
70
131
|
const why = unsupportedWhy(input);
|
|
132
|
+
// a signature for another chain is not "unsupported -> allow": an off-chain signature Toll cannot read is refused
|
|
133
|
+
if (why && isSignature(input) && otherChainSignature(input) !== null && o.onUnsupported !== "allow") {
|
|
134
|
+
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);
|
|
135
|
+
}
|
|
71
136
|
if (why) {
|
|
72
137
|
const blocked = resolvePolicy(o).onUnsupported === "block";
|
|
73
138
|
const r = { kind, input, assessment: null, decision: blocked ? "blocked" : "allowed", basis: "unsupported", paidUrl: null };
|
|
@@ -78,10 +143,14 @@ export async function enforce(kind, build, o = {}) {
|
|
|
78
143
|
}
|
|
79
144
|
let a;
|
|
80
145
|
try {
|
|
81
|
-
a = await checkTx(input, o);
|
|
146
|
+
a = takeConfirmed(o, input) ?? (await checkTx(input, o));
|
|
82
147
|
}
|
|
83
148
|
catch (e) {
|
|
84
149
|
const err = e instanceof TollUnavailableError ? e : new TollUnavailableError(`Toll's check failed: ${e?.message ?? e}`, { reason: "network", cause: e });
|
|
150
|
+
// Toll's 400 for a signature on another chain: blocked, never "unsupported -> allow"
|
|
151
|
+
if (err.reason === "bad-request" && isSignature(input) && OTHER_CHAIN_400.test(err.message) && o.onUnsupported !== "allow") {
|
|
152
|
+
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);
|
|
153
|
+
}
|
|
85
154
|
return unavailable(kind, input, err, o);
|
|
86
155
|
}
|
|
87
156
|
const paidUrl = paidUrlOf(a);
|
|
@@ -112,7 +181,8 @@ export async function enforce(kind, build, o = {}) {
|
|
|
112
181
|
throw new TollBlockedError(a, "WARN");
|
|
113
182
|
}
|
|
114
183
|
async function unavailable(kind, input, err, o) {
|
|
115
|
-
|
|
184
|
+
// eth_sign signs any 32 bytes (a transaction hash included): it always fails closed
|
|
185
|
+
const blocked = resolvePolicy(o).onUnavailable === "block" || isEthSign(input);
|
|
116
186
|
const r = { kind, input, assessment: null, decision: blocked ? "blocked" : "allowed", basis: "unavailable", paidUrl: err.paidUrl, error: err };
|
|
117
187
|
await report(o, r);
|
|
118
188
|
if (blocked)
|
|
@@ -129,12 +199,327 @@ export async function refuse(kind, input, code, message, o = {}) {
|
|
|
129
199
|
await report(o, { kind, input, assessment: a, decision: "blocked", basis: "BLOCK", paidUrl: null });
|
|
130
200
|
throw new TollBlockedError(a, "BLOCK");
|
|
131
201
|
}
|
|
132
|
-
/**
|
|
202
|
+
/**
|
|
203
|
+
* The onAuthorization policy for an EIP-7702 authorization. Resolves true when Toll checked the whole request (the
|
|
204
|
+
* "check" default: the caller asks nothing more), false when "allow" let it through unchecked (the caller still checks
|
|
205
|
+
* the rest of the request); throws when it is blocked. `input` is the request with its authorizationList, or a function
|
|
206
|
+
* that builds it; under "check", null is blocked (nothing to send).
|
|
207
|
+
*/
|
|
133
208
|
export async function gateAuthorization(kind, where, input, o = {}) {
|
|
134
|
-
|
|
209
|
+
const p = o.onAuthorization ?? "check";
|
|
210
|
+
const shown = typeof input === "function" ? null : input;
|
|
211
|
+
if (p === "allow") {
|
|
135
212
|
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 });
|
|
213
|
+
await report(o, { kind, input: shown, assessment: null, decision: "allowed", basis: "unsupported", paidUrl: null });
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
const what = `${where} carries an EIP-7702 authorization, which sets this wallet's code to another contract that can then move everything it holds.`;
|
|
217
|
+
if (p === "block" || input === null) {
|
|
218
|
+
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);
|
|
219
|
+
}
|
|
220
|
+
const build = typeof input === "function" ? input : () => input;
|
|
221
|
+
try {
|
|
222
|
+
// fail closed: an authorization Toll could not read is not signed
|
|
223
|
+
await enforce(kind, build, { ...o, onUnavailable: "block", onUnsupported: "block" });
|
|
224
|
+
}
|
|
225
|
+
catch (e) {
|
|
226
|
+
if (!(e instanceof TollUnavailableError))
|
|
227
|
+
throw e;
|
|
228
|
+
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);
|
|
229
|
+
}
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* A batch (EIP-5792 sendCalls, wallet_sendCalls, a smart account's calls): each call is checked as its own transaction
|
|
234
|
+
* from the batch's sender, in order; the first one blocked stops the batch. Reports carry kind "calls".
|
|
235
|
+
*/
|
|
236
|
+
export async function enforceCalls(build, o = {}) {
|
|
237
|
+
let calls;
|
|
238
|
+
try {
|
|
239
|
+
calls = await build();
|
|
240
|
+
}
|
|
241
|
+
catch (e) {
|
|
242
|
+
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 });
|
|
243
|
+
return [await unavailable("calls", null, err, o)];
|
|
244
|
+
}
|
|
245
|
+
if (!calls.length)
|
|
246
|
+
return [await enforce("calls", () => ({}), o)];
|
|
247
|
+
const out = [];
|
|
248
|
+
try {
|
|
249
|
+
for (const c of calls)
|
|
250
|
+
out.push(await enforce("calls", () => c, o));
|
|
251
|
+
}
|
|
252
|
+
catch (e) {
|
|
253
|
+
// the batch is not sent: the calls already passed give their day reservations back
|
|
254
|
+
await releaseSpend(out, o);
|
|
255
|
+
throw e;
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
258
|
+
}
|
|
259
|
+
/* ------------------------------------------------------------------ spend mode */
|
|
260
|
+
const lc = (x) => String(x ?? "").toLowerCase();
|
|
261
|
+
const fmt = (a) => (a === "native" ? "native ETH" : a);
|
|
262
|
+
const stores = new WeakMap();
|
|
263
|
+
/** the store for these options: the one given, else one in-process store per SpendPolicy object */
|
|
264
|
+
export function storeOf(o) {
|
|
265
|
+
if (o.store)
|
|
266
|
+
return o.store;
|
|
267
|
+
const sp = o.spend ?? {};
|
|
268
|
+
let s = stores.get(sp);
|
|
269
|
+
if (!s) {
|
|
270
|
+
s = new MemorySpendStore();
|
|
271
|
+
stores.set(sp, s);
|
|
272
|
+
}
|
|
273
|
+
return s;
|
|
274
|
+
}
|
|
275
|
+
const lower = (r) => Object.fromEntries(Object.entries(r ?? {}).map(([k, v]) => [lc(k), v]));
|
|
276
|
+
function assetsOf(sp) {
|
|
277
|
+
return [...new Set([...DEFAULT_SPEND_ASSETS, ...Object.keys(sp.assets ?? {}), ...Object.keys(sp.approveAbove ?? {})].map(lc))];
|
|
278
|
+
}
|
|
279
|
+
/** the first counterparty off an allowlist, or null */
|
|
280
|
+
function offList(sp, s) {
|
|
281
|
+
const check = (list, got, what) => {
|
|
282
|
+
if (!list)
|
|
283
|
+
return null;
|
|
284
|
+
const ok = new Set(list.map(lc));
|
|
285
|
+
const bad = got.find((x) => !ok.has(x));
|
|
286
|
+
return bad ? `${what} ${bad} is not on the allowlist` : null;
|
|
287
|
+
};
|
|
288
|
+
return check(sp.allow?.to, s.to, "recipient") ?? check(sp.allow?.spender, s.spender, "spender") ?? check(sp.allow?.payTo, s.payTo, "x402 payee");
|
|
289
|
+
}
|
|
290
|
+
/* previews: Toll's answer kept 30 s for one confirmed send of the identical request */
|
|
291
|
+
const CONFIRM_TTL_MS = 30_000;
|
|
292
|
+
const confirmed = new Map();
|
|
293
|
+
let confirmSeq = 0;
|
|
294
|
+
function rememberConfirm(input, assessment) {
|
|
295
|
+
const t = Date.now();
|
|
296
|
+
for (const [k, v] of confirmed)
|
|
297
|
+
if (v.until <= t)
|
|
298
|
+
confirmed.delete(k);
|
|
299
|
+
const id = `c${t.toString(36)}${(++confirmSeq).toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
300
|
+
confirmed.set(id, { body: toJson(bodyOf(input)), assessment, until: t + CONFIRM_TTL_MS });
|
|
301
|
+
return id;
|
|
302
|
+
}
|
|
303
|
+
function takeConfirmed(o, input) {
|
|
304
|
+
const id = o._confirm;
|
|
305
|
+
if (!id)
|
|
306
|
+
return null;
|
|
307
|
+
const c = confirmed.get(id);
|
|
308
|
+
if (!c || c.until <= Date.now() || c.body !== toJson(bodyOf(input)))
|
|
309
|
+
return null;
|
|
310
|
+
confirmed.delete(id); // once
|
|
311
|
+
return c.assessment;
|
|
312
|
+
}
|
|
313
|
+
/** the reservations of these reports, committed: the request was sent */
|
|
314
|
+
export async function commitSpend(reports, o) {
|
|
315
|
+
if (!o.spend)
|
|
316
|
+
return;
|
|
317
|
+
const store = storeOf(o);
|
|
318
|
+
for (const r of [].concat(reports))
|
|
319
|
+
for (const id of r.spend?.reservations ?? [])
|
|
320
|
+
await store.commit(id);
|
|
321
|
+
}
|
|
322
|
+
/** the reservations of these reports, released: the request was refused or the send threw */
|
|
323
|
+
export async function releaseSpend(reports, o) {
|
|
324
|
+
if (!o.spend)
|
|
137
325
|
return;
|
|
326
|
+
const store = storeOf(o);
|
|
327
|
+
for (const r of [].concat(reports))
|
|
328
|
+
for (const id of r.spend?.reservations ?? [])
|
|
329
|
+
await store.release(id);
|
|
330
|
+
}
|
|
331
|
+
/** the per-endpoint caps whose prefix the URL starts with */
|
|
332
|
+
function endpointCaps(sp, url) {
|
|
333
|
+
if (!url || !sp.perEndpoint)
|
|
334
|
+
return [];
|
|
335
|
+
let u;
|
|
336
|
+
try {
|
|
337
|
+
const x = new URL(url);
|
|
338
|
+
u = x.origin + x.pathname;
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
u = url;
|
|
342
|
+
}
|
|
343
|
+
return Object.entries(sp.perEndpoint).filter(([p]) => u.toLowerCase().startsWith(p.toLowerCase()));
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Spend mode: allowlists -> Toll's verdict (the 0.3.x policy, unchanged) -> caps -> approval. Resolves with the report
|
|
347
|
+
* holding its day reservations (commitSpend after the send, releaseSpend when it throws); throws TollPolicyError for a
|
|
348
|
+
* spend rule, TollBlockedError / TollUnavailableError as before.
|
|
349
|
+
*/
|
|
350
|
+
async function enforceSpend(kind, build, o) {
|
|
351
|
+
const io = o;
|
|
352
|
+
const sp = o.spend;
|
|
353
|
+
const store = storeOf(o);
|
|
354
|
+
const assets = assetsOf(sp);
|
|
355
|
+
const preview = io._preview === true;
|
|
356
|
+
let input = null;
|
|
357
|
+
let buildErr = null;
|
|
358
|
+
try {
|
|
359
|
+
input = await build();
|
|
360
|
+
}
|
|
361
|
+
catch (e) {
|
|
362
|
+
buildErr = e;
|
|
363
|
+
}
|
|
364
|
+
const blockWith = async (base, code, message, s, x = {}) => {
|
|
365
|
+
const r = { ...base, decision: "blocked", basis: "policy", policy: code, ...(s ? { spend: { spend: s, reservations: [] } } : {}) };
|
|
366
|
+
if (!preview)
|
|
367
|
+
await report(o, r);
|
|
368
|
+
throw new TollPolicyError(code, message, { ...x, assessment: base.assessment, report: r });
|
|
369
|
+
};
|
|
370
|
+
// 1. allowlists, from the local read, before Toll is asked
|
|
371
|
+
if (input) {
|
|
372
|
+
const local = spendOf(input, null, assets);
|
|
373
|
+
const why = offList(sp, local);
|
|
374
|
+
if (why)
|
|
375
|
+
return blockWith({ kind, input, assessment: null, decision: "blocked", basis: "policy", paidUrl: null }, "not-allowlisted", `Not signed: ${why}.`, local);
|
|
376
|
+
}
|
|
377
|
+
// 2. Toll's verdict (the 0.3.x policy), reported once at the end
|
|
378
|
+
let verdict = null;
|
|
379
|
+
const vo = { ...o, onReport: (r) => void (verdict = r) };
|
|
380
|
+
let base;
|
|
381
|
+
try {
|
|
382
|
+
base = await enforceVerdict(kind, buildErr ? () => Promise.reject(buildErr) : () => input, vo);
|
|
383
|
+
}
|
|
384
|
+
catch (e) {
|
|
385
|
+
if (verdict && !preview)
|
|
386
|
+
await report(o, verdict);
|
|
387
|
+
if (preview && verdict && e instanceof Error)
|
|
388
|
+
e.report = verdict;
|
|
389
|
+
throw e;
|
|
390
|
+
}
|
|
391
|
+
// 3. what it spends, now with Toll's simulation when there is one
|
|
392
|
+
const s = input ? spendOf(input, base.assessment, assets) : { items: [], outflow: {}, approvals: {}, to: [], spender: [], payTo: [], unknown: true, simulated: false };
|
|
393
|
+
const why = offList(sp, s);
|
|
394
|
+
if (why)
|
|
395
|
+
return blockWith(base, "not-allowlisted", `Not signed: ${why}.`, s);
|
|
396
|
+
if (s.unknown && (sp.onUnknownSpend ?? (o.failClosed ? "block" : "allow")) === "block") {
|
|
397
|
+
return blockWith(base, "unknown-spend", 'Not signed: what this request spends could not be read (no simulation), and onUnknownSpend is "block".', s);
|
|
398
|
+
}
|
|
399
|
+
const caps = lower(sp.assets);
|
|
400
|
+
const touched = [...new Set([...Object.keys(s.outflow), ...Object.keys(s.approvals)])];
|
|
401
|
+
const amountOf = (a) => {
|
|
402
|
+
const out = s.outflow[a] ?? 0n;
|
|
403
|
+
const ap = s.approvals[a] ?? 0n;
|
|
404
|
+
return out > ap ? out : ap;
|
|
405
|
+
};
|
|
406
|
+
// 4. per-call caps
|
|
407
|
+
for (const a of touched) {
|
|
408
|
+
const c = caps[a]?.perCall;
|
|
409
|
+
const amt = amountOf(a);
|
|
410
|
+
if (c !== undefined && amt > c)
|
|
411
|
+
return blockWith(base, "cap-per-call", `Not signed: ${amt} of ${fmt(a)} is over the per-call cap of ${c}.`, s, { asset: a, amount: amt, limit: c });
|
|
412
|
+
}
|
|
413
|
+
const x402url = typeof input?.x402?.url === "string" ? input.x402.url : typeof input?.x402?.resource === "string" ? input.x402.resource : undefined;
|
|
414
|
+
const eps = input?.x402 ? endpointCaps(sp, io._endpoint ?? x402url) : [];
|
|
415
|
+
const epAmount = Object.values(s.outflow).reduce((m, v) => (v > m ? v : m), 0n);
|
|
416
|
+
for (const [p, c] of eps) {
|
|
417
|
+
if (c.perCall !== undefined && epAmount > c.perCall)
|
|
418
|
+
return blockWith(base, "cap-endpoint", `Not paid: ${epAmount} is over the per-call cap of ${c.perCall} for ${p}.`, s, { amount: epAmount, limit: c.perCall });
|
|
419
|
+
}
|
|
420
|
+
// 5. day caps: outflows are reserved; an allowance must fit in what is left today (checked, not reserved)
|
|
421
|
+
const held = [];
|
|
422
|
+
const day = dayStart(Date.now());
|
|
423
|
+
const giveBack = async () => {
|
|
424
|
+
for (const id of held)
|
|
425
|
+
await store.release(id);
|
|
426
|
+
held.length = 0;
|
|
427
|
+
};
|
|
428
|
+
const reserve = async (key, amt, limit, keep, code, msg, asset) => {
|
|
429
|
+
const r = await store.reserve(key, amt, limit, day);
|
|
430
|
+
if (!r.ok) {
|
|
431
|
+
await giveBack();
|
|
432
|
+
await blockWith(base, code, `${msg} (${r.used} of ${limit} used today).`, s, { asset, amount: amt, limit });
|
|
433
|
+
}
|
|
434
|
+
if (keep && !preview)
|
|
435
|
+
held.push(r.id);
|
|
436
|
+
else
|
|
437
|
+
await store.release(r.id);
|
|
438
|
+
};
|
|
439
|
+
for (const a of touched) {
|
|
440
|
+
const c = caps[a]?.perDay;
|
|
441
|
+
if (c === undefined)
|
|
442
|
+
continue;
|
|
443
|
+
const out = s.outflow[a] ?? 0n;
|
|
444
|
+
if (out > 0n)
|
|
445
|
+
await reserve(`day:${a}`, out, c, true, "cap-per-day", `Not signed: ${out} more of ${fmt(a)} is over the day cap of ${c}`, a);
|
|
446
|
+
const ap = s.approvals[a] ?? 0n;
|
|
447
|
+
if (ap > 0n)
|
|
448
|
+
await reserve(`day:${a}`, ap, c, false, "cap-per-day", `Not signed: an allowance of ${ap} ${fmt(a)} is over what is left of the day cap of ${c}`, a);
|
|
449
|
+
}
|
|
450
|
+
for (const [p, c] of eps) {
|
|
451
|
+
if (c.perDay !== undefined && epAmount > 0n)
|
|
452
|
+
await reserve(`endpoint:${p}`, epAmount, c.perDay, true, "cap-endpoint", `Not paid: ${epAmount} more is over the day cap of ${c.perDay} for ${p}`);
|
|
453
|
+
}
|
|
454
|
+
// 6. human approval above a threshold
|
|
455
|
+
const above = lower(sp.approveAbove);
|
|
456
|
+
const over = touched.filter((a) => above[a] !== undefined && amountOf(a) > above[a]).map((a) => ({ asset: a, amount: amountOf(a), threshold: above[a] }));
|
|
457
|
+
let approvalRef;
|
|
458
|
+
let needsApproval = false;
|
|
459
|
+
if (over.length && preview)
|
|
460
|
+
needsApproval = true;
|
|
461
|
+
else if (over.length) {
|
|
462
|
+
let ok = false;
|
|
463
|
+
try {
|
|
464
|
+
const res = sp.approve ? await sp.approve({ kind, input, assessment: base.assessment, spend: s, over }) : { approved: false };
|
|
465
|
+
ok = res?.approved === true;
|
|
466
|
+
approvalRef = res?.approvalRef;
|
|
467
|
+
}
|
|
468
|
+
catch (e) {
|
|
469
|
+
say(o, `approve threw: ${e?.message ?? e}`);
|
|
470
|
+
}
|
|
471
|
+
if (!ok) {
|
|
472
|
+
await giveBack();
|
|
473
|
+
const top = over[0];
|
|
474
|
+
return blockWith(base, "approval-denied", `Not signed: ${top.amount} of ${fmt(top.asset)} is over ${top.threshold} and was not approved${sp.approve ? "" : " (no approve function)"}.`, s, { asset: top.asset, amount: top.amount, limit: top.threshold });
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
const r = { ...base, spend: { spend: s, reservations: [...held], ...(approvalRef !== undefined ? { approvalRef } : {}), ...(needsApproval ? { needsApproval } : {}) } };
|
|
478
|
+
if (!preview) {
|
|
479
|
+
await report(o, r);
|
|
480
|
+
io._collect?.push(r);
|
|
481
|
+
}
|
|
482
|
+
return r;
|
|
483
|
+
}
|
|
484
|
+
/** a request the spend policy refuses outside enforce (a repeated x402 payment id): reported, then TollPolicyError */
|
|
485
|
+
export async function policyRefuse(kind, input, code, message, o) {
|
|
486
|
+
const r = { kind, input, assessment: null, decision: "blocked", basis: "policy", policy: code, paidUrl: null };
|
|
487
|
+
await report(o, r);
|
|
488
|
+
throw new TollPolicyError(code, message, { report: r });
|
|
489
|
+
}
|
|
490
|
+
const kindOf = (i) => i.x402 ? "x402" : i.userOp ? "userOp" : i.typedData || i.rpc?.method === "eth_signTypedData_v4" ? "typedData" : i.message || i.rpc ? "message" : "transaction";
|
|
491
|
+
/**
|
|
492
|
+
* What the guard would do with this request, without doing it: Toll is asked and every spend rule is checked, but
|
|
493
|
+
* nothing is reserved, `approve` is not called and onReport is not called. When allowed, `confirmId` passed as
|
|
494
|
+
* `tollConfirm` on the send within 30 s skips the second Toll call (once, and only for the identical request).
|
|
495
|
+
*/
|
|
496
|
+
export async function preview(input, opts = {}) {
|
|
497
|
+
const { kind: k, ...rest } = opts;
|
|
498
|
+
const kind = k ?? kindOf(input);
|
|
499
|
+
const io = { ...rest, onReport: undefined, _preview: true };
|
|
500
|
+
try {
|
|
501
|
+
const r = await enforce(kind, () => input, io);
|
|
502
|
+
return {
|
|
503
|
+
decision: "allowed",
|
|
504
|
+
basis: r.basis,
|
|
505
|
+
assessment: r.assessment,
|
|
506
|
+
...(r.spend ? { spend: r.spend.spend } : {}),
|
|
507
|
+
...(r.spend?.needsApproval ? { needsApproval: true } : {}),
|
|
508
|
+
paidUrl: r.paidUrl,
|
|
509
|
+
confirmId: r.assessment ? rememberConfirm(input, r.assessment) : "",
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
catch (e) {
|
|
513
|
+
const rep = e.report;
|
|
514
|
+
if (e instanceof TollPolicyError) {
|
|
515
|
+
return { decision: "blocked", basis: "policy", policy: e.code, assessment: rep?.assessment ?? null, ...(rep?.spend ? { spend: rep.spend.spend } : {}), reason: e.message, paidUrl: rep?.paidUrl ?? null, confirmId: "" };
|
|
516
|
+
}
|
|
517
|
+
if (e instanceof TollBlockedError) {
|
|
518
|
+
return { decision: "blocked", basis: e.assessment.action, assessment: e.assessment, reason: e.message, paidUrl: paidUrlOf(e.assessment), confirmId: "" };
|
|
519
|
+
}
|
|
520
|
+
if (e instanceof TollUnavailableError) {
|
|
521
|
+
return { decision: "blocked", basis: e.reason === "unsupported" ? "unsupported" : "unavailable", assessment: null, reason: e.message, paidUrl: e.paidUrl, confirmId: "" };
|
|
522
|
+
}
|
|
523
|
+
throw e;
|
|
138
524
|
}
|
|
139
|
-
await refuse(kind, input, "eip7702-authorization", `${where} carries an EIP-7702 authorization, which sets this wallet's code to another contract that can then move everything it holds. Toll does not read authorizations yet; onAuthorization: "allow" lets it through unchecked.`, o);
|
|
140
525
|
}
|
package/dist/spend.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a request spends, read locally (calldata, typed data, an x402 requirement) and, when Toll's answer carries one,
|
|
3
|
+
* from its simulation (x-toll.simulation). Only outflows of the spend assets count (default: native ETH, WETH and USDG
|
|
4
|
+
* on Robinhood Chain), so selling a coin that is not a spend asset is never capped.
|
|
5
|
+
*/
|
|
6
|
+
import { type CheckInput, type RiskAssessment } from "./check.js";
|
|
7
|
+
/** native ETH as a spend-asset key */
|
|
8
|
+
export declare const NATIVE = "native";
|
|
9
|
+
/** USDG on Robinhood Chain */
|
|
10
|
+
export declare const USDG = "0x5fc5360d0400a0fd4f2af552add042d716f1d168";
|
|
11
|
+
/** WETH on Robinhood Chain */
|
|
12
|
+
export declare const WETH = "0x0bd7d308f8e1639fab988df18a8011f41eacad73";
|
|
13
|
+
/** the spend assets when none are given */
|
|
14
|
+
export declare const DEFAULT_SPEND_ASSETS: readonly string[];
|
|
15
|
+
/** an unlimited allowance (type(uint256).max): larger than any cap */
|
|
16
|
+
export declare const UNLIMITED: bigint;
|
|
17
|
+
export interface SpendItem {
|
|
18
|
+
kind: "value" | "transfer" | "approve" | "x402" | "simulation";
|
|
19
|
+
/** "native" or a lowercase token address */
|
|
20
|
+
asset: string;
|
|
21
|
+
/** base units; UNLIMITED for an unlimited allowance */
|
|
22
|
+
amount: bigint;
|
|
23
|
+
to?: string;
|
|
24
|
+
spender?: string;
|
|
25
|
+
payTo?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface Spend {
|
|
28
|
+
items: SpendItem[];
|
|
29
|
+
/** per asset, base units leaving the signer: the larger of what the call declares and what the simulation shows */
|
|
30
|
+
outflow: Record<string, bigint>;
|
|
31
|
+
/** per asset, the largest allowance granted (UNLIMITED = infinite) */
|
|
32
|
+
approvals: Record<string, bigint>;
|
|
33
|
+
/** who the request pays or calls (a token transfer's recipient, else the transaction's `to`), lowercase */
|
|
34
|
+
to: string[];
|
|
35
|
+
/** spenders an approval or permit names, lowercase */
|
|
36
|
+
spender: string[];
|
|
37
|
+
/** x402 payees, lowercase */
|
|
38
|
+
payTo: string[];
|
|
39
|
+
/** true when what leaves the wallet could not be read (a contract call with no simulation, a raw user operation) */
|
|
40
|
+
unknown: boolean;
|
|
41
|
+
/** true when Toll's simulation was read */
|
|
42
|
+
simulated: boolean;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* What a request spends. `assessment` is Toll's answer (null when Toll was not asked or could not answer): its
|
|
46
|
+
* simulation, when read, beats the declared amounts upward. `assets` are the spend assets ("native" or token addresses).
|
|
47
|
+
*/
|
|
48
|
+
export declare function spendOf(input: CheckInput, assessment: RiskAssessment | null | undefined, assets?: readonly string[]): Spend;
|