@arkade-os/lnurl-client 0.0.0-stage → 0.6.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/README.md +362 -3
- package/dist/arkade.d.ts +258 -0
- package/dist/arkade.js +357 -0
- package/dist/chunk-YKBY6NCA.js +1157 -0
- package/dist/index.d.ts +1176 -0
- package/dist/index.js +66 -0
- package/package.json +40 -3
|
@@ -0,0 +1,1157 @@
|
|
|
1
|
+
// packages/client/src/errors.ts
|
|
2
|
+
var LnurlError = class extends Error {
|
|
3
|
+
/** Human-readable failure reason, taken from the wire `reason`/`error` field. */
|
|
4
|
+
reason;
|
|
5
|
+
/** HTTP status when the failure came with one; absent for LUD-06 bodies answered at HTTP 200. */
|
|
6
|
+
httpStatus;
|
|
7
|
+
/** Machine-readable server code, when the management surface sent one. */
|
|
8
|
+
code;
|
|
9
|
+
/** True if and only if `httpStatus === 429`; every other failure is terminal. */
|
|
10
|
+
retryable;
|
|
11
|
+
/**
|
|
12
|
+
* Creates an `LnurlError`.
|
|
13
|
+
*
|
|
14
|
+
* @param reason - Human-readable failure reason, also used as the message.
|
|
15
|
+
* @param opts - Optional `httpStatus` and `code` captured from the response.
|
|
16
|
+
*/
|
|
17
|
+
constructor(reason, opts) {
|
|
18
|
+
super(reason);
|
|
19
|
+
this.name = "LnurlError";
|
|
20
|
+
this.reason = reason;
|
|
21
|
+
this.httpStatus = opts?.httpStatus;
|
|
22
|
+
this.code = opts?.code;
|
|
23
|
+
this.retryable = opts?.httpStatus === 429;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
var LnurlTransportError = class extends Error {
|
|
27
|
+
/**
|
|
28
|
+
* Creates an `LnurlTransportError`.
|
|
29
|
+
*
|
|
30
|
+
* @param message - Short description of which transport step failed.
|
|
31
|
+
* @param opts - Optional `cause` carrying the original thrown value.
|
|
32
|
+
*/
|
|
33
|
+
constructor(message, opts) {
|
|
34
|
+
super(message, opts);
|
|
35
|
+
this.name = "LnurlTransportError";
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
var LnurlTimeoutError = class extends Error {
|
|
39
|
+
/** The most recent verify snapshot seen before the deadline, when one was seen. */
|
|
40
|
+
lastSnapshot;
|
|
41
|
+
/**
|
|
42
|
+
* Creates an `LnurlTimeoutError`.
|
|
43
|
+
*
|
|
44
|
+
* @param message - Short description including the timeout that elapsed.
|
|
45
|
+
* @param opts - Optional `lastSnapshot` with the latest verify state.
|
|
46
|
+
*/
|
|
47
|
+
constructor(message, opts) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = "LnurlTimeoutError";
|
|
50
|
+
this.lastSnapshot = opts?.lastSnapshot;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// packages/client/src/http.ts
|
|
55
|
+
async function callFetch(url, init, fetchImpl) {
|
|
56
|
+
try {
|
|
57
|
+
return await fetchImpl(url, init);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (err instanceof LnurlError) throw err;
|
|
60
|
+
throw new LnurlTransportError("Request failed", { cause: err });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async function readBody(response) {
|
|
64
|
+
try {
|
|
65
|
+
return await response.json();
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (!response.ok) throw new LnurlError(response.statusText, { httpStatus: response.status });
|
|
68
|
+
throw new LnurlTransportError("Request failed", { cause: err });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function throwForStatus(response, body) {
|
|
72
|
+
const record = typeof body === "object" && body !== null ? body : void 0;
|
|
73
|
+
const ludReason = record?.["status"] === "ERROR" && typeof record?.["reason"] === "string" ? record["reason"] : void 0;
|
|
74
|
+
const apiReason = typeof record?.["error"] === "string" ? record["error"] : void 0;
|
|
75
|
+
const code = typeof record?.["code"] === "string" ? record["code"] : void 0;
|
|
76
|
+
const reason = ludReason ?? apiReason;
|
|
77
|
+
const httpStatus = response.ok ? void 0 : response.status;
|
|
78
|
+
if (reason !== void 0) throw new LnurlError(reason, { httpStatus, code });
|
|
79
|
+
if (!response.ok) throw new LnurlError(apiReason ?? response.statusText, { httpStatus: response.status, code });
|
|
80
|
+
}
|
|
81
|
+
async function fetchJson(url, init, fetchImpl) {
|
|
82
|
+
const response = await callFetch(url, init, fetchImpl);
|
|
83
|
+
const body = await readBody(response);
|
|
84
|
+
throwForStatus(response, body);
|
|
85
|
+
return body;
|
|
86
|
+
}
|
|
87
|
+
function lnurlFetch(url, init, fetchImpl) {
|
|
88
|
+
return fetchJson(url, init, fetchImpl);
|
|
89
|
+
}
|
|
90
|
+
function apiFetch(url, init, fetchImpl) {
|
|
91
|
+
return fetchJson(url, init, fetchImpl);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// packages/client/src/encoding.ts
|
|
95
|
+
import { bech32 } from "@scure/base";
|
|
96
|
+
var ADDRESS_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
97
|
+
function isLnAddress(input) {
|
|
98
|
+
return ADDRESS_PATTERN.test(input);
|
|
99
|
+
}
|
|
100
|
+
var LNURL_BECH32_LIMIT = 1023;
|
|
101
|
+
function decodeLnurl(input) {
|
|
102
|
+
const { prefix, words } = bech32.decode(input, LNURL_BECH32_LIMIT);
|
|
103
|
+
return { prefix, text: new TextDecoder().decode(Uint8Array.from(bech32.fromWords(words))) };
|
|
104
|
+
}
|
|
105
|
+
function isLnUrl(input) {
|
|
106
|
+
try {
|
|
107
|
+
const { prefix, text } = decodeLnurl(input);
|
|
108
|
+
if (prefix !== "lnurl") return false;
|
|
109
|
+
return text.startsWith("https://") || text.startsWith("http://");
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function isValidLnUrl(input) {
|
|
115
|
+
return isLnAddress(input) || isLnUrl(input);
|
|
116
|
+
}
|
|
117
|
+
function toPayRequestUrl(input) {
|
|
118
|
+
if (isLnAddress(input)) {
|
|
119
|
+
const [user, domain] = input.split("@");
|
|
120
|
+
return { url: `https://${domain}/.well-known/lnurlp/${user.toLowerCase()}`, surface: "address" };
|
|
121
|
+
}
|
|
122
|
+
if (isLnUrl(input)) {
|
|
123
|
+
const url = decodeLnurl(input).text;
|
|
124
|
+
const surface = url.includes("/.well-known/lnurlp/") ? "address" : "session";
|
|
125
|
+
return { url, surface };
|
|
126
|
+
}
|
|
127
|
+
throw new LnurlError("Not a valid LNURL or lightning address");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// packages/client/src/token-options.ts
|
|
131
|
+
import { base58, createBase58check } from "@scure/base";
|
|
132
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
133
|
+
var CAIP19 = /^([-a-z0-9]{3,8}):([-_a-zA-Z0-9]{1,32})\/([-a-z0-9]{3,8}):([-.%a-zA-Z0-9]{1,128})$/;
|
|
134
|
+
var UNIT_CODE = /^[A-Za-z0-9]{1,16}$/;
|
|
135
|
+
var tronCheck = createBase58check(sha256);
|
|
136
|
+
var decodes = (fn) => {
|
|
137
|
+
try {
|
|
138
|
+
return fn();
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
var NAMESPACES = {
|
|
144
|
+
eip155: { assetNamespace: "erc20", chain: (r) => /^[1-9][0-9]{0,18}$/.test(r), address: (a) => /^0x[0-9a-fA-F]{40}$/.test(a) },
|
|
145
|
+
solana: {
|
|
146
|
+
assetNamespace: "token",
|
|
147
|
+
chain: () => true,
|
|
148
|
+
address: (a) => decodes(() => /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(a) && base58.decode(a).length === 32)
|
|
149
|
+
},
|
|
150
|
+
tron: {
|
|
151
|
+
assetNamespace: "trc20",
|
|
152
|
+
chain: () => true,
|
|
153
|
+
address: (a) => decodes(() => {
|
|
154
|
+
if (!/^T[1-9A-HJ-NP-Za-km-z]{33}$/.test(a)) return false;
|
|
155
|
+
const bytes = tronCheck.decode(a);
|
|
156
|
+
return bytes.length === 21 && bytes[0] === 65;
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
function isTokenAddress(namespace, address) {
|
|
161
|
+
return NAMESPACES[namespace]?.address(address) ?? false;
|
|
162
|
+
}
|
|
163
|
+
function parseAsset(id) {
|
|
164
|
+
const match = typeof id === "string" ? CAIP19.exec(id) : null;
|
|
165
|
+
if (!match) return void 0;
|
|
166
|
+
const [, namespace, chainReference, assetNamespace, reference] = match;
|
|
167
|
+
const ns = NAMESPACES[namespace];
|
|
168
|
+
if (!ns || ns.assetNamespace !== assetNamespace || !ns.chain(chainReference) || !ns.address(reference)) return void 0;
|
|
169
|
+
return { id, chainId: `${namespace}:${chainReference}`, namespace, chainReference, assetNamespace, reference };
|
|
170
|
+
}
|
|
171
|
+
function unitsOf(payRequest) {
|
|
172
|
+
const out = /* @__PURE__ */ new Map();
|
|
173
|
+
for (const unit of payRequest.units ?? []) {
|
|
174
|
+
const { code, decimals, name } = unit;
|
|
175
|
+
if (typeof code !== "string" || !UNIT_CODE.test(code) || !Number.isInteger(decimals) || decimals < 0 || decimals > 36) continue;
|
|
176
|
+
const key = code.toUpperCase();
|
|
177
|
+
if (out.has(key)) continue;
|
|
178
|
+
out.set(key, { code: key, decimals, ...typeof name === "string" && name.length > 0 && name.length <= 64 ? { name } : {} });
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
var msat = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : void 0;
|
|
183
|
+
function tokenOptions(payRequest) {
|
|
184
|
+
const units = unitsOf(payRequest);
|
|
185
|
+
const offered = (payRequest.paymentOptions ?? []).filter((o) => typeof o?.id === "string" && o.id && typeof o.type === "string" && o.type);
|
|
186
|
+
const uses = /* @__PURE__ */ new Map();
|
|
187
|
+
for (const o of offered) uses.set(o.id, (uses.get(o.id) ?? 0) + 1);
|
|
188
|
+
return offered.flatMap((o) => {
|
|
189
|
+
const asset = parseAsset(o.asset);
|
|
190
|
+
const unit = typeof o.unit === "string" ? units.get(o.unit.toUpperCase()) : void 0;
|
|
191
|
+
if (uses.get(o.id) > 1 || !asset || asset.namespace !== o.type || !unit) return [];
|
|
192
|
+
const min = msat(o.minSendable);
|
|
193
|
+
const max = msat(o.maxSendable);
|
|
194
|
+
return [{
|
|
195
|
+
id: o.id,
|
|
196
|
+
asset,
|
|
197
|
+
unit,
|
|
198
|
+
available: o.available !== false,
|
|
199
|
+
...typeof o.verifiable === "boolean" ? { verifiable: o.verifiable } : {},
|
|
200
|
+
...min !== void 0 ? { minSendable: min } : {},
|
|
201
|
+
...max !== void 0 ? { maxSendable: max } : {},
|
|
202
|
+
...typeof o.provider === "string" && o.provider ? { provider: o.provider } : {}
|
|
203
|
+
}];
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// packages/client/src/payer.ts
|
|
208
|
+
import { sha256 as sha2562 } from "@noble/hashes/sha2.js";
|
|
209
|
+
import { hex as hex2 } from "@scure/base";
|
|
210
|
+
|
|
211
|
+
// packages/client/src/bolt11.ts
|
|
212
|
+
import { bech32 as bech322, hex } from "@scure/base";
|
|
213
|
+
function paymentHashOf(pr) {
|
|
214
|
+
try {
|
|
215
|
+
const { words } = bech322.decode(pr.toLowerCase(), 2e3);
|
|
216
|
+
const end = words.length - 104;
|
|
217
|
+
for (let i = 7; i < end; ) {
|
|
218
|
+
const len = words[i + 1] << 5 | words[i + 2];
|
|
219
|
+
const dataEnd = i + 3 + len;
|
|
220
|
+
if (dataEnd > end) return null;
|
|
221
|
+
if (words[i] === 1 && len === 52) {
|
|
222
|
+
const bytes = bech322.fromWordsUnsafe(words.slice(i + 3, dataEnd));
|
|
223
|
+
return bytes ? hex.encode(bytes.slice(0, 32)) : null;
|
|
224
|
+
}
|
|
225
|
+
i = dataEnd;
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
} catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
var MULTIPLIER_DIVISOR = { m: 1000n, u: 1000000n, n: 1000000000n, p: 1000000000000n };
|
|
233
|
+
function amountMsatOf(pr) {
|
|
234
|
+
let prefix;
|
|
235
|
+
try {
|
|
236
|
+
({ prefix } = bech322.decode(pr.toLowerCase(), 2e3));
|
|
237
|
+
} catch {
|
|
238
|
+
return void 0;
|
|
239
|
+
}
|
|
240
|
+
const match = /^ln[a-z]+(\d+)([munp])?$/.exec(prefix);
|
|
241
|
+
if (!match) return null;
|
|
242
|
+
const [, digits, mult] = match;
|
|
243
|
+
const divisor = mult ? MULTIPLIER_DIVISOR[mult] : 1n;
|
|
244
|
+
const numerator = BigInt(digits) * 100000000000n;
|
|
245
|
+
if (numerator % divisor !== 0n) return void 0;
|
|
246
|
+
const msat2 = numerator / divisor;
|
|
247
|
+
return msat2 <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(msat2) : void 0;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// packages/client/src/payer.ts
|
|
251
|
+
function preimageOpens(preimage, pr) {
|
|
252
|
+
const hash = paymentHashOf(pr);
|
|
253
|
+
if (!hash || !/^[0-9a-f]{64}$/i.test(preimage)) return false;
|
|
254
|
+
return hex2.encode(sha2562(hex2.decode(preimage.toLowerCase()))) === hash;
|
|
255
|
+
}
|
|
256
|
+
function assertTokenAnswer(token, answer) {
|
|
257
|
+
if (answer.paymentOption !== token.id) throw new LnurlError(`The callback answered for ${answer.paymentOption}, not ${token.id}`);
|
|
258
|
+
if (!answer.paymentDestination || !isTokenAddress(token.asset.namespace, answer.paymentDestination)) {
|
|
259
|
+
throw new LnurlError(`The callback returned no ${token.asset.namespace} address to pay`);
|
|
260
|
+
}
|
|
261
|
+
const payment = answer.paymentQuote?.payment;
|
|
262
|
+
const whole = typeof payment?.amount === "string" && /^[0-9]{1,78}$/.test(payment.amount) && /[1-9]/.test(payment.amount);
|
|
263
|
+
if (!whole || typeof payment?.unit !== "string" || payment.unit.toUpperCase() !== token.unit.code) {
|
|
264
|
+
throw new LnurlError(`The callback quoted no whole amount of ${token.unit.code}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async function resolve(input, fetchImpl) {
|
|
268
|
+
const source = toPayRequestUrl(input);
|
|
269
|
+
const body = await lnurlFetch(source.url, void 0, fetchImpl);
|
|
270
|
+
if (body.tag !== "payRequest") throw new LnurlError(`Expected a payRequest, got "${String(body.tag)}"`);
|
|
271
|
+
return { ...body, source };
|
|
272
|
+
}
|
|
273
|
+
async function requestInvoice(payRequest, opts, fetchImpl) {
|
|
274
|
+
const unadvertised = opts.paymentOption !== void 0 && !payRequest.paymentOptions || opts.unit !== void 0 && !payRequest.units;
|
|
275
|
+
if (payRequest.source.surface === "session" && unadvertised) {
|
|
276
|
+
throw new LnurlError("paymentOption and unit need a session payRequest that advertises them; this one does not");
|
|
277
|
+
}
|
|
278
|
+
const amountMsat = opts.amountSat * 1e3;
|
|
279
|
+
const selected = opts.paymentOption ? payRequest.paymentOptions?.find((option) => option.id === opts.paymentOption) : void 0;
|
|
280
|
+
const token = selected?.asset !== void 0 ? tokenOptions(payRequest).find((o) => o.id === selected.id) : void 0;
|
|
281
|
+
if (selected?.asset !== void 0 && !token) throw new LnurlError(`paymentOption ${selected.id} is not a token this client can pay`);
|
|
282
|
+
const min = selected?.minSendable ?? payRequest.minSendable;
|
|
283
|
+
const max = selected?.maxSendable ?? payRequest.maxSendable;
|
|
284
|
+
if (!Number.isSafeInteger(amountMsat)) {
|
|
285
|
+
throw new LnurlError("Amount must be a whole number of millisats");
|
|
286
|
+
}
|
|
287
|
+
if (amountMsat < min || amountMsat > max) {
|
|
288
|
+
throw new LnurlError(`Amount must be between ${min} and ${max} millisats`);
|
|
289
|
+
}
|
|
290
|
+
if (opts.comment !== void 0 && opts.comment.length > 0) {
|
|
291
|
+
const allowed = payRequest.commentAllowed ?? 0;
|
|
292
|
+
if (allowed === 0) throw new LnurlError("This payRequest does not accept comments");
|
|
293
|
+
if (opts.comment.length > allowed) {
|
|
294
|
+
throw new LnurlError(`Comment must be at most ${allowed} characters`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const params = new URLSearchParams();
|
|
298
|
+
params.set("amount", String(amountMsat));
|
|
299
|
+
if (opts.comment) params.set("comment", opts.comment);
|
|
300
|
+
if (opts.paymentOption !== void 0) params.set("paymentOption", opts.paymentOption);
|
|
301
|
+
if (opts.unit !== void 0) params.set("unit", opts.unit);
|
|
302
|
+
const sep = payRequest.callback.includes("?") ? "&" : "?";
|
|
303
|
+
const body = await lnurlFetch(`${payRequest.callback}${sep}${params.toString()}`, void 0, fetchImpl);
|
|
304
|
+
if (typeof body.pr === "string") {
|
|
305
|
+
if (token) throw new LnurlError(`The callback answered token option ${token.id} with an invoice`);
|
|
306
|
+
const invoiceMsat = amountMsatOf(body.pr);
|
|
307
|
+
if (invoiceMsat === void 0) throw new LnurlError("The callback returned an invoice that does not decode");
|
|
308
|
+
if (invoiceMsat !== amountMsat) {
|
|
309
|
+
throw new LnurlError(`The callback returned an invoice for ${invoiceMsat ?? "no"} millisats, not the requested ${amountMsat}`);
|
|
310
|
+
}
|
|
311
|
+
const result = { kind: "bolt11", pr: body.pr, verify: typeof body.verify === "string" ? body.verify : void 0 };
|
|
312
|
+
if (typeof body.verifyBatch === "string") result.verifyBatch = body.verifyBatch;
|
|
313
|
+
if (typeof body.paymentOption === "string") result.paymentOption = body.paymentOption;
|
|
314
|
+
if (body.paymentQuote !== void 0) result.paymentQuote = body.paymentQuote;
|
|
315
|
+
return result;
|
|
316
|
+
}
|
|
317
|
+
if (typeof body.paymentOption === "string") {
|
|
318
|
+
const result = {
|
|
319
|
+
kind: "destination",
|
|
320
|
+
paymentOption: body.paymentOption,
|
|
321
|
+
...typeof body.paymentDestination === "string" ? { paymentDestination: body.paymentDestination } : {},
|
|
322
|
+
...typeof body.paymentDestinationTag === "string" ? { paymentDestinationTag: body.paymentDestinationTag } : {},
|
|
323
|
+
...typeof body.paymentURI === "string" ? { paymentURI: body.paymentURI } : {},
|
|
324
|
+
...body.paymentQuote && typeof body.paymentQuote === "object" ? { paymentQuote: body.paymentQuote } : {},
|
|
325
|
+
...typeof body.expiresAt === "number" ? { expiresAt: body.expiresAt } : {},
|
|
326
|
+
...typeof body.provider === "string" ? { provider: body.provider } : {},
|
|
327
|
+
...typeof body.verify === "string" ? { verify: body.verify } : {},
|
|
328
|
+
...typeof body.verifyBatch === "string" ? { verifyBatch: body.verifyBatch } : {}
|
|
329
|
+
};
|
|
330
|
+
if (token) assertTokenAnswer(token, result);
|
|
331
|
+
return result;
|
|
332
|
+
}
|
|
333
|
+
throw new LnurlError("Unexpected callback response");
|
|
334
|
+
}
|
|
335
|
+
function parseVerifyStatus(body) {
|
|
336
|
+
if (typeof body.pr === "string") {
|
|
337
|
+
const settled = body.settled === true;
|
|
338
|
+
const preimage = typeof body.preimage === "string" ? body.preimage : null;
|
|
339
|
+
if (settled && (!preimage || !preimageOpens(preimage, body.pr))) {
|
|
340
|
+
throw new LnurlError("Verify reported settled with a preimage that does not match the invoice's payment hash");
|
|
341
|
+
}
|
|
342
|
+
return { kind: "bolt11", settled, preimage, pr: body.pr };
|
|
343
|
+
}
|
|
344
|
+
if (typeof body.paymentOption !== "string") {
|
|
345
|
+
throw new LnurlTransportError("Verify response is neither a bolt11 nor a destination status");
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
kind: "destination",
|
|
349
|
+
settled: body.settled === true,
|
|
350
|
+
paymentOption: body.paymentOption,
|
|
351
|
+
...typeof body.paymentDestination === "string" ? { paymentDestination: body.paymentDestination } : {},
|
|
352
|
+
...typeof body.paymentReference === "string" ? { paymentReference: body.paymentReference } : {}
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
function verifyStatusIsFor(status, expected) {
|
|
356
|
+
if ("pr" in expected) {
|
|
357
|
+
const hash = paymentHashOf(expected.pr);
|
|
358
|
+
return status.kind === "bolt11" && hash !== null && paymentHashOf(status.pr) === hash;
|
|
359
|
+
}
|
|
360
|
+
return status.kind === "destination" && status.paymentOption === expected.paymentOption && (status.paymentDestination === void 0 || expected.paymentDestination === void 0 || status.paymentDestination === expected.paymentDestination);
|
|
361
|
+
}
|
|
362
|
+
function sleep(ms, signal) {
|
|
363
|
+
return new Promise((resolve2, reject) => {
|
|
364
|
+
if (signal?.aborted) {
|
|
365
|
+
reject(signal.reason ?? new LnurlError("Aborted"));
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const timer = setTimeout(() => {
|
|
369
|
+
signal?.removeEventListener("abort", onAbort);
|
|
370
|
+
resolve2();
|
|
371
|
+
}, ms);
|
|
372
|
+
const onAbort = () => {
|
|
373
|
+
clearTimeout(timer);
|
|
374
|
+
reject(signal?.reason ?? new LnurlError("Aborted"));
|
|
375
|
+
};
|
|
376
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
async function pollVerify(verifyUrl, opts, fetchImpl) {
|
|
380
|
+
if (!verifyUrl) throw new LnurlError("verify URL is required");
|
|
381
|
+
const intervalMs = opts?.intervalMs ?? 1e3;
|
|
382
|
+
const timeoutMs = opts?.timeoutMs ?? 12e4;
|
|
383
|
+
const deadline = Date.now() + timeoutMs;
|
|
384
|
+
let lastSnapshot;
|
|
385
|
+
for (; ; ) {
|
|
386
|
+
if (opts?.signal?.aborted) throw opts.signal.reason ?? new LnurlError("Aborted");
|
|
387
|
+
const body = await lnurlFetch(verifyUrl, void 0, fetchImpl);
|
|
388
|
+
const status = parseVerifyStatus(body);
|
|
389
|
+
if (opts?.expect && !verifyStatusIsFor(status, opts.expect)) throw new LnurlError("Verify answered about a different payment");
|
|
390
|
+
lastSnapshot = status;
|
|
391
|
+
opts?.onUpdate?.(status);
|
|
392
|
+
if (status.settled) return status;
|
|
393
|
+
if (Date.now() >= deadline) throw new LnurlTimeoutError(`Verify timed out after ${timeoutMs}ms`, { lastSnapshot });
|
|
394
|
+
await sleep(intervalMs, opts?.signal);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// packages/client/src/sse.ts
|
|
399
|
+
function parseFrame(raw) {
|
|
400
|
+
let event = "";
|
|
401
|
+
const dataLines = [];
|
|
402
|
+
for (const line of raw.split("\n")) {
|
|
403
|
+
if (line === "" || line.startsWith(":")) continue;
|
|
404
|
+
const colon = line.indexOf(":");
|
|
405
|
+
if (colon === -1) continue;
|
|
406
|
+
const field = line.slice(0, colon);
|
|
407
|
+
const value = line.slice(colon + 1).startsWith(" ") ? line.slice(colon + 2) : line.slice(colon + 1);
|
|
408
|
+
if (field === "event") event = value;
|
|
409
|
+
else if (field === "data") dataLines.push(value);
|
|
410
|
+
}
|
|
411
|
+
if (dataLines.length === 0) return void 0;
|
|
412
|
+
return { event, data: dataLines.join("\n") };
|
|
413
|
+
}
|
|
414
|
+
function createSseParser() {
|
|
415
|
+
let buffer = "";
|
|
416
|
+
return {
|
|
417
|
+
push(chunk) {
|
|
418
|
+
buffer += chunk.replace(/\r\n|\r/g, "\n");
|
|
419
|
+
const frames = [];
|
|
420
|
+
let idx;
|
|
421
|
+
while ((idx = buffer.indexOf("\n\n")) !== -1) {
|
|
422
|
+
const frame = parseFrame(buffer.slice(0, idx));
|
|
423
|
+
buffer = buffer.slice(idx + 2);
|
|
424
|
+
if (frame !== void 0) frames.push(frame);
|
|
425
|
+
}
|
|
426
|
+
if (buffer.length > MAX_FRAME_CHARS) throw new LnurlTransportError(`SSE frame over ${MAX_FRAME_CHARS} characters`);
|
|
427
|
+
return frames;
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
var MAX_FRAME_CHARS = 1024 * 1024;
|
|
432
|
+
async function readSseStream(body, onFrame, signal) {
|
|
433
|
+
const parser = createSseParser();
|
|
434
|
+
const reader = body.getReader();
|
|
435
|
+
const decoder = new TextDecoder();
|
|
436
|
+
try {
|
|
437
|
+
for (; ; ) {
|
|
438
|
+
if (signal?.aborted) break;
|
|
439
|
+
const { done, value } = await reader.read();
|
|
440
|
+
if (value) for (const f of parser.push(decoder.decode(value, { stream: !done }))) onFrame(f);
|
|
441
|
+
if (done) break;
|
|
442
|
+
}
|
|
443
|
+
} catch (err) {
|
|
444
|
+
void reader.cancel(err).catch(() => void 0);
|
|
445
|
+
throw err;
|
|
446
|
+
} finally {
|
|
447
|
+
reader.releaseLock();
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// packages/client/src/verify-batch.ts
|
|
452
|
+
function dedupeUrls(urls) {
|
|
453
|
+
return [...new Set(urls)];
|
|
454
|
+
}
|
|
455
|
+
async function batchVerify(verifyBatchUrl, verifyUrls, opts, fetchImpl) {
|
|
456
|
+
if (!verifyBatchUrl) throw new LnurlError("verifyBatch URL is required");
|
|
457
|
+
const add = dedupeUrls(verifyUrls);
|
|
458
|
+
if (add.length === 0) throw new LnurlError("at least one verify URL is required");
|
|
459
|
+
const f = fetchImpl ?? ((input, init) => globalThis.fetch(input, init));
|
|
460
|
+
return fetchBatch(verifyBatchUrl, add, opts?.signal, f);
|
|
461
|
+
}
|
|
462
|
+
var TOO_LARGE = /* @__PURE__ */ new Set([414, 431]);
|
|
463
|
+
async function fetchBatch(verifyBatchUrl, urls, signal, f) {
|
|
464
|
+
try {
|
|
465
|
+
const body = await lnurlFetch(withVerifyParams(verifyBatchUrl, urls), { signal }, f);
|
|
466
|
+
return parseBatchResults(body, urls);
|
|
467
|
+
} catch (err) {
|
|
468
|
+
if (urls.length < 2 || !(err instanceof LnurlError) || !TOO_LARGE.has(err.httpStatus ?? 0)) throw err;
|
|
469
|
+
const mid = Math.ceil(urls.length / 2);
|
|
470
|
+
const head = await fetchBatch(verifyBatchUrl, urls.slice(0, mid), signal, f);
|
|
471
|
+
const tail = await fetchBatch(verifyBatchUrl, urls.slice(mid), signal, f);
|
|
472
|
+
return { results: Object.assign(/* @__PURE__ */ Object.create(null), head.results, tail.results) };
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
function withVerifyParams(verifyBatchUrl, verifyUrls) {
|
|
476
|
+
const params = new URLSearchParams();
|
|
477
|
+
for (const url of verifyUrls) params.append("verify", url);
|
|
478
|
+
return `${verifyBatchUrl}${verifyBatchUrl.includes("?") ? "&" : "?"}${params.toString()}`;
|
|
479
|
+
}
|
|
480
|
+
function parseBatchResults(body, requested) {
|
|
481
|
+
const raw = typeof body.results === "object" && body.results !== null ? body.results : void 0;
|
|
482
|
+
if (!raw) throw new LnurlTransportError("Batch verify response carries no results map");
|
|
483
|
+
const results = /* @__PURE__ */ Object.create(null);
|
|
484
|
+
for (const url of requested) {
|
|
485
|
+
const entry = Object.hasOwn(raw, url) ? raw[url] : void 0;
|
|
486
|
+
if (typeof entry !== "object" || entry === null) {
|
|
487
|
+
results[url] = { kind: "error", reason: "no answer for this verify url" };
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
const record = entry;
|
|
491
|
+
if (record.status !== "OK") {
|
|
492
|
+
results[url] = { kind: "error", reason: typeof record.reason === "string" ? record.reason : "unknown error" };
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
try {
|
|
496
|
+
results[url] = { kind: "verify", status: parseVerifyStatus(record) };
|
|
497
|
+
} catch (err) {
|
|
498
|
+
results[url] = { kind: "error", reason: err.message };
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return { results };
|
|
502
|
+
}
|
|
503
|
+
function openVerifyBatchStream(opts, handlers, fetchImpl) {
|
|
504
|
+
if (!opts.verifyBatchUrl) throw new LnurlError("verifyBatch URL is required");
|
|
505
|
+
const openUrls = dedupeUrls(opts.verifyUrls);
|
|
506
|
+
if (openUrls.length === 0) throw new LnurlError("at least one verify URL is required");
|
|
507
|
+
if (opts.signal?.aborted) throw opts.signal.reason ?? new LnurlError("Aborted");
|
|
508
|
+
const f = fetchImpl ?? ((input, init) => globalThis.fetch(input, init));
|
|
509
|
+
const controller = new AbortController();
|
|
510
|
+
const onExternalAbort = () => controller.abort(opts.signal.reason);
|
|
511
|
+
opts.signal?.addEventListener("abort", onExternalAbort, { once: true });
|
|
512
|
+
let sessionId;
|
|
513
|
+
let closed = false;
|
|
514
|
+
function finish() {
|
|
515
|
+
if (closed) return;
|
|
516
|
+
closed = true;
|
|
517
|
+
controller.abort();
|
|
518
|
+
opts.signal?.removeEventListener("abort", onExternalAbort);
|
|
519
|
+
handlers.onClose?.();
|
|
520
|
+
}
|
|
521
|
+
function fail(err) {
|
|
522
|
+
if (closed) return;
|
|
523
|
+
closed = true;
|
|
524
|
+
controller.abort();
|
|
525
|
+
opts.signal?.removeEventListener("abort", onExternalAbort);
|
|
526
|
+
const error = err instanceof LnurlError ? err : err instanceof Error ? new LnurlTransportError(err.message, { cause: err }) : new LnurlTransportError("Batch stream failed");
|
|
527
|
+
handlers.onError?.(error);
|
|
528
|
+
handlers.onClose?.();
|
|
529
|
+
}
|
|
530
|
+
async function applyUpdate(add, remove) {
|
|
531
|
+
const params = new URLSearchParams();
|
|
532
|
+
params.set("session", sessionId);
|
|
533
|
+
for (const url of dedupeUrls(add)) params.append("add", url);
|
|
534
|
+
for (const url of dedupeUrls(remove)) params.append("remove", url);
|
|
535
|
+
const sep = opts.verifyBatchUrl.includes("?") ? "&" : "?";
|
|
536
|
+
try {
|
|
537
|
+
await lnurlFetch(`${opts.verifyBatchUrl}${sep}${params.toString()}`, void 0, f);
|
|
538
|
+
} catch (err) {
|
|
539
|
+
fail(err instanceof LnurlError ? err : new LnurlTransportError("Batch update failed", { cause: err }));
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
function handleFrame(event, raw) {
|
|
543
|
+
let body;
|
|
544
|
+
try {
|
|
545
|
+
body = JSON.parse(raw);
|
|
546
|
+
} catch {
|
|
547
|
+
throw new LnurlTransportError("Batch stream carried a frame that is not JSON");
|
|
548
|
+
}
|
|
549
|
+
if (typeof body !== "object" || body === null) throw new LnurlTransportError("Batch stream carried an empty frame");
|
|
550
|
+
const record = body;
|
|
551
|
+
if (event === "session") {
|
|
552
|
+
if (typeof record.session === "string") sessionId = record.session;
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
if (event === "removed") {
|
|
556
|
+
if (typeof record.verify === "string") handlers.onRemoved?.(record.verify);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const url = typeof record.verify === "string" ? record.verify : void 0;
|
|
560
|
+
if (!url) throw new LnurlTransportError("Batch stream carried a result frame without a verify url");
|
|
561
|
+
handlers.onUpdate?.(url, parseVerifyStatus(record));
|
|
562
|
+
}
|
|
563
|
+
void (async () => {
|
|
564
|
+
try {
|
|
565
|
+
const openUrl = withVerifyParams(opts.verifyBatchUrl, openUrls);
|
|
566
|
+
let response = await f(openUrl, { headers: { Accept: "text/event-stream" }, signal: controller.signal });
|
|
567
|
+
if (response.status === 406) response = await f(openUrl, { signal: controller.signal });
|
|
568
|
+
if (!response.ok) throw new LnurlError(response.statusText, { httpStatus: response.status });
|
|
569
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
570
|
+
if (!contentType.includes("text/event-stream")) {
|
|
571
|
+
const snapshot = await response.json();
|
|
572
|
+
const parsed = parseBatchResults(snapshot, openUrls);
|
|
573
|
+
for (const [url, item] of Object.entries(parsed.results)) {
|
|
574
|
+
if (item.kind === "verify") handlers.onUpdate?.(url, item.status);
|
|
575
|
+
}
|
|
576
|
+
finish();
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
await readSseStream(response.body, (frame) => {
|
|
580
|
+
handleFrame(frame.event, frame.data);
|
|
581
|
+
}, controller.signal);
|
|
582
|
+
} catch (err) {
|
|
583
|
+
if (opts.signal?.aborted) {
|
|
584
|
+
finish();
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
fail(err);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
finish();
|
|
591
|
+
})();
|
|
592
|
+
return {
|
|
593
|
+
get sessionId() {
|
|
594
|
+
return sessionId;
|
|
595
|
+
},
|
|
596
|
+
update(add, remove = []) {
|
|
597
|
+
if (closed) throw new LnurlError("Batch stream has ended");
|
|
598
|
+
if (!sessionId) throw new LnurlError("Batch stream has not yet announced its session");
|
|
599
|
+
void applyUpdate(add, remove);
|
|
600
|
+
},
|
|
601
|
+
close() {
|
|
602
|
+
finish();
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// packages/client/src/session.ts
|
|
608
|
+
function postJson(url, token, body, fetchImpl) {
|
|
609
|
+
return apiFetch(url, {
|
|
610
|
+
method: "POST",
|
|
611
|
+
headers: { "content-type": "application/json", Authorization: `Bearer ${token}` },
|
|
612
|
+
body: JSON.stringify(body)
|
|
613
|
+
}, fetchImpl).then(() => void 0);
|
|
614
|
+
}
|
|
615
|
+
async function openSession(baseUrl, opts, handlers, fetchImpl) {
|
|
616
|
+
const root = baseUrl.replace(/\/+$/, "");
|
|
617
|
+
const token = opts.token;
|
|
618
|
+
const reconnect = opts.reconnect;
|
|
619
|
+
const maxAttempts = reconnect === false ? 0 : reconnect?.maxAttempts ?? 5;
|
|
620
|
+
const baseDelayMs = (typeof reconnect === "object" ? reconnect.baseDelayMs : void 0) ?? 500;
|
|
621
|
+
const resumable = token !== void 0 && reconnect !== false;
|
|
622
|
+
const signal = opts.signal;
|
|
623
|
+
const backoffMs = (attempt) => Math.min(baseDelayMs * 2 ** (attempt - 1), 3e4);
|
|
624
|
+
let closed = false;
|
|
625
|
+
let timer;
|
|
626
|
+
let activeBody = null;
|
|
627
|
+
let info = null;
|
|
628
|
+
let streamDeliveredFrame = false;
|
|
629
|
+
let resolveOpened;
|
|
630
|
+
let rejectOpened;
|
|
631
|
+
const opened = new Promise((res, rej) => {
|
|
632
|
+
resolveOpened = res;
|
|
633
|
+
rejectOpened = rej;
|
|
634
|
+
});
|
|
635
|
+
const close = () => {
|
|
636
|
+
if (closed) return;
|
|
637
|
+
closed = true;
|
|
638
|
+
if (timer !== void 0) {
|
|
639
|
+
clearTimeout(timer);
|
|
640
|
+
timer = void 0;
|
|
641
|
+
}
|
|
642
|
+
const body = activeBody;
|
|
643
|
+
activeBody = null;
|
|
644
|
+
if (body) void body.cancel().catch(() => void 0);
|
|
645
|
+
};
|
|
646
|
+
const makeSession = (created) => ({
|
|
647
|
+
sessionId: created.sessionId,
|
|
648
|
+
lnurl: created.lnurl,
|
|
649
|
+
token: created.token,
|
|
650
|
+
get closed() {
|
|
651
|
+
return closed;
|
|
652
|
+
},
|
|
653
|
+
reportSettled: (preimage) => postJson(`${root}/lnurl/session/${created.sessionId}/settled`, created.token, { preimage }, fetchImpl),
|
|
654
|
+
close
|
|
655
|
+
});
|
|
656
|
+
const postSession = async () => {
|
|
657
|
+
let response;
|
|
658
|
+
try {
|
|
659
|
+
response = await fetchImpl(`${root}/lnurl/session`, {
|
|
660
|
+
method: "POST",
|
|
661
|
+
headers: { "content-type": "application/json" },
|
|
662
|
+
body: JSON.stringify(token !== void 0 ? { token } : {}),
|
|
663
|
+
signal
|
|
664
|
+
});
|
|
665
|
+
} catch (err) {
|
|
666
|
+
if (err instanceof LnurlError) throw err;
|
|
667
|
+
throw new LnurlTransportError("Session request failed", { cause: err });
|
|
668
|
+
}
|
|
669
|
+
if (!response.ok) {
|
|
670
|
+
let reason = response.statusText;
|
|
671
|
+
let code;
|
|
672
|
+
try {
|
|
673
|
+
const data = await response.json();
|
|
674
|
+
if (typeof data.error === "string") reason = data.error;
|
|
675
|
+
else if (typeof data.reason === "string") reason = data.reason;
|
|
676
|
+
if (typeof data.code === "string") code = data.code;
|
|
677
|
+
} catch {
|
|
678
|
+
}
|
|
679
|
+
throw new LnurlError(reason, { httpStatus: response.status, code });
|
|
680
|
+
}
|
|
681
|
+
if (response.body === null) throw new LnurlTransportError("Session stream has no readable body");
|
|
682
|
+
return response;
|
|
683
|
+
};
|
|
684
|
+
const onFrame = (frame) => {
|
|
685
|
+
streamDeliveredFrame = true;
|
|
686
|
+
if (frame.event === "session_created") {
|
|
687
|
+
if (info) return;
|
|
688
|
+
let created;
|
|
689
|
+
try {
|
|
690
|
+
created = JSON.parse(frame.data);
|
|
691
|
+
} catch (err) {
|
|
692
|
+
rejectOpened(new LnurlTransportError("Invalid session_created frame", { cause: err }));
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
const isNonEmptyString = (v) => typeof v === "string" && v.length > 0;
|
|
696
|
+
if (!isNonEmptyString(created.sessionId) || !isNonEmptyString(created.lnurl) || !isNonEmptyString(created.token)) {
|
|
697
|
+
rejectOpened(new LnurlTransportError("session_created frame is missing sessionId, lnurl or token"));
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
info = created;
|
|
701
|
+
resolveOpened(makeSession(created));
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
const current = info;
|
|
705
|
+
if (!current) return;
|
|
706
|
+
if (frame.event === "invoice_request") {
|
|
707
|
+
let req;
|
|
708
|
+
try {
|
|
709
|
+
req = JSON.parse(frame.data);
|
|
710
|
+
} catch (err) {
|
|
711
|
+
handlers.onError?.(new LnurlTransportError("Invalid invoice_request frame", { cause: err }));
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
const respond = {
|
|
715
|
+
answerInvoice: (pr) => postJson(`${root}/lnurl/session/${current.sessionId}/invoice`, current.token, { pr }, fetchImpl),
|
|
716
|
+
rejectInvoice: (reason) => postJson(`${root}/lnurl/session/${current.sessionId}/invoice`, current.token, { error: reason }, fetchImpl)
|
|
717
|
+
};
|
|
718
|
+
try {
|
|
719
|
+
const result = handlers.onInvoiceRequest(
|
|
720
|
+
req.comment === void 0 ? { amountMsat: req.amountMsat } : { amountMsat: req.amountMsat, comment: req.comment },
|
|
721
|
+
respond
|
|
722
|
+
);
|
|
723
|
+
if (result instanceof Promise) result.catch((err) => handlers.onError?.(err));
|
|
724
|
+
} catch (err) {
|
|
725
|
+
handlers.onError?.(err);
|
|
726
|
+
}
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
if (frame.event === "invoice_settled") {
|
|
730
|
+
try {
|
|
731
|
+
handlers.onSettled?.(JSON.parse(frame.data));
|
|
732
|
+
} catch (err) {
|
|
733
|
+
handlers.onError?.(new LnurlTransportError("Invalid invoice_settled frame", { cause: err }));
|
|
734
|
+
}
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
if (frame.event === "error") {
|
|
738
|
+
let message = frame.data;
|
|
739
|
+
try {
|
|
740
|
+
const data = JSON.parse(frame.data);
|
|
741
|
+
if (typeof data.error === "string") message = data.error;
|
|
742
|
+
} catch {
|
|
743
|
+
}
|
|
744
|
+
handlers.onError?.(new Error(message));
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
const sleep2 = (ms) => new Promise((resolve2) => {
|
|
748
|
+
timer = setTimeout(() => {
|
|
749
|
+
timer = void 0;
|
|
750
|
+
resolve2();
|
|
751
|
+
}, ms);
|
|
752
|
+
});
|
|
753
|
+
const run = async () => {
|
|
754
|
+
let attempts = 0;
|
|
755
|
+
for (; ; ) {
|
|
756
|
+
streamDeliveredFrame = false;
|
|
757
|
+
let response;
|
|
758
|
+
try {
|
|
759
|
+
response = await postSession();
|
|
760
|
+
} catch (err) {
|
|
761
|
+
if (!info) {
|
|
762
|
+
rejectOpened(err);
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
if (err instanceof LnurlError && err.httpStatus === 409) {
|
|
766
|
+
handlers.onError?.(err);
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
if (closed || signal?.aborted || !resumable || attempts >= maxAttempts) {
|
|
770
|
+
handlers.onError?.(err);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
attempts += 1;
|
|
774
|
+
handlers.onReconnect?.(attempts);
|
|
775
|
+
await sleep2(backoffMs(attempts));
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
const body = response.body;
|
|
779
|
+
activeBody = body;
|
|
780
|
+
try {
|
|
781
|
+
await readSseStream(body, onFrame, signal);
|
|
782
|
+
} catch (err) {
|
|
783
|
+
if (closed || signal?.aborted) return;
|
|
784
|
+
if (!info) {
|
|
785
|
+
rejectOpened(err);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
handlers.onError?.(err);
|
|
789
|
+
} finally {
|
|
790
|
+
if (activeBody === body) activeBody = null;
|
|
791
|
+
}
|
|
792
|
+
if (!info) {
|
|
793
|
+
rejectOpened(new LnurlTransportError("Session stream ended before session_created"));
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
if (streamDeliveredFrame) attempts = 0;
|
|
797
|
+
if (closed || signal?.aborted || !resumable || attempts >= maxAttempts) return;
|
|
798
|
+
attempts += 1;
|
|
799
|
+
handlers.onReconnect?.(attempts);
|
|
800
|
+
await sleep2(backoffMs(attempts));
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
void run();
|
|
804
|
+
return opened;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// packages/client/src/token.ts
|
|
808
|
+
import { secp256k1 } from "@noble/curves/secp256k1.js";
|
|
809
|
+
import { sha256 as sha2563 } from "@noble/hashes/sha2.js";
|
|
810
|
+
import { hex as hex3 } from "@scure/base";
|
|
811
|
+
var MESSAGE_PREFIX = "lnurl-session:";
|
|
812
|
+
function sessionTokenMessage(domain) {
|
|
813
|
+
const normalised = domain.trim().toLowerCase();
|
|
814
|
+
if (!normalised) throw new Error("a session token must be bound to a domain");
|
|
815
|
+
return sha2563(new TextEncoder().encode(MESSAGE_PREFIX + normalised));
|
|
816
|
+
}
|
|
817
|
+
function tokenFromSignature(signature) {
|
|
818
|
+
return hex3.encode(sha2563(signature));
|
|
819
|
+
}
|
|
820
|
+
function deriveSessionToken(privateKeyHex, domain) {
|
|
821
|
+
const signature = secp256k1.sign(sessionTokenMessage(domain), hex3.decode(privateKeyHex), { prehash: false });
|
|
822
|
+
return tokenFromSignature(signature);
|
|
823
|
+
}
|
|
824
|
+
async function deriveSessionTokenWithSigner(signMessage, domain) {
|
|
825
|
+
const message = sessionTokenMessage(domain);
|
|
826
|
+
const first = await signMessage(message, "ecdsa");
|
|
827
|
+
const second = await signMessage(message, "ecdsa");
|
|
828
|
+
if (first.length !== second.length || !first.every((b, i) => b === second[i])) {
|
|
829
|
+
throw new Error(
|
|
830
|
+
"signer is not deterministic: signing the same message twice differed, so the derived token would change on every call and orphan the address. Use RFC6979 ECDSA, not randomised schnorr."
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
return tokenFromSignature(first);
|
|
834
|
+
}
|
|
835
|
+
function deriveSessionId(tokenHex) {
|
|
836
|
+
return hex3.encode(sha2563(hex3.decode(tokenHex))).slice(0, 32);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// packages/client/src/addresses.ts
|
|
840
|
+
function rootOf(baseUrl) {
|
|
841
|
+
return baseUrl.replace(/\/+$/, "");
|
|
842
|
+
}
|
|
843
|
+
var asRegistered = (a) => ({
|
|
844
|
+
...a,
|
|
845
|
+
handle: a.handle ?? a.username,
|
|
846
|
+
nameless: a.nameless ?? false,
|
|
847
|
+
sessionLnurl: a.sessionLnurl ?? null
|
|
848
|
+
});
|
|
849
|
+
var asListed = (a) => ({
|
|
850
|
+
...a,
|
|
851
|
+
handle: a.handle ?? a.username,
|
|
852
|
+
nameless: a.nameless ?? false,
|
|
853
|
+
sessionLnurl: a.sessionLnurl ?? null
|
|
854
|
+
});
|
|
855
|
+
function registerAddress(baseUrl, req, fetchImpl) {
|
|
856
|
+
const headers = { "content-type": "application/json" };
|
|
857
|
+
if (req.apiKey !== void 0) headers["X-API-Key"] = req.apiKey;
|
|
858
|
+
const body = { token: req.token };
|
|
859
|
+
if (req.username !== void 0) body["username"] = req.username;
|
|
860
|
+
if (req.claimCode !== void 0) body["claimCode"] = req.claimCode;
|
|
861
|
+
if (req.domain !== void 0) body["domain"] = req.domain;
|
|
862
|
+
if (req.nameless !== void 0) body["nameless"] = req.nameless;
|
|
863
|
+
return apiFetch(`${rootOf(baseUrl)}/lnurl/address`, {
|
|
864
|
+
method: "POST",
|
|
865
|
+
headers,
|
|
866
|
+
body: JSON.stringify(body)
|
|
867
|
+
}, fetchImpl).then(asRegistered);
|
|
868
|
+
}
|
|
869
|
+
function listAddresses(baseUrl, token, fetchImpl) {
|
|
870
|
+
return apiFetch(`${rootOf(baseUrl)}/lnurl/address`, {
|
|
871
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
872
|
+
}, fetchImpl).then((entries) => entries.map(asListed));
|
|
873
|
+
}
|
|
874
|
+
function upgradeAddress(baseUrl, req, fetchImpl) {
|
|
875
|
+
const body = {};
|
|
876
|
+
if (req.username !== void 0) body["username"] = req.username;
|
|
877
|
+
if (req.claimCode !== void 0) body["claimCode"] = req.claimCode;
|
|
878
|
+
const query = req.domain !== void 0 ? `?domain=${encodeURIComponent(req.domain)}` : "";
|
|
879
|
+
return apiFetch(`${rootOf(baseUrl)}/lnurl/address/${encodeURIComponent(req.handle)}${query}`, {
|
|
880
|
+
method: "PATCH",
|
|
881
|
+
headers: { "content-type": "application/json", Authorization: `Bearer ${req.token}` },
|
|
882
|
+
body: JSON.stringify(body)
|
|
883
|
+
}, fetchImpl).then(asRegistered);
|
|
884
|
+
}
|
|
885
|
+
function domainCapabilities(baseUrl, opts, fetchImpl) {
|
|
886
|
+
const query = opts?.domain !== void 0 ? `?domain=${encodeURIComponent(opts.domain)}` : "";
|
|
887
|
+
return apiFetch(`${rootOf(baseUrl)}/lnurl/domain${query}`, void 0, fetchImpl);
|
|
888
|
+
}
|
|
889
|
+
function toActivity(row) {
|
|
890
|
+
if (row.paymentOption === "lightning") {
|
|
891
|
+
const activity2 = {
|
|
892
|
+
kind: "bolt11",
|
|
893
|
+
paymentHash: row.paymentHash,
|
|
894
|
+
pr: row.pr,
|
|
895
|
+
preimage: row.preimage,
|
|
896
|
+
swapId: row.swapId,
|
|
897
|
+
payoutReference: row.payoutReference ?? null,
|
|
898
|
+
settled: row.settled,
|
|
899
|
+
amountMsat: row.amountMsat,
|
|
900
|
+
createdAt: row.createdAt,
|
|
901
|
+
settledAt: row.settledAt
|
|
902
|
+
};
|
|
903
|
+
return activity2;
|
|
904
|
+
}
|
|
905
|
+
const activity = {
|
|
906
|
+
kind: "destination",
|
|
907
|
+
verifyId: row.paymentHash,
|
|
908
|
+
paymentOption: row.paymentOption,
|
|
909
|
+
paymentDestination: row.paymentDestination,
|
|
910
|
+
covenantScript: row.covenantScript,
|
|
911
|
+
paymentReference: row.paymentReference,
|
|
912
|
+
payoutReference: row.payoutReference ?? null,
|
|
913
|
+
settled: row.settled,
|
|
914
|
+
amountMsat: row.amountMsat,
|
|
915
|
+
createdAt: row.createdAt,
|
|
916
|
+
settledAt: row.settledAt
|
|
917
|
+
};
|
|
918
|
+
return activity;
|
|
919
|
+
}
|
|
920
|
+
async function listPayments(baseUrl, token, handle, opts, fetchImpl) {
|
|
921
|
+
const params = new URLSearchParams();
|
|
922
|
+
if (opts?.domain !== void 0) params.set("domain", opts.domain);
|
|
923
|
+
if (opts?.since !== void 0) params.set("since", String(opts.since));
|
|
924
|
+
if (opts?.limit !== void 0) params.set("limit", String(opts.limit));
|
|
925
|
+
const query = params.size > 0 ? `?${params.toString()}` : "";
|
|
926
|
+
const page = await apiFetch(
|
|
927
|
+
`${rootOf(baseUrl)}/lnurl/address/${encodeURIComponent(handle)}/payments${query}`,
|
|
928
|
+
{
|
|
929
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
930
|
+
},
|
|
931
|
+
fetchImpl
|
|
932
|
+
);
|
|
933
|
+
return {
|
|
934
|
+
source: { ...page.source, handle: page.source.handle ?? handle },
|
|
935
|
+
payments: page.payments.map(toActivity),
|
|
936
|
+
nextSince: page.nextSince
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
async function revokeAddress(baseUrl, token, handle, opts, fetchImpl) {
|
|
940
|
+
const query = opts?.domain !== void 0 ? `?domain=${encodeURIComponent(opts.domain)}` : "";
|
|
941
|
+
await apiFetch(`${rootOf(baseUrl)}/lnurl/address/${encodeURIComponent(handle)}${query}`, {
|
|
942
|
+
method: "DELETE",
|
|
943
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
944
|
+
}, fetchImpl);
|
|
945
|
+
}
|
|
946
|
+
var COMPRESSED_KEY = /^0[23][0-9a-f]{64}$/i;
|
|
947
|
+
async function registerArkadeIdentity(baseUrl, req, fetchImpl) {
|
|
948
|
+
if (!COMPRESSED_KEY.test(req.claimPublicKey)) {
|
|
949
|
+
throw new LnurlError("claimPublicKey must be a compressed 33-byte public key (02/03 prefix plus 64 hex chars)");
|
|
950
|
+
}
|
|
951
|
+
const body = { arkadeAddress: req.arkadeAddress, claimPublicKey: req.claimPublicKey };
|
|
952
|
+
if (req.boardingAddress !== void 0) body["boardingAddress"] = req.boardingAddress;
|
|
953
|
+
if (req.domain !== void 0) body["domain"] = req.domain;
|
|
954
|
+
await apiFetch(`${rootOf(baseUrl)}/lnurl/address/${encodeURIComponent(req.handle)}/arkade`, {
|
|
955
|
+
method: "POST",
|
|
956
|
+
headers: { "content-type": "application/json", Authorization: `Bearer ${req.token}` },
|
|
957
|
+
body: JSON.stringify(body)
|
|
958
|
+
}, fetchImpl);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// packages/client/src/sync.ts
|
|
962
|
+
var DEFAULT_SYNC_PAGE_LIMIT = 50;
|
|
963
|
+
var SYNC_MAX_ATTEMPTS = 3;
|
|
964
|
+
var SYNC_RETRY_DELAY_MS = 250;
|
|
965
|
+
var NEVER_SETTLES = /* @__PURE__ */ new Set(["onchain"]);
|
|
966
|
+
var wait = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
967
|
+
var isRetryable = (error) => {
|
|
968
|
+
if (error instanceof LnurlError) return error.retryable;
|
|
969
|
+
return error?.retryable === true;
|
|
970
|
+
};
|
|
971
|
+
var toStored = (baseUrl, domain, lightningAddress, handle, entry) => {
|
|
972
|
+
const identifier = entry.kind === "bolt11" ? entry.paymentHash : entry.verifyId;
|
|
973
|
+
return {
|
|
974
|
+
key: `${baseUrl}|${identifier}`,
|
|
975
|
+
baseUrl,
|
|
976
|
+
domain,
|
|
977
|
+
lightningAddress,
|
|
978
|
+
handle,
|
|
979
|
+
identifier,
|
|
980
|
+
kind: entry.kind,
|
|
981
|
+
settled: entry.settled,
|
|
982
|
+
amountMsat: entry.amountMsat,
|
|
983
|
+
createdAt: entry.createdAt,
|
|
984
|
+
settledAt: entry.settledAt,
|
|
985
|
+
swapId: entry.kind === "bolt11" ? entry.swapId : null,
|
|
986
|
+
paymentReference: entry.kind === "destination" ? entry.paymentReference : null,
|
|
987
|
+
// Present on both shapes: every rail eventually credits the owner somewhere.
|
|
988
|
+
payoutReference: entry.payoutReference ?? null,
|
|
989
|
+
preimage: entry.kind === "bolt11" ? entry.preimage : null,
|
|
990
|
+
paymentOption: entry.kind === "destination" ? entry.paymentOption : null,
|
|
991
|
+
covenantScript: entry.kind === "destination" ? entry.covenantScript : null
|
|
992
|
+
};
|
|
993
|
+
};
|
|
994
|
+
var mayStillSettle = (row) => !row.settled && (row.paymentOption === null || !NEVER_SETTLES.has(row.paymentOption));
|
|
995
|
+
var resumeFrom = (tail, fallback) => {
|
|
996
|
+
let oldest = fallback;
|
|
997
|
+
for (const row of tail) if (mayStillSettle(row) && row.createdAt < oldest) oldest = row.createdAt;
|
|
998
|
+
return oldest;
|
|
999
|
+
};
|
|
1000
|
+
var listWithBackoff = (client, token, handle, domain, since, limit) => {
|
|
1001
|
+
const attempt = async (tries) => {
|
|
1002
|
+
try {
|
|
1003
|
+
return await client.listPayments(token, handle, { domain, since, limit });
|
|
1004
|
+
} catch (error) {
|
|
1005
|
+
if (!isRetryable(error) || tries >= SYNC_MAX_ATTEMPTS) throw error;
|
|
1006
|
+
await wait(tries * SYNC_RETRY_DELAY_MS);
|
|
1007
|
+
return attempt(tries + 1);
|
|
1008
|
+
}
|
|
1009
|
+
};
|
|
1010
|
+
return attempt(1);
|
|
1011
|
+
};
|
|
1012
|
+
var syncTarget = async (target, client, store, limit, onStored) => {
|
|
1013
|
+
const watermarkKey = `${target.handle}@${target.domain}`;
|
|
1014
|
+
let since = await store.readWatermark(target.baseUrl, watermarkKey);
|
|
1015
|
+
const tail = [];
|
|
1016
|
+
for (; ; ) {
|
|
1017
|
+
const page = await listWithBackoff(client, target.token, target.handle, target.domain, since, limit);
|
|
1018
|
+
const records = page.payments.map(
|
|
1019
|
+
(entry) => toStored(target.baseUrl, page.source.domain, page.source.lightningAddress, page.source.handle, entry)
|
|
1020
|
+
);
|
|
1021
|
+
await store.upsert(records);
|
|
1022
|
+
onStored(records.length);
|
|
1023
|
+
for (const record of records) tail.push(record);
|
|
1024
|
+
if (tail.length > limit) tail.splice(0, tail.length - limit);
|
|
1025
|
+
const previous = since;
|
|
1026
|
+
since = page.nextSince;
|
|
1027
|
+
await store.writeWatermark(target.baseUrl, watermarkKey, resumeFrom(tail, since));
|
|
1028
|
+
if (page.payments.length < limit) return;
|
|
1029
|
+
if (previous !== void 0 && since <= previous) {
|
|
1030
|
+
throw new LnurlError(
|
|
1031
|
+
`payment sync stalled for ${watermarkKey} at ${target.baseUrl}: ${limit} payments share timestamp ${since}, so the cursor cannot advance past them`
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
};
|
|
1036
|
+
function syncPayments(targets, opts) {
|
|
1037
|
+
const run = async () => {
|
|
1038
|
+
const limit = opts.limit ?? DEFAULT_SYNC_PAGE_LIMIT;
|
|
1039
|
+
let synced = 0;
|
|
1040
|
+
const failures = [];
|
|
1041
|
+
for (const target of targets) {
|
|
1042
|
+
try {
|
|
1043
|
+
await syncTarget(target, opts.client(target.baseUrl), opts.store, limit, (count) => {
|
|
1044
|
+
synced += count;
|
|
1045
|
+
});
|
|
1046
|
+
} catch (error) {
|
|
1047
|
+
failures.push({ baseUrl: target.baseUrl, error });
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
return { synced, failures };
|
|
1051
|
+
};
|
|
1052
|
+
return run();
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// packages/client/src/stores.ts
|
|
1056
|
+
var RECORDS = "arkade-lnurl.payments";
|
|
1057
|
+
var WATERMARKS = "arkade-lnurl.watermarks";
|
|
1058
|
+
var area = () => {
|
|
1059
|
+
if (typeof localStorage === "undefined") {
|
|
1060
|
+
throw new LnurlError("browserPaymentStore needs localStorage; supply your own PaymentSyncStore");
|
|
1061
|
+
}
|
|
1062
|
+
return localStorage;
|
|
1063
|
+
};
|
|
1064
|
+
var read = (key, fallback) => {
|
|
1065
|
+
try {
|
|
1066
|
+
const raw = area().getItem(key);
|
|
1067
|
+
return raw ? JSON.parse(raw) : fallback;
|
|
1068
|
+
} catch {
|
|
1069
|
+
return fallback;
|
|
1070
|
+
}
|
|
1071
|
+
};
|
|
1072
|
+
function browserPaymentStore(prefix = "") {
|
|
1073
|
+
const records = `${prefix}${RECORDS}`;
|
|
1074
|
+
const marks = `${prefix}${WATERMARKS}`;
|
|
1075
|
+
return {
|
|
1076
|
+
async upsert(incoming) {
|
|
1077
|
+
const byKey = new Map(read(records, []).map((r) => [r.key, r]));
|
|
1078
|
+
for (const record of incoming) byKey.set(record.key, record);
|
|
1079
|
+
area().setItem(records, JSON.stringify([...byKey.values()]));
|
|
1080
|
+
},
|
|
1081
|
+
async readWatermark(baseUrl, lightningAddress) {
|
|
1082
|
+
return read(marks, {})[`${baseUrl}|${lightningAddress}`];
|
|
1083
|
+
},
|
|
1084
|
+
async writeWatermark(baseUrl, lightningAddress, since) {
|
|
1085
|
+
const all = read(marks, {});
|
|
1086
|
+
all[`${baseUrl}|${lightningAddress}`] = since;
|
|
1087
|
+
area().setItem(marks, JSON.stringify(all));
|
|
1088
|
+
}
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
function storedPayments(address, prefix = "") {
|
|
1092
|
+
const mine = typeof address === "string" ? (r) => r.lightningAddress === address : (r) => r.domain === address.domain && (r.handle === address.handle || r.handle === void 0 && r.lightningAddress === `${address.handle}@${address.domain}`);
|
|
1093
|
+
return read(`${prefix}${RECORDS}`, []).filter(mine).sort((a, b) => b.createdAt - a.createdAt);
|
|
1094
|
+
}
|
|
1095
|
+
function forgetStoredPayments(prefix = "") {
|
|
1096
|
+
area().removeItem(`${prefix}${RECORDS}`);
|
|
1097
|
+
area().removeItem(`${prefix}${WATERMARKS}`);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// packages/client/src/index.ts
|
|
1101
|
+
function createLnurlClient(opts) {
|
|
1102
|
+
const baseUrl = opts?.baseUrl;
|
|
1103
|
+
const fetchImpl = opts?.fetchImpl ?? ((input, init) => globalThis.fetch(input, init));
|
|
1104
|
+
const needBase = (method) => {
|
|
1105
|
+
if (!baseUrl) throw new LnurlError(`baseUrl is required for ${method}`);
|
|
1106
|
+
return baseUrl;
|
|
1107
|
+
};
|
|
1108
|
+
return {
|
|
1109
|
+
resolve: (input) => resolve(input, fetchImpl),
|
|
1110
|
+
requestInvoice: (payRequest, reqOpts) => requestInvoice(payRequest, reqOpts, fetchImpl),
|
|
1111
|
+
pollVerify: (verifyUrl, pollOpts) => pollVerify(verifyUrl, pollOpts, fetchImpl),
|
|
1112
|
+
batchVerify: (verifyBatchUrl, verifyUrls, opts2) => batchVerify(verifyBatchUrl, verifyUrls, opts2, opts2?.fetchImpl),
|
|
1113
|
+
openSession: async (sessionOpts, handlers) => openSession(needBase("openSession"), sessionOpts, handlers, fetchImpl),
|
|
1114
|
+
openVerifyBatchStream: (streamOpts, handlers) => openVerifyBatchStream(streamOpts, handlers, streamOpts?.fetchImpl),
|
|
1115
|
+
registerAddress: async (req) => registerAddress(needBase("registerAddress"), req, fetchImpl),
|
|
1116
|
+
upgradeAddress: async (req) => upgradeAddress(needBase("upgradeAddress"), req, fetchImpl),
|
|
1117
|
+
domainCapabilities: async (capOpts) => domainCapabilities(needBase("domainCapabilities"), capOpts, fetchImpl),
|
|
1118
|
+
listAddresses: async (token) => listAddresses(needBase("listAddresses"), token, fetchImpl),
|
|
1119
|
+
revokeAddress: async (token, handle, revokeOpts) => revokeAddress(needBase("revokeAddress"), token, handle, revokeOpts, fetchImpl),
|
|
1120
|
+
registerArkadeIdentity: async (req) => registerArkadeIdentity(needBase("registerArkadeIdentity"), req, fetchImpl),
|
|
1121
|
+
listPayments: async (token, handle, listOpts) => listPayments(needBase("listPayments"), token, handle, listOpts, fetchImpl)
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
export {
|
|
1126
|
+
LnurlError,
|
|
1127
|
+
LnurlTransportError,
|
|
1128
|
+
LnurlTimeoutError,
|
|
1129
|
+
deriveSessionToken,
|
|
1130
|
+
deriveSessionTokenWithSigner,
|
|
1131
|
+
deriveSessionId,
|
|
1132
|
+
isLnAddress,
|
|
1133
|
+
isLnUrl,
|
|
1134
|
+
isValidLnUrl,
|
|
1135
|
+
toPayRequestUrl,
|
|
1136
|
+
isTokenAddress,
|
|
1137
|
+
tokenOptions,
|
|
1138
|
+
resolve,
|
|
1139
|
+
requestInvoice,
|
|
1140
|
+
verifyStatusIsFor,
|
|
1141
|
+
pollVerify,
|
|
1142
|
+
batchVerify,
|
|
1143
|
+
openVerifyBatchStream,
|
|
1144
|
+
openSession,
|
|
1145
|
+
registerAddress,
|
|
1146
|
+
listAddresses,
|
|
1147
|
+
upgradeAddress,
|
|
1148
|
+
domainCapabilities,
|
|
1149
|
+
listPayments,
|
|
1150
|
+
revokeAddress,
|
|
1151
|
+
registerArkadeIdentity,
|
|
1152
|
+
syncPayments,
|
|
1153
|
+
browserPaymentStore,
|
|
1154
|
+
storedPayments,
|
|
1155
|
+
forgetStoredPayments,
|
|
1156
|
+
createLnurlClient
|
|
1157
|
+
};
|