@tollxyz/guard 0.3.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 +167 -0
- package/dist/check.d.ts +1 -1
- package/dist/check.js +6 -2
- package/dist/index.d.ts +7 -3
- package/dist/index.js +4 -2
- package/dist/policy.d.ts +129 -3
- package/dist/policy.js +302 -4
- 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 +1 -1
- package/dist/wallets.js +76 -53
- package/dist/x402.d.ts +12 -0
- package/dist/x402.js +148 -7
- package/package.json +2 -2
package/dist/store.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the guard keeps its spend counters and the x402 payment ids it has paid. The default MemorySpendStore lives in
|
|
3
|
+
* this process: it resets on restart and is not shared between processes. Pass your own SpendStore (Redis, a database)
|
|
4
|
+
* for counters that survive restarts or span several workers.
|
|
5
|
+
*/
|
|
6
|
+
/** the start of the UTC day holding `t` (ms) */
|
|
7
|
+
export const dayStart = (t) => t - (((t % 86_400_000) + 86_400_000) % 86_400_000);
|
|
8
|
+
/** an in-process SpendStore: resets on restart, not shared between processes */
|
|
9
|
+
export class MemorySpendStore {
|
|
10
|
+
buckets = new Map();
|
|
11
|
+
where = new Map();
|
|
12
|
+
paid = new Map();
|
|
13
|
+
now;
|
|
14
|
+
seq = 0;
|
|
15
|
+
constructor(o = {}) {
|
|
16
|
+
this.now = o.now ?? Date.now;
|
|
17
|
+
}
|
|
18
|
+
prune(windowStart) {
|
|
19
|
+
// windows older than the previous day are dropped
|
|
20
|
+
for (const b of this.buckets.keys()) {
|
|
21
|
+
const ws = Number(b.slice(b.lastIndexOf("@") + 1));
|
|
22
|
+
if (ws < windowStart - 86_400_000)
|
|
23
|
+
this.buckets.delete(b);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async reserve(k, amt, limit, windowStart) {
|
|
27
|
+
// no await before the check and the write: atomic within this process
|
|
28
|
+
const bk = `${k}@${windowStart}`;
|
|
29
|
+
let b = this.buckets.get(bk);
|
|
30
|
+
if (!b) {
|
|
31
|
+
this.prune(windowStart);
|
|
32
|
+
b = { committed: 0n, pending: new Map() };
|
|
33
|
+
this.buckets.set(bk, b);
|
|
34
|
+
}
|
|
35
|
+
let used = b.committed;
|
|
36
|
+
for (const v of b.pending.values())
|
|
37
|
+
used += v;
|
|
38
|
+
if (amt < 0n || used + amt > limit)
|
|
39
|
+
return { ok: false, id: "", used };
|
|
40
|
+
const id = `r${++this.seq}`;
|
|
41
|
+
b.pending.set(id, amt);
|
|
42
|
+
this.where.set(id, bk);
|
|
43
|
+
return { ok: true, id, used: used + amt };
|
|
44
|
+
}
|
|
45
|
+
async commit(id) {
|
|
46
|
+
const bk = this.where.get(id);
|
|
47
|
+
const b = bk ? this.buckets.get(bk) : undefined;
|
|
48
|
+
this.where.delete(id);
|
|
49
|
+
const v = b?.pending.get(id);
|
|
50
|
+
if (!b || v === undefined)
|
|
51
|
+
return;
|
|
52
|
+
b.pending.delete(id);
|
|
53
|
+
b.committed += v;
|
|
54
|
+
}
|
|
55
|
+
async release(id) {
|
|
56
|
+
const bk = this.where.get(id);
|
|
57
|
+
this.where.delete(id);
|
|
58
|
+
if (bk)
|
|
59
|
+
this.buckets.get(bk)?.pending.delete(id);
|
|
60
|
+
}
|
|
61
|
+
async paymentSeen(id) {
|
|
62
|
+
const until = this.paid.get(id);
|
|
63
|
+
if (until === undefined)
|
|
64
|
+
return false;
|
|
65
|
+
if (until <= this.now()) {
|
|
66
|
+
this.paid.delete(id);
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
async markPaid(id, ttlS) {
|
|
72
|
+
const t = this.now();
|
|
73
|
+
for (const [k, until] of this.paid)
|
|
74
|
+
if (until <= t)
|
|
75
|
+
this.paid.delete(k);
|
|
76
|
+
this.paid.set(id, t + ttlS * 1000);
|
|
77
|
+
}
|
|
78
|
+
}
|
package/dist/wallets.d.ts
CHANGED
|
@@ -28,7 +28,7 @@ export declare function guardViem<T extends object>(client: T, opts?: ViemGuardO
|
|
|
28
28
|
* `calls` are checked one by one as transactions from the smart account's address; a ready `callData` is sent as a
|
|
29
29
|
* user operation with the account's EntryPoint. An EIP-7702 `authorization` goes to onAuthorization.
|
|
30
30
|
*/
|
|
31
|
-
export declare function guardSmartAccount<T extends object>(client: T,
|
|
31
|
+
export declare function guardSmartAccount<T extends object>(client: T, sopts?: ViemGuardOptions): T;
|
|
32
32
|
/** the EIP-712 primary type: the one type no other type refers to (as ethers' TypedDataEncoder.getPrimaryType) */
|
|
33
33
|
export declare function primaryTypeOf(types: Record<string, Array<{
|
|
34
34
|
name: string;
|
package/dist/wallets.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* The wrapper is a Proxy: every other method and field of the client passes through unchanged.
|
|
4
4
|
*/
|
|
5
5
|
import { chainIdOf, isAddress } from "./check.js";
|
|
6
|
-
import { enforce, enforceCalls, gateAuthorization } from "./policy.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;
|
|
@@ -114,61 +144,57 @@ export function guardViem(client, opts = {}) {
|
|
|
114
144
|
for (const m of ["sendTransaction", "signTransaction"]) {
|
|
115
145
|
if (typeof c[m] !== "function")
|
|
116
146
|
continue;
|
|
117
|
-
wrapped[m] = async (
|
|
147
|
+
wrapped[m] = async (raw, ...rest) => {
|
|
148
|
+
const [confirm, args] = takeConfirm(raw);
|
|
118
149
|
// a deployment (no `to`) goes to the unsupported policy
|
|
119
|
-
|
|
120
|
-
return c[m](args, ...rest);
|
|
150
|
+
return guarded(opts, confirm, (o) => gateTx(m, () => txInput(args, args?.data), hasAuthorizations(args), o), () => c[m](args, ...rest));
|
|
121
151
|
};
|
|
122
152
|
}
|
|
123
153
|
if (typeof c.writeContract === "function") {
|
|
124
|
-
wrapped.writeContract = async (
|
|
125
|
-
|
|
154
|
+
wrapped.writeContract = async (raw, ...rest) => {
|
|
155
|
+
const [confirm, args] = takeConfirm(raw);
|
|
156
|
+
const build = async () => {
|
|
126
157
|
const enc = opts.encodeFunctionData ?? (await loadViemEncode());
|
|
127
158
|
let data = String(enc({ abi: args.abi, functionName: args.functionName, args: args.args }));
|
|
128
159
|
if (typeof args.dataSuffix === "string")
|
|
129
160
|
data += args.dataSuffix.replace(/^0x/, "");
|
|
130
161
|
return txInput({ ...args, to: args.address }, data);
|
|
131
|
-
}
|
|
132
|
-
return c.writeContract(args, ...rest);
|
|
162
|
+
};
|
|
163
|
+
return guarded(opts, confirm, (o) => gateTx("writeContract", build, hasAuthorizations(args), o), () => c.writeContract(args, ...rest));
|
|
133
164
|
};
|
|
134
165
|
}
|
|
135
166
|
if (typeof c.signTypedData === "function") {
|
|
136
167
|
wrapped.signTypedData = async (args, ...rest) => {
|
|
137
168
|
const td = { domain: args?.domain ?? {}, types: args?.types ?? {}, primaryType: args?.primaryType, message: args?.message ?? {} };
|
|
138
|
-
|
|
139
|
-
|
|
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));
|
|
140
172
|
};
|
|
141
173
|
}
|
|
142
174
|
if (typeof c.signMessage === "function") {
|
|
143
|
-
wrapped.signMessage = async (args, ...rest) => {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}, opts);
|
|
153
|
-
return c.signMessage(args, ...rest);
|
|
154
|
-
};
|
|
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));
|
|
155
184
|
}
|
|
156
185
|
if (typeof c.sendCalls === "function") {
|
|
157
186
|
wrapped.sendCalls = async (args, ...rest) => {
|
|
158
|
-
|
|
159
|
-
return c.sendCalls(args, ...rest);
|
|
187
|
+
return guarded(opts, undefined, (o) => enforceCalls(() => callsOf(args?.calls, chainOf(args), fromOf(args), opts), o), () => c.sendCalls(args, ...rest));
|
|
160
188
|
};
|
|
161
189
|
}
|
|
162
190
|
if (typeof c.signAuthorization === "function") {
|
|
163
191
|
wrapped.signAuthorization = async (args, ...rest) => {
|
|
164
|
-
|
|
165
|
-
return c.signAuthorization(args, ...rest);
|
|
192
|
+
return guarded(opts, undefined, (o) => gateAuthorization("transaction", "signAuthorization", () => authInput(args, c.chain?.id, fromOf(args)), o), () => c.signAuthorization(args, ...rest));
|
|
166
193
|
};
|
|
167
194
|
}
|
|
168
195
|
if (typeof c.request === "function") {
|
|
169
196
|
wrapped.request = async (args, ...rest) => {
|
|
170
|
-
|
|
171
|
-
return c.request(args, ...rest);
|
|
197
|
+
return guarded(opts, undefined, (o) => checkRequest(args, c, fromOf({}), { ...opts, ...o }), () => c.request(args, ...rest));
|
|
172
198
|
};
|
|
173
199
|
}
|
|
174
200
|
return proxy(client, wrapped);
|
|
@@ -232,9 +258,9 @@ const USEROP_FIELDS = [
|
|
|
232
258
|
* `calls` are checked one by one as transactions from the smart account's address; a ready `callData` is sent as a
|
|
233
259
|
* user operation with the account's EntryPoint. An EIP-7702 `authorization` goes to onAuthorization.
|
|
234
260
|
*/
|
|
235
|
-
export function guardSmartAccount(client,
|
|
261
|
+
export function guardSmartAccount(client, sopts = {}) {
|
|
236
262
|
const c = client;
|
|
237
|
-
const check = async (where, args) => {
|
|
263
|
+
const check = async (where, args, opts) => {
|
|
238
264
|
const acct = args?.account ?? c.account;
|
|
239
265
|
const sender = addrOf(acct);
|
|
240
266
|
const chainId = args?.chain?.id ?? c.chain?.id;
|
|
@@ -262,10 +288,7 @@ export function guardSmartAccount(client, opts = {}) {
|
|
|
262
288
|
for (const m of ["sendUserOperation", "prepareUserOperation"]) {
|
|
263
289
|
if (typeof c[m] !== "function")
|
|
264
290
|
continue;
|
|
265
|
-
wrapped[m] = async (args, ...rest) => {
|
|
266
|
-
await check(m, args);
|
|
267
|
-
return c[m](args, ...rest);
|
|
268
|
-
};
|
|
291
|
+
wrapped[m] = async (args, ...rest) => guarded(sopts, undefined, (o) => check(m, args, { ...sopts, ...o }), () => c[m](args, ...rest));
|
|
269
292
|
}
|
|
270
293
|
return proxy(client, wrapped);
|
|
271
294
|
}
|
|
@@ -326,38 +349,38 @@ export function guardEthers(signer, opts = {}) {
|
|
|
326
349
|
for (const m of ["sendTransaction", "signTransaction"]) {
|
|
327
350
|
if (typeof s[m] !== "function")
|
|
328
351
|
continue;
|
|
329
|
-
wrapped[m] = async (
|
|
330
|
-
|
|
331
|
-
return s[m](tx, ...rest);
|
|
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));
|
|
332
355
|
};
|
|
333
356
|
}
|
|
334
357
|
if (typeof s.signTypedData === "function") {
|
|
335
358
|
wrapped.signTypedData = async (domain, types, value, ...rest) => {
|
|
336
|
-
|
|
337
|
-
|
|
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));
|
|
338
364
|
};
|
|
339
365
|
}
|
|
340
366
|
if (typeof s.signMessage === "function") {
|
|
341
|
-
wrapped.signMessage = async (message, ...rest) => {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
}, opts);
|
|
350
|
-
return s.signMessage(message, ...rest);
|
|
351
|
-
};
|
|
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));
|
|
352
375
|
}
|
|
353
376
|
if (typeof s.authorize === "function") {
|
|
354
377
|
wrapped.authorize = async (auth, ...rest) => {
|
|
355
|
-
|
|
378
|
+
const build = async () => {
|
|
356
379
|
const chainId = chainOut(await ethersChainId(s));
|
|
357
380
|
const from = typeof s.getAddress === "function" ? await s.getAddress() : undefined;
|
|
358
381
|
return authInput(auth ?? {}, chainId, isAddress(from) ? from : undefined);
|
|
359
|
-
}
|
|
360
|
-
return s.authorize(auth, ...rest);
|
|
382
|
+
};
|
|
383
|
+
return guarded(opts, undefined, (o) => gateAuthorization("transaction", "authorize", build, o), () => s.authorize(auth, ...rest));
|
|
361
384
|
};
|
|
362
385
|
}
|
|
363
386
|
return proxy(signer, wrapped);
|
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, 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.",
|
|
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",
|