@tollxyz/guard 0.3.0 → 0.4.1
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 +206 -0
- package/dist/check.d.ts +7 -1
- package/dist/check.js +9 -3
- package/dist/index.d.ts +9 -3
- package/dist/index.js +5 -2
- package/dist/policy.d.ts +135 -3
- package/dist/policy.js +305 -5
- 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/ticket.d.ts +56 -0
- package/dist/ticket.js +90 -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 +3 -2
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.1",
|
|
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; single-use ALLOW tickets for contracts that require one. Zero runtime dependencies.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Toll",
|
|
7
7
|
"type": "module",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"erc-8004",
|
|
38
38
|
"erc-8470",
|
|
39
39
|
"erc-8265",
|
|
40
|
+
"allow-ticket",
|
|
40
41
|
"robinhood-chain",
|
|
41
42
|
"viem",
|
|
42
43
|
"ethers",
|