x402-seatbelt 0.1.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/LICENSE +21 -0
- package/README.md +69 -0
- package/dist/index.cjs +323 -0
- package/dist/index.d.cts +127 -0
- package/dist/index.d.ts +127 -0
- package/dist/index.js +290 -0
- package/package.json +67 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 gmahar82-stack
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# x402-seatbelt
|
|
2
|
+
|
|
3
|
+
**A seatbelt for AI agents that pay with [x402](https://www.x402.org).** Spending budgets, per-payment caps, an emergency stop and an optional **Pay Safe** check, applied to every payment **before it leaves your machine**.
|
|
4
|
+
|
|
5
|
+
Agents that pay for APIs can overspend, pay a service that's down, get charged more than the listed price, or pay a tampered wallet. x402-seatbelt wraps `fetch`, reads each signed payment as it's about to go out, and blocks it when it crosses a line you set. A blocked payment is never delivered, so **nothing is paid**.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { wrapFetchWithPayment } from "@x402/fetch";
|
|
9
|
+
import { createSeatbelt } from "x402-seatbelt";
|
|
10
|
+
|
|
11
|
+
const seatbelt = createSeatbelt({
|
|
12
|
+
maxTotalUsd: 2.0, // budget for all payments
|
|
13
|
+
maxPaymentUsd: 0.05, // no single payment above 5 cents
|
|
14
|
+
paySafe: true, // optional: check each payment with Pay Safe first
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const fetchWithPayment = wrapFetchWithPayment(seatbelt.wrap(fetch), client);
|
|
18
|
+
|
|
19
|
+
await fetchWithPayment("https://api.example.com/paid-endpoint");
|
|
20
|
+
console.log(seatbelt.report()); // { payments: 1, spentUsd: 0.01, blocked: 0, ... }
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The seatbelt goes **inside** the x402 client (`wrap(fetch)` is passed to `wrapFetchWithPayment`), so it sees the paid retry that the client sends.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install x402-seatbelt
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
No dependencies. It works anywhere `fetch` does: Node 18+, Deno, Bun, Cloudflare Workers and browsers. It ships as both ESM and CommonJS, with types.
|
|
32
|
+
|
|
33
|
+
## Options
|
|
34
|
+
|
|
35
|
+
| Option | What happens |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `maxTotalUsd` | A payment that would push the total over the budget is never sent. Parallel payments reserve their amount, so they can't overshoot together. |
|
|
38
|
+
| `maxPaymentUsd` | Any single payment above the cap is blocked. Payments in other assets are blocked too, because their dollar value is unknown. |
|
|
39
|
+
| `maxPayments` | Blocks payments after this many. |
|
|
40
|
+
| `paySafe` | Checks each payment with Pay Safe (see below). Off by default. |
|
|
41
|
+
| `paySafeBlock` | `"stop"` (default) blocks STOP answers; `"caution"` also blocks CAUTION. |
|
|
42
|
+
| `paySafeFailClosed` | Blocks payments when Pay Safe can't be reached (default: let them through). |
|
|
43
|
+
| `paySafeUrl`, `paySafeTimeoutMs` | Endpoint (default: the public Pay Safe) and timeout (default 8000). |
|
|
44
|
+
| `onEvent` | A callback for every payment, block and Pay Safe answer. |
|
|
45
|
+
|
|
46
|
+
- `seatbelt.stop()` blocks every further payment (an emergency stop).
|
|
47
|
+
- `seatbelt.report()` returns payments, `spentUsd`, `pendingUsd`, blocked count, Pay Safe checks and the last 200 events.
|
|
48
|
+
|
|
49
|
+
A blocked payment throws **`PaymentBlockedError`** with a `reason`: `budget`, `max_payment`, `max_payments`, `unknown_value`, `pay_safe`, `pay_safe_unavailable` or `stopped`. The error also carries the `payment`, and Pay Safe's `verdict` when that was the cause.
|
|
50
|
+
|
|
51
|
+
Failed calls aren't counted: a payment only counts when the service accepted it (a `PAYMENT-RESPONSE` header or a 2xx answer). x402 v1 and v2 are both supported.
|
|
52
|
+
|
|
53
|
+
## Pay Safe (optional, off by default)
|
|
54
|
+
|
|
55
|
+
With `paySafe: true`, each payment is checked by [Pay Safe](https://agent-deals.gm-tools.workers.dev/trust) before it's sent. It returns **GO / CAUTION / STOP**:
|
|
56
|
+
|
|
57
|
+
- **Is the service working?** It's monitored continuously across ~25,000 paid agent APIs.
|
|
58
|
+
- **Is the price fair?** It compares the price with the service's own listing, what it charged before, and similar services.
|
|
59
|
+
- **Is the wallet safe?** It checks scam lists and burn addresses, and whether this is the same wallet the service normally uses. A different wallet can mean a tampered payment request.
|
|
60
|
+
|
|
61
|
+
**What is sent:** the service URL and the payment terms (amount, asset, network, pay-to wallet). **Never** the signature, your keys or your request content. Answers are cached for a minute. The check is free. Pay Safe is run by the author of this package, and its accuracy tests are [published](https://agent-deals.gm-tools.workers.dev/trust).
|
|
62
|
+
|
|
63
|
+
## Also for Python
|
|
64
|
+
|
|
65
|
+
[`agentseatbelt`](https://pypi.org/project/agentseatbelt/) does the same for Python agents (httpx, requests and Coinbase's `x402` client), plus LLM cost budgets, rate limits and loop detection.
|
|
66
|
+
|
|
67
|
+
## License
|
|
68
|
+
|
|
69
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
PAY_SAFE_URL: () => PAY_SAFE_URL,
|
|
24
|
+
PaymentBlockedError: () => PaymentBlockedError,
|
|
25
|
+
Seatbelt: () => Seatbelt,
|
|
26
|
+
createSeatbelt: () => createSeatbelt,
|
|
27
|
+
hasPayment: () => hasPayment,
|
|
28
|
+
parsePayment: () => parsePayment,
|
|
29
|
+
parseQuote: () => parseQuote
|
|
30
|
+
});
|
|
31
|
+
module.exports = __toCommonJS(index_exports);
|
|
32
|
+
|
|
33
|
+
// src/payment.ts
|
|
34
|
+
var PAYMENT_HEADERS = ["payment-signature", "x-payment"];
|
|
35
|
+
var RESPONSE_HEADERS = ["payment-response", "x-payment-response"];
|
|
36
|
+
var USDC = /* @__PURE__ */ new Set([
|
|
37
|
+
"0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
|
38
|
+
// Base
|
|
39
|
+
"0x036cbd53842c5426634e7929541ec2318f3dcf7e",
|
|
40
|
+
// Base Sepolia
|
|
41
|
+
"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
|
|
42
|
+
// Ethereum
|
|
43
|
+
"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359",
|
|
44
|
+
// Polygon
|
|
45
|
+
"0xaf88d065e77c8cc2239327c5edb3a432268e5831",
|
|
46
|
+
// Arbitrum
|
|
47
|
+
"0x0b2c639c533813f4aa9d7837caf62653d097ff85",
|
|
48
|
+
// Optimism
|
|
49
|
+
"epjfwdd5aufqssqem2qn1xzybapc8g4wegkzwytdt1v"
|
|
50
|
+
// Solana (lowercased)
|
|
51
|
+
]);
|
|
52
|
+
function decodeBase64Json(value) {
|
|
53
|
+
const text = value.trim();
|
|
54
|
+
try {
|
|
55
|
+
const binary = atob(text);
|
|
56
|
+
return JSON.parse(new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0))));
|
|
57
|
+
} catch {
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(text);
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function toBigInt(value) {
|
|
66
|
+
try {
|
|
67
|
+
return value === void 0 || value === null || value === "" ? null : BigInt(String(value));
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
var usdOf = (asset, raw) => raw !== null && asset && USDC.has(asset.toLowerCase()) ? Number(raw) / 1e6 : null;
|
|
73
|
+
var obj = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : null;
|
|
74
|
+
function hasPayment(headers) {
|
|
75
|
+
return PAYMENT_HEADERS.some((h) => headers.get(h));
|
|
76
|
+
}
|
|
77
|
+
function isSettled(headers) {
|
|
78
|
+
return RESPONSE_HEADERS.some((h) => headers.get(h));
|
|
79
|
+
}
|
|
80
|
+
function parseQuote(headers, body) {
|
|
81
|
+
const raw = headers.get("payment-required");
|
|
82
|
+
const quote = obj(raw ? decodeBase64Json(raw) : body);
|
|
83
|
+
const accepts = quote?.accepts;
|
|
84
|
+
return Array.isArray(accepts) ? accepts.filter((a) => obj(a)) : [];
|
|
85
|
+
}
|
|
86
|
+
function parsePayment(headers, quotes = []) {
|
|
87
|
+
const raw = PAYMENT_HEADERS.map((h) => headers.get(h)).find(Boolean);
|
|
88
|
+
if (!raw) return null;
|
|
89
|
+
const data = obj(decodeBase64Json(raw));
|
|
90
|
+
if (!data) return { version: 0, network: "", payTo: null, asset: null, rawAmount: null, usd: null, terms: null };
|
|
91
|
+
const auth = obj(obj(data.payload)?.authorization) ?? {};
|
|
92
|
+
const accepted = obj(data.accepted);
|
|
93
|
+
if (accepted) {
|
|
94
|
+
const rawAmount2 = toBigInt(accepted.amount);
|
|
95
|
+
const asset2 = typeof accepted.asset === "string" ? accepted.asset : null;
|
|
96
|
+
return {
|
|
97
|
+
version: 2,
|
|
98
|
+
network: String(accepted.network ?? ""),
|
|
99
|
+
payTo: accepted.payTo ?? auth.to ?? null,
|
|
100
|
+
asset: asset2,
|
|
101
|
+
rawAmount: rawAmount2,
|
|
102
|
+
usd: usdOf(asset2, rawAmount2),
|
|
103
|
+
terms: accepted
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const payTo = typeof auth.to === "string" ? auth.to : null;
|
|
107
|
+
const rawAmount = toBigInt(auth.value);
|
|
108
|
+
const network = String(data.network ?? "");
|
|
109
|
+
const match = quotes.find((q) => String(q.payTo ?? "").toLowerCase() === String(payTo ?? "").toLowerCase() && String(q.network ?? "") === network);
|
|
110
|
+
const asset = match && typeof match.asset === "string" ? match.asset : null;
|
|
111
|
+
const terms = match ? { ...match, network, asset: asset ?? "", payTo: payTo ?? "", amount: rawAmount !== null ? rawAmount.toString() : String(match.maxAmountRequired ?? "0") } : null;
|
|
112
|
+
return { version: 1, network, payTo, asset, rawAmount, usd: usdOf(asset, rawAmount), terms };
|
|
113
|
+
}
|
|
114
|
+
function describe(p) {
|
|
115
|
+
const amount = p.usd !== null ? `$${Number(p.usd.toPrecision(6))}` : `${p.rawAmount ?? "?"} units of ${p.asset ?? "an unknown asset"}`;
|
|
116
|
+
return `${amount} to ${p.payTo ?? "?"} on ${p.network || "?"}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// src/seatbelt.ts
|
|
120
|
+
var PAY_SAFE_URL = "https://agent-deals.gm-tools.workers.dev/v1/preflight";
|
|
121
|
+
var PAY_SAFE_CACHE_MS = 6e4;
|
|
122
|
+
var MAX_EVENTS = 200;
|
|
123
|
+
var PaymentBlockedError = class extends Error {
|
|
124
|
+
reason;
|
|
125
|
+
payment;
|
|
126
|
+
verdict;
|
|
127
|
+
constructor(reason, message, payment, verdict) {
|
|
128
|
+
super(message);
|
|
129
|
+
this.name = "PaymentBlockedError";
|
|
130
|
+
this.reason = reason;
|
|
131
|
+
this.payment = payment;
|
|
132
|
+
this.verdict = verdict;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
function requestParts(input, init) {
|
|
136
|
+
const isRequest = typeof Request !== "undefined" && input instanceof Request;
|
|
137
|
+
const url = isRequest ? input.url : input instanceof URL ? input.href : String(input);
|
|
138
|
+
const method = (init?.method ?? (isRequest ? input.method : "GET")).toUpperCase();
|
|
139
|
+
const headers = new Headers(init?.headers ?? (isRequest ? input.headers : void 0));
|
|
140
|
+
return { url, method, headers };
|
|
141
|
+
}
|
|
142
|
+
var Seatbelt = class {
|
|
143
|
+
options;
|
|
144
|
+
spent = 0;
|
|
145
|
+
pending = 0;
|
|
146
|
+
stats = { payments: 0, blocked: 0, unvaluedPayments: 0, paySafeChecks: 0 };
|
|
147
|
+
stopped = false;
|
|
148
|
+
events = [];
|
|
149
|
+
quotes = /* @__PURE__ */ new Map();
|
|
150
|
+
paySafeAnswers = /* @__PURE__ */ new Map();
|
|
151
|
+
constructor(options = {}) {
|
|
152
|
+
if (options.paySafeBlock && !["stop", "caution"].includes(options.paySafeBlock)) {
|
|
153
|
+
throw new Error('paySafeBlock must be "stop" or "caution"');
|
|
154
|
+
}
|
|
155
|
+
this.options = options;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* A fetch that checks every x402 payment before sending it. Pass it to your x402 client:
|
|
159
|
+
*
|
|
160
|
+
* const fetchWithPayment = wrapFetchWithPayment(seatbelt.wrap(fetch), client);
|
|
161
|
+
*/
|
|
162
|
+
wrap(baseFetch = globalThis.fetch) {
|
|
163
|
+
const wrapped = async (input, init) => {
|
|
164
|
+
const { url, method, headers } = requestParts(input, init);
|
|
165
|
+
const payment = hasPayment(headers) ? await this.checkPayment(url, method, headers, baseFetch) : null;
|
|
166
|
+
let response;
|
|
167
|
+
try {
|
|
168
|
+
response = await baseFetch(input, init);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (payment) this.release(payment, url, "the request failed");
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
await this.recordResponse(url, response, payment);
|
|
174
|
+
return response;
|
|
175
|
+
};
|
|
176
|
+
return wrapped;
|
|
177
|
+
}
|
|
178
|
+
/** Stop every further payment (an emergency stop). */
|
|
179
|
+
stop() {
|
|
180
|
+
this.stopped = true;
|
|
181
|
+
}
|
|
182
|
+
report() {
|
|
183
|
+
return {
|
|
184
|
+
...this.stats,
|
|
185
|
+
spentUsd: Math.round(this.spent * 1e6) / 1e6,
|
|
186
|
+
pendingUsd: Math.round(this.pending * 1e6) / 1e6,
|
|
187
|
+
stopped: this.stopped,
|
|
188
|
+
events: [...this.events]
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
// ---- internals -------------------------------------------------------------------------------------------
|
|
192
|
+
event(type, detail, url, payment) {
|
|
193
|
+
const ev = { time: Date.now(), type, detail, url, payment };
|
|
194
|
+
this.events.push(ev);
|
|
195
|
+
if (this.events.length > MAX_EVENTS) this.events.shift();
|
|
196
|
+
try {
|
|
197
|
+
this.options.onEvent?.(ev);
|
|
198
|
+
} catch {
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
block(reason, why, payment, url, verdict) {
|
|
202
|
+
this.stats.blocked++;
|
|
203
|
+
const message = `x402-seatbelt blocked a payment of ${describe(payment)}: ${why}`;
|
|
204
|
+
this.event("blocked", message, url, payment);
|
|
205
|
+
throw new PaymentBlockedError(reason, message, payment, verdict);
|
|
206
|
+
}
|
|
207
|
+
/** Limits, then (optionally) Pay Safe. Reserves the amount so parallel payments can't overshoot the budget. */
|
|
208
|
+
async checkPayment(url, method, headers, baseFetch) {
|
|
209
|
+
const o = this.options;
|
|
210
|
+
const payment = parsePayment(headers, this.quotes.get(url));
|
|
211
|
+
if (!payment) return null;
|
|
212
|
+
this.checkLimits(payment, url);
|
|
213
|
+
if (payment.usd !== null) this.pending += payment.usd;
|
|
214
|
+
try {
|
|
215
|
+
if (o.paySafe) await this.paySafe(url, method, payment, baseFetch);
|
|
216
|
+
this.checkLimits(payment, url, payment.usd ?? 0);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
if (payment.usd !== null) this.pending -= payment.usd;
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
return payment;
|
|
222
|
+
}
|
|
223
|
+
checkLimits(payment, url, reservedByThis = 0) {
|
|
224
|
+
const o = this.options;
|
|
225
|
+
if (this.stopped) this.block("stopped", "the seatbelt was stopped", payment, url);
|
|
226
|
+
if (o.maxPayments !== void 0 && this.stats.payments >= o.maxPayments) {
|
|
227
|
+
this.block("max_payments", `maximum of ${o.maxPayments} payments reached`, payment, url);
|
|
228
|
+
}
|
|
229
|
+
if (o.maxPaymentUsd !== void 0) {
|
|
230
|
+
if (payment.usd === null) this.block("unknown_value", "its dollar value is unknown (not USDC), so maxPaymentUsd can't be applied", payment, url);
|
|
231
|
+
if (payment.usd > o.maxPaymentUsd) this.block("max_payment", `above maxPaymentUsd ($${o.maxPaymentUsd})`, payment, url);
|
|
232
|
+
}
|
|
233
|
+
if (o.maxTotalUsd !== void 0 && payment.usd !== null) {
|
|
234
|
+
const committed = this.spent + this.pending - reservedByThis;
|
|
235
|
+
if (committed + payment.usd > o.maxTotalUsd + 1e-12) {
|
|
236
|
+
this.block("budget", `it would exceed maxTotalUsd ($${o.maxTotalUsd}; $${committed.toFixed(6)} already committed)`, payment, url);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
async paySafe(url, method, payment, baseFetch) {
|
|
241
|
+
const o = this.options;
|
|
242
|
+
const key = [url, payment.payTo?.toLowerCase(), payment.rawAmount?.toString(), payment.asset?.toLowerCase()].join("|");
|
|
243
|
+
const cached = this.paySafeAnswers.get(key);
|
|
244
|
+
let answer;
|
|
245
|
+
if (cached && Date.now() - cached.at < PAY_SAFE_CACHE_MS) {
|
|
246
|
+
answer = cached.answer;
|
|
247
|
+
} else {
|
|
248
|
+
const terms = payment.terms ?? {
|
|
249
|
+
scheme: "exact",
|
|
250
|
+
network: payment.network,
|
|
251
|
+
amount: (payment.rawAmount ?? 0n).toString(),
|
|
252
|
+
asset: payment.asset ?? "",
|
|
253
|
+
payTo: payment.payTo ?? ""
|
|
254
|
+
};
|
|
255
|
+
try {
|
|
256
|
+
const res = await baseFetch(o.paySafeUrl ?? PAY_SAFE_URL, {
|
|
257
|
+
method: "POST",
|
|
258
|
+
headers: { "content-type": "application/json", "user-agent": "x402-seatbelt" },
|
|
259
|
+
body: JSON.stringify({ url, method, payment_required: { x402Version: 2, resource: { url }, accepts: [terms] } }),
|
|
260
|
+
signal: AbortSignal.timeout(o.paySafeTimeoutMs ?? 8e3)
|
|
261
|
+
});
|
|
262
|
+
const body = await res.json();
|
|
263
|
+
if (!res.ok || !["go", "caution", "stop"].includes(body?.verdict)) throw new Error(`unexpected answer (HTTP ${res.status})`);
|
|
264
|
+
answer = body;
|
|
265
|
+
this.paySafeAnswers.set(key, { at: Date.now(), answer });
|
|
266
|
+
} catch (error) {
|
|
267
|
+
this.event("pay_safe", `check unavailable (${error.message}) for ${describe(payment)}`, url, payment);
|
|
268
|
+
if (o.paySafeFailClosed) this.block("pay_safe_unavailable", "Pay Safe couldn't be reached (paySafeFailClosed)", payment, url);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
this.stats.paySafeChecks++;
|
|
273
|
+
this.event("pay_safe", `${answer.verdict.toUpperCase()} for ${describe(payment)}: ${answer.summary ?? ""}`, url, payment);
|
|
274
|
+
if (answer.verdict === "stop" || answer.verdict === "caution" && o.paySafeBlock === "caution") {
|
|
275
|
+
this.block("pay_safe", `Pay Safe says ${answer.verdict.toUpperCase()}: ${answer.summary ?? ""}`, payment, url, answer);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
release(payment, url, why) {
|
|
279
|
+
if (payment.usd !== null) this.pending -= payment.usd;
|
|
280
|
+
this.event("not_charged", `not charged (${why}): ${describe(payment)}`, url, payment);
|
|
281
|
+
}
|
|
282
|
+
async recordResponse(url, response, payment) {
|
|
283
|
+
if (response.status === 402) {
|
|
284
|
+
let body;
|
|
285
|
+
if (!response.headers.get("payment-required")) {
|
|
286
|
+
try {
|
|
287
|
+
body = await response.clone().json();
|
|
288
|
+
} catch {
|
|
289
|
+
body = void 0;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const quotes = parseQuote(response.headers, body);
|
|
293
|
+
if (quotes.length) this.quotes.set(url, quotes);
|
|
294
|
+
}
|
|
295
|
+
if (!payment) return;
|
|
296
|
+
if (!(isSettled(response.headers) || response.ok)) {
|
|
297
|
+
this.release(payment, url, `HTTP ${response.status}`);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
this.stats.payments++;
|
|
301
|
+
if (payment.usd === null) {
|
|
302
|
+
this.stats.unvaluedPayments++;
|
|
303
|
+
this.event("payment", `paid ${describe(payment)} (not valued in USD)`, url, payment);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
this.pending -= payment.usd;
|
|
307
|
+
this.spent += payment.usd;
|
|
308
|
+
this.event("payment", `paid ${describe(payment)} (total $${this.spent.toFixed(6)})`, url, payment);
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
function createSeatbelt(options = {}) {
|
|
312
|
+
return new Seatbelt(options);
|
|
313
|
+
}
|
|
314
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
315
|
+
0 && (module.exports = {
|
|
316
|
+
PAY_SAFE_URL,
|
|
317
|
+
PaymentBlockedError,
|
|
318
|
+
Seatbelt,
|
|
319
|
+
createSeatbelt,
|
|
320
|
+
hasPayment,
|
|
321
|
+
parsePayment,
|
|
322
|
+
parseQuote
|
|
323
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/** Payment terms, in x402 v2 shape. */
|
|
2
|
+
interface PaymentTerms {
|
|
3
|
+
scheme?: string;
|
|
4
|
+
network: string;
|
|
5
|
+
amount: string;
|
|
6
|
+
asset: string;
|
|
7
|
+
payTo: string;
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
interface Payment {
|
|
11
|
+
version: number;
|
|
12
|
+
network: string;
|
|
13
|
+
payTo: string | null;
|
|
14
|
+
asset: string | null;
|
|
15
|
+
/** Amount in the asset's smallest unit. */
|
|
16
|
+
rawAmount: bigint | null;
|
|
17
|
+
/** Dollar value for USDC payments; null when the asset or amount is unknown. */
|
|
18
|
+
usd: number | null;
|
|
19
|
+
/** The terms the client accepted (v2), or rebuilt from the 402 quote (v1). */
|
|
20
|
+
terms: PaymentTerms | null;
|
|
21
|
+
}
|
|
22
|
+
type HeaderSource = {
|
|
23
|
+
get(name: string): string | null;
|
|
24
|
+
};
|
|
25
|
+
declare function hasPayment(headers: HeaderSource): boolean;
|
|
26
|
+
/** The payment options in a 402 answer: the PAYMENT-REQUIRED header, or (x402 v1) the JSON body. */
|
|
27
|
+
declare function parseQuote(headers: HeaderSource, body?: unknown): Record<string, unknown>[];
|
|
28
|
+
/** The payment carried by a request's headers, or null. `quotes` are the options from the 402 answer it
|
|
29
|
+
* responds to (x402 v1 payments don't name their asset). */
|
|
30
|
+
declare function parsePayment(headers: HeaderSource, quotes?: Record<string, unknown>[]): Payment | null;
|
|
31
|
+
|
|
32
|
+
declare const PAY_SAFE_URL = "https://agent-deals.gm-tools.workers.dev/v1/preflight";
|
|
33
|
+
interface SeatbeltOptions {
|
|
34
|
+
/** Total budget for all payments made through this seatbelt (USDC). A payment that would cross it is never sent. */
|
|
35
|
+
maxTotalUsd?: number;
|
|
36
|
+
/** Block any single payment above this (USDC). Payments in other assets are blocked too, since their value is unknown. */
|
|
37
|
+
maxPaymentUsd?: number;
|
|
38
|
+
/** Block payments after this many. */
|
|
39
|
+
maxPayments?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Before each payment is sent, ask the Pay Safe service whether it looks safe: is the service working, is the
|
|
42
|
+
* price fair, is the wallet safe and the one the service normally uses. Sends the service URL and the payment
|
|
43
|
+
* terms (amount, asset, network, pay-to wallet), never the signature or any key. Off by default.
|
|
44
|
+
*/
|
|
45
|
+
paySafe?: boolean;
|
|
46
|
+
/** "stop" (default) blocks payments Pay Safe rates STOP; "caution" also blocks CAUTION. */
|
|
47
|
+
paySafeBlock?: "stop" | "caution";
|
|
48
|
+
/** Block payments when Pay Safe can't be reached (default: let them through). */
|
|
49
|
+
paySafeFailClosed?: boolean;
|
|
50
|
+
paySafeUrl?: string;
|
|
51
|
+
paySafeTimeoutMs?: number;
|
|
52
|
+
/** Called for every event (payments, blocks, Pay Safe answers). */
|
|
53
|
+
onEvent?: (event: SeatbeltEvent) => void;
|
|
54
|
+
}
|
|
55
|
+
type BlockReason = "max_payment" | "budget" | "max_payments" | "unknown_value" | "pay_safe" | "pay_safe_unavailable" | "stopped";
|
|
56
|
+
interface PaySafeVerdict {
|
|
57
|
+
verdict: "go" | "caution" | "stop";
|
|
58
|
+
summary?: string;
|
|
59
|
+
findings?: {
|
|
60
|
+
level: string;
|
|
61
|
+
code: string;
|
|
62
|
+
message: string;
|
|
63
|
+
}[];
|
|
64
|
+
[key: string]: unknown;
|
|
65
|
+
}
|
|
66
|
+
interface SeatbeltEvent {
|
|
67
|
+
time: number;
|
|
68
|
+
type: "payment" | "not_charged" | "blocked" | "pay_safe" | "quote";
|
|
69
|
+
detail: string;
|
|
70
|
+
url?: string;
|
|
71
|
+
payment?: Payment;
|
|
72
|
+
}
|
|
73
|
+
interface SeatbeltReport {
|
|
74
|
+
/** Payments that went through. */
|
|
75
|
+
payments: number;
|
|
76
|
+
/** Their USDC value. */
|
|
77
|
+
spentUsd: number;
|
|
78
|
+
/** Payments sent and still waiting for an answer. */
|
|
79
|
+
pendingUsd: number;
|
|
80
|
+
/** Payments stopped before they were sent. */
|
|
81
|
+
blocked: number;
|
|
82
|
+
/** Payments in assets other than USDC (not counted in spentUsd). */
|
|
83
|
+
unvaluedPayments: number;
|
|
84
|
+
paySafeChecks: number;
|
|
85
|
+
stopped: boolean;
|
|
86
|
+
events: SeatbeltEvent[];
|
|
87
|
+
}
|
|
88
|
+
/** A payment was stopped before it was sent: nothing was paid, the signed payment never left this machine. */
|
|
89
|
+
declare class PaymentBlockedError extends Error {
|
|
90
|
+
readonly reason: BlockReason;
|
|
91
|
+
readonly payment: Payment;
|
|
92
|
+
readonly verdict?: PaySafeVerdict;
|
|
93
|
+
constructor(reason: BlockReason, message: string, payment: Payment, verdict?: PaySafeVerdict);
|
|
94
|
+
}
|
|
95
|
+
type Fetch = typeof fetch;
|
|
96
|
+
declare class Seatbelt {
|
|
97
|
+
private readonly options;
|
|
98
|
+
private spent;
|
|
99
|
+
private pending;
|
|
100
|
+
private stats;
|
|
101
|
+
private stopped;
|
|
102
|
+
private events;
|
|
103
|
+
private quotes;
|
|
104
|
+
private paySafeAnswers;
|
|
105
|
+
constructor(options?: SeatbeltOptions);
|
|
106
|
+
/**
|
|
107
|
+
* A fetch that checks every x402 payment before sending it. Pass it to your x402 client:
|
|
108
|
+
*
|
|
109
|
+
* const fetchWithPayment = wrapFetchWithPayment(seatbelt.wrap(fetch), client);
|
|
110
|
+
*/
|
|
111
|
+
wrap(baseFetch?: Fetch): Fetch;
|
|
112
|
+
/** Stop every further payment (an emergency stop). */
|
|
113
|
+
stop(): void;
|
|
114
|
+
report(): SeatbeltReport;
|
|
115
|
+
private event;
|
|
116
|
+
private block;
|
|
117
|
+
/** Limits, then (optionally) Pay Safe. Reserves the amount so parallel payments can't overshoot the budget. */
|
|
118
|
+
private checkPayment;
|
|
119
|
+
private checkLimits;
|
|
120
|
+
private paySafe;
|
|
121
|
+
private release;
|
|
122
|
+
private recordResponse;
|
|
123
|
+
}
|
|
124
|
+
/** Create a seatbelt. `seatbelt.wrap(fetch)` gives a fetch that checks every x402 payment before it's sent. */
|
|
125
|
+
declare function createSeatbelt(options?: SeatbeltOptions): Seatbelt;
|
|
126
|
+
|
|
127
|
+
export { type BlockReason, PAY_SAFE_URL, type PaySafeVerdict, type Payment, PaymentBlockedError, type PaymentTerms, Seatbelt, type SeatbeltEvent, type SeatbeltOptions, type SeatbeltReport, createSeatbelt, hasPayment, parsePayment, parseQuote };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/** Payment terms, in x402 v2 shape. */
|
|
2
|
+
interface PaymentTerms {
|
|
3
|
+
scheme?: string;
|
|
4
|
+
network: string;
|
|
5
|
+
amount: string;
|
|
6
|
+
asset: string;
|
|
7
|
+
payTo: string;
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
interface Payment {
|
|
11
|
+
version: number;
|
|
12
|
+
network: string;
|
|
13
|
+
payTo: string | null;
|
|
14
|
+
asset: string | null;
|
|
15
|
+
/** Amount in the asset's smallest unit. */
|
|
16
|
+
rawAmount: bigint | null;
|
|
17
|
+
/** Dollar value for USDC payments; null when the asset or amount is unknown. */
|
|
18
|
+
usd: number | null;
|
|
19
|
+
/** The terms the client accepted (v2), or rebuilt from the 402 quote (v1). */
|
|
20
|
+
terms: PaymentTerms | null;
|
|
21
|
+
}
|
|
22
|
+
type HeaderSource = {
|
|
23
|
+
get(name: string): string | null;
|
|
24
|
+
};
|
|
25
|
+
declare function hasPayment(headers: HeaderSource): boolean;
|
|
26
|
+
/** The payment options in a 402 answer: the PAYMENT-REQUIRED header, or (x402 v1) the JSON body. */
|
|
27
|
+
declare function parseQuote(headers: HeaderSource, body?: unknown): Record<string, unknown>[];
|
|
28
|
+
/** The payment carried by a request's headers, or null. `quotes` are the options from the 402 answer it
|
|
29
|
+
* responds to (x402 v1 payments don't name their asset). */
|
|
30
|
+
declare function parsePayment(headers: HeaderSource, quotes?: Record<string, unknown>[]): Payment | null;
|
|
31
|
+
|
|
32
|
+
declare const PAY_SAFE_URL = "https://agent-deals.gm-tools.workers.dev/v1/preflight";
|
|
33
|
+
interface SeatbeltOptions {
|
|
34
|
+
/** Total budget for all payments made through this seatbelt (USDC). A payment that would cross it is never sent. */
|
|
35
|
+
maxTotalUsd?: number;
|
|
36
|
+
/** Block any single payment above this (USDC). Payments in other assets are blocked too, since their value is unknown. */
|
|
37
|
+
maxPaymentUsd?: number;
|
|
38
|
+
/** Block payments after this many. */
|
|
39
|
+
maxPayments?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Before each payment is sent, ask the Pay Safe service whether it looks safe: is the service working, is the
|
|
42
|
+
* price fair, is the wallet safe and the one the service normally uses. Sends the service URL and the payment
|
|
43
|
+
* terms (amount, asset, network, pay-to wallet), never the signature or any key. Off by default.
|
|
44
|
+
*/
|
|
45
|
+
paySafe?: boolean;
|
|
46
|
+
/** "stop" (default) blocks payments Pay Safe rates STOP; "caution" also blocks CAUTION. */
|
|
47
|
+
paySafeBlock?: "stop" | "caution";
|
|
48
|
+
/** Block payments when Pay Safe can't be reached (default: let them through). */
|
|
49
|
+
paySafeFailClosed?: boolean;
|
|
50
|
+
paySafeUrl?: string;
|
|
51
|
+
paySafeTimeoutMs?: number;
|
|
52
|
+
/** Called for every event (payments, blocks, Pay Safe answers). */
|
|
53
|
+
onEvent?: (event: SeatbeltEvent) => void;
|
|
54
|
+
}
|
|
55
|
+
type BlockReason = "max_payment" | "budget" | "max_payments" | "unknown_value" | "pay_safe" | "pay_safe_unavailable" | "stopped";
|
|
56
|
+
interface PaySafeVerdict {
|
|
57
|
+
verdict: "go" | "caution" | "stop";
|
|
58
|
+
summary?: string;
|
|
59
|
+
findings?: {
|
|
60
|
+
level: string;
|
|
61
|
+
code: string;
|
|
62
|
+
message: string;
|
|
63
|
+
}[];
|
|
64
|
+
[key: string]: unknown;
|
|
65
|
+
}
|
|
66
|
+
interface SeatbeltEvent {
|
|
67
|
+
time: number;
|
|
68
|
+
type: "payment" | "not_charged" | "blocked" | "pay_safe" | "quote";
|
|
69
|
+
detail: string;
|
|
70
|
+
url?: string;
|
|
71
|
+
payment?: Payment;
|
|
72
|
+
}
|
|
73
|
+
interface SeatbeltReport {
|
|
74
|
+
/** Payments that went through. */
|
|
75
|
+
payments: number;
|
|
76
|
+
/** Their USDC value. */
|
|
77
|
+
spentUsd: number;
|
|
78
|
+
/** Payments sent and still waiting for an answer. */
|
|
79
|
+
pendingUsd: number;
|
|
80
|
+
/** Payments stopped before they were sent. */
|
|
81
|
+
blocked: number;
|
|
82
|
+
/** Payments in assets other than USDC (not counted in spentUsd). */
|
|
83
|
+
unvaluedPayments: number;
|
|
84
|
+
paySafeChecks: number;
|
|
85
|
+
stopped: boolean;
|
|
86
|
+
events: SeatbeltEvent[];
|
|
87
|
+
}
|
|
88
|
+
/** A payment was stopped before it was sent: nothing was paid, the signed payment never left this machine. */
|
|
89
|
+
declare class PaymentBlockedError extends Error {
|
|
90
|
+
readonly reason: BlockReason;
|
|
91
|
+
readonly payment: Payment;
|
|
92
|
+
readonly verdict?: PaySafeVerdict;
|
|
93
|
+
constructor(reason: BlockReason, message: string, payment: Payment, verdict?: PaySafeVerdict);
|
|
94
|
+
}
|
|
95
|
+
type Fetch = typeof fetch;
|
|
96
|
+
declare class Seatbelt {
|
|
97
|
+
private readonly options;
|
|
98
|
+
private spent;
|
|
99
|
+
private pending;
|
|
100
|
+
private stats;
|
|
101
|
+
private stopped;
|
|
102
|
+
private events;
|
|
103
|
+
private quotes;
|
|
104
|
+
private paySafeAnswers;
|
|
105
|
+
constructor(options?: SeatbeltOptions);
|
|
106
|
+
/**
|
|
107
|
+
* A fetch that checks every x402 payment before sending it. Pass it to your x402 client:
|
|
108
|
+
*
|
|
109
|
+
* const fetchWithPayment = wrapFetchWithPayment(seatbelt.wrap(fetch), client);
|
|
110
|
+
*/
|
|
111
|
+
wrap(baseFetch?: Fetch): Fetch;
|
|
112
|
+
/** Stop every further payment (an emergency stop). */
|
|
113
|
+
stop(): void;
|
|
114
|
+
report(): SeatbeltReport;
|
|
115
|
+
private event;
|
|
116
|
+
private block;
|
|
117
|
+
/** Limits, then (optionally) Pay Safe. Reserves the amount so parallel payments can't overshoot the budget. */
|
|
118
|
+
private checkPayment;
|
|
119
|
+
private checkLimits;
|
|
120
|
+
private paySafe;
|
|
121
|
+
private release;
|
|
122
|
+
private recordResponse;
|
|
123
|
+
}
|
|
124
|
+
/** Create a seatbelt. `seatbelt.wrap(fetch)` gives a fetch that checks every x402 payment before it's sent. */
|
|
125
|
+
declare function createSeatbelt(options?: SeatbeltOptions): Seatbelt;
|
|
126
|
+
|
|
127
|
+
export { type BlockReason, PAY_SAFE_URL, type PaySafeVerdict, type Payment, PaymentBlockedError, type PaymentTerms, Seatbelt, type SeatbeltEvent, type SeatbeltOptions, type SeatbeltReport, createSeatbelt, hasPayment, parsePayment, parseQuote };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// src/payment.ts
|
|
2
|
+
var PAYMENT_HEADERS = ["payment-signature", "x-payment"];
|
|
3
|
+
var RESPONSE_HEADERS = ["payment-response", "x-payment-response"];
|
|
4
|
+
var USDC = /* @__PURE__ */ new Set([
|
|
5
|
+
"0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
|
6
|
+
// Base
|
|
7
|
+
"0x036cbd53842c5426634e7929541ec2318f3dcf7e",
|
|
8
|
+
// Base Sepolia
|
|
9
|
+
"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
|
|
10
|
+
// Ethereum
|
|
11
|
+
"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359",
|
|
12
|
+
// Polygon
|
|
13
|
+
"0xaf88d065e77c8cc2239327c5edb3a432268e5831",
|
|
14
|
+
// Arbitrum
|
|
15
|
+
"0x0b2c639c533813f4aa9d7837caf62653d097ff85",
|
|
16
|
+
// Optimism
|
|
17
|
+
"epjfwdd5aufqssqem2qn1xzybapc8g4wegkzwytdt1v"
|
|
18
|
+
// Solana (lowercased)
|
|
19
|
+
]);
|
|
20
|
+
function decodeBase64Json(value) {
|
|
21
|
+
const text = value.trim();
|
|
22
|
+
try {
|
|
23
|
+
const binary = atob(text);
|
|
24
|
+
return JSON.parse(new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0))));
|
|
25
|
+
} catch {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(text);
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function toBigInt(value) {
|
|
34
|
+
try {
|
|
35
|
+
return value === void 0 || value === null || value === "" ? null : BigInt(String(value));
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
var usdOf = (asset, raw) => raw !== null && asset && USDC.has(asset.toLowerCase()) ? Number(raw) / 1e6 : null;
|
|
41
|
+
var obj = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : null;
|
|
42
|
+
function hasPayment(headers) {
|
|
43
|
+
return PAYMENT_HEADERS.some((h) => headers.get(h));
|
|
44
|
+
}
|
|
45
|
+
function isSettled(headers) {
|
|
46
|
+
return RESPONSE_HEADERS.some((h) => headers.get(h));
|
|
47
|
+
}
|
|
48
|
+
function parseQuote(headers, body) {
|
|
49
|
+
const raw = headers.get("payment-required");
|
|
50
|
+
const quote = obj(raw ? decodeBase64Json(raw) : body);
|
|
51
|
+
const accepts = quote?.accepts;
|
|
52
|
+
return Array.isArray(accepts) ? accepts.filter((a) => obj(a)) : [];
|
|
53
|
+
}
|
|
54
|
+
function parsePayment(headers, quotes = []) {
|
|
55
|
+
const raw = PAYMENT_HEADERS.map((h) => headers.get(h)).find(Boolean);
|
|
56
|
+
if (!raw) return null;
|
|
57
|
+
const data = obj(decodeBase64Json(raw));
|
|
58
|
+
if (!data) return { version: 0, network: "", payTo: null, asset: null, rawAmount: null, usd: null, terms: null };
|
|
59
|
+
const auth = obj(obj(data.payload)?.authorization) ?? {};
|
|
60
|
+
const accepted = obj(data.accepted);
|
|
61
|
+
if (accepted) {
|
|
62
|
+
const rawAmount2 = toBigInt(accepted.amount);
|
|
63
|
+
const asset2 = typeof accepted.asset === "string" ? accepted.asset : null;
|
|
64
|
+
return {
|
|
65
|
+
version: 2,
|
|
66
|
+
network: String(accepted.network ?? ""),
|
|
67
|
+
payTo: accepted.payTo ?? auth.to ?? null,
|
|
68
|
+
asset: asset2,
|
|
69
|
+
rawAmount: rawAmount2,
|
|
70
|
+
usd: usdOf(asset2, rawAmount2),
|
|
71
|
+
terms: accepted
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const payTo = typeof auth.to === "string" ? auth.to : null;
|
|
75
|
+
const rawAmount = toBigInt(auth.value);
|
|
76
|
+
const network = String(data.network ?? "");
|
|
77
|
+
const match = quotes.find((q) => String(q.payTo ?? "").toLowerCase() === String(payTo ?? "").toLowerCase() && String(q.network ?? "") === network);
|
|
78
|
+
const asset = match && typeof match.asset === "string" ? match.asset : null;
|
|
79
|
+
const terms = match ? { ...match, network, asset: asset ?? "", payTo: payTo ?? "", amount: rawAmount !== null ? rawAmount.toString() : String(match.maxAmountRequired ?? "0") } : null;
|
|
80
|
+
return { version: 1, network, payTo, asset, rawAmount, usd: usdOf(asset, rawAmount), terms };
|
|
81
|
+
}
|
|
82
|
+
function describe(p) {
|
|
83
|
+
const amount = p.usd !== null ? `$${Number(p.usd.toPrecision(6))}` : `${p.rawAmount ?? "?"} units of ${p.asset ?? "an unknown asset"}`;
|
|
84
|
+
return `${amount} to ${p.payTo ?? "?"} on ${p.network || "?"}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// src/seatbelt.ts
|
|
88
|
+
var PAY_SAFE_URL = "https://agent-deals.gm-tools.workers.dev/v1/preflight";
|
|
89
|
+
var PAY_SAFE_CACHE_MS = 6e4;
|
|
90
|
+
var MAX_EVENTS = 200;
|
|
91
|
+
var PaymentBlockedError = class extends Error {
|
|
92
|
+
reason;
|
|
93
|
+
payment;
|
|
94
|
+
verdict;
|
|
95
|
+
constructor(reason, message, payment, verdict) {
|
|
96
|
+
super(message);
|
|
97
|
+
this.name = "PaymentBlockedError";
|
|
98
|
+
this.reason = reason;
|
|
99
|
+
this.payment = payment;
|
|
100
|
+
this.verdict = verdict;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
function requestParts(input, init) {
|
|
104
|
+
const isRequest = typeof Request !== "undefined" && input instanceof Request;
|
|
105
|
+
const url = isRequest ? input.url : input instanceof URL ? input.href : String(input);
|
|
106
|
+
const method = (init?.method ?? (isRequest ? input.method : "GET")).toUpperCase();
|
|
107
|
+
const headers = new Headers(init?.headers ?? (isRequest ? input.headers : void 0));
|
|
108
|
+
return { url, method, headers };
|
|
109
|
+
}
|
|
110
|
+
var Seatbelt = class {
|
|
111
|
+
options;
|
|
112
|
+
spent = 0;
|
|
113
|
+
pending = 0;
|
|
114
|
+
stats = { payments: 0, blocked: 0, unvaluedPayments: 0, paySafeChecks: 0 };
|
|
115
|
+
stopped = false;
|
|
116
|
+
events = [];
|
|
117
|
+
quotes = /* @__PURE__ */ new Map();
|
|
118
|
+
paySafeAnswers = /* @__PURE__ */ new Map();
|
|
119
|
+
constructor(options = {}) {
|
|
120
|
+
if (options.paySafeBlock && !["stop", "caution"].includes(options.paySafeBlock)) {
|
|
121
|
+
throw new Error('paySafeBlock must be "stop" or "caution"');
|
|
122
|
+
}
|
|
123
|
+
this.options = options;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* A fetch that checks every x402 payment before sending it. Pass it to your x402 client:
|
|
127
|
+
*
|
|
128
|
+
* const fetchWithPayment = wrapFetchWithPayment(seatbelt.wrap(fetch), client);
|
|
129
|
+
*/
|
|
130
|
+
wrap(baseFetch = globalThis.fetch) {
|
|
131
|
+
const wrapped = async (input, init) => {
|
|
132
|
+
const { url, method, headers } = requestParts(input, init);
|
|
133
|
+
const payment = hasPayment(headers) ? await this.checkPayment(url, method, headers, baseFetch) : null;
|
|
134
|
+
let response;
|
|
135
|
+
try {
|
|
136
|
+
response = await baseFetch(input, init);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (payment) this.release(payment, url, "the request failed");
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
await this.recordResponse(url, response, payment);
|
|
142
|
+
return response;
|
|
143
|
+
};
|
|
144
|
+
return wrapped;
|
|
145
|
+
}
|
|
146
|
+
/** Stop every further payment (an emergency stop). */
|
|
147
|
+
stop() {
|
|
148
|
+
this.stopped = true;
|
|
149
|
+
}
|
|
150
|
+
report() {
|
|
151
|
+
return {
|
|
152
|
+
...this.stats,
|
|
153
|
+
spentUsd: Math.round(this.spent * 1e6) / 1e6,
|
|
154
|
+
pendingUsd: Math.round(this.pending * 1e6) / 1e6,
|
|
155
|
+
stopped: this.stopped,
|
|
156
|
+
events: [...this.events]
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
// ---- internals -------------------------------------------------------------------------------------------
|
|
160
|
+
event(type, detail, url, payment) {
|
|
161
|
+
const ev = { time: Date.now(), type, detail, url, payment };
|
|
162
|
+
this.events.push(ev);
|
|
163
|
+
if (this.events.length > MAX_EVENTS) this.events.shift();
|
|
164
|
+
try {
|
|
165
|
+
this.options.onEvent?.(ev);
|
|
166
|
+
} catch {
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
block(reason, why, payment, url, verdict) {
|
|
170
|
+
this.stats.blocked++;
|
|
171
|
+
const message = `x402-seatbelt blocked a payment of ${describe(payment)}: ${why}`;
|
|
172
|
+
this.event("blocked", message, url, payment);
|
|
173
|
+
throw new PaymentBlockedError(reason, message, payment, verdict);
|
|
174
|
+
}
|
|
175
|
+
/** Limits, then (optionally) Pay Safe. Reserves the amount so parallel payments can't overshoot the budget. */
|
|
176
|
+
async checkPayment(url, method, headers, baseFetch) {
|
|
177
|
+
const o = this.options;
|
|
178
|
+
const payment = parsePayment(headers, this.quotes.get(url));
|
|
179
|
+
if (!payment) return null;
|
|
180
|
+
this.checkLimits(payment, url);
|
|
181
|
+
if (payment.usd !== null) this.pending += payment.usd;
|
|
182
|
+
try {
|
|
183
|
+
if (o.paySafe) await this.paySafe(url, method, payment, baseFetch);
|
|
184
|
+
this.checkLimits(payment, url, payment.usd ?? 0);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (payment.usd !== null) this.pending -= payment.usd;
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
return payment;
|
|
190
|
+
}
|
|
191
|
+
checkLimits(payment, url, reservedByThis = 0) {
|
|
192
|
+
const o = this.options;
|
|
193
|
+
if (this.stopped) this.block("stopped", "the seatbelt was stopped", payment, url);
|
|
194
|
+
if (o.maxPayments !== void 0 && this.stats.payments >= o.maxPayments) {
|
|
195
|
+
this.block("max_payments", `maximum of ${o.maxPayments} payments reached`, payment, url);
|
|
196
|
+
}
|
|
197
|
+
if (o.maxPaymentUsd !== void 0) {
|
|
198
|
+
if (payment.usd === null) this.block("unknown_value", "its dollar value is unknown (not USDC), so maxPaymentUsd can't be applied", payment, url);
|
|
199
|
+
if (payment.usd > o.maxPaymentUsd) this.block("max_payment", `above maxPaymentUsd ($${o.maxPaymentUsd})`, payment, url);
|
|
200
|
+
}
|
|
201
|
+
if (o.maxTotalUsd !== void 0 && payment.usd !== null) {
|
|
202
|
+
const committed = this.spent + this.pending - reservedByThis;
|
|
203
|
+
if (committed + payment.usd > o.maxTotalUsd + 1e-12) {
|
|
204
|
+
this.block("budget", `it would exceed maxTotalUsd ($${o.maxTotalUsd}; $${committed.toFixed(6)} already committed)`, payment, url);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async paySafe(url, method, payment, baseFetch) {
|
|
209
|
+
const o = this.options;
|
|
210
|
+
const key = [url, payment.payTo?.toLowerCase(), payment.rawAmount?.toString(), payment.asset?.toLowerCase()].join("|");
|
|
211
|
+
const cached = this.paySafeAnswers.get(key);
|
|
212
|
+
let answer;
|
|
213
|
+
if (cached && Date.now() - cached.at < PAY_SAFE_CACHE_MS) {
|
|
214
|
+
answer = cached.answer;
|
|
215
|
+
} else {
|
|
216
|
+
const terms = payment.terms ?? {
|
|
217
|
+
scheme: "exact",
|
|
218
|
+
network: payment.network,
|
|
219
|
+
amount: (payment.rawAmount ?? 0n).toString(),
|
|
220
|
+
asset: payment.asset ?? "",
|
|
221
|
+
payTo: payment.payTo ?? ""
|
|
222
|
+
};
|
|
223
|
+
try {
|
|
224
|
+
const res = await baseFetch(o.paySafeUrl ?? PAY_SAFE_URL, {
|
|
225
|
+
method: "POST",
|
|
226
|
+
headers: { "content-type": "application/json", "user-agent": "x402-seatbelt" },
|
|
227
|
+
body: JSON.stringify({ url, method, payment_required: { x402Version: 2, resource: { url }, accepts: [terms] } }),
|
|
228
|
+
signal: AbortSignal.timeout(o.paySafeTimeoutMs ?? 8e3)
|
|
229
|
+
});
|
|
230
|
+
const body = await res.json();
|
|
231
|
+
if (!res.ok || !["go", "caution", "stop"].includes(body?.verdict)) throw new Error(`unexpected answer (HTTP ${res.status})`);
|
|
232
|
+
answer = body;
|
|
233
|
+
this.paySafeAnswers.set(key, { at: Date.now(), answer });
|
|
234
|
+
} catch (error) {
|
|
235
|
+
this.event("pay_safe", `check unavailable (${error.message}) for ${describe(payment)}`, url, payment);
|
|
236
|
+
if (o.paySafeFailClosed) this.block("pay_safe_unavailable", "Pay Safe couldn't be reached (paySafeFailClosed)", payment, url);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
this.stats.paySafeChecks++;
|
|
241
|
+
this.event("pay_safe", `${answer.verdict.toUpperCase()} for ${describe(payment)}: ${answer.summary ?? ""}`, url, payment);
|
|
242
|
+
if (answer.verdict === "stop" || answer.verdict === "caution" && o.paySafeBlock === "caution") {
|
|
243
|
+
this.block("pay_safe", `Pay Safe says ${answer.verdict.toUpperCase()}: ${answer.summary ?? ""}`, payment, url, answer);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
release(payment, url, why) {
|
|
247
|
+
if (payment.usd !== null) this.pending -= payment.usd;
|
|
248
|
+
this.event("not_charged", `not charged (${why}): ${describe(payment)}`, url, payment);
|
|
249
|
+
}
|
|
250
|
+
async recordResponse(url, response, payment) {
|
|
251
|
+
if (response.status === 402) {
|
|
252
|
+
let body;
|
|
253
|
+
if (!response.headers.get("payment-required")) {
|
|
254
|
+
try {
|
|
255
|
+
body = await response.clone().json();
|
|
256
|
+
} catch {
|
|
257
|
+
body = void 0;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const quotes = parseQuote(response.headers, body);
|
|
261
|
+
if (quotes.length) this.quotes.set(url, quotes);
|
|
262
|
+
}
|
|
263
|
+
if (!payment) return;
|
|
264
|
+
if (!(isSettled(response.headers) || response.ok)) {
|
|
265
|
+
this.release(payment, url, `HTTP ${response.status}`);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
this.stats.payments++;
|
|
269
|
+
if (payment.usd === null) {
|
|
270
|
+
this.stats.unvaluedPayments++;
|
|
271
|
+
this.event("payment", `paid ${describe(payment)} (not valued in USD)`, url, payment);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
this.pending -= payment.usd;
|
|
275
|
+
this.spent += payment.usd;
|
|
276
|
+
this.event("payment", `paid ${describe(payment)} (total $${this.spent.toFixed(6)})`, url, payment);
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
function createSeatbelt(options = {}) {
|
|
280
|
+
return new Seatbelt(options);
|
|
281
|
+
}
|
|
282
|
+
export {
|
|
283
|
+
PAY_SAFE_URL,
|
|
284
|
+
PaymentBlockedError,
|
|
285
|
+
Seatbelt,
|
|
286
|
+
createSeatbelt,
|
|
287
|
+
hasPayment,
|
|
288
|
+
parsePayment,
|
|
289
|
+
parseQuote
|
|
290
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "x402-seatbelt",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A seatbelt for AI agents that pay with x402: spending budgets, per-payment caps and an optional Pay Safe check before every payment. Wraps fetch; works with @x402/fetch.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/index.d.cts",
|
|
17
|
+
"default": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
|
32
|
+
"typecheck": "tsc --noEmit",
|
|
33
|
+
"test": "node --import tsx --test test/seatbelt.test.ts"
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"x402",
|
|
37
|
+
"agents",
|
|
38
|
+
"ai-agents",
|
|
39
|
+
"payments",
|
|
40
|
+
"usdc",
|
|
41
|
+
"budget",
|
|
42
|
+
"safety",
|
|
43
|
+
"fetch",
|
|
44
|
+
"seatbelt",
|
|
45
|
+
"spending-limit"
|
|
46
|
+
],
|
|
47
|
+
"author": "gmahar82-stack",
|
|
48
|
+
"license": "MIT",
|
|
49
|
+
"repository": {
|
|
50
|
+
"type": "git",
|
|
51
|
+
"url": "git+https://github.com/gmahar82-stack/x402-seatbelt.git"
|
|
52
|
+
},
|
|
53
|
+
"homepage": "https://github.com/gmahar82-stack/x402-seatbelt#readme",
|
|
54
|
+
"bugs": {
|
|
55
|
+
"url": "https://github.com/gmahar82-stack/x402-seatbelt/issues"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@types/node": "^26.6.3",
|
|
59
|
+
"@x402/core": "^2.27.0",
|
|
60
|
+
"@x402/evm": "^2.27.0",
|
|
61
|
+
"@x402/fetch": "^2.27.0",
|
|
62
|
+
"tsup": "^8.5.1",
|
|
63
|
+
"tsx": "^4.23.15",
|
|
64
|
+
"typescript": "^5.9.3",
|
|
65
|
+
"viem": "^2.56.9"
|
|
66
|
+
}
|
|
67
|
+
}
|