lnurlcash-kit 0.1.0-next.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/CHANGELOG.md +59 -0
- package/LICENSE +26 -0
- package/README.md +199 -0
- package/SECURITY.md +39 -0
- package/THREAT-MODEL.md +158 -0
- package/dist/index.d.ts +193 -0
- package/dist/index.js +708 -0
- package/llms.txt +66 -0
- package/package.json +68 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
import { bech32 } from '@scure/base';
|
|
2
|
+
import { sha256 } from '@noble/hashes/sha2.js';
|
|
3
|
+
import { utf8ToBytes, bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
|
|
4
|
+
import { secp256k1 } from '@noble/curves/secp256k1.js';
|
|
5
|
+
|
|
6
|
+
// src/urls.ts
|
|
7
|
+
var isBech32Lnurl = (data) => data.trim().toUpperCase().startsWith("LNURL1");
|
|
8
|
+
var toBech32Lnurl = (url) => {
|
|
9
|
+
const bytes = new TextEncoder().encode(url);
|
|
10
|
+
return bech32.encode("lnurl", bech32.toWords(bytes), 2048).toUpperCase();
|
|
11
|
+
};
|
|
12
|
+
var fromBech32Lnurl = (data) => {
|
|
13
|
+
const safe = data.trim().toUpperCase();
|
|
14
|
+
if (!safe.startsWith("LNURL1")) return null;
|
|
15
|
+
try {
|
|
16
|
+
const decoded = bech32.decode(
|
|
17
|
+
`LNURL1${safe.slice(6)}`,
|
|
18
|
+
2048
|
|
19
|
+
);
|
|
20
|
+
return new TextDecoder().decode(bech32.fromWords(decoded.words));
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
var INSECURE_HOSTS = ["127.0.0.1", "0.0.0.0", "localhost"];
|
|
26
|
+
var isInsecureHost = (host) => INSECURE_HOSTS.includes(host) || host.endsWith(".onion");
|
|
27
|
+
var isAllowedServiceUrl = (value) => {
|
|
28
|
+
let url;
|
|
29
|
+
try {
|
|
30
|
+
url = new URL(value);
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
if (url.protocol === "https:") return true;
|
|
35
|
+
if (url.protocol === "http:") return isInsecureHost(url.hostname);
|
|
36
|
+
return false;
|
|
37
|
+
};
|
|
38
|
+
var fromLud17 = (url) => {
|
|
39
|
+
const match = url.match(/^(?:lnurlw|lnurlp|lnurlc|keyauth):\/\/([^/]+)/i);
|
|
40
|
+
if (!match) return url;
|
|
41
|
+
const host = match[1].split(":")[0];
|
|
42
|
+
const scheme = isInsecureHost(host) ? "http" : "https";
|
|
43
|
+
return url.replace(/^[a-z]+:\/\//i, `${scheme}://`);
|
|
44
|
+
};
|
|
45
|
+
var toLud17w = (url) => url.replace(/^https?:\/\//, "lnurlw://");
|
|
46
|
+
var isLightningAddress = (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
|
|
47
|
+
var lnAddressToUrl = (address) => {
|
|
48
|
+
const [name, domain] = address.trim().split("@");
|
|
49
|
+
const scheme = isInsecureHost(domain.split(":")[0]) ? "http" : "https";
|
|
50
|
+
return `${scheme}://${domain}/.well-known/lnurlp/${name}`;
|
|
51
|
+
};
|
|
52
|
+
var isBareMintDomain = (value) => {
|
|
53
|
+
const trimmed = value.trim();
|
|
54
|
+
if (isLightningAddress(trimmed)) return false;
|
|
55
|
+
if (/^@?[^\s@/]+\.[^\s@/]+$/.test(trimmed)) return true;
|
|
56
|
+
return isInsecureHost(trimmed.replace(/^@/, "").split(":")[0]);
|
|
57
|
+
};
|
|
58
|
+
var bareMintDomainToUrl = (value) => lnAddressToUrl(`mint@${value.trim().replace(/^@/, "")}`);
|
|
59
|
+
var isLoopbackLightningAddress = (value) => {
|
|
60
|
+
const parts = value.trim().split("@");
|
|
61
|
+
if (parts.length !== 2) return false;
|
|
62
|
+
const [name, domain] = parts;
|
|
63
|
+
if (!name || !domain) return false;
|
|
64
|
+
return isInsecureHost(domain.split(":")[0]);
|
|
65
|
+
};
|
|
66
|
+
var resolveMintInput = (value) => {
|
|
67
|
+
const trimmed = value.trim();
|
|
68
|
+
if (!trimmed) return null;
|
|
69
|
+
if (isBech32Lnurl(trimmed)) {
|
|
70
|
+
const url = fromBech32Lnurl(trimmed);
|
|
71
|
+
return url && isAllowedServiceUrl(url) ? url : null;
|
|
72
|
+
}
|
|
73
|
+
if (isLightningAddress(trimmed)) return lnAddressToUrl(trimmed);
|
|
74
|
+
if (isLoopbackLightningAddress(trimmed)) return lnAddressToUrl(trimmed);
|
|
75
|
+
if (isBareMintDomain(trimmed)) return bareMintDomainToUrl(trimmed);
|
|
76
|
+
return null;
|
|
77
|
+
};
|
|
78
|
+
var LNURLP_PATH_RE = /^(.*\/\.well-known\/)lnurlp\/([^/]+)$/;
|
|
79
|
+
var mintAddressUrl = (payUrl) => {
|
|
80
|
+
let parsed;
|
|
81
|
+
try {
|
|
82
|
+
parsed = new URL(payUrl);
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const match = parsed.pathname.match(LNURLP_PATH_RE);
|
|
87
|
+
if (!match) return null;
|
|
88
|
+
return `${parsed.origin}${match[1]}lnurlw/${match[2]}`;
|
|
89
|
+
};
|
|
90
|
+
var lightningAddressUsername = (payUrl) => {
|
|
91
|
+
try {
|
|
92
|
+
return new URL(payUrl).pathname.match(LNURLP_PATH_RE)?.[2] ?? null;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
var resolveLnurlInput = (value) => {
|
|
98
|
+
const trimmed = value.trim();
|
|
99
|
+
if (!trimmed) return null;
|
|
100
|
+
if (isBech32Lnurl(trimmed)) {
|
|
101
|
+
const url = fromBech32Lnurl(trimmed);
|
|
102
|
+
return url && isAllowedServiceUrl(url) ? url : null;
|
|
103
|
+
}
|
|
104
|
+
if (/^(lnurlw|lnurlp|lnurlc|keyauth):\/\//i.test(trimmed)) {
|
|
105
|
+
const url = fromLud17(trimmed);
|
|
106
|
+
return isAllowedServiceUrl(url) ? url : null;
|
|
107
|
+
}
|
|
108
|
+
if (isLightningAddress(trimmed)) return lnAddressToUrl(trimmed);
|
|
109
|
+
if (isLoopbackLightningAddress(trimmed)) return lnAddressToUrl(trimmed);
|
|
110
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
111
|
+
return isAllowedServiceUrl(trimmed) ? trimmed : null;
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
};
|
|
115
|
+
var serverOf = (url) => {
|
|
116
|
+
try {
|
|
117
|
+
return new URL(url).host;
|
|
118
|
+
} catch {
|
|
119
|
+
return url;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
var hashK1 = (k1) => bytesToHex(sha256(hexToBytes(k1)));
|
|
123
|
+
var defaultRandomSecret = () => bytesToHex(crypto.getRandomValues(new Uint8Array(32)));
|
|
124
|
+
var isPreimage = (value) => /^[0-9a-fA-F]{64}$/.test(value.trim());
|
|
125
|
+
|
|
126
|
+
// src/note.ts
|
|
127
|
+
var noteK1 = (url) => {
|
|
128
|
+
try {
|
|
129
|
+
return new URL(url).searchParams.get("k1")?.toLowerCase() ?? null;
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
var requireNoteK1 = (url) => {
|
|
135
|
+
const k1 = noteK1(url);
|
|
136
|
+
if (!k1) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
"This note carries no secret in its URL - it may be held on a device."
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
return k1;
|
|
142
|
+
};
|
|
143
|
+
var noteDeclaredAmount = (url) => {
|
|
144
|
+
try {
|
|
145
|
+
const raw = new URL(url).searchParams.get("amount");
|
|
146
|
+
if (raw === null) return null;
|
|
147
|
+
const n = Number(raw);
|
|
148
|
+
return Number.isFinite(n) ? n : null;
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
var noteSignature = (url) => {
|
|
154
|
+
try {
|
|
155
|
+
return new URL(url).searchParams.get("sig");
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
var resolveNoteInput = (value) => {
|
|
161
|
+
const url = resolveLnurlInput(value);
|
|
162
|
+
const k1 = url ? noteK1(url) : null;
|
|
163
|
+
if (!url || !k1 || !isPreimage(k1)) return null;
|
|
164
|
+
return url;
|
|
165
|
+
};
|
|
166
|
+
var isValidNoteInput = (value) => resolveNoteInput(value) !== null;
|
|
167
|
+
var buildNoteUrl = (withdrawLink, k1, amountMsat) => {
|
|
168
|
+
const url = new URL(fromLud17(withdrawLink.trim()));
|
|
169
|
+
url.searchParams.set("k1", k1.trim().toLowerCase());
|
|
170
|
+
if (amountMsat !== void 0) {
|
|
171
|
+
url.searchParams.set("amount", String(amountMsat));
|
|
172
|
+
}
|
|
173
|
+
return url.toString();
|
|
174
|
+
};
|
|
175
|
+
var withNewK1 = (url, k1, amountMsat, signature) => {
|
|
176
|
+
const newUrl = new URL(url);
|
|
177
|
+
newUrl.searchParams.set("k1", k1.toLowerCase());
|
|
178
|
+
newUrl.searchParams.set("amount", String(amountMsat));
|
|
179
|
+
if (signature) newUrl.searchParams.set("sig", signature);
|
|
180
|
+
else newUrl.searchParams.delete("sig");
|
|
181
|
+
return newUrl.toString();
|
|
182
|
+
};
|
|
183
|
+
var withoutK1 = (url, amountMsat, signature) => {
|
|
184
|
+
const newUrl = new URL(url);
|
|
185
|
+
newUrl.searchParams.delete("k1");
|
|
186
|
+
newUrl.searchParams.set("amount", String(amountMsat));
|
|
187
|
+
if (signature) newUrl.searchParams.set("sig", signature);
|
|
188
|
+
else newUrl.searchParams.delete("sig");
|
|
189
|
+
return newUrl.toString();
|
|
190
|
+
};
|
|
191
|
+
var LIGHTNING_SIGNED_MESSAGE_PREFIX = utf8ToBytes("Lightning Signed Message:");
|
|
192
|
+
var noteSignatureMessage = (k1, amountMsat) => `LNURLcash:${amountMsat}:${hashK1(k1)}`;
|
|
193
|
+
var noteSignatureDigest = (k1, amountMsat) => sha256(
|
|
194
|
+
sha256(
|
|
195
|
+
new Uint8Array([
|
|
196
|
+
...LIGHTNING_SIGNED_MESSAGE_PREFIX,
|
|
197
|
+
...utf8ToBytes(noteSignatureMessage(k1, amountMsat))
|
|
198
|
+
])
|
|
199
|
+
)
|
|
200
|
+
);
|
|
201
|
+
var verifyNoteSignature = (k1, amountMsat, signatureHex, mintPubkeyHex) => {
|
|
202
|
+
let wireSig;
|
|
203
|
+
try {
|
|
204
|
+
wireSig = hexToBytes(signatureHex);
|
|
205
|
+
} catch {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
if (wireSig.length !== 65) return false;
|
|
209
|
+
let digest;
|
|
210
|
+
try {
|
|
211
|
+
digest = noteSignatureDigest(k1, amountMsat);
|
|
212
|
+
} catch {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
const target = mintPubkeyHex.trim().toLowerCase();
|
|
216
|
+
const recoveryIdFirst = new Uint8Array([
|
|
217
|
+
wireSig[64],
|
|
218
|
+
...wireSig.subarray(0, 64)
|
|
219
|
+
]);
|
|
220
|
+
for (const candidate of [recoveryIdFirst, wireSig]) {
|
|
221
|
+
try {
|
|
222
|
+
const recovered = secp256k1.recoverPublicKey(candidate, digest, {
|
|
223
|
+
prehash: false
|
|
224
|
+
});
|
|
225
|
+
if (bytesToHex(recovered) === target) return true;
|
|
226
|
+
} catch {
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return false;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// src/fees.ts
|
|
233
|
+
var parseMintFee = (metadata) => {
|
|
234
|
+
let entries;
|
|
235
|
+
try {
|
|
236
|
+
entries = JSON.parse(metadata);
|
|
237
|
+
} catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
if (!Array.isArray(entries)) return null;
|
|
241
|
+
for (const entry of entries) {
|
|
242
|
+
if (!Array.isArray(entry) || entry[0] !== "text/plain") continue;
|
|
243
|
+
const match = typeof entry[1] === "string" && entry[1].match(/^Mint fees:\s*(\d+)\s*,\s*(\d+)\s*$/);
|
|
244
|
+
if (!match) continue;
|
|
245
|
+
const baseFeeMsat = Number(match[1]);
|
|
246
|
+
const feePpm = Number(match[2]);
|
|
247
|
+
if (!Number.isSafeInteger(baseFeeMsat) || !Number.isSafeInteger(feePpm)) continue;
|
|
248
|
+
if (feePpm >= 1e6) continue;
|
|
249
|
+
if (baseFeeMsat === 0 && feePpm === 0) return null;
|
|
250
|
+
return { baseFeeMsat, feePpm };
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
};
|
|
254
|
+
var proportionalFee = (grossMsat, feePpm) => Math.floor(grossMsat / 1e6) * feePpm + Math.floor(grossMsat % 1e6 * feePpm / 1e6);
|
|
255
|
+
var applyMintFee = (grossMsat, fee) => Math.max(0, grossMsat - fee.baseFeeMsat - proportionalFee(grossMsat, fee.feePpm));
|
|
256
|
+
var grossUpForMintFee = (netMsat, fee) => {
|
|
257
|
+
if (netMsat <= 0) return 0;
|
|
258
|
+
let hi = netMsat + fee.baseFeeMsat;
|
|
259
|
+
while (applyMintFee(hi, fee) < netMsat) hi *= 2;
|
|
260
|
+
let lo = 0;
|
|
261
|
+
while (lo < hi) {
|
|
262
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
263
|
+
if (applyMintFee(mid, fee) >= netMsat) hi = mid;
|
|
264
|
+
else lo = mid + 1;
|
|
265
|
+
}
|
|
266
|
+
return lo;
|
|
267
|
+
};
|
|
268
|
+
var formatFeePercent = (ppm) => (ppm / 1e4).toFixed(4).replace(/\.?0+$/, "");
|
|
269
|
+
var describeMintFee = (fee) => [
|
|
270
|
+
fee.baseFeeMsat > 0 ? `${Math.round(fee.baseFeeMsat / 1e3)} sat flat` : null,
|
|
271
|
+
fee.feePpm > 0 ? `${formatFeePercent(fee.feePpm)}% of the amount paid` : null
|
|
272
|
+
].filter(Boolean).join(" + ");
|
|
273
|
+
|
|
274
|
+
// src/bolt11.ts
|
|
275
|
+
var isBolt11Invoice = (value) => /^ln(bc|tb|bcrt|tbs|sb)[0-9]*[munp]?1[a-z0-9]+$/.test(
|
|
276
|
+
value.trim().toLowerCase()
|
|
277
|
+
);
|
|
278
|
+
var sameInvoice = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
279
|
+
var BOLT11_AMOUNT_MSAT_PER_UNIT = {
|
|
280
|
+
"": 1e11,
|
|
281
|
+
m: 1e8,
|
|
282
|
+
u: 1e5,
|
|
283
|
+
n: 100,
|
|
284
|
+
p: 0.1
|
|
285
|
+
};
|
|
286
|
+
var decodeBolt11AmountMsat = (pr) => {
|
|
287
|
+
const trimmed = pr.trim().toLowerCase();
|
|
288
|
+
const sep = trimmed.lastIndexOf("1");
|
|
289
|
+
if (sep < 2) return null;
|
|
290
|
+
const hrp = trimmed.slice(0, sep);
|
|
291
|
+
const match = hrp.match(/^ln(?:bc|tb|bcrt|tbs|sb)(\d+)?([munp])?$/);
|
|
292
|
+
if (!match) return null;
|
|
293
|
+
const [, digits, multiplier] = match;
|
|
294
|
+
if (!digits) return null;
|
|
295
|
+
const msat = Number(digits) * BOLT11_AMOUNT_MSAT_PER_UNIT[multiplier || ""];
|
|
296
|
+
return Number.isInteger(msat) ? msat : null;
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
// src/errors.ts
|
|
300
|
+
var LnurlcashError = class extends Error {
|
|
301
|
+
constructor(message) {
|
|
302
|
+
super(message);
|
|
303
|
+
this.name = new.target.name;
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
var RequestRefusedError = class extends LnurlcashError {
|
|
307
|
+
};
|
|
308
|
+
var ProtocolError = class extends LnurlcashError {
|
|
309
|
+
};
|
|
310
|
+
var ServiceRejectedError = class extends LnurlcashError {
|
|
311
|
+
reason;
|
|
312
|
+
constructor(reason) {
|
|
313
|
+
super(reason || "The service rejected the request.");
|
|
314
|
+
this.reason = reason;
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
var PendingNoteError = class extends ServiceRejectedError {
|
|
318
|
+
constructor(reason = "pending") {
|
|
319
|
+
super(reason);
|
|
320
|
+
this.message = "This note has another operation in progress - try again in a moment.";
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
var NoteSpentError = class extends ServiceRejectedError {
|
|
324
|
+
constructor(reason) {
|
|
325
|
+
super(reason);
|
|
326
|
+
this.message = `This note has already been spent (service says: "${reason}").`;
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
var NoteUnknownError = class extends ServiceRejectedError {
|
|
330
|
+
constructor(reason) {
|
|
331
|
+
super(reason);
|
|
332
|
+
this.message = `The service doesn't recognise this note (service says: "${reason}").`;
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
var AmbiguousMintError = class extends LnurlcashError {
|
|
336
|
+
};
|
|
337
|
+
var AmbiguousMutationError = class extends AmbiguousMintError {
|
|
338
|
+
newSecrets;
|
|
339
|
+
constructor(message, newSecrets) {
|
|
340
|
+
super(message);
|
|
341
|
+
this.newSecrets = newSecrets;
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
var classifyNoteError = (reason) => {
|
|
345
|
+
if (/spent/i.test(reason)) return new NoteSpentError(reason);
|
|
346
|
+
if (/unknown|not found/i.test(reason)) return new NoteUnknownError(reason);
|
|
347
|
+
return new ServiceRejectedError(reason);
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
// src/transport.ts
|
|
351
|
+
var resolveOptions = (options = {}) => ({
|
|
352
|
+
// Wrapped, never referenced bare: in a browser window.fetch is a method
|
|
353
|
+
// that demands its receiver, and calling a detached copy throws
|
|
354
|
+
// 'Illegal invocation'. Node and DOM test environments never enforce
|
|
355
|
+
// that, so only a REAL browser catches the bare form - the worst
|
|
356
|
+
// possible place to find out.
|
|
357
|
+
fetch: options.fetch ?? ((...args) => globalThis.fetch(...args)),
|
|
358
|
+
timeoutMs: options.timeoutMs ?? 3e4,
|
|
359
|
+
offline: options.offline ?? false,
|
|
360
|
+
randomSecret: options.randomSecret ?? defaultRandomSecret
|
|
361
|
+
});
|
|
362
|
+
var REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
|
|
363
|
+
var MAX_REDIRECTS = 5;
|
|
364
|
+
var MAX_BODY_BYTES = 1048576;
|
|
365
|
+
var fetchFollowingRedirects = async (startUrl, options) => {
|
|
366
|
+
let current = startUrl;
|
|
367
|
+
for (let redirects = 0; ; redirects++) {
|
|
368
|
+
let res;
|
|
369
|
+
try {
|
|
370
|
+
res = await options.fetch(current, {
|
|
371
|
+
signal: AbortSignal.timeout(options.timeoutMs),
|
|
372
|
+
redirect: "manual"
|
|
373
|
+
});
|
|
374
|
+
} catch (err) {
|
|
375
|
+
if (err.name === "TimeoutError") {
|
|
376
|
+
throw new AmbiguousMintError(
|
|
377
|
+
"The service took too long to respond - its answer, if any, was lost."
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
throw new AmbiguousMintError(
|
|
381
|
+
"Failed to reach the service - it may be offline, or may not allow cross-origin requests."
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
const location = res.headers.get("location");
|
|
385
|
+
if (!REDIRECT_STATUSES.has(res.status) || !location) return res;
|
|
386
|
+
if (redirects >= MAX_REDIRECTS) {
|
|
387
|
+
throw new AmbiguousMintError("The service redirected too many times.");
|
|
388
|
+
}
|
|
389
|
+
let next;
|
|
390
|
+
try {
|
|
391
|
+
next = new URL(location, current).toString();
|
|
392
|
+
} catch {
|
|
393
|
+
next = null;
|
|
394
|
+
}
|
|
395
|
+
if (!next || !isAllowedServiceUrl(next)) {
|
|
396
|
+
throw new AmbiguousMintError(
|
|
397
|
+
"The service tried to redirect somewhere this library will not fetch - the original request may already have been processed."
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
current = next;
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
var readBodyText = async (res) => {
|
|
404
|
+
const declared = Number(res.headers.get("content-length"));
|
|
405
|
+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
|
|
406
|
+
throw new AmbiguousMintError("The service returned an oversized response.");
|
|
407
|
+
}
|
|
408
|
+
if (!res.body) return "";
|
|
409
|
+
const reader = res.body.getReader();
|
|
410
|
+
const chunks = [];
|
|
411
|
+
let total = 0;
|
|
412
|
+
for (; ; ) {
|
|
413
|
+
const { done, value } = await reader.read();
|
|
414
|
+
if (done) break;
|
|
415
|
+
total += value.byteLength;
|
|
416
|
+
if (total > MAX_BODY_BYTES) {
|
|
417
|
+
await reader.cancel().catch(() => {
|
|
418
|
+
});
|
|
419
|
+
throw new AmbiguousMintError("The service returned an oversized response.");
|
|
420
|
+
}
|
|
421
|
+
chunks.push(value);
|
|
422
|
+
}
|
|
423
|
+
const bytes = new Uint8Array(total);
|
|
424
|
+
let offset = 0;
|
|
425
|
+
for (const chunk of chunks) {
|
|
426
|
+
bytes.set(chunk, offset);
|
|
427
|
+
offset += chunk.byteLength;
|
|
428
|
+
}
|
|
429
|
+
return new TextDecoder().decode(bytes);
|
|
430
|
+
};
|
|
431
|
+
var lnurlFetch = async (url, options) => {
|
|
432
|
+
if (options.offline) {
|
|
433
|
+
throw new RequestRefusedError(
|
|
434
|
+
"Offline mode is on - no request was made."
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
if (!isAllowedServiceUrl(url.toString())) {
|
|
438
|
+
throw new RequestRefusedError(
|
|
439
|
+
"Refusing to fetch that URL - only https, or http to a loopback or .onion host, is allowed."
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
const res = await fetchFollowingRedirects(url.toString(), options);
|
|
443
|
+
const text = await readBodyText(res);
|
|
444
|
+
let body;
|
|
445
|
+
try {
|
|
446
|
+
body = JSON.parse(text);
|
|
447
|
+
} catch {
|
|
448
|
+
throw new AmbiguousMintError("The service returned an unreadable response.");
|
|
449
|
+
}
|
|
450
|
+
if (body?.status === "ERROR") {
|
|
451
|
+
throw new ServiceRejectedError(
|
|
452
|
+
typeof body.reason === "string" ? body.reason : ""
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
return body;
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
// src/client.ts
|
|
459
|
+
var fetchNoteInfo = async (url, options = {}) => {
|
|
460
|
+
const opts = resolveOptions(options);
|
|
461
|
+
const reqUrl = new URL(url);
|
|
462
|
+
reqUrl.searchParams.delete("sig");
|
|
463
|
+
let body;
|
|
464
|
+
try {
|
|
465
|
+
body = await lnurlFetch(reqUrl, opts);
|
|
466
|
+
} catch (err) {
|
|
467
|
+
if (err instanceof ServiceRejectedError) throw classifyNoteError(err.reason);
|
|
468
|
+
throw err;
|
|
469
|
+
}
|
|
470
|
+
if (body?.tag !== "withdrawRequest" || typeof body.callback !== "string" || typeof body.k1 !== "string" || typeof body.maxWithdrawable !== "number" || !Number.isSafeInteger(body.maxWithdrawable) || body.maxWithdrawable < 0 || body.minWithdrawable !== void 0 && (typeof body.minWithdrawable !== "number" || !Number.isSafeInteger(body.minWithdrawable) || body.minWithdrawable < 0 || body.minWithdrawable > body.maxWithdrawable)) {
|
|
471
|
+
throw new ProtocolError("Not a withdrawRequest (unexpected response).");
|
|
472
|
+
}
|
|
473
|
+
const queried = noteK1(url);
|
|
474
|
+
if (queried && body.k1.toLowerCase() !== queried) {
|
|
475
|
+
throw new ProtocolError(
|
|
476
|
+
"The service echoed back a different k1 than was queried - the note may have been redeemed elsewhere, or the service isn't spec-compliant."
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
return body;
|
|
480
|
+
};
|
|
481
|
+
var probeBurnedNote = async (url, options = {}) => {
|
|
482
|
+
try {
|
|
483
|
+
await fetchNoteInfo(url, options);
|
|
484
|
+
return "live";
|
|
485
|
+
} catch (err) {
|
|
486
|
+
if (err instanceof NoteSpentError || err instanceof NoteUnknownError) {
|
|
487
|
+
return "gone";
|
|
488
|
+
}
|
|
489
|
+
return "unknown";
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
var fetchMintAddress = async (url, options = {}) => {
|
|
493
|
+
const body = await lnurlFetch(url, resolveOptions(options));
|
|
494
|
+
if (body?.tag !== "withdrawRequest" || typeof body.callback !== "string" || typeof body.payLink !== "string" || typeof body.maxWithdrawable !== "number") {
|
|
495
|
+
throw new ProtocolError("Not a mint address response (unexpected shape).");
|
|
496
|
+
}
|
|
497
|
+
const { mintPubkey, ...rest } = body;
|
|
498
|
+
return { ...rest, nodePubkey: mintPubkey };
|
|
499
|
+
};
|
|
500
|
+
var callbackRequest = async (callback, params, options) => {
|
|
501
|
+
const opts = resolveOptions(options);
|
|
502
|
+
let cbUrl;
|
|
503
|
+
try {
|
|
504
|
+
cbUrl = new URL(callback);
|
|
505
|
+
} catch {
|
|
506
|
+
throw new RequestRefusedError("The service provided an invalid callback URL.");
|
|
507
|
+
}
|
|
508
|
+
if (!params.some(([key]) => key === "k1")) {
|
|
509
|
+
throw new RequestRefusedError(
|
|
510
|
+
"At least one k1 is required - there is no note to operate on."
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
for (const [key, value] of params) cbUrl.searchParams.append(key, value);
|
|
514
|
+
let body;
|
|
515
|
+
try {
|
|
516
|
+
body = await lnurlFetch(cbUrl, opts);
|
|
517
|
+
} catch (err) {
|
|
518
|
+
if (err instanceof ServiceRejectedError) {
|
|
519
|
+
if (err.reason === "pending") throw new PendingNoteError(err.reason);
|
|
520
|
+
throw classifyNoteError(err.reason);
|
|
521
|
+
}
|
|
522
|
+
throw err;
|
|
523
|
+
}
|
|
524
|
+
if (body?.status !== "OK") {
|
|
525
|
+
throw new AmbiguousMintError(
|
|
526
|
+
"The service did not confirm the operation - it may still have been applied."
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
return body;
|
|
530
|
+
};
|
|
531
|
+
var meltNote = async (callback, k1, pr, options = {}) => {
|
|
532
|
+
const body = await callbackRequest(
|
|
533
|
+
callback,
|
|
534
|
+
[
|
|
535
|
+
["k1", k1],
|
|
536
|
+
["pr", pr.trim()]
|
|
537
|
+
],
|
|
538
|
+
options
|
|
539
|
+
);
|
|
540
|
+
return {
|
|
541
|
+
verify: body.verify,
|
|
542
|
+
pr: typeof body.pr === "string" ? body.pr : void 0
|
|
543
|
+
};
|
|
544
|
+
};
|
|
545
|
+
var rotateNoteWithHash = async (callback, k1, h, options = {}) => {
|
|
546
|
+
const body = await callbackRequest(
|
|
547
|
+
callback,
|
|
548
|
+
[
|
|
549
|
+
["k1", k1],
|
|
550
|
+
["h", h]
|
|
551
|
+
],
|
|
552
|
+
options
|
|
553
|
+
);
|
|
554
|
+
return { signature: body.sig };
|
|
555
|
+
};
|
|
556
|
+
var splitNoteWithHash = async (callback, k1s, amountMsat, h, h2, options = {}) => {
|
|
557
|
+
const body = await callbackRequest(
|
|
558
|
+
callback,
|
|
559
|
+
[
|
|
560
|
+
...k1s.map((k1) => ["k1", k1]),
|
|
561
|
+
["amount", String(amountMsat)],
|
|
562
|
+
["h", h],
|
|
563
|
+
["h2", h2]
|
|
564
|
+
],
|
|
565
|
+
options
|
|
566
|
+
);
|
|
567
|
+
return { signature: body.sig, changeSignature: body.sig2 };
|
|
568
|
+
};
|
|
569
|
+
var mergeNotesWithHash = async (callback, k1s, h, options = {}) => {
|
|
570
|
+
const body = await callbackRequest(
|
|
571
|
+
callback,
|
|
572
|
+
[...k1s.map((k1) => ["k1", k1]), ["h", h]],
|
|
573
|
+
options
|
|
574
|
+
);
|
|
575
|
+
return { signature: body.sig };
|
|
576
|
+
};
|
|
577
|
+
var rotateNote = async (callback, k1, options = {}) => {
|
|
578
|
+
const opts = resolveOptions(options);
|
|
579
|
+
const newK1 = opts.randomSecret();
|
|
580
|
+
try {
|
|
581
|
+
const result = await rotateNoteWithHash(callback, k1, hashK1(newK1), options);
|
|
582
|
+
return { k1: newK1, signature: result.signature };
|
|
583
|
+
} catch (err) {
|
|
584
|
+
if (err instanceof AmbiguousMintError) {
|
|
585
|
+
throw new AmbiguousMutationError(err.message, [newK1]);
|
|
586
|
+
}
|
|
587
|
+
throw err;
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
var splitNote = async (callback, k1s, amountMsat, options = {}) => {
|
|
591
|
+
const opts = resolveOptions(options);
|
|
592
|
+
const newK1 = opts.randomSecret();
|
|
593
|
+
const changeK1 = opts.randomSecret();
|
|
594
|
+
try {
|
|
595
|
+
const result = await splitNoteWithHash(
|
|
596
|
+
callback,
|
|
597
|
+
k1s,
|
|
598
|
+
amountMsat,
|
|
599
|
+
hashK1(newK1),
|
|
600
|
+
hashK1(changeK1),
|
|
601
|
+
options
|
|
602
|
+
);
|
|
603
|
+
return {
|
|
604
|
+
k1: newK1,
|
|
605
|
+
signature: result.signature,
|
|
606
|
+
change: changeK1,
|
|
607
|
+
changeSignature: result.changeSignature
|
|
608
|
+
};
|
|
609
|
+
} catch (err) {
|
|
610
|
+
if (err instanceof AmbiguousMintError) {
|
|
611
|
+
throw new AmbiguousMutationError(err.message, [newK1, changeK1]);
|
|
612
|
+
}
|
|
613
|
+
throw err;
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
var mergeNotes = async (callback, k1s, options = {}) => {
|
|
617
|
+
const opts = resolveOptions(options);
|
|
618
|
+
const newK1 = opts.randomSecret();
|
|
619
|
+
try {
|
|
620
|
+
const result = await mergeNotesWithHash(callback, k1s, hashK1(newK1), options);
|
|
621
|
+
return { k1: newK1, signature: result.signature };
|
|
622
|
+
} catch (err) {
|
|
623
|
+
if (err instanceof AmbiguousMintError) {
|
|
624
|
+
throw new AmbiguousMutationError(err.message, [newK1]);
|
|
625
|
+
}
|
|
626
|
+
throw err;
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
var settleNote = async (baseUrl, k1, expectedAmountMsat, signature, options = {}) => {
|
|
630
|
+
const info = await fetchNoteInfo(
|
|
631
|
+
withNewK1(baseUrl, k1, expectedAmountMsat, signature),
|
|
632
|
+
options
|
|
633
|
+
);
|
|
634
|
+
try {
|
|
635
|
+
const rotated = await rotateNote(info.callback, k1, options);
|
|
636
|
+
return {
|
|
637
|
+
k1: rotated.k1,
|
|
638
|
+
amountMsat: info.maxWithdrawable,
|
|
639
|
+
signature: rotated.signature,
|
|
640
|
+
callback: info.callback
|
|
641
|
+
};
|
|
642
|
+
} catch {
|
|
643
|
+
return {
|
|
644
|
+
k1,
|
|
645
|
+
amountMsat: info.maxWithdrawable,
|
|
646
|
+
signature,
|
|
647
|
+
callback: info.callback
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
var fetchPayRequest = async (url, options = {}) => {
|
|
652
|
+
const body = await lnurlFetch(url, resolveOptions(options));
|
|
653
|
+
if (body?.tag !== "payRequest" || typeof body.callback !== "string") {
|
|
654
|
+
throw new ProtocolError("Not a payRequest (unexpected response).");
|
|
655
|
+
}
|
|
656
|
+
const mintFee = typeof body.metadata === "string" ? parseMintFee(body.metadata) : null;
|
|
657
|
+
return { ...body, mintFee: mintFee ?? void 0 };
|
|
658
|
+
};
|
|
659
|
+
var requestInvoice = async (payCallback, amountMsat, options = {}) => {
|
|
660
|
+
const cbUrl = new URL(payCallback);
|
|
661
|
+
cbUrl.searchParams.set("amount", String(amountMsat));
|
|
662
|
+
const body = await lnurlFetch(cbUrl, resolveOptions(options));
|
|
663
|
+
if (typeof body?.pr !== "string") {
|
|
664
|
+
throw new ProtocolError("The service did not return an invoice.");
|
|
665
|
+
}
|
|
666
|
+
const invoiceMsat = decodeBolt11AmountMsat(body.pr);
|
|
667
|
+
if (invoiceMsat !== null && invoiceMsat !== amountMsat) {
|
|
668
|
+
throw new ProtocolError(
|
|
669
|
+
`The service returned an invoice for ${invoiceMsat} msat, not the ${amountMsat} requested.`
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
return {
|
|
673
|
+
pr: body.pr,
|
|
674
|
+
verify: typeof body.verify === "string" ? body.verify : void 0,
|
|
675
|
+
disposable: body.disposable !== false
|
|
676
|
+
};
|
|
677
|
+
};
|
|
678
|
+
var fetchInvoiceVerification = async (verifyUrl, options = {}) => {
|
|
679
|
+
const body = await lnurlFetch(verifyUrl, resolveOptions(options));
|
|
680
|
+
if (typeof body?.settled !== "boolean" || typeof body?.pr !== "string") {
|
|
681
|
+
throw new ProtocolError("The service returned an unexpected verify response.");
|
|
682
|
+
}
|
|
683
|
+
return {
|
|
684
|
+
settled: body.settled,
|
|
685
|
+
preimage: typeof body.preimage === "string" ? body.preimage : null,
|
|
686
|
+
pr: body.pr
|
|
687
|
+
};
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
// src/index.ts
|
|
691
|
+
var createClient = (options = {}) => ({
|
|
692
|
+
fetchNoteInfo: (url) => fetchNoteInfo(url, options),
|
|
693
|
+
probeBurnedNote: (url) => probeBurnedNote(url, options),
|
|
694
|
+
fetchMintAddress: (url) => fetchMintAddress(url, options),
|
|
695
|
+
meltNote: (callback, k1, pr) => meltNote(callback, k1, pr, options),
|
|
696
|
+
rotateNote: (callback, k1) => rotateNote(callback, k1, options),
|
|
697
|
+
rotateNoteWithHash: (callback, k1, h) => rotateNoteWithHash(callback, k1, h, options),
|
|
698
|
+
splitNote: (callback, k1s, amountMsat) => splitNote(callback, k1s, amountMsat, options),
|
|
699
|
+
splitNoteWithHash: (callback, k1s, amountMsat, h, h2) => splitNoteWithHash(callback, k1s, amountMsat, h, h2, options),
|
|
700
|
+
mergeNotes: (callback, k1s) => mergeNotes(callback, k1s, options),
|
|
701
|
+
mergeNotesWithHash: (callback, k1s, h) => mergeNotesWithHash(callback, k1s, h, options),
|
|
702
|
+
settleNote: (baseUrl, k1, expectedAmountMsat, signature) => settleNote(baseUrl, k1, expectedAmountMsat, signature, options),
|
|
703
|
+
fetchPayRequest: (url) => fetchPayRequest(url, options),
|
|
704
|
+
requestInvoice: (payCallback, amountMsat) => requestInvoice(payCallback, amountMsat, options),
|
|
705
|
+
fetchInvoiceVerification: (verifyUrl) => fetchInvoiceVerification(verifyUrl, options)
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
export { AmbiguousMintError, AmbiguousMutationError, LnurlcashError, NoteSpentError, NoteUnknownError, PendingNoteError, ProtocolError, RequestRefusedError, ServiceRejectedError, applyMintFee, buildNoteUrl, classifyNoteError, createClient, decodeBolt11AmountMsat, defaultRandomSecret, describeMintFee, fetchInvoiceVerification, fetchMintAddress, fetchNoteInfo, fetchPayRequest, formatFeePercent, fromBech32Lnurl, fromLud17, grossUpForMintFee, hashK1, isAllowedServiceUrl, isBech32Lnurl, isBolt11Invoice, isLightningAddress, isPreimage, isValidNoteInput, lightningAddressUsername, meltNote, mergeNotes, mergeNotesWithHash, mintAddressUrl, noteDeclaredAmount, noteK1, noteSignature, noteSignatureDigest, noteSignatureMessage, parseMintFee, probeBurnedNote, requestInvoice, requireNoteK1, resolveLnurlInput, resolveMintInput, resolveNoteInput, rotateNote, rotateNoteWithHash, sameInvoice, serverOf, settleNote, splitNote, splitNoteWithHash, toBech32Lnurl, toLud17w, verifyNoteSignature, withNewK1, withoutK1 };
|