@odingard/guardian-x402 0.0.0-stage → 0.1.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/LICENSE +201 -0
- package/README.md +269 -2
- package/dist/axios.js +85 -0
- package/dist/chunk-C2QTXTL6.js +1443 -0
- package/dist/chunk-DJIAG2IA.js +58 -0
- package/dist/fetch.js +62 -0
- package/dist/index.js +57 -0
- package/dist/mcp.js +60 -0
- package/dist-dts/axios.d.ts +9 -0
- package/dist-dts/constants.d.ts +58 -0
- package/dist-dts/context.d.ts +56 -0
- package/dist-dts/fetch.d.ts +9 -0
- package/dist-dts/guard.d.ts +209 -0
- package/dist-dts/index.d.ts +32 -0
- package/dist-dts/ledger.d.ts +84 -0
- package/dist-dts/mcp.d.ts +30 -0
- package/dist-dts/terms.d.ts +79 -0
- package/dist-dts/util.d.ts +51 -0
- package/dist-dts/version.d.ts +14 -0
- package/package.json +89 -4
|
@@ -0,0 +1,1443 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertSupportedX402,
|
|
3
|
+
flowStorage,
|
|
4
|
+
requestStorage
|
|
5
|
+
} from "./chunk-DJIAG2IA.js";
|
|
6
|
+
|
|
7
|
+
// src/constants.ts
|
|
8
|
+
var PAY_TOOL = "x402.pay";
|
|
9
|
+
var PAY_ABOVE_CAP_TOOL = "x402.pay.above_cap";
|
|
10
|
+
var BASE_SEPOLIA = "eip155:84532";
|
|
11
|
+
var BASE_MAINNET = "eip155:8453";
|
|
12
|
+
var PINNED_ASSETS = Object.freeze([
|
|
13
|
+
Object.freeze({
|
|
14
|
+
network: BASE_SEPOLIA,
|
|
15
|
+
chainId: 84532,
|
|
16
|
+
networkName: "Base Sepolia",
|
|
17
|
+
asset: "0x036cbd53842c5426634e7929541ec2318f3dcf7e",
|
|
18
|
+
name: "USDC",
|
|
19
|
+
version: "2",
|
|
20
|
+
decimals: 6,
|
|
21
|
+
symbol: "USDC"
|
|
22
|
+
}),
|
|
23
|
+
Object.freeze({
|
|
24
|
+
network: BASE_MAINNET,
|
|
25
|
+
chainId: 8453,
|
|
26
|
+
networkName: "Base",
|
|
27
|
+
asset: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
|
28
|
+
name: "USD Coin",
|
|
29
|
+
version: "2",
|
|
30
|
+
decimals: 6,
|
|
31
|
+
symbol: "USDC"
|
|
32
|
+
})
|
|
33
|
+
]);
|
|
34
|
+
var V1_NETWORK_NAMES = Object.freeze({
|
|
35
|
+
"base-sepolia": BASE_SEPOLIA,
|
|
36
|
+
base: BASE_MAINNET
|
|
37
|
+
});
|
|
38
|
+
var EIP3009_TYPES = Object.freeze({
|
|
39
|
+
TransferWithAuthorization: Object.freeze([
|
|
40
|
+
Object.freeze({ name: "from", type: "address" }),
|
|
41
|
+
Object.freeze({ name: "to", type: "address" }),
|
|
42
|
+
Object.freeze({ name: "value", type: "uint256" }),
|
|
43
|
+
Object.freeze({ name: "validAfter", type: "uint256" }),
|
|
44
|
+
Object.freeze({ name: "validBefore", type: "uint256" }),
|
|
45
|
+
Object.freeze({ name: "nonce", type: "bytes32" })
|
|
46
|
+
])
|
|
47
|
+
});
|
|
48
|
+
var DEFAULT_MAX_TIMEOUT_SECONDS_LIMIT = 300;
|
|
49
|
+
var DEFAULT_SIGNING_WINDOW_MS = 3e4;
|
|
50
|
+
var DEFAULT_CLAIM_POLL_INTERVAL_MS = 2e3;
|
|
51
|
+
var DEFAULT_GUARDIAN_CALL_TIMEOUT_MS = 15e3;
|
|
52
|
+
var MAX_SIGNING_WINDOW_MS = 12e4;
|
|
53
|
+
var MAX_CLAIM_POLL_INTERVAL_MS = 6e4;
|
|
54
|
+
var MAX_GUARDIAN_CALL_TIMEOUT_MS = 6e4;
|
|
55
|
+
var RECONCILE_MARGIN_MS = 3e5;
|
|
56
|
+
function pinnedAsset(network, asset) {
|
|
57
|
+
const lower = asset.toLowerCase();
|
|
58
|
+
return PINNED_ASSETS.find((a) => a.network === network && a.asset === lower);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/util.ts
|
|
62
|
+
var X402GuardRefused = class extends Error {
|
|
63
|
+
code;
|
|
64
|
+
/** Guardian's own reason code, when Guardian refused. */
|
|
65
|
+
guardianReasonCode;
|
|
66
|
+
/**
|
|
67
|
+
* EXECUTE_FAILED only: `execute` was sent but its outcome is unknown, so
|
|
68
|
+
* Guardian may have redeemed the decision. Nothing was signed. Look the
|
|
69
|
+
* request up by `requestId` before retrying; a retry needs a new decision.
|
|
70
|
+
*/
|
|
71
|
+
outcomeUnknown;
|
|
72
|
+
/** The Guardian request this refusal is about, when there is one. */
|
|
73
|
+
requestId;
|
|
74
|
+
constructor(code, detail, guardianReasonCode, extra) {
|
|
75
|
+
super(
|
|
76
|
+
`[guardian-x402 ${code}${guardianReasonCode ? `:${guardianReasonCode}` : ""}] ${detail}`
|
|
77
|
+
);
|
|
78
|
+
this.name = "X402GuardRefused";
|
|
79
|
+
this.code = code;
|
|
80
|
+
if (guardianReasonCode !== void 0) {
|
|
81
|
+
this.guardianReasonCode = guardianReasonCode;
|
|
82
|
+
}
|
|
83
|
+
if (extra?.outcomeUnknown === true) this.outcomeUnknown = true;
|
|
84
|
+
if (extra?.requestId !== void 0) this.requestId = extra.requestId;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
var refusal = (code, detail) => ({
|
|
88
|
+
ok: false,
|
|
89
|
+
code,
|
|
90
|
+
detail
|
|
91
|
+
});
|
|
92
|
+
function isPlainObject(value) {
|
|
93
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
const proto = Object.getPrototypeOf(value);
|
|
97
|
+
return proto === Object.prototype || proto === null;
|
|
98
|
+
}
|
|
99
|
+
function canonical(value, depth = 0) {
|
|
100
|
+
if (depth > 32) throw new TypeError("too deep");
|
|
101
|
+
if (value === null) return "null";
|
|
102
|
+
switch (typeof value) {
|
|
103
|
+
case "string":
|
|
104
|
+
case "boolean":
|
|
105
|
+
return JSON.stringify(value);
|
|
106
|
+
case "number":
|
|
107
|
+
if (!Number.isFinite(value)) throw new TypeError("non-finite number");
|
|
108
|
+
return JSON.stringify(value);
|
|
109
|
+
case "bigint":
|
|
110
|
+
return JSON.stringify(`bigint:${value.toString()}`);
|
|
111
|
+
case "object":
|
|
112
|
+
break;
|
|
113
|
+
default:
|
|
114
|
+
throw new TypeError(`cannot compare ${typeof value}`);
|
|
115
|
+
}
|
|
116
|
+
if (Array.isArray(value)) {
|
|
117
|
+
return `[${value.map((v) => canonical(v, depth + 1)).join(",")}]`;
|
|
118
|
+
}
|
|
119
|
+
if (!isPlainObject(value)) throw new TypeError("not a plain object");
|
|
120
|
+
const keys = Object.keys(value).sort();
|
|
121
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(value[k], depth + 1)}`).join(",")}}`;
|
|
122
|
+
}
|
|
123
|
+
function sameData(a, b) {
|
|
124
|
+
try {
|
|
125
|
+
return canonical(a) === canonical(b);
|
|
126
|
+
} catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function deepFreeze(value) {
|
|
131
|
+
if (value !== null && typeof value === "object") {
|
|
132
|
+
for (const key of Object.keys(value)) {
|
|
133
|
+
deepFreeze(value[key]);
|
|
134
|
+
}
|
|
135
|
+
Object.freeze(value);
|
|
136
|
+
}
|
|
137
|
+
return value;
|
|
138
|
+
}
|
|
139
|
+
function frozenCopy(value) {
|
|
140
|
+
return deepFreeze(JSON.parse(JSON.stringify(value)));
|
|
141
|
+
}
|
|
142
|
+
var ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
143
|
+
var ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
|
|
144
|
+
var ATOMIC_RE = /^[1-9][0-9]{0,77}$/;
|
|
145
|
+
function formatAtomic(amount, decimals) {
|
|
146
|
+
const digits = amount.padStart(decimals + 1, "0");
|
|
147
|
+
const whole = digits.slice(0, digits.length - decimals);
|
|
148
|
+
const fraction = digits.slice(digits.length - decimals).replace(/0+$/, "");
|
|
149
|
+
return fraction ? `${whole}.${fraction}` : whole;
|
|
150
|
+
}
|
|
151
|
+
function withTimeout(promise, ms) {
|
|
152
|
+
return new Promise((resolve) => {
|
|
153
|
+
const timer = setTimeout(() => resolve({ ok: false, timedOut: true }), ms);
|
|
154
|
+
promise.then(
|
|
155
|
+
(value) => {
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
resolve({ ok: true, value });
|
|
158
|
+
},
|
|
159
|
+
(error) => {
|
|
160
|
+
clearTimeout(timer);
|
|
161
|
+
resolve({ ok: false, timedOut: false, error });
|
|
162
|
+
}
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
167
|
+
|
|
168
|
+
// src/ledger.ts
|
|
169
|
+
var LEDGER_KEY = "guardian-x402:v1:ledger";
|
|
170
|
+
var MAX_CAS_ATTEMPTS = 16;
|
|
171
|
+
var KEEP_FINAL_MS = 40 * 24 * 3600 * 1e3;
|
|
172
|
+
function processStore() {
|
|
173
|
+
const data = /* @__PURE__ */ new Map();
|
|
174
|
+
let counter = 0;
|
|
175
|
+
return {
|
|
176
|
+
async get(key) {
|
|
177
|
+
return data.get(key) ?? null;
|
|
178
|
+
},
|
|
179
|
+
async compareAndSet(key, expectedVersion, value) {
|
|
180
|
+
const current = data.get(key) ?? null;
|
|
181
|
+
if ((current?.version ?? null) !== expectedVersion) return false;
|
|
182
|
+
counter += 1;
|
|
183
|
+
data.set(key, { value, version: String(counter) });
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
function periodId(ms, period) {
|
|
189
|
+
const iso = new Date(ms).toISOString();
|
|
190
|
+
return period === "day" ? iso.slice(0, 10) : iso.slice(0, 7);
|
|
191
|
+
}
|
|
192
|
+
var counts = (e) => e.state !== "released";
|
|
193
|
+
function isLive(e, nowMs) {
|
|
194
|
+
if (e.state === "released") return false;
|
|
195
|
+
if (e.state === "reserved") return true;
|
|
196
|
+
return e.validBefore === void 0 || e.validBefore * 1e3 > nowMs;
|
|
197
|
+
}
|
|
198
|
+
var Ledger = class {
|
|
199
|
+
constructor(store, budgets) {
|
|
200
|
+
this.store = store;
|
|
201
|
+
this.budgets = budgets;
|
|
202
|
+
}
|
|
203
|
+
store;
|
|
204
|
+
budgets;
|
|
205
|
+
async read() {
|
|
206
|
+
let current;
|
|
207
|
+
try {
|
|
208
|
+
current = await this.store.get(LEDGER_KEY);
|
|
209
|
+
} catch {
|
|
210
|
+
throw new X402GuardRefused(
|
|
211
|
+
"STORE_UNAVAILABLE",
|
|
212
|
+
"The guard's store cannot be read."
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
if (current === null) return { doc: { entries: [] }, version: null };
|
|
216
|
+
try {
|
|
217
|
+
const doc = JSON.parse(current.value);
|
|
218
|
+
if (!doc || !Array.isArray(doc.entries)) throw new Error("bad");
|
|
219
|
+
return { doc, version: current.version };
|
|
220
|
+
} catch {
|
|
221
|
+
throw new X402GuardRefused(
|
|
222
|
+
"STORE_UNAVAILABLE",
|
|
223
|
+
"The guard's store holds data it cannot read."
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/** Read-modify-write with compare-and-set; `change` may throw to abort. */
|
|
228
|
+
async update(change) {
|
|
229
|
+
for (let attempt = 0; attempt < MAX_CAS_ATTEMPTS; attempt += 1) {
|
|
230
|
+
const { doc, version } = await this.read();
|
|
231
|
+
const result = change(doc);
|
|
232
|
+
let written;
|
|
233
|
+
try {
|
|
234
|
+
written = await this.store.compareAndSet(
|
|
235
|
+
LEDGER_KEY,
|
|
236
|
+
version,
|
|
237
|
+
JSON.stringify(doc)
|
|
238
|
+
);
|
|
239
|
+
} catch {
|
|
240
|
+
throw new X402GuardRefused(
|
|
241
|
+
"STORE_UNAVAILABLE",
|
|
242
|
+
"The guard's store cannot be written."
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
if (written) return result;
|
|
246
|
+
}
|
|
247
|
+
throw new X402GuardRefused(
|
|
248
|
+
"STORE_UNAVAILABLE",
|
|
249
|
+
"The guard's store kept changing; nothing was reserved."
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
async entries() {
|
|
253
|
+
return (await this.read()).doc.entries;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Atomically checks budgets and records a reservation. Returns the live
|
|
257
|
+
* entries for the same request that existed before this one: same
|
|
258
|
+
* request key, or one of `sameRequestIds`.
|
|
259
|
+
*/
|
|
260
|
+
async reserve(entry, nowMs, sameRequestIds = []) {
|
|
261
|
+
return this.update((doc) => {
|
|
262
|
+
doc.entries = doc.entries.filter(
|
|
263
|
+
(e) => !(nowMs - e.createdAtMs > KEEP_FINAL_MS && !isLive(e, nowMs))
|
|
264
|
+
);
|
|
265
|
+
for (const budget of this.budgets) {
|
|
266
|
+
if (budget.network !== entry.network || budget.asset.toLowerCase() !== entry.asset) {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const pid = periodId(entry.createdAtMs, budget.period);
|
|
270
|
+
let used = 0n;
|
|
271
|
+
for (const e of doc.entries) {
|
|
272
|
+
if (counts(e) && e.network === entry.network && e.asset === entry.asset && periodId(e.createdAtMs, budget.period) === pid) {
|
|
273
|
+
used += BigInt(e.amount);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (used + BigInt(entry.amount) > BigInt(budget.limit)) {
|
|
277
|
+
throw new X402GuardRefused(
|
|
278
|
+
"BUDGET_EXCEEDED",
|
|
279
|
+
`This payment would exceed the local ${budget.period} budget for this asset.`
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const liveForRequest = doc.entries.filter(
|
|
284
|
+
(e) => (e.requestKey === entry.requestKey || sameRequestIds.includes(e.id)) && isLive(e, nowMs)
|
|
285
|
+
);
|
|
286
|
+
doc.entries.push(entry);
|
|
287
|
+
return { liveForRequest };
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
async patch(id, change) {
|
|
291
|
+
return this.update((doc) => {
|
|
292
|
+
const entry = doc.entries.find((e) => e.id === id);
|
|
293
|
+
if (entry) change(entry);
|
|
294
|
+
return entry ? { ...entry } : void 0;
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
async patchByNonce(nonce, change) {
|
|
298
|
+
const lower = nonce.toLowerCase();
|
|
299
|
+
return this.update((doc) => {
|
|
300
|
+
const entry = doc.entries.find((e) => e.nonce === lower);
|
|
301
|
+
if (entry) change(entry);
|
|
302
|
+
return entry ? { ...entry } : void 0;
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
/** Release an entry only if no signature was made for it. */
|
|
306
|
+
async releaseUnsigned(id) {
|
|
307
|
+
await this.patch(id, (e) => {
|
|
308
|
+
if (e.state === "reserved") e.state = "released";
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// src/terms.ts
|
|
314
|
+
var KNOWN_EXTRA = /* @__PURE__ */ new Set([
|
|
315
|
+
"name",
|
|
316
|
+
"version",
|
|
317
|
+
"assetTransferMethod",
|
|
318
|
+
"paymentFlow"
|
|
319
|
+
]);
|
|
320
|
+
function hostOf(url) {
|
|
321
|
+
try {
|
|
322
|
+
return new URL(url).host.toLowerCase();
|
|
323
|
+
} catch {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
function mcpResourceMatches(resourceUrl, toolName, serverUrl) {
|
|
328
|
+
if (resourceUrl === `mcp://tool/${toolName}`) return true;
|
|
329
|
+
if (serverUrl === null) return false;
|
|
330
|
+
let server;
|
|
331
|
+
let resource;
|
|
332
|
+
try {
|
|
333
|
+
server = new URL(serverUrl);
|
|
334
|
+
resource = new URL(resourceUrl);
|
|
335
|
+
} catch {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
if (server.protocol !== "https:" && server.protocol !== "http:") return false;
|
|
339
|
+
const expected = new URL(`/tools/${encodeURIComponent(toolName)}`, server.origin);
|
|
340
|
+
return resource.href === expected.href;
|
|
341
|
+
}
|
|
342
|
+
function readTerms(x402Version, requirement, paymentRequired, request, options) {
|
|
343
|
+
if (x402Version !== 1 && x402Version !== 2) {
|
|
344
|
+
return refusal(
|
|
345
|
+
"UNSUPPORTED_VERSION",
|
|
346
|
+
`x402Version ${String(x402Version)} is not supported (only 1 and 2).`
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
if (!isPlainObject(requirement)) {
|
|
350
|
+
return refusal(
|
|
351
|
+
"MALFORMED_PAYMENT_REQUIRED",
|
|
352
|
+
"A payment entry is not an object."
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
const r = requirement;
|
|
356
|
+
if (r.scheme !== "exact") {
|
|
357
|
+
return refusal(
|
|
358
|
+
"UNSUPPORTED_SCHEME",
|
|
359
|
+
`Scheme ${JSON.stringify(r.scheme)} is not supported (only "exact").`
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
let network;
|
|
363
|
+
if (typeof r.network === "string") {
|
|
364
|
+
if (x402Version === 2) {
|
|
365
|
+
if (r.network === BASE_SEPOLIA || r.network === BASE_MAINNET) {
|
|
366
|
+
network = r.network;
|
|
367
|
+
}
|
|
368
|
+
} else if (Object.hasOwn(V1_NETWORK_NAMES, r.network)) {
|
|
369
|
+
network = V1_NETWORK_NAMES[r.network];
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (network === void 0) {
|
|
373
|
+
return refusal(
|
|
374
|
+
"UNSUPPORTED_NETWORK",
|
|
375
|
+
`Network ${JSON.stringify(r.network)} is not supported.`
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
if (!options.enabledNetworks.includes(network)) {
|
|
379
|
+
return refusal(
|
|
380
|
+
"NETWORK_NOT_ENABLED",
|
|
381
|
+
`Network ${network} is not enabled in this guard.`
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
if (typeof r.asset !== "string" || !ADDRESS_RE.test(r.asset)) {
|
|
385
|
+
return refusal("UNSUPPORTED_ASSET", "The asset is not an address.");
|
|
386
|
+
}
|
|
387
|
+
const pinned = pinnedAsset(network, r.asset);
|
|
388
|
+
if (!pinned) {
|
|
389
|
+
return refusal(
|
|
390
|
+
"UNSUPPORTED_ASSET",
|
|
391
|
+
`Asset ${r.asset} is not a supported asset on ${network}.`
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
const rawAmount = x402Version === 1 ? r.maxAmountRequired : r.amount;
|
|
395
|
+
if (typeof rawAmount !== "string" || !ATOMIC_RE.test(rawAmount)) {
|
|
396
|
+
return refusal(
|
|
397
|
+
"INVALID_AMOUNT",
|
|
398
|
+
"The amount is not a positive integer string in atomic units."
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
if (x402Version === 1 && r.amount !== void 0 && r.amount !== rawAmount) {
|
|
402
|
+
return refusal(
|
|
403
|
+
"INVALID_AMOUNT",
|
|
404
|
+
"The v1 entry names two different amounts."
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
if (typeof r.payTo !== "string" || !ADDRESS_RE.test(r.payTo) || r.payTo.toLowerCase() === ZERO_ADDRESS) {
|
|
408
|
+
return refusal("INVALID_PAY_TO", "payTo is not a plain address.");
|
|
409
|
+
}
|
|
410
|
+
const timeout = r.maxTimeoutSeconds;
|
|
411
|
+
if (typeof timeout !== "number" || !Number.isSafeInteger(timeout) || timeout <= 0) {
|
|
412
|
+
return refusal(
|
|
413
|
+
"INVALID_TIMEOUT",
|
|
414
|
+
"maxTimeoutSeconds is not a positive whole number."
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
if (timeout > options.maxTimeoutSecondsLimit) {
|
|
418
|
+
return refusal(
|
|
419
|
+
"TIMEOUT_ABOVE_LIMIT",
|
|
420
|
+
`maxTimeoutSeconds ${timeout} is above the limit of ${options.maxTimeoutSecondsLimit}.`
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
if (!isPlainObject(r.extra)) {
|
|
424
|
+
return refusal("DOMAIN_MISMATCH", "extra.name and extra.version are missing.");
|
|
425
|
+
}
|
|
426
|
+
const extra = r.extra;
|
|
427
|
+
if (Object.hasOwn(extra, "splits")) {
|
|
428
|
+
return refusal("SPLITS_REFUSED", "extra.splits is not supported.");
|
|
429
|
+
}
|
|
430
|
+
if (extra.assetTransferMethod !== void 0 && extra.assetTransferMethod !== "eip3009") {
|
|
431
|
+
return refusal(
|
|
432
|
+
"UNSUPPORTED_TRANSFER_METHOD",
|
|
433
|
+
`Transfer method ${JSON.stringify(extra.assetTransferMethod)} is not supported (only eip3009).`
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
if (extra.paymentFlow !== void 0 && extra.paymentFlow !== "authorization") {
|
|
437
|
+
return refusal(
|
|
438
|
+
"UNSUPPORTED_PAYMENT_FLOW",
|
|
439
|
+
`Payment flow ${JSON.stringify(extra.paymentFlow)} is not supported (only authorization).`
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
const unknownExtra = Object.keys(extra).filter((k) => !KNOWN_EXTRA.has(k));
|
|
443
|
+
if (unknownExtra.length > 0) {
|
|
444
|
+
return refusal(
|
|
445
|
+
"UNKNOWN_EXTRA_FIELD",
|
|
446
|
+
`extra has fields the guard cannot read: ${unknownExtra.join(", ")}.`
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
if (extra.name !== pinned.name || extra.version !== pinned.version) {
|
|
450
|
+
return refusal(
|
|
451
|
+
"DOMAIN_MISMATCH",
|
|
452
|
+
`extra.name/version do not match the pinned ${pinned.symbol} on ${pinned.networkName}.`
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
let resourceUrl;
|
|
456
|
+
if (x402Version === 2) {
|
|
457
|
+
const pr = paymentRequired;
|
|
458
|
+
resourceUrl = isPlainObject(pr?.resource) ? pr.resource.url : void 0;
|
|
459
|
+
} else {
|
|
460
|
+
resourceUrl = r.resource;
|
|
461
|
+
}
|
|
462
|
+
if (typeof resourceUrl !== "string" || hostOf(resourceUrl) === null) {
|
|
463
|
+
return refusal("MISSING_RESOURCE", "The resource URL is missing or invalid.");
|
|
464
|
+
}
|
|
465
|
+
if (request === null) {
|
|
466
|
+
return refusal(
|
|
467
|
+
"ORIGINAL_REQUEST_MISSING",
|
|
468
|
+
"The guard did not see the original request for this payment."
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
if (request.transport === "mcp") {
|
|
472
|
+
if (x402Version !== 2) {
|
|
473
|
+
return refusal(
|
|
474
|
+
"V1_NOT_SUPPORTED_ON_MCP",
|
|
475
|
+
"Only x402 v2 is supported on MCP."
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
if (!mcpResourceMatches(resourceUrl, request.toolName, request.serverUrl)) {
|
|
479
|
+
return refusal(
|
|
480
|
+
"RESOURCE_HOST_MISMATCH",
|
|
481
|
+
`The resource ${resourceUrl} is not the called tool ${request.toolName} (expected mcp://tool/${request.toolName}${request.serverUrl === null ? "" : " or <serverUrl origin>/tools/" + request.toolName}).`
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
} else {
|
|
485
|
+
const originalHost = hostOf(request.originalUrl);
|
|
486
|
+
if (originalHost === null) {
|
|
487
|
+
return refusal(
|
|
488
|
+
"ORIGINAL_REQUEST_MISSING",
|
|
489
|
+
"The original request URL cannot be read."
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
if (hostOf(resourceUrl) !== originalHost) {
|
|
493
|
+
return refusal(
|
|
494
|
+
"RESOURCE_HOST_MISMATCH",
|
|
495
|
+
`The resource host does not match the host the agent asked (${originalHost}).`
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
const terms = {
|
|
500
|
+
schema: "odingard.guardian.x402.pay.v1",
|
|
501
|
+
x402Version,
|
|
502
|
+
scheme: "exact",
|
|
503
|
+
network,
|
|
504
|
+
networkName: pinned.networkName,
|
|
505
|
+
asset: pinned.asset,
|
|
506
|
+
assetSymbol: pinned.symbol,
|
|
507
|
+
amount: rawAmount,
|
|
508
|
+
amountDisplay: `${formatAtomic(rawAmount, pinned.decimals)} ${pinned.symbol}`,
|
|
509
|
+
payTo: r.payTo.toLowerCase(),
|
|
510
|
+
maxTimeoutSeconds: timeout,
|
|
511
|
+
extra: {
|
|
512
|
+
name: pinned.name,
|
|
513
|
+
version: pinned.version,
|
|
514
|
+
assetTransferMethod: "eip3009",
|
|
515
|
+
paymentFlow: "authorization"
|
|
516
|
+
},
|
|
517
|
+
resourceUrl,
|
|
518
|
+
request: {
|
|
519
|
+
transport: request.transport,
|
|
520
|
+
method: request.method,
|
|
521
|
+
originalUrl: request.originalUrl,
|
|
522
|
+
finalUrl: request.finalUrl
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
return { ok: true, terms, pinned };
|
|
526
|
+
}
|
|
527
|
+
function requestKeyOf(terms) {
|
|
528
|
+
if (terms.request.transport === "mcp") {
|
|
529
|
+
return JSON.stringify(["mcp", terms.request.method, terms.request.originalUrl]);
|
|
530
|
+
}
|
|
531
|
+
return JSON.stringify([
|
|
532
|
+
terms.request.transport,
|
|
533
|
+
terms.request.method.toUpperCase(),
|
|
534
|
+
terms.request.originalUrl,
|
|
535
|
+
terms.resourceUrl
|
|
536
|
+
]);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// src/guard.ts
|
|
540
|
+
import { randomBytes } from "node:crypto";
|
|
541
|
+
import {
|
|
542
|
+
classifyRequestResult,
|
|
543
|
+
verifyReceiptOffline,
|
|
544
|
+
GUARDIAN_DEFAULT_REQUEST_TTL_MS,
|
|
545
|
+
GUARDIAN_MAX_REQUEST_TTL_MS
|
|
546
|
+
} from "@odingard/guardian-sdk";
|
|
547
|
+
import {
|
|
548
|
+
x402Client,
|
|
549
|
+
x402HTTPClient
|
|
550
|
+
} from "@x402/core/client";
|
|
551
|
+
import { registerExactEvmScheme } from "@x402/evm/exact/client";
|
|
552
|
+
var newId = (prefix) => `${prefix}_${randomBytes(12).toString("hex")}`;
|
|
553
|
+
function refused(code, detail, guardianReasonCode, extra) {
|
|
554
|
+
return new X402GuardRefused(code, detail, guardianReasonCode, extra);
|
|
555
|
+
}
|
|
556
|
+
var CLAIM_STILL_HELD = /* @__PURE__ */ new Set([
|
|
557
|
+
"APPROVAL_PENDING",
|
|
558
|
+
"APPROVAL_REQUIRED",
|
|
559
|
+
"SERVICE_UNAVAILABLE",
|
|
560
|
+
"RATE_LIMITED",
|
|
561
|
+
"DB_BUSY",
|
|
562
|
+
"HTTP_429"
|
|
563
|
+
]);
|
|
564
|
+
function claimStillHeld(reasonCode) {
|
|
565
|
+
return CLAIM_STILL_HELD.has(reasonCode) || /^HTTP_5\d\d$/.test(reasonCode);
|
|
566
|
+
}
|
|
567
|
+
var OUTCOME_UNKNOWN_DETAIL = (requestId) => `Guardian may have redeemed this decision (execute outcome unknown); nothing was signed. Look up request ${requestId} before retrying; a retry needs a new decision.`;
|
|
568
|
+
function toBig(value) {
|
|
569
|
+
if (typeof value === "bigint") return value;
|
|
570
|
+
if (typeof value === "string" && /^\d{1,78}$/.test(value)) return BigInt(value);
|
|
571
|
+
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) {
|
|
572
|
+
return BigInt(value);
|
|
573
|
+
}
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
var hasExactKeys = (obj, keys) => {
|
|
577
|
+
const own = Reflect.ownKeys(obj);
|
|
578
|
+
return own.length === keys.length && keys.every((k) => own.includes(k));
|
|
579
|
+
};
|
|
580
|
+
function mismatch(field) {
|
|
581
|
+
return refused(
|
|
582
|
+
"TYPED_DATA_MISMATCH",
|
|
583
|
+
`The data to sign does not match the approved terms (${field}).`
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
var X402Guard = class {
|
|
587
|
+
client;
|
|
588
|
+
config;
|
|
589
|
+
ledger;
|
|
590
|
+
durable;
|
|
591
|
+
startedAtMs;
|
|
592
|
+
now;
|
|
593
|
+
termsOptions;
|
|
594
|
+
requestTtlMs;
|
|
595
|
+
signingWindowMs;
|
|
596
|
+
pollMs;
|
|
597
|
+
callTimeoutMs;
|
|
598
|
+
walletAddress;
|
|
599
|
+
wallet;
|
|
600
|
+
log = [];
|
|
601
|
+
/** PaymentRequired object → the request it answered (set by the HTTP hook). */
|
|
602
|
+
pending = /* @__PURE__ */ new WeakMap();
|
|
603
|
+
constructor(config) {
|
|
604
|
+
assertSupportedX402("@x402/core");
|
|
605
|
+
assertSupportedX402("@x402/evm");
|
|
606
|
+
this.config = config;
|
|
607
|
+
this.now = config.now ?? Date.now;
|
|
608
|
+
if (!config.guardian || typeof config.guardian.request !== "function") {
|
|
609
|
+
throw new Error("guardian-x402: `guardian` must be a Guardian client.");
|
|
610
|
+
}
|
|
611
|
+
if (typeof config.connectionId !== "string" || !config.connectionId) {
|
|
612
|
+
throw new Error("guardian-x402: `connectionId` is required.");
|
|
613
|
+
}
|
|
614
|
+
const signer = config.signer;
|
|
615
|
+
if (!signer || typeof signer.address !== "string" || !ADDRESS_RE.test(signer.address) || typeof signer.signTypedData !== "function") {
|
|
616
|
+
throw new Error(
|
|
617
|
+
"guardian-x402: `signer` must have an address and signTypedData."
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
this.wallet = signer;
|
|
621
|
+
this.walletAddress = signer.address.toLowerCase();
|
|
622
|
+
this.guardedSigner = Object.freeze({
|
|
623
|
+
address: signer.address,
|
|
624
|
+
signTypedData: (message) => this.signGuarded(message),
|
|
625
|
+
signTransaction: async () => {
|
|
626
|
+
throw refused("SIGNING_REFUSED", "The guard does not sign transactions.");
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
const enabled = config.enabledNetworks ?? [BASE_SEPOLIA];
|
|
630
|
+
for (const n of enabled) {
|
|
631
|
+
if (n !== BASE_SEPOLIA && n !== BASE_MAINNET) {
|
|
632
|
+
throw new Error(`guardian-x402: network ${String(n)} is not supported.`);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
const limit = config.maxTimeoutSecondsLimit ?? DEFAULT_MAX_TIMEOUT_SECONDS_LIMIT;
|
|
636
|
+
if (!Number.isSafeInteger(limit) || limit <= 0) {
|
|
637
|
+
throw new Error("guardian-x402: maxTimeoutSecondsLimit must be a positive whole number.");
|
|
638
|
+
}
|
|
639
|
+
this.termsOptions = Object.freeze({
|
|
640
|
+
enabledNetworks: Object.freeze([...enabled]),
|
|
641
|
+
maxTimeoutSecondsLimit: limit
|
|
642
|
+
});
|
|
643
|
+
this.requestTtlMs = config.requestTtlMs ?? GUARDIAN_DEFAULT_REQUEST_TTL_MS;
|
|
644
|
+
if (!Number.isSafeInteger(this.requestTtlMs) || this.requestTtlMs < 1 || this.requestTtlMs > GUARDIAN_MAX_REQUEST_TTL_MS) {
|
|
645
|
+
throw new Error(
|
|
646
|
+
`guardian-x402: requestTtlMs must be from 1 to ${GUARDIAN_MAX_REQUEST_TTL_MS}.`
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
this.signingWindowMs = boundedMs(
|
|
650
|
+
"signingWindowMs",
|
|
651
|
+
config.signingWindowMs ?? DEFAULT_SIGNING_WINDOW_MS,
|
|
652
|
+
MAX_SIGNING_WINDOW_MS
|
|
653
|
+
);
|
|
654
|
+
this.pollMs = boundedMs(
|
|
655
|
+
"claimPollIntervalMs",
|
|
656
|
+
config.claimPollIntervalMs ?? DEFAULT_CLAIM_POLL_INTERVAL_MS,
|
|
657
|
+
MAX_CLAIM_POLL_INTERVAL_MS
|
|
658
|
+
);
|
|
659
|
+
this.callTimeoutMs = boundedMs(
|
|
660
|
+
"guardianCallTimeoutMs",
|
|
661
|
+
config.guardianCallTimeoutMs ?? DEFAULT_GUARDIAN_CALL_TIMEOUT_MS,
|
|
662
|
+
MAX_GUARDIAN_CALL_TIMEOUT_MS
|
|
663
|
+
);
|
|
664
|
+
for (const l of config.limits ?? []) {
|
|
665
|
+
if (!pinnedAsset(l.network, l.asset)) {
|
|
666
|
+
throw new Error(`guardian-x402: limit for an unsupported asset ${l.asset} on ${l.network}.`);
|
|
667
|
+
}
|
|
668
|
+
if (!ATOMIC_RE.test(l.askAbove) && l.askAbove !== "0") {
|
|
669
|
+
throw new Error("guardian-x402: askAbove must be an atomic integer string.");
|
|
670
|
+
}
|
|
671
|
+
if (!ATOMIC_RE.test(l.max)) {
|
|
672
|
+
throw new Error("guardian-x402: max must be a positive atomic integer string.");
|
|
673
|
+
}
|
|
674
|
+
for (const p of l.payTo ?? []) {
|
|
675
|
+
if (!ADDRESS_RE.test(p)) throw new Error(`guardian-x402: payTo ${p} is not an address.`);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
const budgets = config.budgets ?? [];
|
|
679
|
+
if (budgets.length > 0 && !config.store) {
|
|
680
|
+
throw new Error("guardian-x402: budgets need a durable `store`.");
|
|
681
|
+
}
|
|
682
|
+
for (const b of budgets) {
|
|
683
|
+
if (!pinnedAsset(b.network, b.asset) || !ATOMIC_RE.test(b.limit)) {
|
|
684
|
+
throw new Error("guardian-x402: each budget needs a pinned asset and an atomic limit.");
|
|
685
|
+
}
|
|
686
|
+
if (b.period !== "day" && b.period !== "month") {
|
|
687
|
+
throw new Error("guardian-x402: budget period must be day or month.");
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
if (config.spendControls === false) {
|
|
691
|
+
throw new Error("guardian-x402: x402 spendControls cannot be turned off.");
|
|
692
|
+
}
|
|
693
|
+
this.durable = config.store !== void 0;
|
|
694
|
+
this.ledger = new Ledger(config.store ?? processStore(), budgets);
|
|
695
|
+
this.startedAtMs = this.now();
|
|
696
|
+
this.client = new GuardedX402Client(this, config);
|
|
697
|
+
}
|
|
698
|
+
// ---------------------------------------------------------------- records
|
|
699
|
+
/** Local records (newest last). No keys, signatures or payment payloads. */
|
|
700
|
+
records() {
|
|
701
|
+
return [...this.log];
|
|
702
|
+
}
|
|
703
|
+
/** @internal */
|
|
704
|
+
record(r) {
|
|
705
|
+
const full = Object.freeze({ at: this.now(), ...r });
|
|
706
|
+
this.log.push(full);
|
|
707
|
+
try {
|
|
708
|
+
this.config.onRecord?.(full);
|
|
709
|
+
} catch {
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
/** Authorizations the guard signed that may still be settled. */
|
|
713
|
+
async liveAuthorizations() {
|
|
714
|
+
const now = this.now();
|
|
715
|
+
return (await this.ledger.entries()).filter(
|
|
716
|
+
(e) => (e.state === "signed" || e.state === "signing" || e.state === "spent") && isLive(e, now)
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Releases budget held by authorizations whose `validBefore` has passed
|
|
721
|
+
* and whose nonce the chain shows unused. Needs `authorizationState`.
|
|
722
|
+
*/
|
|
723
|
+
async reconcile() {
|
|
724
|
+
const reader = this.config.authorizationState;
|
|
725
|
+
let released = 0;
|
|
726
|
+
let spent = 0;
|
|
727
|
+
const now = this.now();
|
|
728
|
+
for (const e of await this.ledger.entries()) {
|
|
729
|
+
const holdUntil = e.holdUntilMs ?? e.createdAtMs + LONGEST_FLOW_MS;
|
|
730
|
+
if (e.state === "reserved" && now > holdUntil) {
|
|
731
|
+
await this.ledger.releaseUnsigned(e.id);
|
|
732
|
+
released += 1;
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
if (!reader || e.state !== "signed" && e.state !== "signing" || e.validBefore === void 0 || now <= e.validBefore * 1e3 + RECONCILE_MARGIN_MS || !e.nonce || !e.from) {
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
let used;
|
|
739
|
+
try {
|
|
740
|
+
used = await reader({ network: e.network, asset: e.asset, from: e.from, nonce: e.nonce });
|
|
741
|
+
} catch {
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
if (used === false) {
|
|
745
|
+
await this.ledger.patch(e.id, (x) => {
|
|
746
|
+
if (x.state === "signed" || x.state === "signing") x.state = "released";
|
|
747
|
+
});
|
|
748
|
+
this.record({ event: "released", nonce: e.nonce, network: e.network });
|
|
749
|
+
released += 1;
|
|
750
|
+
} else if (used === true) {
|
|
751
|
+
await this.ledger.patch(e.id, (x) => {
|
|
752
|
+
if (x.state === "signed" || x.state === "signing") x.state = "spent";
|
|
753
|
+
});
|
|
754
|
+
this.record({ event: "spent", nonce: e.nonce, network: e.network });
|
|
755
|
+
spent += 1;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
return { released, spent };
|
|
759
|
+
}
|
|
760
|
+
// ------------------------------------------------------- transport glue
|
|
761
|
+
/**
|
|
762
|
+
* An x402 HTTP client for this guard. Its only `onPaymentRequired` hook
|
|
763
|
+
* records which request the 402 answered; it never returns headers.
|
|
764
|
+
*/
|
|
765
|
+
httpClient() {
|
|
766
|
+
const http = new x402HTTPClient(this.client);
|
|
767
|
+
http.onPaymentRequired(async ({ paymentRequired, requestUrl }) => {
|
|
768
|
+
const ctx = requestStorage.getStore();
|
|
769
|
+
if (ctx && ctx.info.transport === "http" && paymentRequired && typeof paymentRequired === "object") {
|
|
770
|
+
this.pending.set(paymentRequired, {
|
|
771
|
+
ctx,
|
|
772
|
+
info: { ...ctx.info, finalUrl: typeof requestUrl === "string" ? requestUrl : null }
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
return void 0;
|
|
776
|
+
});
|
|
777
|
+
return http;
|
|
778
|
+
}
|
|
779
|
+
/** @internal */
|
|
780
|
+
requestInfoFor(paymentRequired) {
|
|
781
|
+
const ctx = requestStorage.getStore() ?? null;
|
|
782
|
+
if (ctx?.info.transport === "mcp") return { info: ctx.info, ctx };
|
|
783
|
+
const found = this.pending.get(paymentRequired);
|
|
784
|
+
if (found && found.ctx === ctx) return { info: found.info, ctx };
|
|
785
|
+
return { info: null, ctx };
|
|
786
|
+
}
|
|
787
|
+
/** @internal Fetch: "Payment already attempted" after the payload was signed. */
|
|
788
|
+
async markSignedNotSent(ctx) {
|
|
789
|
+
const id = ctx.signedEntries.at(-1);
|
|
790
|
+
if (!id) return;
|
|
791
|
+
const entry = await this.ledger.patch(id, (e) => {
|
|
792
|
+
e.sent = "not-sent";
|
|
793
|
+
}).catch(() => void 0);
|
|
794
|
+
this.record({
|
|
795
|
+
event: "signed-not-sent",
|
|
796
|
+
...entry?.nonce ? { nonce: entry.nonce } : {},
|
|
797
|
+
...entry?.requestId ? { requestId: entry.requestId } : {},
|
|
798
|
+
detail: "Signed, not sent: x402 stopped before sending. The authorization stays live until validBefore."
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
/** @internal v1 settlement, read by the transport wrapper. */
|
|
802
|
+
async recordV1Settlement(ctx, settle) {
|
|
803
|
+
const id = [...ctx.signedEntries].reverse().find((e) => ctx.versions.get(e) === 1);
|
|
804
|
+
if (!id || !settle) return;
|
|
805
|
+
const entry = await this.ledger.patch(id, (e) => {
|
|
806
|
+
if (settle.success === true && (e.state === "signed" || e.state === "signing")) {
|
|
807
|
+
e.state = "spent";
|
|
808
|
+
}
|
|
809
|
+
}).catch(() => void 0);
|
|
810
|
+
this.recordSettlement(entry, settle);
|
|
811
|
+
}
|
|
812
|
+
recordSettlement(entry, settle) {
|
|
813
|
+
this.record({
|
|
814
|
+
event: "settlement",
|
|
815
|
+
...entry?.requestId ? { requestId: entry.requestId } : {},
|
|
816
|
+
...entry?.nonce ? { nonce: entry.nonce } : {},
|
|
817
|
+
success: settle.success === true,
|
|
818
|
+
...typeof settle.transaction === "string" ? { transaction: settle.transaction } : {},
|
|
819
|
+
...typeof settle.network === "string" ? { network: settle.network } : {},
|
|
820
|
+
...typeof settle.errorReason === "string" ? { errorReason: settle.errorReason } : {}
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
// --------------------------------------------------------- core hooks
|
|
824
|
+
/** @internal Drops entries the guard cannot pay before x402 selects one. */
|
|
825
|
+
policy = (x402Version, requirements) => {
|
|
826
|
+
const flow = flowStorage.getStore();
|
|
827
|
+
if (!flow) return [];
|
|
828
|
+
return requirements.filter((r) => {
|
|
829
|
+
const read = readTerms(x402Version, r, flow.paymentRequired, flow.request, this.termsOptions);
|
|
830
|
+
if (!read.ok) flow.dropped.push({ code: read.code, detail: read.detail });
|
|
831
|
+
return read.ok;
|
|
832
|
+
});
|
|
833
|
+
};
|
|
834
|
+
/** @internal Pre-check before x402 runs: refuse what nothing can pay. */
|
|
835
|
+
precheck(paymentRequired, request) {
|
|
836
|
+
if (!isPlainObject(paymentRequired)) {
|
|
837
|
+
throw refused("MALFORMED_PAYMENT_REQUIRED", "The payment terms are not an object.");
|
|
838
|
+
}
|
|
839
|
+
const version = paymentRequired.x402Version;
|
|
840
|
+
if (version !== 1 && version !== 2) {
|
|
841
|
+
throw refused("UNSUPPORTED_VERSION", `x402Version ${String(version)} is not supported.`);
|
|
842
|
+
}
|
|
843
|
+
const accepts = paymentRequired.accepts;
|
|
844
|
+
if (!Array.isArray(accepts) || accepts.length === 0) {
|
|
845
|
+
throw refused("MALFORMED_PAYMENT_REQUIRED", "The payment terms list no payment options.");
|
|
846
|
+
}
|
|
847
|
+
let first = null;
|
|
848
|
+
for (const r of accepts) {
|
|
849
|
+
const read = readTerms(version, r, paymentRequired, request, this.termsOptions);
|
|
850
|
+
if (read.ok) return;
|
|
851
|
+
first ??= { code: read.code, detail: read.detail };
|
|
852
|
+
}
|
|
853
|
+
throw refused(first.code, first.detail);
|
|
854
|
+
}
|
|
855
|
+
/** @internal The decision point: x402's onBeforePaymentCreation. */
|
|
856
|
+
beforePaymentCreation = async (ctx) => {
|
|
857
|
+
const flow = flowStorage.getStore();
|
|
858
|
+
try {
|
|
859
|
+
if (!flow || flow.paymentRequired !== ctx.paymentRequired) {
|
|
860
|
+
throw refused("NO_PAYMENT_FLOW", "This payment did not start through the guard.");
|
|
861
|
+
}
|
|
862
|
+
if (flow.decided) {
|
|
863
|
+
throw refused("NO_PAYMENT_FLOW", "This payment was already decided.");
|
|
864
|
+
}
|
|
865
|
+
flow.decided = true;
|
|
866
|
+
flow.selectedRequirements = ctx.selectedRequirements;
|
|
867
|
+
await this.decide(flow, ctx);
|
|
868
|
+
return void 0;
|
|
869
|
+
} catch (error) {
|
|
870
|
+
const err = error instanceof X402GuardRefused ? error : refused("SIGNING_REFUSED", "The guard failed while deciding; nothing was signed.");
|
|
871
|
+
if (flow) {
|
|
872
|
+
flow.refusal = err;
|
|
873
|
+
if (flow.ledgerId) await this.ledger.releaseUnsigned(flow.ledgerId).catch(() => void 0);
|
|
874
|
+
}
|
|
875
|
+
this.recordRefusal(err, flow?.id);
|
|
876
|
+
return { abort: true, reason: err.message };
|
|
877
|
+
}
|
|
878
|
+
};
|
|
879
|
+
limitFor(terms) {
|
|
880
|
+
return (this.config.limits ?? []).find(
|
|
881
|
+
(l) => l.network === terms.network && l.asset.toLowerCase() === terms.asset
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
async decide(flow, ctx) {
|
|
885
|
+
const read = readTerms(
|
|
886
|
+
ctx.paymentRequired.x402Version,
|
|
887
|
+
ctx.selectedRequirements,
|
|
888
|
+
ctx.paymentRequired,
|
|
889
|
+
flow.request,
|
|
890
|
+
this.termsOptions
|
|
891
|
+
);
|
|
892
|
+
if (!read.ok) throw refused(read.code, read.detail);
|
|
893
|
+
const terms = frozenCopy(read.terms);
|
|
894
|
+
let snapshot;
|
|
895
|
+
try {
|
|
896
|
+
snapshot = frozenCopy(ctx.selectedRequirements);
|
|
897
|
+
} catch {
|
|
898
|
+
throw refused("MALFORMED_PAYMENT_REQUIRED", "The selected entry cannot be copied.");
|
|
899
|
+
}
|
|
900
|
+
const amount = BigInt(terms.amount);
|
|
901
|
+
const limit = this.limitFor(terms);
|
|
902
|
+
if (limit && amount > BigInt(limit.max)) {
|
|
903
|
+
throw refused("ABOVE_MAX", `${terms.amountDisplay} is above the local max for this asset.`);
|
|
904
|
+
}
|
|
905
|
+
if (limit?.payTo && !limit.payTo.some((p) => p.toLowerCase() === terms.payTo)) {
|
|
906
|
+
throw refused("PAY_TO_NOT_ALLOWED", `payTo ${terms.payTo} is not on the allowlist.`);
|
|
907
|
+
}
|
|
908
|
+
const reasons = [];
|
|
909
|
+
if (!limit) reasons.push("NO_CAP_FOR_ASSET");
|
|
910
|
+
else if (amount > BigInt(limit.askAbove)) reasons.push("ABOVE_ASK_ABOVE");
|
|
911
|
+
const now = this.now();
|
|
912
|
+
if (!this.durable && now < this.startedAtMs + this.termsOptions.maxTimeoutSecondsLimit * 1e3) {
|
|
913
|
+
reasons.push("RECENT_START_WITHOUT_STORE");
|
|
914
|
+
}
|
|
915
|
+
const entry = {
|
|
916
|
+
id: newId("x4e"),
|
|
917
|
+
requestKey: requestKeyOf(terms),
|
|
918
|
+
network: terms.network,
|
|
919
|
+
asset: terms.asset,
|
|
920
|
+
amount: terms.amount,
|
|
921
|
+
createdAtMs: now,
|
|
922
|
+
holdUntilMs: now + this.longestFlowMs(),
|
|
923
|
+
state: "reserved",
|
|
924
|
+
sent: "unknown"
|
|
925
|
+
};
|
|
926
|
+
const { liveForRequest } = await this.ledger.reserve(
|
|
927
|
+
entry,
|
|
928
|
+
now,
|
|
929
|
+
flow.requestContext?.signedEntries ?? []
|
|
930
|
+
);
|
|
931
|
+
flow.ledgerId = entry.id;
|
|
932
|
+
if (liveForRequest.length > 0) {
|
|
933
|
+
reasons.push("LIVE_AUTHORIZATION_FOR_REQUEST");
|
|
934
|
+
const exposure = liveForRequest.reduce((s, e) => s + BigInt(e.amount), amount);
|
|
935
|
+
if (limit && exposure > BigInt(limit.max)) {
|
|
936
|
+
throw refused(
|
|
937
|
+
"ABOVE_MAX",
|
|
938
|
+
"This payment plus live authorizations for the same request is above the local max."
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
const aboveCap = reasons.length > 0;
|
|
943
|
+
const tool = aboveCap ? PAY_ABOVE_CAP_TOOL : PAY_TOOL;
|
|
944
|
+
const args = { ...terms, reviewReasons: [...reasons] };
|
|
945
|
+
const { requestId, capabilityToken, viaAsk } = await this.askGuardian(
|
|
946
|
+
tool,
|
|
947
|
+
args,
|
|
948
|
+
terms,
|
|
949
|
+
aboveCap
|
|
950
|
+
);
|
|
951
|
+
const executed = await withTimeout(
|
|
952
|
+
Promise.resolve().then(
|
|
953
|
+
() => this.config.guardian.execute({ requestId, capabilityToken })
|
|
954
|
+
),
|
|
955
|
+
this.callTimeoutMs
|
|
956
|
+
);
|
|
957
|
+
if (!executed.ok) {
|
|
958
|
+
flow.executeOutcomeUnknown = { requestId };
|
|
959
|
+
throw refused(
|
|
960
|
+
"EXECUTE_FAILED",
|
|
961
|
+
`${executed.timedOut ? "Guardian did not answer execute in time." : "Guardian execute failed."} ${OUTCOME_UNKNOWN_DETAIL(requestId)}`,
|
|
962
|
+
void 0,
|
|
963
|
+
{ outcomeUnknown: true, requestId }
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
const ex = executed.value;
|
|
967
|
+
const exOk = isPlainObject(ex) && ex.ok === true && isPlainObject(ex.receipt) && ex.receipt.status === "sent-verified" && ex.receipt.requestId === requestId && typeof ex.receipt.receiptReference === "string";
|
|
968
|
+
if (!exOk) {
|
|
969
|
+
const code = isPlainObject(ex) && typeof ex.reasonCode === "string" ? ex.reasonCode : "EXECUTE_FAILED";
|
|
970
|
+
const definitiveRefusal = isPlainObject(ex) && ex.ok === false && ex.outcomeUnknown !== true;
|
|
971
|
+
if (!definitiveRefusal) {
|
|
972
|
+
flow.executeOutcomeUnknown = { requestId };
|
|
973
|
+
throw refused(
|
|
974
|
+
"EXECUTE_FAILED",
|
|
975
|
+
`Guardian did not redeem the decision cleanly. ${OUTCOME_UNKNOWN_DETAIL(requestId)}`,
|
|
976
|
+
code,
|
|
977
|
+
{ outcomeUnknown: true, requestId }
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
throw refused("EXECUTE_FAILED", "Guardian did not redeem the decision.", code, {
|
|
981
|
+
requestId
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
const receiptReference = ex.receipt.receiptReference;
|
|
985
|
+
flow.redeemed = { requestId, receiptReference };
|
|
986
|
+
await this.ledger.patch(entry.id, (e) => {
|
|
987
|
+
e.requestId = requestId;
|
|
988
|
+
}).catch(() => void 0);
|
|
989
|
+
let approvedByPerson = false;
|
|
990
|
+
if (aboveCap) {
|
|
991
|
+
if (!viaAsk) {
|
|
992
|
+
throw refused(
|
|
993
|
+
"PERSON_APPROVAL_REQUIRED",
|
|
994
|
+
"This payment needs a person's approval; a rule ALLOW is not enough."
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
await this.checkApprovedByPerson(requestId, tool, args);
|
|
998
|
+
approvedByPerson = true;
|
|
999
|
+
}
|
|
1000
|
+
const token = {
|
|
1001
|
+
terms,
|
|
1002
|
+
pinned: read.pinned,
|
|
1003
|
+
requirementSnapshot: snapshot,
|
|
1004
|
+
requestId,
|
|
1005
|
+
receiptReference,
|
|
1006
|
+
expiresAtMs: this.now() + this.signingWindowMs,
|
|
1007
|
+
used: false
|
|
1008
|
+
};
|
|
1009
|
+
flow.token = token;
|
|
1010
|
+
this.record({
|
|
1011
|
+
event: "redeemed",
|
|
1012
|
+
flowId: flow.id,
|
|
1013
|
+
tool,
|
|
1014
|
+
requestId,
|
|
1015
|
+
receiptReference,
|
|
1016
|
+
terms,
|
|
1017
|
+
reviewReasons: [...reasons],
|
|
1018
|
+
approvedByPerson
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
/** The longest a flow can hold a reservation before it signs. */
|
|
1022
|
+
longestFlowMs() {
|
|
1023
|
+
return 4 * this.callTimeoutMs + this.requestTtlMs + this.pollMs + this.signingWindowMs + 6e4;
|
|
1024
|
+
}
|
|
1025
|
+
async guardianCall(fn) {
|
|
1026
|
+
const r = await withTimeout(Promise.resolve().then(fn), this.callTimeoutMs);
|
|
1027
|
+
if (!r.ok) {
|
|
1028
|
+
throw r.timedOut ? refused("GUARDIAN_TIMEOUT", "Guardian did not answer in time.") : refused("GUARDIAN_UNREACHABLE", "Guardian could not be reached.");
|
|
1029
|
+
}
|
|
1030
|
+
return r.value;
|
|
1031
|
+
}
|
|
1032
|
+
async askGuardian(tool, args, terms, aboveCap) {
|
|
1033
|
+
const purpose = `Pay ${terms.amountDisplay} on ${terms.networkName} over x402${aboveCap ? " (needs a person's approval)" : ""}`;
|
|
1034
|
+
const answer = await this.guardianCall(
|
|
1035
|
+
() => this.config.guardian.request({
|
|
1036
|
+
action: "tool.invoke",
|
|
1037
|
+
connectionId: this.config.connectionId,
|
|
1038
|
+
toolName: tool,
|
|
1039
|
+
arguments: args,
|
|
1040
|
+
purpose,
|
|
1041
|
+
ttlMs: this.requestTtlMs
|
|
1042
|
+
})
|
|
1043
|
+
);
|
|
1044
|
+
const startedAt = this.now();
|
|
1045
|
+
if (!wellFormed(answer)) {
|
|
1046
|
+
throw refused("GUARDIAN_UNREACHABLE", "Guardian's answer was malformed.");
|
|
1047
|
+
}
|
|
1048
|
+
const decision = classifyRequestResult(answer);
|
|
1049
|
+
if (decision.decision === "DENY") {
|
|
1050
|
+
if (decision.reasonCode === "SERVICE_UNAVAILABLE") {
|
|
1051
|
+
throw refused("GUARDIAN_UNREACHABLE", "Guardian could not be reached.");
|
|
1052
|
+
}
|
|
1053
|
+
throw refused("GUARDIAN_DENIED", "Guardian denied this payment.", decision.reasonCode);
|
|
1054
|
+
}
|
|
1055
|
+
if (decision.decision === "ALLOW") {
|
|
1056
|
+
if (aboveCap) {
|
|
1057
|
+
throw refused(
|
|
1058
|
+
"PERSON_APPROVAL_REQUIRED",
|
|
1059
|
+
"This payment needs a person's approval; a rule ALLOW is not enough."
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
return {
|
|
1063
|
+
requestId: decision.requestId,
|
|
1064
|
+
capabilityToken: decision.capabilityToken,
|
|
1065
|
+
viaAsk: false
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
const requestId = decision.requestId;
|
|
1069
|
+
const deadline = startedAt + this.requestTtlMs;
|
|
1070
|
+
while (this.now() < deadline) {
|
|
1071
|
+
await sleep(Math.max(1, Math.min(this.pollMs, deadline - this.now())));
|
|
1072
|
+
let claimed;
|
|
1073
|
+
try {
|
|
1074
|
+
claimed = await this.guardianCall(() => this.config.guardian.claim(requestId));
|
|
1075
|
+
} catch {
|
|
1076
|
+
continue;
|
|
1077
|
+
}
|
|
1078
|
+
if (!wellFormed(claimed)) {
|
|
1079
|
+
throw refused("GUARDIAN_UNREACHABLE", "Guardian's answer was malformed.");
|
|
1080
|
+
}
|
|
1081
|
+
if (claimed.ok && claimed.requestId !== requestId) {
|
|
1082
|
+
throw refused("GUARDIAN_UNREACHABLE", "Guardian answered for a different request.");
|
|
1083
|
+
}
|
|
1084
|
+
if (claimed.ok && claimed.decision === "ALLOW") {
|
|
1085
|
+
if (typeof claimed.capabilityToken !== "string" || !claimed.capabilityToken) {
|
|
1086
|
+
throw refused("GUARDIAN_DENIED", "Guardian issued no capability.", "CAPABILITY_MISSING");
|
|
1087
|
+
}
|
|
1088
|
+
return { requestId, capabilityToken: claimed.capabilityToken, viaAsk: true };
|
|
1089
|
+
}
|
|
1090
|
+
const held = claimed.ok && claimed.decision === "ASK" || !claimed.ok && claimStillHeld(claimed.reasonCode);
|
|
1091
|
+
if (held) continue;
|
|
1092
|
+
throw refused(
|
|
1093
|
+
"GUARDIAN_DENIED",
|
|
1094
|
+
"Guardian denied this payment.",
|
|
1095
|
+
claimed.ok ? claimed.reasonCode ?? "DENIED" : claimed.reasonCode
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
throw refused("APPROVAL_TIMEOUT", "No person approved this payment in time.");
|
|
1099
|
+
}
|
|
1100
|
+
/** The receipt must show a person approved this exact request. */
|
|
1101
|
+
async checkApprovedByPerson(requestId, tool, args) {
|
|
1102
|
+
const fail = (why) => refused("RECEIPT_CHECK_FAILED", `Cannot confirm a person approved this payment (${why}).`);
|
|
1103
|
+
const got = await this.guardianCall(() => this.config.guardian.portableReceipt(requestId));
|
|
1104
|
+
if (!isPlainObject(got) || got.ok !== true) throw fail("no receipt");
|
|
1105
|
+
const receipt = got.receipt;
|
|
1106
|
+
if (this.config.receiptVerificationKeys !== void 0) {
|
|
1107
|
+
const verified = verifyReceiptOffline(receipt, this.config.receiptVerificationKeys);
|
|
1108
|
+
if (!verified.valid) throw fail("receipt does not verify");
|
|
1109
|
+
}
|
|
1110
|
+
if (!isPlainObject(receipt)) throw fail("malformed receipt");
|
|
1111
|
+
const subject = receipt.subject;
|
|
1112
|
+
const action = receipt.action;
|
|
1113
|
+
const decision = receipt.decision;
|
|
1114
|
+
const approval = receipt.approval;
|
|
1115
|
+
if (!isPlainObject(subject) || subject.requestId !== requestId) throw fail("other request");
|
|
1116
|
+
if (!isPlainObject(action) || action.requestId !== requestId) throw fail("other request");
|
|
1117
|
+
if (action.action !== "tool.invoke") throw fail("action");
|
|
1118
|
+
const params = action.materialParameters;
|
|
1119
|
+
if (!isPlainObject(params) || params.toolName !== tool || params.connectionId !== this.config.connectionId || !sameData(params.arguments, args)) {
|
|
1120
|
+
throw fail("terms differ");
|
|
1121
|
+
}
|
|
1122
|
+
if (!isPlainObject(decision) || decision.decision !== "ASK") throw fail("not an ASK");
|
|
1123
|
+
if (!isPlainObject(approval) || approval.state !== "approved" && approval.state !== "consumed") {
|
|
1124
|
+
throw fail("not approved");
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
// -------------------------------------------------------------- signer
|
|
1128
|
+
/** @internal The only signer x402 gets: EIP-3009 typed data, nothing else. */
|
|
1129
|
+
guardedSigner;
|
|
1130
|
+
recordedRefusals = /* @__PURE__ */ new WeakSet();
|
|
1131
|
+
/** @internal Records a refusal once. */
|
|
1132
|
+
recordRefusal(err, flowId) {
|
|
1133
|
+
if (this.recordedRefusals.has(err)) return;
|
|
1134
|
+
this.recordedRefusals.add(err);
|
|
1135
|
+
this.record({
|
|
1136
|
+
event: "refused",
|
|
1137
|
+
...flowId ? { flowId } : {},
|
|
1138
|
+
code: err.code,
|
|
1139
|
+
...err.guardianReasonCode ? { guardianReasonCode: err.guardianReasonCode } : {},
|
|
1140
|
+
...err.requestId ? { requestId: err.requestId } : {},
|
|
1141
|
+
...err.outcomeUnknown ? { outcomeUnknown: true } : {},
|
|
1142
|
+
detail: err.message
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
async signGuarded(raw) {
|
|
1146
|
+
try {
|
|
1147
|
+
return await this.signChecked(raw);
|
|
1148
|
+
} catch (error) {
|
|
1149
|
+
if (error instanceof X402GuardRefused) {
|
|
1150
|
+
this.recordRefusal(error, flowStorage.getStore()?.id);
|
|
1151
|
+
}
|
|
1152
|
+
throw error;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
async signChecked(raw) {
|
|
1156
|
+
const flow = flowStorage.getStore();
|
|
1157
|
+
if (!flow) throw refused("NO_PAYMENT_FLOW", "Signing outside a guarded payment is refused.");
|
|
1158
|
+
flow.signAttempted = true;
|
|
1159
|
+
const token = flow.token;
|
|
1160
|
+
if (!token) throw refused("NO_SIGNING_TOKEN", "No approved payment in this flow.");
|
|
1161
|
+
if (token.used) throw refused("SIGNING_TOKEN_USED", "This approval was already used to sign.");
|
|
1162
|
+
token.used = true;
|
|
1163
|
+
if (this.now() > token.expiresAtMs) {
|
|
1164
|
+
throw refused("SIGNING_TOKEN_EXPIRED", "The approval's signing window has passed.");
|
|
1165
|
+
}
|
|
1166
|
+
const pr = flow.paymentRequired;
|
|
1167
|
+
const live = readTerms(
|
|
1168
|
+
pr?.x402Version,
|
|
1169
|
+
flow.selectedRequirements,
|
|
1170
|
+
flow.paymentRequired,
|
|
1171
|
+
flow.request,
|
|
1172
|
+
this.termsOptions
|
|
1173
|
+
);
|
|
1174
|
+
if (!live.ok || !sameData(live.terms, token.terms) || !sameData(jsonCopy(flow.selectedRequirements), token.requirementSnapshot)) {
|
|
1175
|
+
throw refused("TERMS_CHANGED", "The payment terms changed after the decision.");
|
|
1176
|
+
}
|
|
1177
|
+
let data;
|
|
1178
|
+
try {
|
|
1179
|
+
data = structuredClone(raw);
|
|
1180
|
+
} catch {
|
|
1181
|
+
throw mismatch("not plain data");
|
|
1182
|
+
}
|
|
1183
|
+
const checked = this.checkTypedData(data, token);
|
|
1184
|
+
const ledgerId = flow.ledgerId;
|
|
1185
|
+
if (!ledgerId) throw refused("SIGNING_REFUSED", "No reservation for this payment.");
|
|
1186
|
+
const marked = await this.ledger.patch(ledgerId, (e) => {
|
|
1187
|
+
if (e.state !== "reserved") {
|
|
1188
|
+
throw refused("SIGNING_REFUSED", "This payment's reservation was released; nothing was signed.");
|
|
1189
|
+
}
|
|
1190
|
+
e.state = "signing";
|
|
1191
|
+
e.from = checked.from;
|
|
1192
|
+
e.nonce = checked.nonce;
|
|
1193
|
+
e.validBefore = checked.validBefore;
|
|
1194
|
+
});
|
|
1195
|
+
if (!marked) throw refused("SIGNING_REFUSED", "No reservation for this payment.");
|
|
1196
|
+
let signature;
|
|
1197
|
+
try {
|
|
1198
|
+
signature = await this.wallet.signTypedData(
|
|
1199
|
+
data
|
|
1200
|
+
);
|
|
1201
|
+
} catch (error) {
|
|
1202
|
+
throw error instanceof X402GuardRefused ? error : refused("SIGNING_REFUSED", "The wallet failed to sign.");
|
|
1203
|
+
}
|
|
1204
|
+
flow.signed = { nonce: checked.nonce, from: checked.from, validBefore: checked.validBefore };
|
|
1205
|
+
await this.ledger.patch(ledgerId, (e) => {
|
|
1206
|
+
if (e.state === "signing") e.state = "signed";
|
|
1207
|
+
}).catch(() => void 0);
|
|
1208
|
+
if (flow.requestContext) {
|
|
1209
|
+
flow.requestContext.signedEntries.push(ledgerId);
|
|
1210
|
+
flow.requestContext.versions.set(ledgerId, token.terms.x402Version);
|
|
1211
|
+
}
|
|
1212
|
+
this.record({
|
|
1213
|
+
event: "signed",
|
|
1214
|
+
flowId: flow.id,
|
|
1215
|
+
requestId: token.requestId,
|
|
1216
|
+
receiptReference: token.receiptReference,
|
|
1217
|
+
nonce: checked.nonce,
|
|
1218
|
+
from: checked.from,
|
|
1219
|
+
validBefore: checked.validBefore,
|
|
1220
|
+
network: token.terms.network
|
|
1221
|
+
});
|
|
1222
|
+
return signature;
|
|
1223
|
+
}
|
|
1224
|
+
checkTypedData(data, token) {
|
|
1225
|
+
const { terms, pinned } = token;
|
|
1226
|
+
if (!isPlainObject(data) || !hasExactKeys(data, ["domain", "types", "primaryType", "message"])) {
|
|
1227
|
+
throw mismatch("shape");
|
|
1228
|
+
}
|
|
1229
|
+
if (data.primaryType !== "TransferWithAuthorization") throw mismatch("primaryType");
|
|
1230
|
+
if (!sameData(data.types, EIP3009_TYPES)) throw mismatch("types");
|
|
1231
|
+
const domain = data.domain;
|
|
1232
|
+
if (!isPlainObject(domain) || !hasExactKeys(domain, ["name", "version", "chainId", "verifyingContract"])) {
|
|
1233
|
+
throw mismatch("domain fields");
|
|
1234
|
+
}
|
|
1235
|
+
const chainId = toBig(domain.chainId);
|
|
1236
|
+
if (chainId === null || chainId !== BigInt(pinned.chainId)) throw mismatch("chainId");
|
|
1237
|
+
if (typeof domain.verifyingContract !== "string" || domain.verifyingContract.toLowerCase() !== terms.asset) {
|
|
1238
|
+
throw mismatch("verifyingContract");
|
|
1239
|
+
}
|
|
1240
|
+
if (domain.name !== terms.extra.name || domain.name !== pinned.name) throw mismatch("name");
|
|
1241
|
+
if (domain.version !== terms.extra.version || domain.version !== pinned.version) {
|
|
1242
|
+
throw mismatch("version");
|
|
1243
|
+
}
|
|
1244
|
+
const m = data.message;
|
|
1245
|
+
if (!isPlainObject(m) || !hasExactKeys(m, ["from", "to", "value", "validAfter", "validBefore", "nonce"])) {
|
|
1246
|
+
throw mismatch("message fields");
|
|
1247
|
+
}
|
|
1248
|
+
if (typeof m.from !== "string" || m.from.toLowerCase() !== this.walletAddress) {
|
|
1249
|
+
throw mismatch("from");
|
|
1250
|
+
}
|
|
1251
|
+
if (typeof m.to !== "string" || m.to.toLowerCase() !== terms.payTo) throw mismatch("to");
|
|
1252
|
+
const value = toBig(m.value);
|
|
1253
|
+
if (value === null || value !== BigInt(terms.amount)) throw mismatch("value");
|
|
1254
|
+
const nowSec = BigInt(Math.floor(this.now() / 1e3));
|
|
1255
|
+
const validAfter = toBig(m.validAfter);
|
|
1256
|
+
if (validAfter === null || validAfter > nowSec) throw mismatch("validAfter");
|
|
1257
|
+
const validBefore = toBig(m.validBefore);
|
|
1258
|
+
if (validBefore === null || validBefore > nowSec + BigInt(terms.maxTimeoutSeconds) || validBefore <= validAfter) {
|
|
1259
|
+
throw mismatch("validBefore");
|
|
1260
|
+
}
|
|
1261
|
+
if (typeof m.nonce !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(m.nonce)) {
|
|
1262
|
+
throw mismatch("nonce");
|
|
1263
|
+
}
|
|
1264
|
+
return {
|
|
1265
|
+
from: m.from.toLowerCase(),
|
|
1266
|
+
nonce: m.nonce.toLowerCase(),
|
|
1267
|
+
validBefore: Number(validBefore)
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
// --------------------------------------------------------- after hooks
|
|
1271
|
+
/** @internal */
|
|
1272
|
+
afterPaymentCreation = async (ctx) => {
|
|
1273
|
+
const flow = flowStorage.getStore();
|
|
1274
|
+
if (!flow?.signed) return;
|
|
1275
|
+
const auth = ctx.paymentPayload?.payload?.authorization;
|
|
1276
|
+
if (typeof auth?.nonce !== "string" || auth.nonce.toLowerCase() !== flow.signed.nonce) {
|
|
1277
|
+
this.record({
|
|
1278
|
+
event: "refused",
|
|
1279
|
+
flowId: flow.id,
|
|
1280
|
+
code: "TYPED_DATA_MISMATCH",
|
|
1281
|
+
detail: "The payload's nonce is not the one the guard signed."
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
};
|
|
1285
|
+
/** @internal v2 only (x402 skips this hook for v1). */
|
|
1286
|
+
paymentResponse = async (ctx) => {
|
|
1287
|
+
const auth = ctx.paymentPayload?.payload?.authorization;
|
|
1288
|
+
const settle = ctx.settleResponse;
|
|
1289
|
+
if (typeof auth?.nonce !== "string" || !settle) return;
|
|
1290
|
+
const entry = await this.ledger.patchByNonce(auth.nonce, (e) => {
|
|
1291
|
+
if (settle.success === true && (e.state === "signed" || e.state === "signing")) {
|
|
1292
|
+
e.state = "spent";
|
|
1293
|
+
}
|
|
1294
|
+
}).catch(() => void 0);
|
|
1295
|
+
this.recordSettlement(entry, settle);
|
|
1296
|
+
};
|
|
1297
|
+
/** @internal Called when a flow ends. */
|
|
1298
|
+
async endFlow(flow) {
|
|
1299
|
+
if (flow.signed) return;
|
|
1300
|
+
if (flow.redeemed) {
|
|
1301
|
+
this.record({
|
|
1302
|
+
event: "redeemed-not-signed",
|
|
1303
|
+
flowId: flow.id,
|
|
1304
|
+
requestId: flow.redeemed.requestId,
|
|
1305
|
+
receiptReference: flow.redeemed.receiptReference,
|
|
1306
|
+
detail: "Redeemed, not signed. The decision is spent; a retry needs a new decision."
|
|
1307
|
+
});
|
|
1308
|
+
} else if (flow.executeOutcomeUnknown) {
|
|
1309
|
+
this.record({
|
|
1310
|
+
event: "redeemed-not-signed",
|
|
1311
|
+
flowId: flow.id,
|
|
1312
|
+
requestId: flow.executeOutcomeUnknown.requestId,
|
|
1313
|
+
outcomeUnknown: true,
|
|
1314
|
+
detail: "Redeemed or unknown, not signed. The decision may be spent; look up the request before retrying, and a retry needs a new decision."
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
if (flow.ledgerId) await this.ledger.releaseUnsigned(flow.ledgerId).catch(() => void 0);
|
|
1318
|
+
}
|
|
1319
|
+
/** @internal */
|
|
1320
|
+
newFlow(paymentRequired) {
|
|
1321
|
+
const { info, ctx } = isPlainObject(paymentRequired) ? this.requestInfoFor(paymentRequired) : { info: null, ctx: requestStorage.getStore() ?? null };
|
|
1322
|
+
return {
|
|
1323
|
+
id: newId("x4f"),
|
|
1324
|
+
paymentRequired,
|
|
1325
|
+
request: info,
|
|
1326
|
+
requestContext: ctx,
|
|
1327
|
+
dropped: [],
|
|
1328
|
+
decided: false,
|
|
1329
|
+
signAttempted: false
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
};
|
|
1333
|
+
var LONGEST_FLOW_MS = 4 * MAX_GUARDIAN_CALL_TIMEOUT_MS + GUARDIAN_MAX_REQUEST_TTL_MS + MAX_CLAIM_POLL_INTERVAL_MS + MAX_SIGNING_WINDOW_MS + 6e4;
|
|
1334
|
+
function boundedMs(name, value, max) {
|
|
1335
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > max) {
|
|
1336
|
+
throw new Error(`guardian-x402: ${name} must be a whole number of ms from 1 to ${max}.`);
|
|
1337
|
+
}
|
|
1338
|
+
return value;
|
|
1339
|
+
}
|
|
1340
|
+
function jsonCopy(value) {
|
|
1341
|
+
try {
|
|
1342
|
+
return JSON.parse(JSON.stringify(value));
|
|
1343
|
+
} catch {
|
|
1344
|
+
return /* @__PURE__ */ Symbol("not comparable");
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
function wellFormed(value) {
|
|
1348
|
+
if (!isPlainObject(value)) return false;
|
|
1349
|
+
if (value.ok === true) {
|
|
1350
|
+
return typeof value.requestId === "string" && (value.decision === "ALLOW" || value.decision === "ASK" || value.decision === "DENY");
|
|
1351
|
+
}
|
|
1352
|
+
return value.ok === false && typeof value.reasonCode === "string";
|
|
1353
|
+
}
|
|
1354
|
+
var GuardedX402Client = class extends x402Client {
|
|
1355
|
+
/** @internal */
|
|
1356
|
+
guard;
|
|
1357
|
+
sealed = false;
|
|
1358
|
+
constructor(guard, config) {
|
|
1359
|
+
super(config.paymentRequirementsSelector);
|
|
1360
|
+
this.guard = guard;
|
|
1361
|
+
if (config.spendControls) super.setSpendControls(config.spendControls);
|
|
1362
|
+
this.registerPolicy(guard.policy);
|
|
1363
|
+
this.onBeforePaymentCreation(guard.beforePaymentCreation);
|
|
1364
|
+
this.onAfterPaymentCreation(guard.afterPaymentCreation);
|
|
1365
|
+
this.onPaymentResponse(guard.paymentResponse);
|
|
1366
|
+
registerExactEvmScheme(this, {
|
|
1367
|
+
signer: guard.guardedSigner,
|
|
1368
|
+
networks: [...config.enabledNetworks ?? [BASE_SEPOLIA]]
|
|
1369
|
+
});
|
|
1370
|
+
this.sealed = true;
|
|
1371
|
+
}
|
|
1372
|
+
register(...args) {
|
|
1373
|
+
if (this.sealed) throw refused("GUARD_SEALED", "The guarded client takes no other schemes.");
|
|
1374
|
+
return super.register(...args);
|
|
1375
|
+
}
|
|
1376
|
+
registerV1(...args) {
|
|
1377
|
+
if (this.sealed) throw refused("GUARD_SEALED", "The guarded client takes no other schemes.");
|
|
1378
|
+
return super.registerV1(...args);
|
|
1379
|
+
}
|
|
1380
|
+
registerExtension(...args) {
|
|
1381
|
+
if (this.sealed) throw refused("GUARD_SEALED", "The guarded client takes no extensions.");
|
|
1382
|
+
return super.registerExtension(...args);
|
|
1383
|
+
}
|
|
1384
|
+
/**
|
|
1385
|
+
* Refused once sealed: a failure hook can return its own payload, which
|
|
1386
|
+
* x402 would send in place of a guard refusal.
|
|
1387
|
+
*/
|
|
1388
|
+
onPaymentCreationFailure(...args) {
|
|
1389
|
+
if (this.sealed) {
|
|
1390
|
+
throw refused("GUARD_SEALED", "onPaymentCreationFailure hooks are refused on the guarded client.");
|
|
1391
|
+
}
|
|
1392
|
+
return super.onPaymentCreationFailure(...args);
|
|
1393
|
+
}
|
|
1394
|
+
setSpendControls(controls) {
|
|
1395
|
+
if (this.sealed && controls === false) {
|
|
1396
|
+
throw refused("GUARD_SEALED", "x402 spendControls cannot be turned off.");
|
|
1397
|
+
}
|
|
1398
|
+
return super.setSpendControls(controls);
|
|
1399
|
+
}
|
|
1400
|
+
async createPaymentPayload(paymentRequired) {
|
|
1401
|
+
const flow = this.guard.newFlow(paymentRequired);
|
|
1402
|
+
return flowStorage.run(flow, async () => {
|
|
1403
|
+
try {
|
|
1404
|
+
this.guard.precheck(paymentRequired, flow.request);
|
|
1405
|
+
return await super.createPaymentPayload(paymentRequired);
|
|
1406
|
+
} catch (error) {
|
|
1407
|
+
const own = flow.refusal;
|
|
1408
|
+
if (own) throw own;
|
|
1409
|
+
if (error instanceof X402GuardRefused) {
|
|
1410
|
+
this.guard.recordRefusal(error, flow.id);
|
|
1411
|
+
throw error;
|
|
1412
|
+
}
|
|
1413
|
+
const msg = error instanceof Error ? error.message : "";
|
|
1414
|
+
const dropped = flow.dropped[0];
|
|
1415
|
+
if (dropped && /filtered out by policies/.test(msg)) {
|
|
1416
|
+
const err = refused(dropped.code, dropped.detail);
|
|
1417
|
+
this.guard.recordRefusal(err, flow.id);
|
|
1418
|
+
throw err;
|
|
1419
|
+
}
|
|
1420
|
+
throw error;
|
|
1421
|
+
} finally {
|
|
1422
|
+
await this.guard.endFlow(flow);
|
|
1423
|
+
}
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
};
|
|
1427
|
+
function createX402Guard(config) {
|
|
1428
|
+
return new X402Guard(config);
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
export {
|
|
1432
|
+
PAY_TOOL,
|
|
1433
|
+
PAY_ABOVE_CAP_TOOL,
|
|
1434
|
+
BASE_SEPOLIA,
|
|
1435
|
+
BASE_MAINNET,
|
|
1436
|
+
PINNED_ASSETS,
|
|
1437
|
+
EIP3009_TYPES,
|
|
1438
|
+
X402GuardRefused,
|
|
1439
|
+
readTerms,
|
|
1440
|
+
X402Guard,
|
|
1441
|
+
GuardedX402Client,
|
|
1442
|
+
createX402Guard
|
|
1443
|
+
};
|