bsv-x402 0.1.1 → 0.2.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/dist/index.cjs +728 -14
- package/dist/index.d.cts +169 -3
- package/dist/index.d.ts +169 -3
- package/dist/index.js +719 -14
- package/package.json +13 -2
package/dist/index.cjs
CHANGED
|
@@ -20,37 +20,751 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/index.ts
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
|
+
BFG_DAILY_CEILING_SATOSHIS: () => BFG_DAILY_CEILING_SATOSHIS,
|
|
24
|
+
BFG_PER_TX_CEILING_SATOSHIS: () => BFG_PER_TX_CEILING_SATOSHIS,
|
|
25
|
+
LocalStorageAdapter: () => LocalStorageAdapter,
|
|
26
|
+
RateLimiter: () => RateLimiter,
|
|
27
|
+
TIER_PRESETS: () => TIER_PRESETS,
|
|
28
|
+
WalletTwoFactorProvider: () => WalletTwoFactorProvider,
|
|
29
|
+
createX402Fetch: () => createX402Fetch,
|
|
23
30
|
parseChallenge: () => parseChallenge,
|
|
31
|
+
resolveSitePolicy: () => resolveSitePolicy,
|
|
32
|
+
resolveSpendLimits: () => resolveSpendLimits,
|
|
24
33
|
x402Fetch: () => x402Fetch
|
|
25
34
|
});
|
|
26
35
|
module.exports = __toCommonJS(index_exports);
|
|
27
36
|
|
|
28
37
|
// src/challenge.ts
|
|
29
38
|
function parseChallenge(header) {
|
|
30
|
-
|
|
39
|
+
let parsed;
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(header);
|
|
42
|
+
} catch {
|
|
43
|
+
throw new Error("X402-Challenge: invalid JSON");
|
|
44
|
+
}
|
|
45
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
46
|
+
throw new Error("X402-Challenge: expected object");
|
|
47
|
+
}
|
|
48
|
+
const obj = parsed;
|
|
49
|
+
if (typeof obj.nonce !== "string" || obj.nonce.length === 0) {
|
|
50
|
+
throw new Error("X402-Challenge: missing or invalid nonce");
|
|
51
|
+
}
|
|
52
|
+
if (typeof obj.payee !== "string" || obj.payee.length === 0) {
|
|
53
|
+
throw new Error("X402-Challenge: missing or invalid payee");
|
|
54
|
+
}
|
|
55
|
+
if (typeof obj.network !== "string" || obj.network.length === 0) {
|
|
56
|
+
throw new Error("X402-Challenge: missing or invalid network");
|
|
57
|
+
}
|
|
58
|
+
if (typeof obj.amount !== "number" || !Number.isFinite(obj.amount) || !Number.isInteger(obj.amount) || obj.amount <= 0) {
|
|
59
|
+
throw new Error("X402-Challenge: amount must be a positive integer");
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
nonce: obj.nonce,
|
|
63
|
+
payee: obj.payee,
|
|
64
|
+
amount: obj.amount,
|
|
65
|
+
network: obj.network
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// src/limits.ts
|
|
70
|
+
var BFG_DAILY_CEILING_SATOSHIS = 1e10;
|
|
71
|
+
var BFG_PER_TX_CEILING_SATOSHIS = 1e9;
|
|
72
|
+
var WINDOW_MS = {
|
|
73
|
+
minute: 6e4,
|
|
74
|
+
hour: 36e5,
|
|
75
|
+
day: 864e5,
|
|
76
|
+
week: 6048e5
|
|
77
|
+
};
|
|
78
|
+
function windowToMs(window2) {
|
|
79
|
+
return WINDOW_MS[window2];
|
|
80
|
+
}
|
|
81
|
+
function makeLimits(windows, perTxMaxSatoshis, opts = {}) {
|
|
82
|
+
return {
|
|
83
|
+
windows,
|
|
84
|
+
perTxMaxSatoshis,
|
|
85
|
+
yellowLightThreshold: 0.8,
|
|
86
|
+
requirePerSitePrompt: false,
|
|
87
|
+
sitePolicies: {},
|
|
88
|
+
require2fa: {
|
|
89
|
+
onCircuitBreakerReset: true,
|
|
90
|
+
onTierChange: true,
|
|
91
|
+
onHighValueTx: false,
|
|
92
|
+
highValueThreshold: 0,
|
|
93
|
+
onNewSiteApproval: false
|
|
94
|
+
},
|
|
95
|
+
...opts
|
|
96
|
+
};
|
|
31
97
|
}
|
|
98
|
+
var TOO_YOUNG_TO_DIE = {
|
|
99
|
+
interactive: makeLimits(
|
|
100
|
+
[{ window: "day", maxSatoshis: 1e8, maxTransactions: Infinity }],
|
|
101
|
+
1e8
|
|
102
|
+
),
|
|
103
|
+
programmatic: makeLimits(
|
|
104
|
+
[{ window: "day", maxSatoshis: 1e8, maxTransactions: Infinity }],
|
|
105
|
+
1e6
|
|
106
|
+
)
|
|
107
|
+
};
|
|
108
|
+
var HEY_NOT_TOO_ROUGH = {
|
|
109
|
+
interactive: makeLimits(
|
|
110
|
+
[
|
|
111
|
+
{ window: "day", maxSatoshis: 1e8, maxTransactions: 100 },
|
|
112
|
+
{ window: "week", maxSatoshis: 5e8, maxTransactions: 500 }
|
|
113
|
+
],
|
|
114
|
+
1e7,
|
|
115
|
+
{
|
|
116
|
+
requirePerSitePrompt: true,
|
|
117
|
+
require2fa: {
|
|
118
|
+
onCircuitBreakerReset: true,
|
|
119
|
+
onTierChange: true,
|
|
120
|
+
onHighValueTx: true,
|
|
121
|
+
highValueThreshold: 5e7,
|
|
122
|
+
onNewSiteApproval: true
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
),
|
|
126
|
+
programmatic: makeLimits(
|
|
127
|
+
[
|
|
128
|
+
{ window: "day", maxSatoshis: 1e8, maxTransactions: 1e4 },
|
|
129
|
+
{ window: "week", maxSatoshis: 5e8, maxTransactions: 5e4 }
|
|
130
|
+
],
|
|
131
|
+
1e5,
|
|
132
|
+
{
|
|
133
|
+
requirePerSitePrompt: true,
|
|
134
|
+
require2fa: {
|
|
135
|
+
onCircuitBreakerReset: true,
|
|
136
|
+
onTierChange: true,
|
|
137
|
+
onHighValueTx: true,
|
|
138
|
+
highValueThreshold: 5e7,
|
|
139
|
+
onNewSiteApproval: true
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
};
|
|
144
|
+
var HURT_ME_PLENTY = {
|
|
145
|
+
interactive: makeLimits(
|
|
146
|
+
[
|
|
147
|
+
{ window: "minute", maxSatoshis: 5e6, maxTransactions: 10 },
|
|
148
|
+
{ window: "hour", maxSatoshis: 5e7, maxTransactions: 60 },
|
|
149
|
+
{ window: "day", maxSatoshis: 2e8, maxTransactions: 200 },
|
|
150
|
+
{ window: "week", maxSatoshis: 1e9, maxTransactions: 1e3 }
|
|
151
|
+
],
|
|
152
|
+
2e7,
|
|
153
|
+
{
|
|
154
|
+
requirePerSitePrompt: true,
|
|
155
|
+
require2fa: {
|
|
156
|
+
onCircuitBreakerReset: true,
|
|
157
|
+
onTierChange: true,
|
|
158
|
+
onHighValueTx: true,
|
|
159
|
+
highValueThreshold: 1e8,
|
|
160
|
+
onNewSiteApproval: true
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
),
|
|
164
|
+
programmatic: makeLimits(
|
|
165
|
+
[
|
|
166
|
+
{ window: "minute", maxSatoshis: 5e6, maxTransactions: 1e3 },
|
|
167
|
+
{ window: "hour", maxSatoshis: 5e7, maxTransactions: 6e3 },
|
|
168
|
+
{ window: "day", maxSatoshis: 2e8, maxTransactions: 2e4 },
|
|
169
|
+
{ window: "week", maxSatoshis: 1e9, maxTransactions: 1e5 }
|
|
170
|
+
],
|
|
171
|
+
2e5,
|
|
172
|
+
{
|
|
173
|
+
requirePerSitePrompt: true,
|
|
174
|
+
require2fa: {
|
|
175
|
+
onCircuitBreakerReset: true,
|
|
176
|
+
onTierChange: true,
|
|
177
|
+
onHighValueTx: true,
|
|
178
|
+
highValueThreshold: 1e8,
|
|
179
|
+
onNewSiteApproval: true
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
};
|
|
184
|
+
var ULTRA_VIOLENCE = {
|
|
185
|
+
interactive: makeLimits(
|
|
186
|
+
[...HURT_ME_PLENTY.interactive.windows],
|
|
187
|
+
HURT_ME_PLENTY.interactive.perTxMaxSatoshis,
|
|
188
|
+
{
|
|
189
|
+
requirePerSitePrompt: true,
|
|
190
|
+
require2fa: {
|
|
191
|
+
onCircuitBreakerReset: true,
|
|
192
|
+
onTierChange: true,
|
|
193
|
+
onHighValueTx: false,
|
|
194
|
+
highValueThreshold: 0,
|
|
195
|
+
onNewSiteApproval: true
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
),
|
|
199
|
+
programmatic: makeLimits(
|
|
200
|
+
[...HURT_ME_PLENTY.programmatic.windows],
|
|
201
|
+
HURT_ME_PLENTY.programmatic.perTxMaxSatoshis,
|
|
202
|
+
{
|
|
203
|
+
requirePerSitePrompt: true,
|
|
204
|
+
require2fa: {
|
|
205
|
+
onCircuitBreakerReset: true,
|
|
206
|
+
onTierChange: true,
|
|
207
|
+
onHighValueTx: false,
|
|
208
|
+
highValueThreshold: 0,
|
|
209
|
+
onNewSiteApproval: true
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
)
|
|
213
|
+
};
|
|
214
|
+
var NIGHTMARE = {
|
|
215
|
+
interactive: makeLimits([], BFG_PER_TX_CEILING_SATOSHIS),
|
|
216
|
+
programmatic: makeLimits([], BFG_PER_TX_CEILING_SATOSHIS)
|
|
217
|
+
};
|
|
218
|
+
var TIER_PRESETS = {
|
|
219
|
+
"I'm Too Young to Die": TOO_YOUNG_TO_DIE,
|
|
220
|
+
"Hey, Not Too Rough": HEY_NOT_TOO_ROUGH,
|
|
221
|
+
"Hurt Me Plenty": HURT_ME_PLENTY,
|
|
222
|
+
"Ultra-Violence": ULTRA_VIOLENCE,
|
|
223
|
+
"Nightmare!": NIGHTMARE
|
|
224
|
+
};
|
|
225
|
+
function cloneSpendLimits(preset) {
|
|
226
|
+
return {
|
|
227
|
+
...preset,
|
|
228
|
+
windows: preset.windows.map((w) => ({ ...w })),
|
|
229
|
+
require2fa: { ...preset.require2fa },
|
|
230
|
+
sitePolicies: { ...preset.sitePolicies }
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
function resolveSpendLimits(tier = "Hey, Not Too Rough", mode = "interactive", overrides) {
|
|
234
|
+
const preset = TIER_PRESETS[tier][mode];
|
|
235
|
+
const base = cloneSpendLimits(preset);
|
|
236
|
+
if (!overrides) return base;
|
|
237
|
+
return {
|
|
238
|
+
...base,
|
|
239
|
+
...overrides,
|
|
240
|
+
// Deep-merge require2fa if provided
|
|
241
|
+
require2fa: overrides.require2fa ? { ...base.require2fa, ...overrides.require2fa } : base.require2fa,
|
|
242
|
+
// Deep-merge sitePolicies if provided
|
|
243
|
+
sitePolicies: overrides.sitePolicies ? { ...base.sitePolicies, ...overrides.sitePolicies } : base.sitePolicies
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
var RateLimiter = class {
|
|
247
|
+
constructor(limits, state, now) {
|
|
248
|
+
this.limits = limits;
|
|
249
|
+
this.entries = state?.entries ?? [];
|
|
250
|
+
this.broken = state?.circuitBroken ?? false;
|
|
251
|
+
this.now = now ?? Date.now;
|
|
252
|
+
}
|
|
253
|
+
check(challenge, origin) {
|
|
254
|
+
if (this.broken) {
|
|
255
|
+
return { action: "block", reason: "Circuit breaker tripped \u2014 call resetLimits() to clear", severity: "trip" };
|
|
256
|
+
}
|
|
257
|
+
if (!Number.isFinite(challenge.amount) || !Number.isInteger(challenge.amount) || challenge.amount <= 0) {
|
|
258
|
+
return { action: "block", reason: "Invalid transaction amount rejected", severity: "reject" };
|
|
259
|
+
}
|
|
260
|
+
if (challenge.amount > BFG_PER_TX_CEILING_SATOSHIS) {
|
|
261
|
+
return { action: "block", reason: `Exceeds BFG per-tx ceiling (${BFG_PER_TX_CEILING_SATOSHIS} sats)`, severity: "reject" };
|
|
262
|
+
}
|
|
263
|
+
const dayAgo = this.now() - WINDOW_MS.day;
|
|
264
|
+
const dailyTotal = this.sumSatoshis(dayAgo);
|
|
265
|
+
if (dailyTotal + challenge.amount > BFG_DAILY_CEILING_SATOSHIS) {
|
|
266
|
+
return { action: "block", reason: `Exceeds BFG daily ceiling (${BFG_DAILY_CEILING_SATOSHIS} sats)`, severity: "trip" };
|
|
267
|
+
}
|
|
268
|
+
if (challenge.amount > this.limits.perTxMaxSatoshis) {
|
|
269
|
+
return { action: "block", reason: `Exceeds per-tx limit (${this.limits.perTxMaxSatoshis} sats)`, severity: "reject" };
|
|
270
|
+
}
|
|
271
|
+
const isCustomSite = this.hasCustomPolicy(origin);
|
|
272
|
+
const effectiveLimits = this.effectiveWindows(origin);
|
|
273
|
+
const effectivePerTx = this.effectivePerTxMax(origin);
|
|
274
|
+
if (effectivePerTx !== void 0 && challenge.amount > effectivePerTx) {
|
|
275
|
+
return { action: "block", reason: `Exceeds per-tx limit for ${origin} (${effectivePerTx} sats)`, severity: "reject" };
|
|
276
|
+
}
|
|
277
|
+
let yellowLight;
|
|
278
|
+
for (const wl of effectiveLimits) {
|
|
279
|
+
const cutoff = this.now() - windowToMs(wl.window);
|
|
280
|
+
const windowEntries = this.entriesInWindow(cutoff, isCustomSite ? origin : void 0);
|
|
281
|
+
const totalSats = windowEntries.reduce((sum, e) => sum + e.satoshis, 0);
|
|
282
|
+
const totalTx = windowEntries.length;
|
|
283
|
+
if (totalSats + challenge.amount > wl.maxSatoshis) {
|
|
284
|
+
return { action: "block", reason: `Exceeds ${wl.window} sats limit (${wl.maxSatoshis})`, severity: "window" };
|
|
285
|
+
}
|
|
286
|
+
if (totalTx + 1 > wl.maxTransactions) {
|
|
287
|
+
return { action: "block", reason: `Exceeds ${wl.window} tx count limit (${wl.maxTransactions})`, severity: "window" };
|
|
288
|
+
}
|
|
289
|
+
if (this.limits.yellowLightThreshold < 1 && !yellowLight && totalSats + challenge.amount > wl.maxSatoshis * this.limits.yellowLightThreshold) {
|
|
290
|
+
yellowLight = {
|
|
291
|
+
origin,
|
|
292
|
+
currentSpend: totalSats,
|
|
293
|
+
limit: wl.maxSatoshis,
|
|
294
|
+
window: wl.window,
|
|
295
|
+
challenge
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (yellowLight) {
|
|
300
|
+
return { action: "yellow-light", detail: yellowLight };
|
|
301
|
+
}
|
|
302
|
+
return { action: "allow" };
|
|
303
|
+
}
|
|
304
|
+
record(entry) {
|
|
305
|
+
this.entries.push(entry);
|
|
306
|
+
this.prune();
|
|
307
|
+
}
|
|
308
|
+
trip() {
|
|
309
|
+
this.broken = true;
|
|
310
|
+
}
|
|
311
|
+
reset() {
|
|
312
|
+
this.broken = false;
|
|
313
|
+
}
|
|
314
|
+
isBroken() {
|
|
315
|
+
return this.broken;
|
|
316
|
+
}
|
|
317
|
+
getState() {
|
|
318
|
+
return {
|
|
319
|
+
entries: [...this.entries],
|
|
320
|
+
circuitBroken: this.broken,
|
|
321
|
+
hmac: ""
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
hasCustomPolicy(origin) {
|
|
325
|
+
const policy = this.limits.sitePolicies[origin];
|
|
326
|
+
return policy?.action === "custom" && !!policy.limits;
|
|
327
|
+
}
|
|
328
|
+
effectiveWindows(origin) {
|
|
329
|
+
const policy = this.limits.sitePolicies[origin];
|
|
330
|
+
if (policy?.action === "custom" && policy.limits) {
|
|
331
|
+
return policy.limits;
|
|
332
|
+
}
|
|
333
|
+
return this.limits.windows;
|
|
334
|
+
}
|
|
335
|
+
effectivePerTxMax(origin) {
|
|
336
|
+
const policy = this.limits.sitePolicies[origin];
|
|
337
|
+
if (policy?.action === "custom" && policy.perTxMaxSatoshis !== void 0) {
|
|
338
|
+
return policy.perTxMaxSatoshis;
|
|
339
|
+
}
|
|
340
|
+
return void 0;
|
|
341
|
+
}
|
|
342
|
+
entriesInWindow(cutoff, filterOrigin) {
|
|
343
|
+
return this.entries.filter(
|
|
344
|
+
(e) => e.timestamp >= cutoff && (filterOrigin === void 0 || e.origin === filterOrigin)
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
sumSatoshis(cutoff) {
|
|
348
|
+
return this.entries.filter((e) => e.timestamp >= cutoff).reduce((sum, e) => sum + e.satoshis, 0);
|
|
349
|
+
}
|
|
350
|
+
prune() {
|
|
351
|
+
let longestWindow = WINDOW_MS.day;
|
|
352
|
+
for (const wl of this.limits.windows) {
|
|
353
|
+
longestWindow = Math.max(longestWindow, windowToMs(wl.window));
|
|
354
|
+
}
|
|
355
|
+
if (this.limits.sitePolicies) {
|
|
356
|
+
for (const policy of Object.values(this.limits.sitePolicies)) {
|
|
357
|
+
if (policy?.action === "custom" && Array.isArray(policy.limits)) {
|
|
358
|
+
for (const wl of policy.limits) {
|
|
359
|
+
longestWindow = Math.max(longestWindow, windowToMs(wl.window));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const cutoff = this.now() - longestWindow;
|
|
365
|
+
this.entries = this.entries.filter((e) => e.timestamp >= cutoff);
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
// src/site-policy.ts
|
|
370
|
+
var defaultSitePrompt = async (origin) => {
|
|
371
|
+
if (typeof globalThis.confirm !== "function") return "global";
|
|
372
|
+
const allow = globalThis.confirm(
|
|
373
|
+
`First payment to ${origin}.
|
|
374
|
+
|
|
375
|
+
Use your global spending limits for this site?
|
|
376
|
+
|
|
377
|
+
OK = Use global limits
|
|
378
|
+
Cancel = Block this site`
|
|
379
|
+
);
|
|
380
|
+
return allow ? "global" : "block";
|
|
381
|
+
};
|
|
382
|
+
async function resolveSitePolicy(origin, limits, twoFactorProvider, promptFn = defaultSitePrompt) {
|
|
383
|
+
const existing = limits.sitePolicies[origin];
|
|
384
|
+
if (existing) return existing;
|
|
385
|
+
if (!limits.requirePerSitePrompt) {
|
|
386
|
+
return { origin, action: "global" };
|
|
387
|
+
}
|
|
388
|
+
if (limits.require2fa.onNewSiteApproval) {
|
|
389
|
+
if (!twoFactorProvider) {
|
|
390
|
+
return { origin, action: "block" };
|
|
391
|
+
}
|
|
392
|
+
const verified = await twoFactorProvider.verify({
|
|
393
|
+
type: "new-site-approval",
|
|
394
|
+
origin
|
|
395
|
+
});
|
|
396
|
+
if (!verified) {
|
|
397
|
+
return { origin, action: "block" };
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const action = await promptFn(origin);
|
|
401
|
+
return { origin, action };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// src/storage.ts
|
|
405
|
+
var STATE_KEY = "x402:limit-state";
|
|
406
|
+
var POLICIES_KEY = "x402:site-policies";
|
|
407
|
+
async function computeHmac(data, key) {
|
|
408
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
409
|
+
"raw",
|
|
410
|
+
key,
|
|
411
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
412
|
+
false,
|
|
413
|
+
["sign"]
|
|
414
|
+
);
|
|
415
|
+
const encoded = new TextEncoder().encode(data);
|
|
416
|
+
const sig = await crypto.subtle.sign("HMAC", cryptoKey, encoded);
|
|
417
|
+
return Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
418
|
+
}
|
|
419
|
+
function serializeForHmac(state) {
|
|
420
|
+
return JSON.stringify({
|
|
421
|
+
entries: state.entries,
|
|
422
|
+
circuitBroken: state.circuitBroken
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
var LocalStorageAdapter = class {
|
|
426
|
+
constructor(keyDeriver, storage) {
|
|
427
|
+
this.keyDeriver = keyDeriver;
|
|
428
|
+
this.storage = storage ?? globalThis.localStorage;
|
|
429
|
+
}
|
|
430
|
+
async load() {
|
|
431
|
+
const raw = this.storage.getItem(STATE_KEY);
|
|
432
|
+
if (!raw) return null;
|
|
433
|
+
let state;
|
|
434
|
+
try {
|
|
435
|
+
state = JSON.parse(raw);
|
|
436
|
+
} catch {
|
|
437
|
+
console.warn("x402: limit state JSON parse failed \u2014 treating as tampered");
|
|
438
|
+
return { entries: [], circuitBroken: true, hmac: "" };
|
|
439
|
+
}
|
|
440
|
+
if (this.keyDeriver) {
|
|
441
|
+
if (!state.hmac) {
|
|
442
|
+
console.warn("x402: limit state missing HMAC \u2014 treating as tampered");
|
|
443
|
+
return { entries: [], circuitBroken: true, hmac: "" };
|
|
444
|
+
}
|
|
445
|
+
const key = await this.keyDeriver();
|
|
446
|
+
const expected = await computeHmac(serializeForHmac(state), key);
|
|
447
|
+
if (expected !== state.hmac) {
|
|
448
|
+
console.warn("x402: limit state HMAC mismatch \u2014 state may have been tampered with");
|
|
449
|
+
return { entries: [], circuitBroken: true, hmac: "" };
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
state.entries = (Array.isArray(state.entries) ? state.entries : []).filter(
|
|
453
|
+
(e) => e != null && typeof e.origin === "string" && typeof e.txid === "string" && typeof e.satoshis === "number" && Number.isFinite(e.satoshis) && e.satoshis >= 0 && typeof e.timestamp === "number" && Number.isFinite(e.timestamp) && e.timestamp > 0
|
|
454
|
+
);
|
|
455
|
+
return state;
|
|
456
|
+
}
|
|
457
|
+
async save(state) {
|
|
458
|
+
if (this.keyDeriver) {
|
|
459
|
+
const key = await this.keyDeriver();
|
|
460
|
+
state.hmac = await computeHmac(serializeForHmac(state), key);
|
|
461
|
+
}
|
|
462
|
+
this.storage.setItem(STATE_KEY, JSON.stringify(state));
|
|
463
|
+
}
|
|
464
|
+
async loadSitePolicies() {
|
|
465
|
+
const raw = this.storage.getItem(POLICIES_KEY);
|
|
466
|
+
if (!raw) return {};
|
|
467
|
+
try {
|
|
468
|
+
return JSON.parse(raw);
|
|
469
|
+
} catch {
|
|
470
|
+
return {};
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
async saveSitePolicies(policies) {
|
|
474
|
+
this.storage.setItem(POLICIES_KEY, JSON.stringify(policies));
|
|
475
|
+
}
|
|
476
|
+
};
|
|
32
477
|
|
|
33
478
|
// src/x402-fetch.ts
|
|
479
|
+
var BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
480
|
+
function base58DecodeCheck(address) {
|
|
481
|
+
let leadingZeros = 0;
|
|
482
|
+
for (const c of address) {
|
|
483
|
+
if (c === "1") leadingZeros++;
|
|
484
|
+
else break;
|
|
485
|
+
}
|
|
486
|
+
let n = BigInt(0);
|
|
487
|
+
for (const c of address) {
|
|
488
|
+
const i = BASE58_ALPHABET.indexOf(c);
|
|
489
|
+
if (i < 0) throw new Error(`Invalid Base58 character: ${c}`);
|
|
490
|
+
n = n * 58n + BigInt(i);
|
|
491
|
+
}
|
|
492
|
+
const hexFromBigint = n === 0n ? "" : n.toString(16);
|
|
493
|
+
const paddedHex = hexFromBigint.length % 2 ? "0" + hexFromBigint : hexFromBigint;
|
|
494
|
+
const bigintBytes = [];
|
|
495
|
+
for (let i = 0; i < paddedHex.length; i += 2) {
|
|
496
|
+
bigintBytes.push(parseInt(paddedHex.slice(i, i + 2), 16));
|
|
497
|
+
}
|
|
498
|
+
const allBytes = new Uint8Array(leadingZeros + bigintBytes.length);
|
|
499
|
+
allBytes.set(bigintBytes, leadingZeros);
|
|
500
|
+
if (allBytes.length !== 25) {
|
|
501
|
+
throw new Error(`Invalid address length: expected 25 bytes, got ${allBytes.length}`);
|
|
502
|
+
}
|
|
503
|
+
const body = allBytes.slice(0, 21);
|
|
504
|
+
const checksum = allBytes.slice(21);
|
|
505
|
+
const version = allBytes[0];
|
|
506
|
+
if (version !== 0 && version !== 111) {
|
|
507
|
+
throw new Error(`Unsupported address version: 0x${version.toString(16).padStart(2, "0")}`);
|
|
508
|
+
}
|
|
509
|
+
return { version, payload: body.slice(1) };
|
|
510
|
+
}
|
|
511
|
+
function payeeAddressToLockingScript(address) {
|
|
512
|
+
const { payload } = base58DecodeCheck(address);
|
|
513
|
+
if (payload.length !== 20) {
|
|
514
|
+
throw new Error(`Invalid pubkey hash length: expected 20 bytes, got ${payload.length}`);
|
|
515
|
+
}
|
|
516
|
+
const pubkeyHash = Array.from(payload).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
517
|
+
return `76a914${pubkeyHash}88ac`;
|
|
518
|
+
}
|
|
519
|
+
async function defaultConstructProof(challenge) {
|
|
520
|
+
const cwi = globalThis.CWI;
|
|
521
|
+
if (!cwi || typeof cwi.createAction !== "function") {
|
|
522
|
+
throw new Error(
|
|
523
|
+
"No BRC-100 wallet detected. Install a CWI-compliant browser extension or provide a custom proofConstructor in X402Config."
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
const result = await cwi.createAction({
|
|
527
|
+
description: `x402 payment: ${challenge.amount} sats to ${challenge.payee}`,
|
|
528
|
+
outputs: [{
|
|
529
|
+
satoshis: challenge.amount,
|
|
530
|
+
lockingScript: payeeAddressToLockingScript(challenge.payee),
|
|
531
|
+
description: `Payment to ${challenge.payee}`
|
|
532
|
+
}],
|
|
533
|
+
labels: ["x402-payment"],
|
|
534
|
+
options: {
|
|
535
|
+
returnTXIDOnly: false,
|
|
536
|
+
noSend: false
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
if (!result || !result.txid) {
|
|
540
|
+
throw new Error("Wallet declined payment or returned invalid result");
|
|
541
|
+
}
|
|
542
|
+
if (!result.rawTx || typeof result.rawTx !== "string" || result.rawTx.length === 0) {
|
|
543
|
+
throw new Error("Wallet did not return raw transaction");
|
|
544
|
+
}
|
|
545
|
+
return {
|
|
546
|
+
txid: result.txid,
|
|
547
|
+
rawTx: result.rawTx
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
function createMutex() {
|
|
551
|
+
let chain = Promise.resolve();
|
|
552
|
+
return (fn) => {
|
|
553
|
+
const result = chain.then(fn, fn);
|
|
554
|
+
chain = result.then(() => {
|
|
555
|
+
}, () => {
|
|
556
|
+
});
|
|
557
|
+
return result;
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
function createX402Fetch(config = {}) {
|
|
561
|
+
const tier = config.tier ?? "Hey, Not Too Rough";
|
|
562
|
+
const mode = config.mode ?? "interactive";
|
|
563
|
+
if (tier === "Nightmare!" && config.nightmareConfirmation !== "NIGHTMARE") {
|
|
564
|
+
throw new Error('Nightmare! tier requires nightmareConfirmation: "NIGHTMARE"');
|
|
565
|
+
}
|
|
566
|
+
const limits = resolveSpendLimits(tier, mode, config.limits);
|
|
567
|
+
const storage = config.storage ?? new LocalStorageAdapter();
|
|
568
|
+
const twoFactor = config.twoFactorProvider;
|
|
569
|
+
const constructProof = config.proofConstructor ?? defaultConstructProof;
|
|
570
|
+
const nowFn = config.now ?? Date.now;
|
|
571
|
+
const mutex = createMutex();
|
|
572
|
+
const needs2fa = limits.require2fa;
|
|
573
|
+
if (!twoFactor && (needs2fa.onCircuitBreakerReset || needs2fa.onHighValueTx || needs2fa.onNewSiteApproval || needs2fa.onTierChange)) {
|
|
574
|
+
console.warn("x402: tier requires 2FA but no twoFactorProvider configured \u2014 2FA-gated actions will be blocked");
|
|
575
|
+
}
|
|
576
|
+
let limiter;
|
|
577
|
+
let initialised = false;
|
|
578
|
+
async function ensureInitialised() {
|
|
579
|
+
if (limiter && initialised) return limiter;
|
|
580
|
+
const state = await storage.load();
|
|
581
|
+
limiter = new RateLimiter(limits, state ?? void 0, nowFn);
|
|
582
|
+
const policies = await storage.loadSitePolicies();
|
|
583
|
+
Object.assign(limits.sitePolicies, policies);
|
|
584
|
+
initialised = true;
|
|
585
|
+
return limiter;
|
|
586
|
+
}
|
|
587
|
+
async function persist(rl) {
|
|
588
|
+
await storage.save(rl.getState());
|
|
589
|
+
await storage.saveSitePolicies(limits.sitePolicies);
|
|
590
|
+
}
|
|
591
|
+
const fetchFn = async function x402Fetch2(input, init) {
|
|
592
|
+
const response = await fetch(input, init);
|
|
593
|
+
if (response.status !== 402) return response;
|
|
594
|
+
const challengeHeader = response.headers.get("X402-Challenge");
|
|
595
|
+
if (!challengeHeader) return response;
|
|
596
|
+
let challenge;
|
|
597
|
+
try {
|
|
598
|
+
challenge = parseChallenge(challengeHeader);
|
|
599
|
+
} catch {
|
|
600
|
+
return response;
|
|
601
|
+
}
|
|
602
|
+
const origin = extractOrigin(input);
|
|
603
|
+
return mutex(async () => {
|
|
604
|
+
const rl = await ensureInitialised();
|
|
605
|
+
const sitePolicy = await resolveSitePolicy(origin, limits, twoFactor);
|
|
606
|
+
if (sitePolicy.action === "block") return response;
|
|
607
|
+
if (!limits.sitePolicies[origin]) {
|
|
608
|
+
limits.sitePolicies[origin] = sitePolicy;
|
|
609
|
+
await storage.saveSitePolicies(limits.sitePolicies);
|
|
610
|
+
}
|
|
611
|
+
const result = rl.check(challenge, origin);
|
|
612
|
+
if (result.action === "block") {
|
|
613
|
+
if (result.severity === "trip") {
|
|
614
|
+
rl.trip();
|
|
615
|
+
await persist(rl);
|
|
616
|
+
config.onLimitReached?.(result.reason);
|
|
617
|
+
return response;
|
|
618
|
+
}
|
|
619
|
+
if (result.severity === "window" && twoFactor) {
|
|
620
|
+
config.onLimitReached?.(result.reason);
|
|
621
|
+
const override = await twoFactor.verify({
|
|
622
|
+
type: "limit-override",
|
|
623
|
+
amount: challenge.amount,
|
|
624
|
+
origin,
|
|
625
|
+
reason: result.reason
|
|
626
|
+
});
|
|
627
|
+
if (override) {
|
|
628
|
+
} else {
|
|
629
|
+
return response;
|
|
630
|
+
}
|
|
631
|
+
} else {
|
|
632
|
+
config.onLimitReached?.(result.reason);
|
|
633
|
+
return response;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
if (result.action === "yellow-light") {
|
|
637
|
+
const proceed = config.onYellowLight ? await config.onYellowLight(result.detail) : false;
|
|
638
|
+
if (!proceed) return response;
|
|
639
|
+
}
|
|
640
|
+
if (limits.require2fa.onHighValueTx && challenge.amount > limits.require2fa.highValueThreshold) {
|
|
641
|
+
if (!twoFactor) return response;
|
|
642
|
+
const verified = await twoFactor.verify({
|
|
643
|
+
type: "high-value-tx",
|
|
644
|
+
amount: challenge.amount,
|
|
645
|
+
origin
|
|
646
|
+
});
|
|
647
|
+
if (!verified) return response;
|
|
648
|
+
}
|
|
649
|
+
const proof = await constructProof(challenge);
|
|
650
|
+
rl.record({
|
|
651
|
+
timestamp: nowFn(),
|
|
652
|
+
origin,
|
|
653
|
+
satoshis: challenge.amount,
|
|
654
|
+
txid: proof.txid
|
|
655
|
+
});
|
|
656
|
+
await persist(rl);
|
|
657
|
+
const headers = new Headers(init?.headers);
|
|
658
|
+
headers.set("X402-Proof", JSON.stringify(proof));
|
|
659
|
+
return fetch(input, { ...init, headers });
|
|
660
|
+
});
|
|
661
|
+
};
|
|
662
|
+
fetchFn.resetLimits = async () => {
|
|
663
|
+
const rl = await ensureInitialised();
|
|
664
|
+
if (limits.require2fa.onCircuitBreakerReset) {
|
|
665
|
+
if (!twoFactor) throw new Error("2FA required for circuit breaker reset but no twoFactorProvider configured");
|
|
666
|
+
const verified = await twoFactor.verify({ type: "circuit-breaker-reset" });
|
|
667
|
+
if (!verified) throw new Error("2FA verification failed for circuit breaker reset");
|
|
668
|
+
}
|
|
669
|
+
rl.reset();
|
|
670
|
+
await persist(rl);
|
|
671
|
+
};
|
|
672
|
+
fetchFn.getState = () => {
|
|
673
|
+
if (!limiter) return { entries: [], circuitBroken: false };
|
|
674
|
+
const state = limiter.getState();
|
|
675
|
+
return { entries: state.entries, circuitBroken: state.circuitBroken };
|
|
676
|
+
};
|
|
677
|
+
return fetchFn;
|
|
678
|
+
}
|
|
679
|
+
var singleton;
|
|
34
680
|
async function x402Fetch(input, init) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
681
|
+
if (!singleton) singleton = createX402Fetch();
|
|
682
|
+
return singleton(input, init);
|
|
683
|
+
}
|
|
684
|
+
function resolveRelativeUrl(url) {
|
|
685
|
+
const loc = globalThis.location;
|
|
686
|
+
if (loc?.href) {
|
|
687
|
+
return new URL(url, loc.href).origin;
|
|
688
|
+
}
|
|
689
|
+
return "unknown";
|
|
690
|
+
}
|
|
691
|
+
function extractOrigin(input) {
|
|
692
|
+
if (input instanceof URL) return input.origin;
|
|
693
|
+
if (typeof input === "string") {
|
|
694
|
+
try {
|
|
695
|
+
return new URL(input).origin;
|
|
696
|
+
} catch {
|
|
697
|
+
try {
|
|
698
|
+
return resolveRelativeUrl(input);
|
|
699
|
+
} catch {
|
|
700
|
+
return "unknown";
|
|
701
|
+
}
|
|
702
|
+
}
|
|
38
703
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
704
|
+
try {
|
|
705
|
+
return new URL(input.url).origin;
|
|
706
|
+
} catch {
|
|
707
|
+
try {
|
|
708
|
+
return resolveRelativeUrl(input.url);
|
|
709
|
+
} catch {
|
|
710
|
+
return "unknown";
|
|
711
|
+
}
|
|
42
712
|
}
|
|
43
|
-
const challenge = parseChallenge(challengeHeader);
|
|
44
|
-
const proof = await constructProof(challenge);
|
|
45
|
-
const headers = new Headers(init?.headers);
|
|
46
|
-
headers.set("X402-Proof", JSON.stringify(proof));
|
|
47
|
-
return fetch(input, { ...init, headers });
|
|
48
713
|
}
|
|
49
|
-
|
|
50
|
-
|
|
714
|
+
|
|
715
|
+
// src/two-factor.ts
|
|
716
|
+
var WalletTwoFactorProvider = class {
|
|
717
|
+
async verify(action) {
|
|
718
|
+
if (typeof window === "undefined" || !window.CWI) {
|
|
719
|
+
return this.promptFallback(action);
|
|
720
|
+
}
|
|
721
|
+
const challengeData = `x402-2fa:${JSON.stringify(action)}:${Date.now()}`;
|
|
722
|
+
try {
|
|
723
|
+
const sig = await window.CWI.createSignature({
|
|
724
|
+
data: new TextEncoder().encode(challengeData),
|
|
725
|
+
protocolID: [1, "x402-2fa"],
|
|
726
|
+
keyID: "spending-limits"
|
|
727
|
+
});
|
|
728
|
+
return sig !== null;
|
|
729
|
+
} catch {
|
|
730
|
+
return false;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
promptFallback(action) {
|
|
734
|
+
if (typeof window === "undefined" || !window.prompt) return false;
|
|
735
|
+
const message = describeAction(action);
|
|
736
|
+
const result = window.prompt(`${message}
|
|
737
|
+
|
|
738
|
+
Type CONFIRM to proceed:`);
|
|
739
|
+
return result === "CONFIRM";
|
|
740
|
+
}
|
|
741
|
+
};
|
|
742
|
+
function describeAction(action) {
|
|
743
|
+
switch (action.type) {
|
|
744
|
+
case "circuit-breaker-reset":
|
|
745
|
+
return "Reset spending circuit breaker? This re-enables automated payments.";
|
|
746
|
+
case "tier-change":
|
|
747
|
+
return `Change spending tier from "${action.from}" to "${action.to}"?`;
|
|
748
|
+
case "high-value-tx":
|
|
749
|
+
return `Approve high-value payment of ${action.amount} sats to ${action.origin}?`;
|
|
750
|
+
case "new-site-approval":
|
|
751
|
+
return `Allow automated payments to ${action.origin}?`;
|
|
752
|
+
case "limit-override":
|
|
753
|
+
return `Spending limit reached: ${action.reason}
|
|
754
|
+
Allow this payment of ${action.amount} sats to ${action.origin}?`;
|
|
755
|
+
}
|
|
51
756
|
}
|
|
52
757
|
// Annotate the CommonJS export names for ESM import in node:
|
|
53
758
|
0 && (module.exports = {
|
|
759
|
+
BFG_DAILY_CEILING_SATOSHIS,
|
|
760
|
+
BFG_PER_TX_CEILING_SATOSHIS,
|
|
761
|
+
LocalStorageAdapter,
|
|
762
|
+
RateLimiter,
|
|
763
|
+
TIER_PRESETS,
|
|
764
|
+
WalletTwoFactorProvider,
|
|
765
|
+
createX402Fetch,
|
|
54
766
|
parseChallenge,
|
|
767
|
+
resolveSitePolicy,
|
|
768
|
+
resolveSpendLimits,
|
|
55
769
|
x402Fetch
|
|
56
770
|
});
|