@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/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
- const hasPayment = (input, init) => {
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 false;
66
+ return null;
66
67
  try {
67
68
  const hs = new Headers(h);
68
- return PAY_HEADERS.some((n) => hs.has(n));
69
+ for (const n of PAY_HEADERS)
70
+ if (hs.has(n))
71
+ return hs.get(n) ?? "";
69
72
  }
70
73
  catch {
71
- return false;
74
+ /* unreadable headers */
72
75
  }
76
+ return null;
73
77
  };
74
- return read(init?.headers) || (typeof Request !== "undefined" && input instanceof Request && read(input.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
- }, toll);
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.3.0",
4
- "description": "Toll's live pre-sign check in front of an AI agent's wallet: viem, viem smart-account, ethers and x402 fetch wrappers that ask Toll (ALLOW / WARN / BLOCK, ERC-8265) before anything is signed on Robinhood Chain: transactions, messages, typed data, batches, user operations, EIP-7702 authorizations. Zero runtime dependencies.",
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",