thunder-bridge 1.4.2 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -13,8 +13,8 @@ function hexToBytes(hex) {
13
13
  }
14
14
  return bytes;
15
15
  }
16
- function isHex(text2) {
17
- return /^[0-9a-f]+$/i.test(text2) && text2.length % 2 === 0;
16
+ function isHex(text3) {
17
+ return /^[0-9a-f]+$/i.test(text3) && text3.length % 2 === 0;
18
18
  }
19
19
 
20
20
  // ../core/sha256.ts
@@ -108,8 +108,8 @@ function sha256(bytes) {
108
108
  }
109
109
  return asBytes(state);
110
110
  }
111
- function sha256Hex(text2) {
112
- return bytesToHex(sha256(new TextEncoder().encode(text2)));
111
+ function sha256Hex(text3) {
112
+ return bytesToHex(sha256(new TextEncoder().encode(text3)));
113
113
  }
114
114
  function withPadding(bytes) {
115
115
  const padded = new Uint8Array(
@@ -175,6 +175,7 @@ function rotate(value, by) {
175
175
 
176
176
  // ../core/bolt11.ts
177
177
  var BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
178
+ var BECH32_GENERATOR = [996825010, 642813549, 513874426, 1027748829, 705979059];
178
179
  var CHECKSUM_WORDS = 6;
179
180
  var TIMESTAMP_WORDS = 7;
180
181
  var SIGNATURE_WORDS = 104;
@@ -192,18 +193,27 @@ var HRP_MULTIPLIER_MSAT = {
192
193
  n: MSAT_PER_BTC / 1e9,
193
194
  p: MSAT_PER_BTC / 1e12
194
195
  };
195
- var HRP_AMOUNT = /^ln[a-z]+?(\d+)([munp]?)$/;
196
+ var HRP = /^ln(?:bcrt|bc|tbs|tb|sb)(?:(\d+)([munp]?))?$/;
196
197
  function decodeInvoice(bolt11) {
197
198
  const parts = splitBech32(bolt11);
198
199
  if (parts === null) {
199
- return { paymentHash: null, descriptionHash: null, amountMsat: null, expiresAt: null };
200
+ return {
201
+ paymentHash: null,
202
+ descriptionHash: null,
203
+ amountMsat: null,
204
+ issuedAt: null,
205
+ expiresAt: null
206
+ };
200
207
  }
201
208
  const tagged = taggedFields(parts.words);
209
+ const issuedAt = issuedAtOf(parts.words);
210
+ const expiry = tagged.get(EXPIRY_TAG);
202
211
  return {
203
212
  paymentHash: hexTag(tagged, PAYMENT_HASH_TAG, PAYMENT_HASH_WORDS),
204
213
  descriptionHash: hexTag(tagged, DESCRIPTION_HASH_TAG, DESCRIPTION_HASH_WORDS),
205
214
  amountMsat: amountFromHrp(parts.hrp),
206
- expiresAt: expiryOf(parts.words, tagged)
215
+ issuedAt,
216
+ expiresAt: issuedAt === null ? null : issuedAt + (expiry ? readNumber(expiry) : DEFAULT_EXPIRY_SECS)
207
217
  };
208
218
  }
209
219
  function preimageMatchesHash(preimage, paymentHash) {
@@ -214,7 +224,7 @@ function preimageMatchesHash(preimage, paymentHash) {
214
224
  }
215
225
  function splitBech32(bolt11) {
216
226
  const lower = bolt11.toLowerCase();
217
- if (isBolt12Encoding(lower)) {
227
+ if (isBolt12Encoding(lower) || bolt11 !== lower && bolt11 !== bolt11.toUpperCase()) {
218
228
  return null;
219
229
  }
220
230
  const separator = lower.lastIndexOf("1");
@@ -233,7 +243,28 @@ function splitBech32(bolt11) {
233
243
  }
234
244
  words.push(value);
235
245
  }
236
- return { hrp: lower.slice(0, separator), words: words.slice(0, -CHECKSUM_WORDS) };
246
+ const hrp = lower.slice(0, separator);
247
+ if (!HRP.test(hrp) || bech32Polymod([...expandedHrp(hrp), ...words]) !== 1) {
248
+ return null;
249
+ }
250
+ return { hrp, words: words.slice(0, -CHECKSUM_WORDS) };
251
+ }
252
+ function expandedHrp(hrp) {
253
+ const codes = [...hrp].map((char) => char.charCodeAt(0));
254
+ return [...codes.map((code) => code >> 5), 0, ...codes.map((code) => code & 31)];
255
+ }
256
+ function bech32Polymod(values) {
257
+ let checksum = 1;
258
+ for (const value of values) {
259
+ const top = checksum >> 25;
260
+ checksum = (checksum & 33554431) << 5 ^ value;
261
+ for (let bit = 0; bit < 5; bit++) {
262
+ if (top >> bit & 1) {
263
+ checksum ^= BECH32_GENERATOR[bit];
264
+ }
265
+ }
266
+ }
267
+ return checksum;
237
268
  }
238
269
  function isBolt12Encoding(lower) {
239
270
  return lower.startsWith("lno") || lower.startsWith("lni") || lower.startsWith("lnr");
@@ -263,19 +294,17 @@ function hexTag(fields, tag, expectedWords) {
263
294
  }
264
295
  return bytesToHex(wordsToBytes(words));
265
296
  }
266
- function expiryOf(words, fields) {
297
+ function issuedAtOf(words) {
267
298
  if (words.length < TIMESTAMP_WORDS + SIGNATURE_WORDS) {
268
299
  return null;
269
300
  }
270
- const expiry = fields.get(EXPIRY_TAG);
271
- return readNumber(words.slice(0, TIMESTAMP_WORDS)) + (expiry ? readNumber(expiry) : DEFAULT_EXPIRY_SECS);
301
+ return readNumber(words.slice(0, TIMESTAMP_WORDS));
272
302
  }
273
303
  function amountFromHrp(hrp) {
274
- const match = HRP_AMOUNT.exec(hrp);
275
- if (match === null) {
304
+ const [, digits, multiplier] = HRP.exec(hrp) ?? [];
305
+ if (digits === void 0 || multiplier === "p" && !digits.endsWith("0")) {
276
306
  return null;
277
307
  }
278
- const [, digits, multiplier] = match;
279
308
  return multiplier ? Number(digits) * HRP_MULTIPLIER_MSAT[multiplier] : Number(digits) * MSAT_PER_BTC;
280
309
  }
281
310
  function readNumber(words) {
@@ -296,180 +325,74 @@ function wordsToBytes(words) {
296
325
  return Uint8Array.from(bytes);
297
326
  }
298
327
 
299
- // ../core/url.ts
300
- var PRIVATE_SUFFIXES = ["localhost", "local", "internal", "lan", "arpa", "test", "invalid"];
301
- function publicHttps(raw) {
302
- let url;
303
- try {
304
- url = new URL(raw);
305
- } catch {
306
- return false;
307
- }
308
- return url.protocol === "https:" && hostIsPublic(url.hostname.toLowerCase());
309
- }
310
- function sameOrigin(one, other) {
311
- try {
312
- return new URL(one).origin === new URL(other).origin;
313
- } catch {
314
- return false;
315
- }
316
- }
317
- function publicAddress(literal) {
318
- return literal.includes(":") ? ipv6IsGlobal(literal) : ipv4IsGlobal(literal);
319
- }
320
- function hostIsPublic(host) {
321
- if (host.startsWith("[")) {
322
- return publicAddress(host.slice(1, -1));
323
- }
324
- const name = host.replace(/\.$/, "");
325
- if (/^\d+\.\d+\.\d+\.\d+$/.test(name)) {
326
- return publicAddress(name);
327
- }
328
- const labels = name.split(".");
329
- if (labels.length < 2 || labels.some((label) => label === "")) {
330
- return false;
331
- }
332
- return !PRIVATE_SUFFIXES.includes(labels[labels.length - 1]);
333
- }
334
- function ipv4IsGlobal(literal) {
335
- const parts = literal.split(".").map(Number);
336
- if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
337
- return false;
338
- }
339
- const [a, b] = parts;
340
- if (a === 0 || a === 10 || a === 127 || a >= 240) {
341
- return false;
342
- }
343
- if (a === 100 && b >= 64 && b < 128) {
344
- return false;
345
- }
346
- if (a === 169 && b === 254) {
347
- return false;
348
- }
349
- if (a === 172 && b >= 16 && b < 32) {
350
- return false;
351
- }
352
- if (a === 192 && b === 168) {
353
- return false;
354
- }
355
- return true;
328
+ // ../core/hmac.ts
329
+ async function hmacHex(secret, payload) {
330
+ const raw = typeof secret === "string" ? new TextEncoder().encode(secret) : secret;
331
+ const key = await crypto.subtle.importKey(
332
+ "raw",
333
+ asBuffer(raw),
334
+ { name: "HMAC", hash: "SHA-256" },
335
+ false,
336
+ ["sign"]
337
+ );
338
+ const message = typeof payload === "string" ? new TextEncoder().encode(payload) : payload;
339
+ return bytesToHex(new Uint8Array(await crypto.subtle.sign("HMAC", key, asBuffer(message))));
356
340
  }
357
- function ipv6IsGlobal(literal) {
358
- const groups = ipv6Groups(literal.toLowerCase());
359
- if (groups.length !== 8 || groups.some(Number.isNaN)) {
360
- return false;
361
- }
362
- const leading = groups.slice(0, 5).every((group) => group === 0);
363
- if (leading && groups[5] === 0 && groups[6] === 0 && groups[7] <= 1) {
364
- return false;
365
- }
366
- if (leading && groups[5] === 65535) {
367
- const [high, low] = [groups[6], groups[7]];
368
- return ipv4IsGlobal(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
369
- }
370
- const first = groups[0];
371
- if ((first & 65024) === 64512) {
372
- return false;
373
- }
374
- if ((first & 65472) === 65152) {
341
+ function equalInConstantTime(one, other) {
342
+ if (one.length !== other.length) {
375
343
  return false;
376
344
  }
377
- return true;
378
- }
379
- function ipv6Groups(address) {
380
- const [head = "", tail] = address.split("::");
381
- const left = head.split(":").filter((group) => group !== "");
382
- const right = (tail ?? "").split(":").filter((group) => group !== "");
383
- const gap = tail === void 0 ? [] : Array(8 - left.length - right.length).fill("0");
384
- return [...left, ...gap, ...right].map((group) => parseInt(group, 16));
385
- }
386
-
387
- // ../core/lnurl.ts
388
- var BECH32_CHARSET2 = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
389
- var BECH32_GENERATOR = [996825010, 642813549, 513874426, 1027748829, 705979059];
390
- var CHECKSUM_SLOTS = [0, 1, 2, 3, 4, 5];
391
- var LNURL_HRP = "lnurl";
392
- function toLnurl(endpoint) {
393
- const scheme = parsedScheme(endpoint);
394
- if (scheme !== "https:" && scheme !== "http:") {
395
- throw new Error(`${endpoint} is not an http or https URL`);
396
- }
397
- const words = toWords(new TextEncoder().encode(endpoint));
398
- const data = [...words, ...checksumWords(LNURL_HRP, words)];
399
- return `${LNURL_HRP}1${data.map((word) => BECH32_CHARSET2[word]).join("")}`.toUpperCase();
400
- }
401
- function parsedScheme(endpoint) {
402
- try {
403
- return new URL(endpoint).protocol;
404
- } catch {
405
- return null;
345
+ let difference = 0;
346
+ for (let at = 0; at < one.length; at++) {
347
+ difference |= one.charCodeAt(at) ^ other.charCodeAt(at);
406
348
  }
349
+ return difference === 0;
407
350
  }
408
- function toWords(bytes) {
409
- const words = [];
410
- let accumulator = 0;
411
- let bits = 0;
412
- for (const byte of bytes) {
413
- accumulator = accumulator << 8 | byte;
414
- bits += 8;
415
- while (bits >= 5) {
416
- bits -= 5;
417
- words.push(accumulator >> bits & 31);
418
- }
419
- }
420
- if (bits > 0) {
421
- words.push(accumulator << 5 - bits & 31);
422
- }
423
- return words;
424
- }
425
- function checksumWords(hrp, words) {
426
- const expanded = [...hrp].map((char) => char.charCodeAt(0));
427
- const values = [
428
- ...expanded.map((code) => code >> 5),
429
- 0,
430
- ...expanded.map((code) => code & 31),
431
- ...words,
432
- ...CHECKSUM_SLOTS.map(() => 0)
433
- ];
434
- const polymod = bech32Polymod(values) ^ 1;
435
- return CHECKSUM_SLOTS.map((slot) => polymod >> 5 * (5 - slot) & 31);
436
- }
437
- function bech32Polymod(values) {
438
- let checksum = 1;
439
- for (const value of values) {
440
- const top = checksum >> 25;
441
- checksum = (checksum & 33554431) << 5 ^ value;
442
- for (let bit = 0; bit < 5; bit++) {
443
- if (top >> bit & 1) {
444
- checksum ^= BECH32_GENERATOR[bit];
445
- }
446
- }
447
- }
448
- return checksum;
351
+ function asBuffer(bytes) {
352
+ return bytes instanceof Uint8Array && bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
449
353
  }
450
354
 
451
355
  // ../core/sealed.ts
452
- var VERSION = "v1";
356
+ var VERSION = "v2";
357
+ var UNPADDED_VERSION = "v1";
453
358
  var IV_BYTES = 12;
454
359
  var MIN_SECRET_CHARS = 32;
455
360
  var MAX_PLAIN_BYTES = 3e3;
361
+ var SIZE_CLASSES = [256, 1024, MAX_PLAIN_BYTES];
362
+ var END_OF_TEXT = 128;
456
363
  var INFO = new TextEncoder().encode("thunder-bridge/sealed");
457
- async function seal(secret, plaintext) {
458
- const body = new TextEncoder().encode(plaintext);
459
- if (body.length > MAX_PLAIN_BYTES) {
460
- throw new Error(`sealed takes at most ${MAX_PLAIN_BYTES} bytes, this was ${body.length}`);
364
+ async function seal(secret, plaintext, paymentHash) {
365
+ return await sealUnder(
366
+ secret,
367
+ plaintext,
368
+ crypto.getRandomValues(new Uint8Array(IV_BYTES)),
369
+ boundTo(paymentHash)
370
+ );
371
+ }
372
+ async function sealStable(secret, plaintext) {
373
+ const derived = await hmacHex(secret, `sealed-nonce|${plaintext}`);
374
+ const nonce = new Uint8Array(IV_BYTES);
375
+ for (let at = 0; at < IV_BYTES; at++) {
376
+ nonce[at] = Number.parseInt(derived.slice(at * 2, at * 2 + 2), 16);
461
377
  }
378
+ return await sealUnder(secret, plaintext, nonce, boundTo(void 0));
379
+ }
380
+ async function sealUnder(secret, plaintext, iv, additionalData) {
381
+ const body = padToClass(plaintext);
462
382
  const key = await keyFor(secret);
463
- const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
464
- const cipher = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, body));
383
+ const cipher = new Uint8Array(
384
+ await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData }, key, body)
385
+ );
465
386
  const joined = new Uint8Array(iv.length + cipher.length);
466
387
  joined.set(iv);
467
388
  joined.set(cipher, iv.length);
468
389
  return `${VERSION}.${toBase64Url(joined)}`;
469
390
  }
470
- async function unseal(secret, sealed) {
391
+ async function unseal(secret, sealed, paymentHash) {
471
392
  const key = await keyFor(secret);
472
- if (!sealed.startsWith(`${VERSION}.`)) {
393
+ const padded = sealed.startsWith(`${VERSION}.`);
394
+ const unbound = !padded && sealed.startsWith(`${UNPADDED_VERSION}.`);
395
+ if (!padded && (!unbound || paymentHash !== void 0)) {
473
396
  return null;
474
397
  }
475
398
  const joined = fromBase64Url(sealed.slice(VERSION.length + 1));
@@ -477,20 +400,50 @@ async function unseal(secret, sealed) {
477
400
  return null;
478
401
  }
479
402
  try {
480
- const body = await crypto.subtle.decrypt(
481
- { name: "AES-GCM", iv: joined.slice(0, IV_BYTES) },
482
- key,
483
- joined.slice(IV_BYTES)
403
+ const body = new Uint8Array(
404
+ await crypto.subtle.decrypt(
405
+ {
406
+ name: "AES-GCM",
407
+ iv: joined.slice(0, IV_BYTES),
408
+ additionalData: padded ? boundTo(paymentHash) : void 0
409
+ },
410
+ key,
411
+ joined.slice(IV_BYTES)
412
+ )
484
413
  );
485
- return new TextDecoder().decode(body);
414
+ const text3 = padded ? unpad(body) : body;
415
+ return text3 === null ? null : new TextDecoder().decode(text3);
486
416
  } catch {
487
417
  return null;
488
418
  }
489
419
  }
490
- async function keyFor(secret) {
420
+ function refuseAWeakSecret(secret) {
491
421
  if (secret.length < MIN_SECRET_CHARS) {
492
422
  throw new Error(`the sealing secret needs ${MIN_SECRET_CHARS} characters of randomness`);
493
423
  }
424
+ }
425
+ function boundTo(paymentHash) {
426
+ return new TextEncoder().encode(
427
+ paymentHash === void 0 ? VERSION : `${VERSION}|${paymentHash.toLowerCase()}`
428
+ );
429
+ }
430
+ function padToClass(plaintext) {
431
+ const text3 = new TextEncoder().encode(plaintext);
432
+ const size = SIZE_CLASSES.find((limit) => text3.length <= limit);
433
+ if (size === void 0) {
434
+ throw new Error(`sealed takes at most ${MAX_PLAIN_BYTES} bytes, this was ${text3.length}`);
435
+ }
436
+ const body = new Uint8Array(size + 1);
437
+ body.set(text3);
438
+ body[text3.length] = END_OF_TEXT;
439
+ return body;
440
+ }
441
+ function unpad(body) {
442
+ const end = body.lastIndexOf(END_OF_TEXT);
443
+ return end === -1 ? null : body.subarray(0, end);
444
+ }
445
+ async function keyFor(secret) {
446
+ refuseAWeakSecret(secret);
494
447
  const material = await crypto.subtle.importKey(
495
448
  "raw",
496
449
  new TextEncoder().encode(secret),
@@ -513,12 +466,12 @@ function toBase64Url(bytes) {
513
466
  }
514
467
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
515
468
  }
516
- function fromBase64Url(text2) {
517
- if (text2.length === 0 || !/^[A-Za-z0-9_-]+$/.test(text2)) {
469
+ function fromBase64Url(text3) {
470
+ if (text3.length === 0 || !/^[A-Za-z0-9_-]+$/.test(text3)) {
518
471
  return null;
519
472
  }
520
473
  try {
521
- const binary = atob(text2.replace(/-/g, "+").replace(/_/g, "/"));
474
+ const binary = atob(text3.replace(/-/g, "+").replace(/_/g, "/"));
522
475
  const bytes = new Uint8Array(binary.length);
523
476
  for (let at = 0; at < bytes.length; at++) {
524
477
  bytes[at] = binary.charCodeAt(at);
@@ -529,32 +482,109 @@ function fromBase64Url(text2) {
529
482
  }
530
483
  }
531
484
 
532
- // ../core/hmac.ts
533
- async function hmacHex(secret, payload) {
534
- const raw = typeof secret === "string" ? new TextEncoder().encode(secret) : secret;
535
- const key = await crypto.subtle.importKey(
536
- "raw",
537
- asBuffer(raw),
538
- { name: "HMAC", hash: "SHA-256" },
539
- false,
540
- ["sign"]
541
- );
542
- const message = typeof payload === "string" ? new TextEncoder().encode(payload) : payload;
543
- return bytesToHex(new Uint8Array(await crypto.subtle.sign("HMAC", key, asBuffer(message))));
544
- }
545
- function equalInConstantTime(one, other) {
546
- if (one.length !== other.length) {
547
- return false;
485
+ // src/errors.ts
486
+ var AmountError = class extends Error {
487
+ /**
488
+ * Whether a failure is one of these, without asking whether it is this exact
489
+ * class. Every entry point carries its own copy of the class, so a price
490
+ * refused inside `thunder-bridge/price` is not `instanceof` the `AmountError`
491
+ * imported from `thunder-bridge`. The name and the code are the same in every
492
+ * copy, so this holds where `instanceof` does not
493
+ */
494
+ static is(failure) {
495
+ return failure instanceof Error && failure.name === "AmountError" && "code" in failure;
548
496
  }
549
- let difference = 0;
550
- for (let at = 0; at < one.length; at++) {
551
- difference |= one.charCodeAt(at) ^ other.charCodeAt(at);
497
+ code;
498
+ constructor(code, detail) {
499
+ super(`${code}: ${detail}`);
500
+ this.name = "AmountError";
501
+ this.code = code;
552
502
  }
553
- return difference === 0;
554
- }
555
- function asBuffer(bytes) {
556
- return bytes instanceof Uint8Array && bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
503
+ };
504
+ var GatewayCheatError = class extends Error {
505
+ code;
506
+ paymentId;
507
+ constructor(code, paymentId) {
508
+ super(`gateway verification failed: ${code}`);
509
+ this.name = "GatewayCheatError";
510
+ this.code = code;
511
+ this.paymentId = paymentId;
512
+ }
513
+ };
514
+ var WrapRefusedError = class extends Error {
515
+ code;
516
+ constructor(code, detail) {
517
+ super(`wrapped invoice refused: ${code}, ${detail}`);
518
+ this.name = "WrapRefusedError";
519
+ this.code = code;
520
+ }
521
+ };
522
+ var UnverifiedRecipientError = class extends Error {
523
+ lnAddress;
524
+ paymentId;
525
+ constructor(lnAddress, paymentId, cause) {
526
+ super(`could not reach ${lnAddress} to verify the invoice: ${String(cause)}`);
527
+ this.name = "UnverifiedRecipientError";
528
+ this.lnAddress = lnAddress;
529
+ this.paymentId = paymentId;
530
+ }
531
+ };
532
+ var ProblemError = class extends Error {
533
+ static NO_WALLET_AVAILABLE = "urn:problem-type:thunder-bridge:no-wallet-available";
534
+ static REQUEST_IN_FLIGHT = "urn:problem-type:thunder-bridge:request-in-flight";
535
+ static IDEMPOTENCY_KEY_REUSED = "urn:problem-type:thunder-bridge:idempotency-key-reused";
536
+ static PAYMENT_ALREADY_WATCHED = "urn:problem-type:thunder-bridge:payment-already-watched";
537
+ static INVALID_REQUEST = "urn:problem-type:thunder-bridge:invalid-request";
538
+ static CALLER_UNKNOWN = "urn:problem-type:thunder-bridge:caller-unknown";
539
+ static VERIFY_HOST_REFUSED = "urn:problem-type:thunder-bridge:verify-host-refused";
540
+ static VERIFY_UNCONFIRMED = "urn:problem-type:thunder-bridge:verify-unconfirmed";
541
+ static VERIFY_UNCONSENTED = "urn:problem-type:thunder-bridge:verify-unconsented";
542
+ static WEBHOOK_UNCONFIRMED = "urn:problem-type:thunder-bridge:webhook-unconfirmed";
543
+ static TOO_MANY_PENDING = "urn:problem-type:thunder-bridge:too-many-pending";
544
+ /**
545
+ * Whether a problem carries this type. Branch on the type, never on the prose,
546
+ * and reach for `instanceof` first: every type this SDK gives a class to has one
547
+ */
548
+ static is(problem, type) {
549
+ return problem.type === type;
550
+ }
551
+ type;
552
+ title;
553
+ status;
554
+ detail;
555
+ constructor(problem) {
556
+ const title = problem.title ?? "Request failed";
557
+ super(problem.detail ? `${title}: ${problem.detail}` : title);
558
+ this.name = "ProblemError";
559
+ this.type = problem.type ?? "about:blank";
560
+ this.title = title;
561
+ this.status = problem.status ?? 0;
562
+ this.detail = problem.detail ?? null;
563
+ }
564
+ };
565
+ function isProblemType(problem, type) {
566
+ return problem.type === type;
557
567
  }
568
+ var NO_WALLET_AVAILABLE = "urn:problem-type:thunder-bridge:no-wallet-available";
569
+ var REQUEST_IN_FLIGHT = "urn:problem-type:thunder-bridge:request-in-flight";
570
+ var IDEMPOTENCY_KEY_REUSED = "urn:problem-type:thunder-bridge:idempotency-key-reused";
571
+ var PAYMENT_ALREADY_WATCHED = "urn:problem-type:thunder-bridge:payment-already-watched";
572
+ var IdempotencyConflictError = class extends ProblemError {
573
+ conflict;
574
+ constructor(problem, conflict) {
575
+ super(problem);
576
+ this.name = "IdempotencyConflictError";
577
+ this.conflict = conflict;
578
+ }
579
+ };
580
+ var NoWalletAvailableError = class extends ProblemError {
581
+ wallets;
582
+ constructor(problem, wallets) {
583
+ super({ ...problem, type: NO_WALLET_AVAILABLE });
584
+ this.name = "NoWalletAvailableError";
585
+ this.wallets = wallets;
586
+ }
587
+ };
558
588
 
559
589
  // src/currency.ts
560
590
  var ISO_4217_MINOR_UNITS = {
@@ -588,7 +618,8 @@ var ISO_4217_MINOR_UNITS = {
588
618
  function minorUnitsOf(currency) {
589
619
  const digits = ISO_4217_MINOR_UNITS[currency.toUpperCase()];
590
620
  if (digits === void 0) {
591
- throw new Error(
621
+ throw new AmountError(
622
+ "unknown-currency",
592
623
  `${currency.toUpperCase()} is not a currency this knows the ISO 4217 minor unit of`
593
624
  );
594
625
  }
@@ -598,16 +629,218 @@ function minorScaleOf(currency) {
598
629
  return 10 ** minorUnitsOf(currency);
599
630
  }
600
631
 
601
- // ../core/ed25519.ts
602
- var ALGORITHM = { name: "Ed25519" };
603
- var SEED_BYTES = 32;
604
- var SIGNATURE_BYTES = 64;
605
- var PUBLIC_KEY_BYTES = 32;
606
- var PKCS8_HEADER = hexToBytes("302e020100300506032b657004220420");
607
- async function signingKeyFromSeed(seed) {
608
- if (seed.length !== SEED_BYTES) {
609
- throw new Error(`an ed25519 seed is ${SEED_BYTES} bytes`);
610
- }
632
+ // src/price.ts
633
+ var MSAT_PER_BTC2 = 1e11;
634
+ var BASIS_POINTS = 1e4;
635
+ var DEFAULT_HOLD_SECS = 60;
636
+ var DEFAULT_MIN_VENUES = 2;
637
+ var DEFAULT_MAX_SPREAD_BPS = 200;
638
+ var MILLIS = 1e3;
639
+ function coinbase(baseUrl = "https://api.coinbase.com") {
640
+ return async (currency) => {
641
+ const asked = currency.toUpperCase();
642
+ const answer = await asJson(`${baseUrl}/v2/prices/BTC-${asked}/spot`, "coinbase");
643
+ const quoted2 = answer.data;
644
+ if (quoted2?.currency !== asked) {
645
+ throw new Error(`coinbase quoted ${quoted2?.currency} when asked for ${asked}`);
646
+ }
647
+ return asMinor(quoted2.amount, "coinbase", asked);
648
+ };
649
+ }
650
+ function kraken(baseUrl = "https://api.kraken.com") {
651
+ return async (currency) => {
652
+ const asked = currency.toUpperCase();
653
+ const answer = await asJson(`${baseUrl}/0/public/Ticker?pair=XBT${asked}`, "kraken");
654
+ if (answer.error?.length) {
655
+ throw new Error(`kraken refused XBT${asked}: ${answer.error[0]}`);
656
+ }
657
+ const pairs = Object.values(answer.result ?? {});
658
+ if (pairs.length !== 1) {
659
+ throw new Error(`kraken answered ${pairs.length} pairs for XBT${asked}`);
660
+ }
661
+ return asMinor(pairs[0]?.c?.[0], "kraken", asked);
662
+ };
663
+ }
664
+ function bitstamp(baseUrl = "https://www.bitstamp.net") {
665
+ return async (currency) => {
666
+ const pair = `btc${currency.toLowerCase()}`;
667
+ const answer = await asJson(`${baseUrl}/api/v2/ticker/${pair}/`, "bitstamp");
668
+ if (Array.isArray(answer)) {
669
+ throw new Error(`bitstamp has no ${pair} pair`);
670
+ }
671
+ return asMinor(answer.last, "bitstamp", currency);
672
+ };
673
+ }
674
+ function coinmate(baseUrl = "https://coinmate.io") {
675
+ return async (currency) => {
676
+ const pair = `BTC_${currency.toUpperCase()}`;
677
+ const answer = await asJson(`${baseUrl}/api/ticker?currencyPair=${pair}`, "coinmate");
678
+ if (answer.error !== false) {
679
+ throw new Error(`coinmate refused ${pair}: ${answer.errorMessage ?? "no reason"}`);
680
+ }
681
+ return asMinor(answer.data?.last, "coinmate", currency);
682
+ };
683
+ }
684
+ function medianOf(tickers = [coinbase(), kraken(), bitstamp(), coinmate()], options = {}) {
685
+ const holdFor = (options.holdForSecs ?? DEFAULT_HOLD_SECS) * MILLIS;
686
+ const minVenues = options.minVenues ?? DEFAULT_MIN_VENUES;
687
+ const maxSpread = options.maxSpreadBps ?? DEFAULT_MAX_SPREAD_BPS;
688
+ const held = /* @__PURE__ */ new Map();
689
+ return async (currency) => {
690
+ const asked = currency.toUpperCase();
691
+ const remembered = held.get(asked);
692
+ if (remembered && Date.now() - remembered.at < holdFor) {
693
+ return remembered.price;
694
+ }
695
+ const answers = await Promise.allSettled(tickers.map((ticker) => ticker(asked)));
696
+ const quoted2 = answers.filter((answer) => answer.status === "fulfilled").map((answer) => answer.value).sort((one, other) => one - other);
697
+ if (quoted2.length < minVenues) {
698
+ throw new Error(
699
+ `${quoted2.length} of ${tickers.length} venues quoted ${asked}, ${minVenues} wanted: ${refusals(answers)}`
700
+ );
701
+ }
702
+ const spread2 = spreadBpsOf(quoted2);
703
+ if (spread2 > maxSpread) {
704
+ throw new Error(
705
+ `venues disagree on ${asked} by ${Math.round(spread2)} bps, more than the ${maxSpread} allowed: ${quoted2.join(", ")}`
706
+ );
707
+ }
708
+ const price = middleOf(quoted2);
709
+ held.set(asked, { at: Date.now(), price });
710
+ return price;
711
+ };
712
+ }
713
+ function msatFor(amountMinor, priceMinorPerBtc, options = {}) {
714
+ if (!Number.isInteger(amountMinor) || amountMinor <= 0) {
715
+ throw new Error("amountMinor must be a whole number of minor units above zero");
716
+ }
717
+ if (!Number.isFinite(priceMinorPerBtc) || priceMinorPerBtc <= 0) {
718
+ throw new Error(`${priceMinorPerBtc} is not a usable price`);
719
+ }
720
+ const spreadBps = options.spreadBps ?? 0;
721
+ if (!Number.isInteger(spreadBps) || spreadBps < 0) {
722
+ throw new Error("spreadBps must be a whole number of basis points, none or more");
723
+ }
724
+ const wanted = BigInt(amountMinor) * BigInt(MSAT_PER_BTC2) * BigInt(BASIS_POINTS + spreadBps);
725
+ const per = BigInt(Math.round(priceMinorPerBtc)) * BigInt(BASIS_POINTS);
726
+ const msat2 = (wanted + per - 1n) / per;
727
+ if (msat2 > BigInt(Number.MAX_SAFE_INTEGER)) {
728
+ throw new Error(`${amountMinor} at ${priceMinorPerBtc} is more millisatoshi than exist`);
729
+ }
730
+ return Number(msat2);
731
+ }
732
+ async function asJson(url, venue) {
733
+ const answer = await fetch(url, { headers: { accept: "application/json" } });
734
+ if (!answer.ok) {
735
+ throw new Error(`${venue} answered ${answer.status}`);
736
+ }
737
+ return answer.json();
738
+ }
739
+ function asMinor(quoted2, venue, currency) {
740
+ const major2 = Number(quoted2);
741
+ if (!Number.isFinite(major2) || major2 <= 0) {
742
+ throw new Error(`${venue} quoted ${String(quoted2)} for ${currency.toUpperCase()}`);
743
+ }
744
+ return Math.round(major2 * minorScaleOf(currency));
745
+ }
746
+ function spreadBpsOf(sorted) {
747
+ const lowest = sorted[0] ?? 0;
748
+ const highest = sorted[sorted.length - 1] ?? 0;
749
+ return lowest === 0 ? Number.POSITIVE_INFINITY : (highest - lowest) / lowest * BASIS_POINTS;
750
+ }
751
+ function middleOf(sorted) {
752
+ const middle = Math.floor(sorted.length / 2);
753
+ if (sorted.length % 2 === 1) {
754
+ return sorted[middle] ?? 0;
755
+ }
756
+ return Math.round(((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2);
757
+ }
758
+ function refusals(answers) {
759
+ return answers.filter((answer) => answer.status === "rejected").map(
760
+ (answer) => answer.reason instanceof Error ? answer.reason.message : String(answer.reason)
761
+ ).join("; ");
762
+ }
763
+
764
+ // src/amount.ts
765
+ var MSAT_PER_SAT = 1e3;
766
+ var MAX_SATS = Math.floor(Number.MAX_SAFE_INTEGER / MSAT_PER_SAT);
767
+ function sats(whole) {
768
+ if (!Number.isInteger(whole) || whole < 1 || whole > MAX_SATS) {
769
+ throw new AmountError("not-whole-satoshi", `${whole} is not a payable count of satoshi`);
770
+ }
771
+ return whole * MSAT_PER_SAT;
772
+ }
773
+ function msat(exact) {
774
+ if (!Number.isSafeInteger(exact) || exact < 1) {
775
+ throw new AmountError(
776
+ "not-whole-millisatoshi",
777
+ `${exact} is not a payable count of millisatoshi`
778
+ );
779
+ }
780
+ return exact;
781
+ }
782
+ function fiat(major2, currency, options) {
783
+ const amountMinor = minorFrom(major2, currency);
784
+ const rate = options?.rate ?? medianOf();
785
+ return async () => {
786
+ const wanted = msatFor(amountMinor, await rate(currency), { spreadBps: options?.spreadBps });
787
+ return sats(Math.ceil(wanted / MSAT_PER_SAT));
788
+ };
789
+ }
790
+ async function amountNow(amount) {
791
+ return typeof amount === "function" ? msat(await amount()) : msat(amount);
792
+ }
793
+ function minorFrom(major2, currency) {
794
+ const digits = minorUnitsOf(currency);
795
+ if (typeof major2 === "number") {
796
+ return payable(Math.round(major2 * minorScaleOf(currency)), major2, currency);
797
+ }
798
+ const parsed = /^(\d+)(?:[.,](\d+))?$/.exec(major2.trim());
799
+ if (parsed === null) {
800
+ throw new AmountError(
801
+ "not-a-decimal",
802
+ `${major2} is not a decimal ${currency.toUpperCase()} amount`
803
+ );
804
+ }
805
+ const fraction = parsed[2] ?? "";
806
+ if (fraction.length > digits) {
807
+ throw new AmountError(
808
+ "too-precise",
809
+ `${major2} carries more decimals than ${currency.toUpperCase()} has, which is ${digits}`
810
+ );
811
+ }
812
+ return payable(Number(`${parsed[1]}${fraction.padEnd(digits, "0")}`), major2, currency);
813
+ }
814
+ function payable(minor, major2, currency) {
815
+ if (!Number.isSafeInteger(minor) || minor < 1) {
816
+ throw new AmountError(
817
+ "not-a-decimal",
818
+ `${major2} is not a ${currency.toUpperCase()} price this can charge`
819
+ );
820
+ }
821
+ return minor;
822
+ }
823
+
824
+ // ../core/ed25519.ts
825
+ var ALGORITHM = { name: "Ed25519" };
826
+ var SEED_BYTES = 32;
827
+ var SIGNATURE_BYTES = 64;
828
+ var PUBLIC_KEY_BYTES = 32;
829
+ var PKCS8_HEADER = hexToBytes("302e020100300506032b657004220420");
830
+ var SIGN_BIT_CLEARED = 127;
831
+ var SMALL_ORDER = [
832
+ "00".repeat(32),
833
+ `01${"00".repeat(31)}`,
834
+ "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05",
835
+ "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a",
836
+ `ec${"ff".repeat(30)}7f`,
837
+ `ed${"ff".repeat(30)}7f`,
838
+ `ee${"ff".repeat(30)}7f`
839
+ ];
840
+ async function signingKeyFromSeed(seed) {
841
+ if (seed.length !== SEED_BYTES) {
842
+ throw new Error(`an ed25519 seed is ${SEED_BYTES} bytes`);
843
+ }
611
844
  const wrapped = new Uint8Array(PKCS8_HEADER.length + SEED_BYTES);
612
845
  wrapped.set(PKCS8_HEADER);
613
846
  wrapped.set(seed, PKCS8_HEADER.length);
@@ -622,6 +855,9 @@ async function verifyHex(publicKeyHex, signatureHex, payload) {
622
855
  if (!isHex(publicKeyHex) || publicKeyHex.length !== PUBLIC_KEY_BYTES * 2) {
623
856
  return false;
624
857
  }
858
+ if (hasSmallOrder(publicKeyHex)) {
859
+ return false;
860
+ }
625
861
  if (!isHex(signatureHex) || signatureHex.length !== SIGNATURE_BYTES * 2) {
626
862
  return false;
627
863
  }
@@ -643,8 +879,13 @@ async function verifyHex(publicKeyHex, signatureHex, payload) {
643
879
  return false;
644
880
  }
645
881
  }
646
- function fromBase64Url2(text2) {
647
- const padded = text2.replace(/-/g, "+").replace(/_/g, "/");
882
+ function hasSmallOrder(publicKeyHex) {
883
+ const point = hexToBytes(publicKeyHex);
884
+ point[PUBLIC_KEY_BYTES - 1] = point[PUBLIC_KEY_BYTES - 1] & SIGN_BIT_CLEARED;
885
+ return SMALL_ORDER.includes(bytesToHex(point));
886
+ }
887
+ function fromBase64Url2(text3) {
888
+ const padded = text3.replace(/-/g, "+").replace(/_/g, "/");
648
889
  const raw = atob(padded + "=".repeat((4 - padded.length % 4) % 4));
649
890
  const bytes = new Uint8Array(raw.length);
650
891
  for (let at = 0; at < raw.length; at++) {
@@ -656,135 +897,766 @@ function asBuffer2(bytes) {
656
897
  return bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
657
898
  }
658
899
 
659
- // src/wire.ts
660
- var ASSET_CODE = "BTC";
661
- var ASSET_SCALE = 11;
662
- var REASONS = /* @__PURE__ */ new Set([
663
- "address-unusable",
664
- "unreachable",
665
- "amount-not-accepted",
666
- "cannot-prove-delivery",
667
- "invoice-refused"
668
- ]);
669
- function createRequestBody(params, trigger, replay) {
670
- return JSON.stringify({
671
- ln_addresses: params.lnAddresses,
672
- incoming_amount: toAmount(params.amountMsat),
673
- webhook: params.webhookUrl ? { url: params.webhookUrl } : void 0,
674
- trigger: trigger ?? void 0,
675
- replay
676
- });
900
+ // ../core/caller.ts
901
+ var KEY_HEADER = "x-client-key";
902
+ var SIGNATURE_HEADER = "x-signature";
903
+ var TIMESTAMP_HEADER = "x-timestamp";
904
+ var PREFIX = "ed25519=";
905
+ var SIGNING_LABEL = "request-signing";
906
+ async function callerKey(secret) {
907
+ return await signingKeyFromSeed(hexToBytes(await hmacHex(secret, SIGNING_LABEL)));
677
908
  }
678
- function quoteRequestBody(params) {
679
- return JSON.stringify({
680
- ln_addresses: params.lnAddresses,
681
- amount: toAmount(params.amountMsat)
682
- });
909
+ function paymentNamedBy(caller, paymentHash) {
910
+ return sha256Hex(`${caller}.payment-id.${paymentHash}`);
683
911
  }
684
- function quoteFromWire(body) {
685
- const wire = asObject(body);
686
- if (wire === null) {
687
- return null;
912
+ async function signedAs(key, method, path, body) {
913
+ const timestamp = String(Math.floor(Date.now() / 1e3));
914
+ return {
915
+ [KEY_HEADER]: key.publicKeyHex,
916
+ [TIMESTAMP_HEADER]: timestamp,
917
+ [SIGNATURE_HEADER]: `${PREFIX}${await key.sign(spoken(timestamp, method, path, body))}`
918
+ };
919
+ }
920
+ function spoken(timestamp, method, path, body) {
921
+ return new TextEncoder().encode(
922
+ `${timestamp}.${method.toUpperCase()}.${path}.${sha256Hex(body)}`
923
+ );
924
+ }
925
+
926
+ // src/charge.ts
927
+ async function priced(charge) {
928
+ const paidTo = typeof charge.paidTo === "string" ? [charge.paidTo] : [...charge.paidTo];
929
+ if (paidTo.length === 0) {
930
+ throw new Error("name at least one lightning address to pay");
688
931
  }
689
- const lnAddress = text(wire["ln_address"]);
690
- const amountMsat = msatFrom(wire["amount"], 1);
691
- const feeMsat = msatFrom(wire["fee"], 0);
692
- const minMsat = msatFrom(wire["min_amount"], 0);
693
- const maxMsat = msatFrom(wire["max_amount"], 0);
694
- const metadata = text(wire["metadata"]);
695
- const refusals3 = refusalsFrom(wire["refusals"]);
696
- if (lnAddress === null || amountMsat === null || feeMsat === null) {
697
- return null;
932
+ return { paidTo, amountMsat: await amountNow(charge.amount) };
933
+ }
934
+
935
+ // ../core/url.ts
936
+ var PRIVATE_SUFFIXES = ["localhost", "local", "internal", "lan", "arpa", "test", "invalid"];
937
+ function publicHttps(raw) {
938
+ let url;
939
+ try {
940
+ url = new URL(raw);
941
+ } catch {
942
+ return false;
698
943
  }
699
- if (minMsat === null || maxMsat === null || metadata === null || refusals3 === null) {
700
- return null;
944
+ return url.protocol === "https:" && hostIsPublic(url.hostname.toLowerCase());
945
+ }
946
+ function sameOrigin(one, other) {
947
+ try {
948
+ return new URL(one).origin === new URL(other).origin;
949
+ } catch {
950
+ return false;
701
951
  }
702
- return { lnAddress, amountMsat, feeMsat, minMsat, maxMsat, metadata, refusals: refusals3 };
703
952
  }
704
- function paymentFromWire(body) {
705
- const wire = asObject(body);
706
- if (wire === null) {
707
- return null;
953
+ function publicAddress(literal) {
954
+ return literal.includes(":") ? ipv6IsGlobal(literal) : ipv4IsGlobal(literal);
955
+ }
956
+ function hostIsPublic(host) {
957
+ if (host.startsWith("[")) {
958
+ return publicAddress(host.slice(1, -1));
708
959
  }
709
- const id = text(wire["id"]);
710
- const lnAddress = text(wire["ln_address"]);
711
- const bolt11 = text(wire["bolt11"]);
712
- const paymentHash = text(wire["payment_hash"]);
713
- const verifyUrl = text(wire["verify_url"]);
714
- const amountMsat = msatFrom(wire["incoming_amount"], 1);
715
- const expiresAt = secondsFrom(wire["expires_at"]);
716
- const createdAt = secondsFrom(wire["created_at"]);
717
- const status = wire["status"];
718
- const preimage = wire["preimage"] ?? null;
719
- if (id === null || lnAddress === null || bolt11 === null) {
720
- return null;
960
+ const name = host.replace(/\.$/, "");
961
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(name)) {
962
+ return publicAddress(name);
721
963
  }
722
- if (paymentHash === null || verifyUrl === null) {
723
- return null;
964
+ const labels = name.split(".");
965
+ if (labels.length < 2 || labels.some((label) => label === "")) {
966
+ return false;
724
967
  }
725
- if (amountMsat === null || expiresAt === null || createdAt === null) {
726
- return null;
968
+ return !PRIVATE_SUFFIXES.includes(labels[labels.length - 1]);
969
+ }
970
+ function ipv4IsGlobal(literal) {
971
+ const parts = literal.split(".").map(Number);
972
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
973
+ return false;
727
974
  }
728
- if (!isStatus(status)) {
729
- return null;
975
+ const [a, b, c] = parts;
976
+ if (a === 0 || a === 10 || a === 127 || a >= 224) {
977
+ return false;
730
978
  }
731
- if (preimage !== null && typeof preimage !== "string") {
732
- return null;
979
+ if (a === 100 && b >= 64 && b < 128) {
980
+ return false;
733
981
  }
734
- return {
735
- id,
736
- lnAddress,
737
- amountMsat,
738
- status,
739
- paymentHash,
740
- bolt11,
741
- preimage,
742
- expiresAt,
743
- createdAt,
744
- verifyUrl
745
- };
746
- }
747
- function watchRequestBody(params, trigger) {
748
- const expiresAt = new Date(params.expiresAt * 1e3);
749
- if (Number.isNaN(expiresAt.getTime())) {
750
- throw new TypeError(`expiresAt must be a usable unix time in seconds, got ${params.expiresAt}`);
982
+ if (a === 169 && b === 254) {
983
+ return false;
984
+ }
985
+ if (a === 172 && b >= 16 && b < 32) {
986
+ return false;
987
+ }
988
+ if (a === 192 && (b === 168 || b === 0 && (c === 0 || c === 2))) {
989
+ return false;
990
+ }
991
+ if (a === 198 && (b === 18 || b === 19 || b === 51 && c === 100)) {
992
+ return false;
993
+ }
994
+ if (a === 203 && b === 0 && c === 113) {
995
+ return false;
996
+ }
997
+ return true;
998
+ }
999
+ function ipv6IsGlobal(literal) {
1000
+ const groups = ipv6Groups(literal.toLowerCase());
1001
+ if (groups.length !== 8 || groups.some(Number.isNaN)) {
1002
+ return false;
1003
+ }
1004
+ const [first, second, third, fourth, fifth, sixth, high, low] = groups;
1005
+ const embedded = (one, other) => ipv4IsGlobal(`${one >> 8}.${one & 255}.${other >> 8}.${other & 255}`);
1006
+ const zeroBefore = (count) => groups.slice(0, count).every((group) => group === 0);
1007
+ if (zeroBefore(5) && sixth === 65535) {
1008
+ return embedded(high, low);
1009
+ }
1010
+ if (zeroBefore(6) || zeroBefore(4) && fifth === 65535 && sixth === 0) {
1011
+ return false;
1012
+ }
1013
+ if (first === 100 && second === 65435) {
1014
+ return third === 0 && fourth === 0 && fifth === 0 && sixth === 0 && embedded(high, low);
1015
+ }
1016
+ if (first === 8194) {
1017
+ return embedded(second, third);
1018
+ }
1019
+ if (first === 8193 && second === 0) {
1020
+ return false;
1021
+ }
1022
+ if (first === 256 && second === 0 && third === 0 && fourth === 0) {
1023
+ return false;
1024
+ }
1025
+ if ((first & 65024) === 64512 || (first & 65280) === 65280) {
1026
+ return false;
1027
+ }
1028
+ if ((first & 65472) === 65152 || (first & 65472) === 65216) {
1029
+ return false;
751
1030
  }
1031
+ return true;
1032
+ }
1033
+ function ipv6Groups(address) {
1034
+ const halves = withDottedTailAsGroups(address).split("::");
1035
+ const [head = "", tail] = halves;
1036
+ const left = head.split(":").filter((group) => group !== "");
1037
+ const right = (tail ?? "").split(":").filter((group) => group !== "");
1038
+ const missing = 8 - left.length - right.length;
1039
+ if (halves.length > 2 || missing < 0) {
1040
+ return [Number.NaN];
1041
+ }
1042
+ const gap = tail === void 0 ? [] : Array(missing).fill("0");
1043
+ return [...left, ...gap, ...right].map(
1044
+ (group) => /^[0-9a-f]{1,4}$/.test(group) ? parseInt(group, 16) : Number.NaN
1045
+ );
1046
+ }
1047
+ function withDottedTailAsGroups(address) {
1048
+ const dotted = /^(.*:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(address);
1049
+ if (dotted === null) {
1050
+ return address;
1051
+ }
1052
+ const [a, b, c, d] = dotted[2].split(".").map(Number);
1053
+ if ([a, b, c, d].some((part) => part > 255)) {
1054
+ return `${dotted[1]}x`;
1055
+ }
1056
+ return `${dotted[1]}${(a << 8 | b).toString(16)}:${(c << 8 | d).toString(16)}`;
1057
+ }
1058
+
1059
+ // ../core/outbound.ts
1060
+ var HTTP_TIMEOUT_MS = 15e3;
1061
+ var LOOKUP_TIMEOUT_MS = 5e3;
1062
+ var HOPS_FOLLOWED = 2;
1063
+ var REDIRECTS = [301, 302, 303, 307, 308];
1064
+ var KEEPS_THE_METHOD = [307, 308];
1065
+ var CREDENTIALS = ["authorization", "cookie", "proxy-authorization"];
1066
+ var BODY_LIMIT_BYTES = 262144;
1067
+ async function ask(send, url, sent = {}) {
1068
+ const capped = AbortSignal.timeout(HTTP_TIMEOUT_MS);
1069
+ const signal = sent.deadline ? AbortSignal.any([sent.deadline, capped]) : capped;
1070
+ let target = url;
1071
+ let carried = sent;
1072
+ for (let hop = 0; hop <= HOPS_FOLLOWED; hop += 1) {
1073
+ const at = await addressesToReach(target);
1074
+ const response = await send(target, carried, signal, at);
1075
+ if (!REDIRECTS.includes(response.status)) {
1076
+ return await answerOf(response);
1077
+ }
1078
+ const location = response.headers.get("location");
1079
+ if (location === null) {
1080
+ throw new Error(`${target} answered ${response.status} without saying where to`);
1081
+ }
1082
+ await response.body?.cancel();
1083
+ const next = new URL(location, target).toString();
1084
+ if (sent.staysOnOrigin && !sameOrigin(target, next)) {
1085
+ throw new Error(`${target} redirected off its own origin, to ${next}`);
1086
+ }
1087
+ carried = KEEPS_THE_METHOD.includes(response.status) ? carried : withoutBody(carried);
1088
+ carried = sameOrigin(target, next) ? carried : withoutCredentials(carried);
1089
+ target = next;
1090
+ }
1091
+ throw new Error(`${url} redirected more than ${HOPS_FOLLOWED} times`);
1092
+ }
1093
+ async function addressesToReach(url) {
1094
+ if (!publicHttps(url)) {
1095
+ throw new Error(`${url} is not a public https URL`);
1096
+ }
1097
+ const found = await resolved(url);
1098
+ if (found.length === 0 || !found.every((one) => publicAddress(one.address))) {
1099
+ throw new Error(`${url} resolves to an address we do not reach`);
1100
+ }
1101
+ return found;
1102
+ }
1103
+ async function resolved(url) {
1104
+ const { lookup } = await import("dns/promises");
1105
+ const { setTimeout: sleep } = await import("timers/promises");
1106
+ const host = new URL(url).hostname.replace(/^\[|]$/g, "");
1107
+ const found = await Promise.race([
1108
+ lookup(host, { all: true, verbatim: true }).catch(() => []),
1109
+ sleep(LOOKUP_TIMEOUT_MS, null, { ref: false })
1110
+ ]);
1111
+ return found ?? [];
1112
+ }
1113
+ async function answerOf(response) {
1114
+ const { status, ok, headers } = response;
1115
+ const reader = response.body?.getReader();
1116
+ if (!reader) {
1117
+ return { status, ok, body: "", truncated: false, headers };
1118
+ }
1119
+ const read = [];
1120
+ let bytes = 0;
1121
+ while (bytes <= BODY_LIMIT_BYTES) {
1122
+ const { done, value } = await reader.read();
1123
+ if (done) {
1124
+ return { status, ok, body: text(read), truncated: false, headers };
1125
+ }
1126
+ read.push(value);
1127
+ bytes += value.length;
1128
+ }
1129
+ await reader.cancel();
1130
+ return { status, ok, body: text(read), truncated: true, headers };
1131
+ }
1132
+ function withoutBody(sent) {
1133
+ const headers = Object.fromEntries(
1134
+ Object.entries(sent.headers ?? {}).filter(([name]) => name.toLowerCase() !== "content-type")
1135
+ );
1136
+ return { ...sent, method: "GET", headers, body: void 0 };
1137
+ }
1138
+ function withoutCredentials(sent) {
1139
+ const headers = Object.fromEntries(
1140
+ Object.entries(sent.headers ?? {}).filter(
1141
+ ([name]) => !CREDENTIALS.includes(name.toLowerCase())
1142
+ )
1143
+ );
1144
+ return { ...sent, headers };
1145
+ }
1146
+ function text(chunks) {
1147
+ const decoder = new TextDecoder();
1148
+ let decoded2 = "";
1149
+ for (const chunk of chunks) {
1150
+ decoded2 += decoder.decode(chunk, { stream: true });
1151
+ }
1152
+ return decoded2 + decoder.decode();
1153
+ }
1154
+
1155
+ // ../core/refusal.ts
1156
+ var WalletRefused = class extends Error {
1157
+ reason;
1158
+ constructor(reason, message) {
1159
+ super(message);
1160
+ this.reason = reason;
1161
+ }
1162
+ };
1163
+ var NoWalletAvailable = class extends Error {
1164
+ wallets;
1165
+ constructor(wallets) {
1166
+ super(wallets.map((wallet) => `${wallet.address} ${wallet.reason}`).join(", "));
1167
+ this.wallets = wallets;
1168
+ }
1169
+ };
1170
+
1171
+ // ../core/lnurl.ts
1172
+ var VERIFY_WITHOUT_PREIMAGE = ["zeuspay.com", "zeusnuts.com", "ecash.love"];
1173
+ var RESOLVE_TIMEOUT_MS = 3e4;
1174
+ var NAMES_ONE_ACCOUNT = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$/;
1175
+ var MIN_PACE_SECS = 1;
1176
+ var MAX_PACE_SECS = 3600;
1177
+ var MIN_PER_SECOND = 0.01;
1178
+ var MAX_PER_SECOND = 100;
1179
+ async function resolve(send, addresses, amountMsat) {
1180
+ const served = await firstThatServes(
1181
+ addresses,
1182
+ (address, deadline) => resolveAddress(send, address, amountMsat, deadline)
1183
+ );
1184
+ return served.won;
1185
+ }
1186
+ async function quote(send, addresses, amountMsat) {
1187
+ const served = await firstThatServes(
1188
+ addresses,
1189
+ (address2, deadline) => probe(send, address2, amountMsat, deadline)
1190
+ );
1191
+ const { address, pay } = served.won;
1192
+ return {
1193
+ won: {
1194
+ address,
1195
+ minMsat: pay.minSendable,
1196
+ maxMsat: pay.maxSendable,
1197
+ metadata: pay.metadata
1198
+ },
1199
+ refusals: served.refusals
1200
+ };
1201
+ }
1202
+ async function firstThatServes(addresses, attempt) {
1203
+ const deadline = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
1204
+ const refusals3 = [];
1205
+ for (const address of addresses) {
1206
+ if (deadline.aborted) {
1207
+ break;
1208
+ }
1209
+ try {
1210
+ return { won: await attempt(address, deadline), refusals: refusals3 };
1211
+ } catch (refusal) {
1212
+ if (!(refusal instanceof WalletRefused)) {
1213
+ throw refusal;
1214
+ }
1215
+ console.warn(`wallet ${address} refused, ${refusal.reason}: ${refusal.message}`);
1216
+ refusals3.push({ address, reason: refusal.reason });
1217
+ }
1218
+ }
1219
+ throw new NoWalletAvailable(refusals3);
1220
+ }
1221
+ async function probe(send, address, amountMsat, deadline) {
1222
+ const [user, domain] = splitAddress(address);
1223
+ if (cannotReleaseAPreimage(domain)) {
1224
+ throw new WalletRefused("cannot-prove-delivery", `${address} never releases a preimage`);
1225
+ }
1226
+ const pay = await answered(
1227
+ send,
1228
+ `https://${domain}/.well-known/lnurlp/${user}`,
1229
+ deadline
1230
+ );
1231
+ if (pay.tag !== "payRequest" || !pay.callback) {
1232
+ throw new WalletRefused("unreachable", `${address} answered with no payRequest`);
1233
+ }
1234
+ if (amountMsat < pay.minSendable || amountMsat > pay.maxSendable) {
1235
+ throw new WalletRefused(
1236
+ "amount-not-accepted",
1237
+ `${address} takes [${pay.minSendable}, ${pay.maxSendable}] msat, not ${amountMsat}`
1238
+ );
1239
+ }
1240
+ return { address, pay };
1241
+ }
1242
+ async function resolveAddress(send, address, amountMsat, deadline) {
1243
+ const { pay } = await probe(send, address, amountMsat, deadline);
1244
+ const invoice = await answered(
1245
+ send,
1246
+ withAmount(pay.callback, amountMsat),
1247
+ deadline
1248
+ );
1249
+ if (!invoice.pr) {
1250
+ throw new WalletRefused("unreachable", `${address} returned no invoice`);
1251
+ }
1252
+ if (!invoice.verify || !publicHttps(invoice.verify)) {
1253
+ throw new WalletRefused(
1254
+ "cannot-prove-delivery",
1255
+ `${address} publishes no usable LUD-21 verify URL`
1256
+ );
1257
+ }
1258
+ const issued = decodeIssued(address, invoice.pr);
1259
+ if (issued.amountMsat !== amountMsat) {
1260
+ throw new WalletRefused(
1261
+ "invoice-refused",
1262
+ `${address} issued an invoice for ${issued.amountMsat} msat, not ${amountMsat}`
1263
+ );
1264
+ }
1265
+ if (issued.descriptionHash !== sha256Hex(pay.metadata)) {
1266
+ throw new WalletRefused(
1267
+ "invoice-refused",
1268
+ `${address} does not bind its invoice to the metadata it serves`
1269
+ );
1270
+ }
1271
+ return {
1272
+ address,
1273
+ bolt11: invoice.pr,
1274
+ verifyUrl: invoice.verify,
1275
+ paymentHash: issued.paymentHash,
1276
+ expiresAt: issued.expiresAt
1277
+ };
1278
+ }
1279
+ async function answered(send, url, deadline) {
1280
+ try {
1281
+ return await fetchJson(send, url, deadline);
1282
+ } catch (cause) {
1283
+ throw new WalletRefused("unreachable", String(cause));
1284
+ }
1285
+ }
1286
+ function decodeIssued(address, bolt11) {
1287
+ const { paymentHash, descriptionHash, amountMsat, expiresAt } = decodeInvoice(bolt11);
1288
+ if (paymentHash === null || expiresAt === null) {
1289
+ throw new WalletRefused("invoice-refused", `${address} issued an invoice we cannot decode`);
1290
+ }
1291
+ return { paymentHash, descriptionHash, amountMsat, expiresAt };
1292
+ }
1293
+ async function checkSettled(send, verifyUrl, paymentHash) {
1294
+ const answer = await answeredJson(send, verifyUrl, { staysOnOrigin: true });
1295
+ const asked = {
1296
+ pace: paceAskedFor(answer.headers),
1297
+ ceiling: ceilingAskedFor(answer.headers)
1298
+ };
1299
+ if (!answer.said.settled || !answer.said.preimage) {
1300
+ return { preimage: null, ...asked };
1301
+ }
1302
+ if (!preimageMatchesHash(answer.said.preimage, paymentHash)) {
1303
+ throw new Error(`verify returned a preimage that does not hash to ${paymentHash}`);
1304
+ }
1305
+ return { preimage: answer.said.preimage, ...asked };
1306
+ }
1307
+ function paceAskedFor(headers) {
1308
+ const asked = /max-age\s*=\s*(\d+)/i.exec(headers.get("cache-control") ?? "");
1309
+ if (!asked) {
1310
+ return null;
1311
+ }
1312
+ return Math.min(Math.max(Number(asked[1]), MIN_PACE_SECS), MAX_PACE_SECS);
1313
+ }
1314
+ function ceilingAskedFor(headers) {
1315
+ const asked = /^\s*(\d+)\s*(?:;\s*w\s*=\s*(\d+))?/i.exec(headers.get("ratelimit-limit") ?? "");
1316
+ if (!asked) {
1317
+ return null;
1318
+ }
1319
+ const perWindow = Number(asked[1]);
1320
+ const window = asked[2] === void 0 ? 1 : Number(asked[2]);
1321
+ if (perWindow === 0 || window === 0) {
1322
+ return null;
1323
+ }
1324
+ return Math.min(Math.max(perWindow / window, MIN_PER_SECOND), MAX_PER_SECOND);
1325
+ }
1326
+ function cannotReleaseAPreimage(host) {
1327
+ const lowered = host.toLowerCase();
1328
+ return VERIFY_WITHOUT_PREIMAGE.some(
1329
+ (known) => lowered === known || lowered.endsWith(`.${known}`)
1330
+ );
1331
+ }
1332
+ function withAmount(callback, amountMsat) {
1333
+ const separator = callback.includes("?") ? "&" : "?";
1334
+ return `${callback}${separator}amount=${amountMsat}`;
1335
+ }
1336
+ function splitAddress(address) {
1337
+ const [user, domain, ...rest] = address.split("@");
1338
+ if (!user || !domain || rest.length > 0 || !NAMES_ONE_ACCOUNT.test(user) || !isNothingButAHost(domain) || !publicHttps(`https://${domain}/`)) {
1339
+ throw new WalletRefused("address-unusable", `${address} is not a usable lightning address`);
1340
+ }
1341
+ return [user, domain];
1342
+ }
1343
+ function isNothingButAHost(domain) {
1344
+ try {
1345
+ const parsed = new URL(`https://${domain}/`);
1346
+ return parsed.host === domain.toLowerCase() && parsed.pathname === "/" && parsed.search === "" && parsed.hash === "";
1347
+ } catch {
1348
+ return false;
1349
+ }
1350
+ }
1351
+ async function fetchJson(send, url, deadline) {
1352
+ return (await answeredJson(send, url, { deadline })).said;
1353
+ }
1354
+ async function answeredJson(send, url, sent) {
1355
+ const answer = await ask(send, url, { headers: { accept: "application/json" }, ...sent });
1356
+ if (!answer.ok) {
1357
+ throw new Error(`${url} answered ${answer.status}`);
1358
+ }
1359
+ if (answer.truncated) {
1360
+ throw new Error(`${url} answered with more than the ${BODY_LIMIT_BYTES} bytes we read`);
1361
+ }
1362
+ return { said: JSON.parse(answer.body), headers: answer.headers };
1363
+ }
1364
+
1365
+ // ../core/pinned.ts
1366
+ var HTTPS_PORT = 443;
1367
+ var CARRIES_NO_BODY = [204, 205, 304];
1368
+ var pinnedToTheAddressWeVerified = async (url, sent, signal, at) => {
1369
+ const asked = new URL(url);
1370
+ const first = at[0];
1371
+ if (first === void 0) {
1372
+ throw new Error(`${url} resolved to nothing worth connecting to`);
1373
+ }
1374
+ const { request } = await import("https");
1375
+ const { Readable } = await import("stream");
1376
+ return new Promise((settle, fail) => {
1377
+ if (signal.aborted) {
1378
+ fail(signal.reason);
1379
+ return;
1380
+ }
1381
+ const call = request(reaching(asked, sent, at, first), (answer) => {
1382
+ const status = answer.statusCode ?? 502;
1383
+ try {
1384
+ settle(
1385
+ new Response(
1386
+ CARRIES_NO_BODY.includes(status) ? null : Readable.toWeb(answer),
1387
+ { status, headers: headersOf(answer.headers) }
1388
+ )
1389
+ );
1390
+ } catch (unreadable) {
1391
+ answer.destroy();
1392
+ fail(new Error(`${url} answered with something HTTP cannot carry`, { cause: unreadable }));
1393
+ }
1394
+ });
1395
+ const give = () => call.destroy(new Error(`${url} took longer than allowed`));
1396
+ signal.addEventListener("abort", give, { once: true });
1397
+ call.on("close", () => signal.removeEventListener("abort", give));
1398
+ call.on("error", fail);
1399
+ if (sent.body !== void 0) {
1400
+ call.write(sent.body);
1401
+ }
1402
+ call.end();
1403
+ });
1404
+ };
1405
+ function reaching(asked, sent, at, first) {
1406
+ return {
1407
+ host: asked.hostname,
1408
+ servername: asked.hostname,
1409
+ port: asked.port === "" ? HTTPS_PORT : Number(asked.port),
1410
+ path: `${asked.pathname}${asked.search}`,
1411
+ method: sent.method ?? "GET",
1412
+ headers: { ...sent.headers, host: asked.host },
1413
+ agent: false,
1414
+ lookup: (_name, options, done) => {
1415
+ if (options.all === true) {
1416
+ done(
1417
+ null,
1418
+ at.map((one) => ({ ...one }))
1419
+ );
1420
+ } else {
1421
+ done(null, first.address, first.family);
1422
+ }
1423
+ }
1424
+ };
1425
+ }
1426
+ function headersOf(raw) {
1427
+ const headers = new Headers();
1428
+ for (const [name, value] of Object.entries(raw)) {
1429
+ if (Array.isArray(value)) {
1430
+ for (const one of value) {
1431
+ headers.append(name, one);
1432
+ }
1433
+ } else if (value !== void 0) {
1434
+ headers.set(name, value);
1435
+ }
1436
+ }
1437
+ return headers;
1438
+ }
1439
+
1440
+ // src/verify.ts
1441
+ var HTTP_TIMEOUT_MS2 = 15e3;
1442
+ var ISSUED_SKEW_SECS = 300;
1443
+ var NAMES_AN_ADDRESS = ["text/identifier", "text/email"];
1444
+ var DEFAULT_WRAP_PROPORTION = 0.01;
1445
+ var DEFAULT_WRAP_BASE_MSAT = 1e3;
1446
+ async function proveOrigin(payment, asked, askedAt) {
1447
+ const cheat = (code) => new GatewayCheatError(code, payment.id);
1448
+ const listed2 = asked.paidTo.find((address) => equalIgnoringCase(address, payment.lnAddress));
1449
+ if (listed2 === void 0) {
1450
+ throw cheat("address_not_requested");
1451
+ }
1452
+ if (payment.amountMsat !== asked.amountMsat) {
1453
+ throw cheat("amount_mismatch");
1454
+ }
1455
+ const invoice = decodeInvoice(payment.bolt11);
1456
+ if (invoice.paymentHash === null || !equalIgnoringCase(invoice.paymentHash, payment.paymentHash)) {
1457
+ throw cheat("hash_mismatch");
1458
+ }
1459
+ if (invoice.amountMsat !== asked.amountMsat) {
1460
+ throw cheat("amount_mismatch");
1461
+ }
1462
+ if (askedAt !== void 0 && (invoice.issuedAt ?? 0) < askedAt - ISSUED_SKEW_SECS) {
1463
+ throw cheat("invoice_stale");
1464
+ }
1465
+ const payRequest = await payRequestFor(listed2, payment);
1466
+ if (typeof payRequest.metadata !== "string" || typeof payRequest.callback !== "string") {
1467
+ throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "no payRequest served");
1468
+ }
1469
+ if (!namesTheAddress(payRequest.metadata, listed2)) {
1470
+ throw new UnverifiedRecipientError(
1471
+ payment.lnAddress,
1472
+ payment.id,
1473
+ "its metadata names no text/identifier for this address, so no invoice binds to it"
1474
+ );
1475
+ }
1476
+ if (invoice.descriptionHash !== sha256Hex(payRequest.metadata)) {
1477
+ throw cheat("description_hash_mismatch");
1478
+ }
1479
+ if (!sameOrigin(payment.verifyUrl, payRequest.callback)) {
1480
+ throw cheat("verify_url_foreign");
1481
+ }
1482
+ const issued = await reachable(payment.verifyUrl, payment);
1483
+ if (typeof issued.pr !== "string" || !equalIgnoringCase(issued.pr, payment.bolt11)) {
1484
+ throw cheat("invoice_not_issued");
1485
+ }
1486
+ if (askedAt !== void 0 && issued.settled === true) {
1487
+ throw cheat("invoice_settled");
1488
+ }
1489
+ }
1490
+ function namesTheAddress(metadata, address) {
1491
+ try {
1492
+ const entries = JSON.parse(metadata);
1493
+ return Array.isArray(entries) && entries.some(
1494
+ (entry) => Array.isArray(entry) && NAMES_AN_ADDRESS.includes(entry[0]) && typeof entry[1] === "string" && equalIgnoringCase(entry[1], address)
1495
+ );
1496
+ } catch {
1497
+ return false;
1498
+ }
1499
+ }
1500
+ async function proveSettlement(payment, asked) {
1501
+ await proveOrigin(payment, asked);
1502
+ const verified = await reachable(payment.verifyUrl, payment);
1503
+ if (verified.settled !== true || typeof verified.preimage !== "string") {
1504
+ return null;
1505
+ }
1506
+ if (!preimageMatchesHash(verified.preimage, payment.paymentHash)) {
1507
+ throw new GatewayCheatError("preimage_mismatch", payment.id);
1508
+ }
1509
+ return verified.preimage;
1510
+ }
1511
+ function carriesProof(report) {
1512
+ if (report.status !== "paid" || report.preimage === null) {
1513
+ return false;
1514
+ }
1515
+ if (report.bolt11 !== void 0 && report.bolt11 !== null) {
1516
+ const invoiceHash = decodeInvoice(report.bolt11).paymentHash;
1517
+ if (invoiceHash === null || !equalIgnoringCase(invoiceHash, report.paymentHash)) {
1518
+ return false;
1519
+ }
1520
+ }
1521
+ return preimageMatchesHash(report.preimage, report.paymentHash);
1522
+ }
1523
+ function wrapFeeCeiling(amountMsat, allowance) {
1524
+ const proportional = amountMsat * (allowance?.proportion ?? DEFAULT_WRAP_PROPORTION);
1525
+ return msat(Math.max(allowance?.baseMsat ?? DEFAULT_WRAP_BASE_MSAT, Math.floor(proportional)));
1526
+ }
1527
+ function proveWrapped(wrapped, recipient, allowance) {
1528
+ const offered = decodeInvoice(wrapped);
1529
+ const owed = decodeInvoice(recipient);
1530
+ const refuse2 = (code, detail) => {
1531
+ throw new WrapRefusedError(code, detail);
1532
+ };
1533
+ if (offered.paymentHash === null || offered.amountMsat === null || offered.expiresAt === null) {
1534
+ refuse2("undecodable", "the wrapped invoice carries no hash, amount or expiry");
1535
+ }
1536
+ if (owed.paymentHash === null || owed.amountMsat === null || owed.expiresAt === null) {
1537
+ refuse2("undecodable", "the recipient's invoice carries no hash, amount or expiry");
1538
+ }
1539
+ if (offered.paymentHash?.toLowerCase() !== owed.paymentHash?.toLowerCase()) {
1540
+ refuse2("hash_mismatch", "settling the wrap would not settle the recipient");
1541
+ }
1542
+ const asked = offered.amountMsat ?? 0;
1543
+ const due = owed.amountMsat ?? 0;
1544
+ if (asked < due) {
1545
+ refuse2("amount_below_recipient", `${asked} msat cannot forward ${due} msat`);
1546
+ }
1547
+ const ceiling = wrapFeeCeiling(due, allowance);
1548
+ if (asked - due > ceiling) {
1549
+ refuse2("fee_above_allowance", `${asked - due} msat over ${due}, and ${ceiling} was allowed`);
1550
+ }
1551
+ if ((owed.expiresAt ?? 0) <= (offered.expiresAt ?? 0)) {
1552
+ refuse2("recipient_expires_first", "the operator could be paid and find it cannot deliver");
1553
+ }
1554
+ }
1555
+ async function payRequestFor(listed2, payment) {
1556
+ const at = listed2.indexOf("@");
1557
+ const name = listed2.slice(0, at);
1558
+ const domain = listed2.slice(at + 1).toLowerCase();
1559
+ if (at < 1 || !publicHttps(`https://${domain}/`)) {
1560
+ throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "not a usable address");
1561
+ }
1562
+ return reachable(`https://${domain}/.well-known/lnurlp/${name}`, payment);
1563
+ }
1564
+ async function reachable(url, payment) {
1565
+ try {
1566
+ if (!publicHttps(url)) {
1567
+ throw new Error(`${url} is not a public https URL`);
1568
+ }
1569
+ const response = await fetch(url, {
1570
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS2),
1571
+ headers: { accept: "application/json" }
1572
+ });
1573
+ if (response.redirected && !sameOrigin(response.url, url)) {
1574
+ throw new Error(`redirected off its own origin, to ${response.url}`);
1575
+ }
1576
+ if (!response.ok) {
1577
+ throw new Error(`answered ${response.status}`);
1578
+ }
1579
+ const body = JSON.parse(await boundedText(response));
1580
+ if (body === null || typeof body !== "object") {
1581
+ throw new Error("answered with no JSON object");
1582
+ }
1583
+ return body;
1584
+ } catch (cause) {
1585
+ throw new UnverifiedRecipientError(payment.lnAddress, payment.id, cause);
1586
+ }
1587
+ }
1588
+ async function boundedText(response) {
1589
+ const reader = response.body?.getReader();
1590
+ const decoder = new TextDecoder();
1591
+ let text3 = "";
1592
+ let bytes = 0;
1593
+ for (let chunk = await reader?.read(); chunk && !chunk.done; chunk = await reader?.read()) {
1594
+ bytes += chunk.value.length;
1595
+ if (bytes > BODY_LIMIT_BYTES) {
1596
+ await reader?.cancel();
1597
+ throw new Error(`answered with more than the ${BODY_LIMIT_BYTES} bytes a proof reads`);
1598
+ }
1599
+ text3 += decoder.decode(chunk.value, { stream: true });
1600
+ }
1601
+ return text3 + decoder.decode();
1602
+ }
1603
+ function equalIgnoringCase(one, other) {
1604
+ return one.toLowerCase() === other.toLowerCase();
1605
+ }
1606
+
1607
+ // src/wire.ts
1608
+ var ASSET_CODE = "BTC";
1609
+ var ASSET_SCALE = 11;
1610
+ var REASONS = /* @__PURE__ */ new Set([
1611
+ "address-unusable",
1612
+ "unreachable",
1613
+ "amount-not-accepted",
1614
+ "cannot-prove-delivery",
1615
+ "invoice-refused"
1616
+ ]);
1617
+ function createRequestBody(priced2, webhookUrl, trigger, replay) {
752
1618
  return JSON.stringify({
753
- payment_hash: params.paymentHash,
754
- verify_url: params.verifyUrl,
755
- expires_at: expiresAt.toISOString(),
1619
+ ln_addresses: priced2.paidTo,
1620
+ incoming_amount: toAmount(priced2.amountMsat),
1621
+ webhook: webhookUrl ? { url: webhookUrl } : void 0,
756
1622
  trigger: trigger ?? void 0,
757
- replay: params.replay,
758
- sealed: params.sealed,
759
- webhook: params.webhookUrl ? { url: params.webhookUrl } : void 0
1623
+ replay
760
1624
  });
761
1625
  }
762
- function settlementFromWire(body) {
1626
+ function quoteRequestBody(priced2) {
1627
+ return JSON.stringify({
1628
+ ln_addresses: priced2.paidTo,
1629
+ amount: toAmount(priced2.amountMsat)
1630
+ });
1631
+ }
1632
+ function quoteFromWire(body) {
763
1633
  const wire = asObject(body);
764
1634
  if (wire === null) {
765
1635
  return null;
766
1636
  }
767
- const id = text(wire["id"]);
768
- const paymentHash = text(wire["payment_hash"]);
769
- const settledAt = secondsFrom(wire["settled_at"]);
770
- const status = wire["status"];
771
- const preimage = wire["preimage"] ?? null;
772
- if (id === null || paymentHash === null || settledAt === null || !isStatus(status)) {
1637
+ const lnAddress = text2(wire["ln_address"]);
1638
+ const amountMsat = msatFrom(wire["amount"], 1);
1639
+ const feeMsat = msatFrom(wire["fee"], 0);
1640
+ const minMsat = msatFrom(wire["min_amount"], 0);
1641
+ const maxMsat = msatFrom(wire["max_amount"], 0);
1642
+ const metadata = text2(wire["metadata"]);
1643
+ const refusals3 = refusalsFrom(wire["refusals"]);
1644
+ if (lnAddress === null || amountMsat === null || feeMsat === null) {
773
1645
  return null;
774
1646
  }
775
- if (preimage !== null && typeof preimage !== "string") {
1647
+ if (minMsat === null || maxMsat === null || metadata === null || refusals3 === null) {
776
1648
  return null;
777
1649
  }
778
- return { id, status, paymentHash, preimage, settledAt };
1650
+ return { lnAddress, amountMsat, feeMsat, minMsat, maxMsat, metadata, refusals: refusals3 };
779
1651
  }
780
- function triggerEventFromWire(body) {
1652
+ function paymentFromWire(body) {
781
1653
  const wire = asObject(body);
782
1654
  if (wire === null) {
783
1655
  return null;
784
1656
  }
785
- const id = text(wire["id"]);
786
- const paymentHash = text(wire["payment_hash"]);
787
- const verifyUrl = text(wire["verify_url"]);
1657
+ const id = text2(wire["id"]);
1658
+ const paymentHash = text2(wire["payment_hash"]);
1659
+ const verifyUrl = text2(wire["verify_url"]);
788
1660
  const expiresAt = secondsFrom(wire["expires_at"]);
789
1661
  const createdAt = secondsFrom(wire["created_at"]);
790
1662
  const status = wire["status"];
@@ -802,41 +1674,80 @@ function triggerEventFromWire(body) {
802
1674
  if (sealed !== null && typeof sealed !== "string") {
803
1675
  return null;
804
1676
  }
805
- return {
1677
+ const reported = {
806
1678
  id,
807
- kind: kindOf(wire),
1679
+ status,
808
1680
  paymentHash,
809
1681
  verifyUrl,
810
- status,
811
1682
  preimage,
812
1683
  expiresAt,
813
1684
  createdAt,
814
- sealed,
815
- lnAddress: text(wire["ln_address"]),
816
- amountMsat: wire["incoming_amount"] === void 0 ? null : msatFrom(wire["incoming_amount"], 1)
1685
+ sealed
817
1686
  };
1687
+ const lnAddress = text2(wire["ln_address"]);
1688
+ const amountMsat = wire["incoming_amount"] === void 0 ? null : msatFrom(wire["incoming_amount"], 1);
1689
+ const bolt11 = text2(wire["bolt11"]);
1690
+ if (lnAddress !== null && amountMsat !== null && bolt11 !== null) {
1691
+ return { ...reported, kind: "minted", lnAddress, amountMsat, bolt11 };
1692
+ }
1693
+ if (lnAddress === null && amountMsat === null && bolt11 === null) {
1694
+ return { ...reported, kind: "watched", lnAddress: null, amountMsat: null, bolt11: null };
1695
+ }
1696
+ return null;
1697
+ }
1698
+ function mintedFromWire(body) {
1699
+ const payment = paymentFromWire(body);
1700
+ return payment !== null && payment.kind === "minted" ? payment : null;
1701
+ }
1702
+ function watchRequestBody(handover, trigger) {
1703
+ const expiresAt = new Date(handover.expiresAt * 1e3);
1704
+ if (Number.isNaN(expiresAt.getTime())) {
1705
+ throw new TypeError(
1706
+ `expiresAt must be a usable unix time in seconds, got ${handover.expiresAt}`
1707
+ );
1708
+ }
1709
+ return JSON.stringify({
1710
+ payment_hash: handover.paymentHash,
1711
+ verify_url: handover.verifyUrl,
1712
+ expires_at: expiresAt.toISOString(),
1713
+ trigger: trigger ?? void 0,
1714
+ replay: handover.replay,
1715
+ sealed: handover.sealed,
1716
+ webhook: handover.webhookUrl ? { url: handover.webhookUrl } : void 0
1717
+ });
1718
+ }
1719
+ function settlementFromWire(body) {
1720
+ const wire = asObject(body);
1721
+ if (wire === null) {
1722
+ return null;
1723
+ }
1724
+ const id = text2(wire["id"]);
1725
+ const paymentHash = text2(wire["payment_hash"]);
1726
+ const settledAt = secondsFrom(wire["settled_at"]);
1727
+ const status = wire["status"];
1728
+ const preimage = wire["preimage"] ?? null;
1729
+ if (id === null || paymentHash === null || settledAt === null || !isStatus(status)) {
1730
+ return null;
1731
+ }
1732
+ if (preimage !== null && typeof preimage !== "string") {
1733
+ return null;
1734
+ }
1735
+ return { id, status, paymentHash, preimage, settledAt };
818
1736
  }
819
1737
  function socketTicketFromWire(body) {
820
1738
  const wire = asObject(body);
821
1739
  if (wire === null) {
822
1740
  return null;
823
1741
  }
824
- const ticket = text(wire["ticket"]);
1742
+ const ticket = text2(wire["ticket"]);
825
1743
  const expiresAt = secondsFrom(wire["expires_at"]);
826
1744
  if (ticket === null || expiresAt === null) {
827
1745
  return null;
828
1746
  }
829
1747
  return { ticket, expiresAt };
830
1748
  }
831
- function kindOf(wire) {
832
- const said = wire["kind"];
833
- if (said === "minted" || said === "watched") {
834
- return said;
835
- }
836
- return text(wire["ln_address"]) === null ? "watched" : "minted";
837
- }
838
- function toAmount(msat) {
839
- return { value: String(msat), asset_code: ASSET_CODE, asset_scale: ASSET_SCALE };
1749
+ function toAmount(msat2) {
1750
+ return { value: String(msat2), asset_code: ASSET_CODE, asset_scale: ASSET_SCALE };
840
1751
  }
841
1752
  function asObject(value) {
842
1753
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
@@ -844,7 +1755,7 @@ function asObject(value) {
844
1755
  }
845
1756
  return value;
846
1757
  }
847
- function text(value) {
1758
+ function text2(value) {
848
1759
  return typeof value === "string" ? value : null;
849
1760
  }
850
1761
  function msatFrom(value, least) {
@@ -859,8 +1770,8 @@ function msatFrom(value, least) {
859
1770
  if (typeof digits !== "string" || !/^[0-9]+$/.test(digits)) {
860
1771
  return null;
861
1772
  }
862
- const msat = Number(digits);
863
- return Number.isSafeInteger(msat) && msat >= least ? msat : null;
1773
+ const msat2 = Number(digits);
1774
+ return Number.isSafeInteger(msat2) && msat2 >= least ? msat2 : null;
864
1775
  }
865
1776
  function refusalsFrom(value) {
866
1777
  if (!Array.isArray(value)) {
@@ -869,7 +1780,7 @@ function refusalsFrom(value) {
869
1780
  const refusals3 = [];
870
1781
  for (const entry of value) {
871
1782
  const failure = asObject(entry);
872
- const address = failure === null ? null : text(failure["address"]);
1783
+ const address = failure === null ? null : text2(failure["address"]);
873
1784
  const reason = failure?.["reason"];
874
1785
  if (address === null || typeof reason !== "string" || !REASONS.has(reason)) {
875
1786
  return null;
@@ -890,118 +1801,70 @@ function isStatus(value) {
890
1801
  }
891
1802
 
892
1803
  // src/webhook.ts
893
- var SIGNATURE_HEADER = "x-signature";
894
- var TIMESTAMP_HEADER = "x-timestamp";
1804
+ var SIGNATURE_HEADER2 = "x-signature";
1805
+ var TIMESTAMP_HEADER2 = "x-timestamp";
895
1806
  var GATEWAY_KEY_PREFIX = "ed25519=";
896
1807
  var DEFAULT_TOLERANCE_SECS = 300;
897
1808
  var CHALLENGE = "webhook-challenge";
898
1809
  var VERIFY_CHALLENGE = "verify-challenge";
899
- async function verifyWebhookSignature(body, signature, credential, timestamp, options = {}) {
900
- if (!recent(timestamp, options.toleranceSecs ?? DEFAULT_TOLERANCE_SECS)) {
901
- return false;
902
- }
903
- if (!signature.startsWith(GATEWAY_KEY_PREFIX)) {
904
- return false;
905
- }
906
- return await verifyHex(
907
- credential.publicKey.toLowerCase(),
908
- signature.slice(GATEWAY_KEY_PREFIX.length).toLowerCase(),
909
- signed(timestamp, body)
910
- );
1810
+ async function readPayment(request, credential, options = {}) {
1811
+ const body = await believable(request, credential, options);
1812
+ return body === null ? null : proved(decoded(body, paymentFromWire));
911
1813
  }
912
- async function parseWebhook(body, signature, credential, timestamp, options = {}) {
913
- if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) {
914
- return null;
915
- }
916
- const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
917
- try {
918
- return paymentFromWire(JSON.parse(text2));
919
- } catch {
920
- return null;
921
- }
1814
+ async function readSettlement(request, credential, options = {}) {
1815
+ const body = await believable(request, credential, options);
1816
+ return body === null ? null : proved(decoded(body, settlementFromWire));
922
1817
  }
923
- async function parseWebhookRequest(request, credential, options = {}) {
924
- const signature = request.headers.get(SIGNATURE_HEADER);
925
- const timestamp = request.headers.get(TIMESTAMP_HEADER);
926
- if (signature === null || timestamp === null) {
927
- return null;
928
- }
929
- return parseWebhook(await request.text(), signature, credential, timestamp, options);
1818
+ async function answerWebhookChallenge(request, credential, options = {}) {
1819
+ const body = await believable(request, credential, options);
1820
+ const nonce = body === null ? null : nonceOf(body, CHALLENGE);
1821
+ return nonce === null ? null : answered2(nonce);
930
1822
  }
931
- async function parseWatchedWebhook(body, signature, credential, timestamp, options = {}) {
932
- if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) {
933
- return null;
934
- }
935
- const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
936
- try {
937
- return triggerEventFromWire(JSON.parse(text2));
938
- } catch {
1823
+ async function answerVerifyChallenge(request) {
1824
+ if (request.method !== "POST") {
939
1825
  return null;
940
1826
  }
1827
+ const nonce = nonceOf(await request.clone().text(), VERIFY_CHALLENGE);
1828
+ return nonce === null ? null : answered2(nonce);
941
1829
  }
942
- async function parseWatchedWebhookRequest(request, credential, options = {}) {
943
- const signature = request.headers.get(SIGNATURE_HEADER);
944
- const timestamp = request.headers.get(TIMESTAMP_HEADER);
1830
+ async function believable(request, credential, options) {
1831
+ const signature = request.headers.get(SIGNATURE_HEADER2);
1832
+ const timestamp = request.headers.get(TIMESTAMP_HEADER2);
945
1833
  if (signature === null || timestamp === null) {
946
1834
  return null;
947
1835
  }
948
- return parseWatchedWebhook(await request.text(), signature, credential, timestamp, options);
949
- }
950
- async function parseSettlement(body, signature, credential, timestamp, options = {}) {
951
- if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) {
952
- return null;
953
- }
954
- try {
955
- return settlementFromWire(JSON.parse(textOf(body)));
956
- } catch {
1836
+ if (!recent(timestamp, options.toleranceSecs ?? DEFAULT_TOLERANCE_SECS)) {
957
1837
  return null;
958
1838
  }
959
- }
960
- async function parseSettlementRequest(request, credential, options = {}) {
961
- const signature = request.headers.get(SIGNATURE_HEADER);
962
- const timestamp = request.headers.get(TIMESTAMP_HEADER);
963
- if (signature === null || timestamp === null) {
1839
+ if (!signature.startsWith(GATEWAY_KEY_PREFIX)) {
964
1840
  return null;
965
1841
  }
966
- return await parseSettlement(await request.text(), signature, credential, timestamp, options);
967
- }
968
- function isProvablySettled(settled) {
969
- if (settled.status !== "paid" || settled.preimage === null) {
970
- return false;
971
- }
972
- return preimageMatchesHash(settled.preimage, settled.paymentHash);
1842
+ const body = await request.clone().text();
1843
+ const signed = await verifyHex(
1844
+ credential.publicKey.toLowerCase(),
1845
+ signature.slice(GATEWAY_KEY_PREFIX.length).toLowerCase(),
1846
+ stamped(timestamp, body)
1847
+ );
1848
+ return signed ? body : null;
973
1849
  }
974
- async function answerWebhookChallenge(body, signature, credential, timestamp, options = {}) {
975
- if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) {
976
- return null;
977
- }
978
- const nonce = challenged(typeof body === "string" ? body : new TextDecoder().decode(body));
979
- return nonce === null ? null : JSON.stringify({ nonce });
1850
+ function proved(read) {
1851
+ return read !== null && read.status === "paid" && !carriesProof(read) ? null : read;
980
1852
  }
981
- async function answerWebhookChallengeRequest(request, credential, options = {}) {
982
- const signature = request.headers.get(SIGNATURE_HEADER);
983
- const timestamp = request.headers.get(TIMESTAMP_HEADER);
984
- if (signature === null || timestamp === null) {
1853
+ function decoded(body, from) {
1854
+ try {
1855
+ return from(JSON.parse(body));
1856
+ } catch {
985
1857
  return null;
986
1858
  }
987
- const answer = await answerWebhookChallenge(
988
- await request.clone().text(),
989
- signature,
990
- credential,
991
- timestamp,
992
- options
993
- );
994
- return answer === null ? null : new Response(answer, { headers: { "content-type": "application/json" } });
995
1859
  }
996
- function textOf(body) {
997
- return typeof body === "string" ? body : new TextDecoder().decode(body);
998
- }
999
- function challenged(text2) {
1000
- return nonceOf(text2, CHALLENGE);
1860
+ function answered2(nonce) {
1861
+ return new Response(JSON.stringify({ nonce }), {
1862
+ headers: { "content-type": "application/json" }
1863
+ });
1001
1864
  }
1002
- function nonceOf(text2, type) {
1865
+ function nonceOf(text3, type) {
1003
1866
  try {
1004
- const said = JSON.parse(text2);
1867
+ const said = JSON.parse(text3);
1005
1868
  if (said["type"] !== type || typeof said["nonce"] !== "string") {
1006
1869
  return null;
1007
1870
  }
@@ -1010,17 +1873,6 @@ function nonceOf(text2, type) {
1010
1873
  return null;
1011
1874
  }
1012
1875
  }
1013
- function answerVerifyChallenge(body) {
1014
- const nonce = nonceOf(body, VERIFY_CHALLENGE);
1015
- return nonce === null ? null : JSON.stringify({ nonce });
1016
- }
1017
- async function answerVerifyChallengeRequest(request) {
1018
- if (request.method !== "POST") {
1019
- return null;
1020
- }
1021
- const answer = answerVerifyChallenge(await request.clone().text());
1022
- return answer === null ? null : new Response(answer, { headers: { "content-type": "application/json" } });
1023
- }
1024
1876
  function recent(timestamp, toleranceSecs) {
1025
1877
  const sent = Number(timestamp);
1026
1878
  if (!Number.isFinite(sent)) {
@@ -1028,14 +1880,8 @@ function recent(timestamp, toleranceSecs) {
1028
1880
  }
1029
1881
  return Math.abs(Math.floor(Date.now() / 1e3) - sent) <= toleranceSecs;
1030
1882
  }
1031
- function signed(timestamp, body) {
1032
- const encoder = new TextEncoder();
1033
- const stamp = encoder.encode(`${timestamp}.`);
1034
- const rest = typeof body === "string" ? encoder.encode(body) : new Uint8Array(body);
1035
- const payload = new Uint8Array(stamp.length + rest.length);
1036
- payload.set(stamp);
1037
- payload.set(rest, stamp.length);
1038
- return payload;
1883
+ function stamped(timestamp, body) {
1884
+ return new TextEncoder().encode(`${timestamp}.${body}`);
1039
1885
  }
1040
1886
 
1041
1887
  // src/bank.ts
@@ -1044,83 +1890,120 @@ var DEFAULT_LOOK_BACK_SECS = 7 * 24 * 60 * 60;
1044
1890
  var DEFAULT_POLL_EVERY_SECS = 30;
1045
1891
  var IBAN = /^[A-Z]{2}[0-9]{2}[0-9A-Z]{8,30}$/;
1046
1892
  var FORBIDDEN_IN_SPD = "*";
1047
- async function bankTransfer(params) {
1893
+ var MAX_REFERENCE_CHARS = 60;
1894
+ async function bankTransfer(gateway, params) {
1048
1895
  const currency = params.currency ?? DEFAULT_CURRENCY;
1049
- refuseUnusable(params, currency);
1050
- await refuseAnOpenGateway(params);
1051
- const spd = shortPaymentDescriptor(params, currency);
1052
- const subject = subjectOf(params.reference, params.amountMinor, currency);
1053
- const polling = new URL(params.verifyUrl);
1054
- polling.searchParams.set("ref", params.reference);
1055
- polling.searchParams.set("minor", String(params.amountMinor));
1056
- polling.searchParams.set("cc", currency);
1057
- polling.searchParams.set("sig", await hmacHex(params.secret, `verify|${subject}`));
1058
- const verifyUrl = polling.toString();
1059
- const watched = await params.gateway.watchPayment({
1060
- paymentHash: hashOf(await hmacHex(params.secret, `preimage|${subject}`)),
1896
+ const iban = accountOf(params.iban);
1897
+ refuseUnusable(params, iban, currency);
1898
+ await refuseAnOpenGateway(gateway, params);
1899
+ const spd = shortPaymentDescriptor(params, iban, currency);
1900
+ const subject = subjectOf({
1901
+ iban,
1902
+ amountMinor: params.amountMinor,
1903
+ currency,
1904
+ expiresAt: params.expiresAt,
1905
+ reference: params.reference
1906
+ });
1907
+ const verifyUrl = await answeredAt(params, subject);
1908
+ const paymentHash = hashOf(await hmacHex(params.secret, `preimage|${subject}`));
1909
+ const watched = await gateway.watch({
1910
+ paymentHash,
1061
1911
  verifyUrl,
1062
1912
  expiresAt: params.expiresAt,
1063
1913
  trigger: params.trigger,
1064
1914
  replay: params.replay,
1065
- sealed: params.sealed,
1915
+ sealed: params.sealed ? await seal(params.sealed.secret, JSON.stringify(params.sealed.data), paymentHash) : void 0,
1066
1916
  webhookUrl: params.webhookUrl
1067
1917
  });
1068
- return { id: watched.id, paymentHash: watched.paymentHash, verifyUrl, spd };
1918
+ return { id: watched.id, paymentHash, verifyUrl, spd };
1069
1919
  }
1070
1920
  function bankVerifyEndpoint(config) {
1921
+ refuseAWeakSecret(config.secret);
1922
+ const answersFor = accountOf(config.iban);
1923
+ if (!IBAN.test(answersFor)) {
1924
+ throw new Error(`${config.iban} is not an IBAN, so this endpoint answers for no account`);
1925
+ }
1071
1926
  const paced = {
1072
1927
  "cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS}`
1073
1928
  };
1074
1929
  return async (request) => {
1075
- const consented = await answerVerifyChallengeRequest(request);
1930
+ const consented = await answerVerifyChallenge(request);
1076
1931
  if (consented !== null) {
1077
1932
  return consented;
1078
1933
  }
1079
- const asked = readQuery(new URL(request.url));
1080
- if (asked === null) {
1934
+ const sealed = new URL(request.url).searchParams.get("q");
1935
+ if (sealed === null) {
1081
1936
  return Response.json({ settled: false }, { status: 400 });
1082
1937
  }
1083
- const subject = subjectOf(asked.reference, asked.amountMinor, asked.currency);
1084
- const expected = await hmacHex(config.secret, `verify|${subject}`);
1085
- if (!equalInConstantTime(asked.signature, expected)) {
1938
+ const subject = await unseal(config.secret, sealed);
1939
+ const asked = subject === null ? null : askedFrom(subject);
1940
+ if (asked === null || asked.iban !== answersFor) {
1086
1941
  return Response.json({ settled: false }, { status: 403 });
1087
1942
  }
1088
- const since = unixNow() - (config.lookBackSecs ?? DEFAULT_LOOK_BACK_SECS);
1089
- const landed = (await config.statement(since)).some((credit) => pays(credit, asked));
1090
- if (!landed) {
1091
- return Response.json({ settled: false }, { headers: paced });
1092
- }
1093
- return Response.json(
1094
- { settled: true, preimage: await hmacHex(config.secret, `preimage|${subject}`) },
1095
- { headers: paced }
1096
- );
1943
+ const preimage = await creditedPreimage({
1944
+ secret: config.secret,
1945
+ asked,
1946
+ statement: config.statement,
1947
+ lookBackSecs: config.lookBackSecs
1948
+ });
1949
+ return preimage === null ? Response.json({ settled: false }, { headers: paced }) : Response.json({ settled: true, preimage }, { headers: paced });
1097
1950
  };
1098
1951
  }
1099
- function readQuery(url) {
1100
- const reference = url.searchParams.get("ref");
1101
- const minor = Number(url.searchParams.get("minor"));
1102
- const currency = url.searchParams.get("cc");
1103
- const signature = url.searchParams.get("sig");
1104
- if (!reference || !currency || !signature) {
1952
+ async function answeredAt(params, subject) {
1953
+ if (params.answerBy === "agent") {
1954
+ return `agent:${(await callerKey(params.secret)).publicKeyHex}`;
1955
+ }
1956
+ const polling = new URL(params.verifyUrl ?? "");
1957
+ polling.searchParams.set("q", await sealStable(params.secret, subject));
1958
+ return polling.toString();
1959
+ }
1960
+ async function creditedPreimage(params) {
1961
+ const since = unixNow() - (params.lookBackSecs ?? DEFAULT_LOOK_BACK_SECS);
1962
+ const landed = (await params.statement(since)).some((credit) => pays(credit, params.asked));
1963
+ if (!landed) {
1964
+ return null;
1965
+ }
1966
+ return await hmacHex(params.secret, `preimage|${subjectOf(params.asked)}`);
1967
+ }
1968
+ function askedFrom(subject) {
1969
+ const [iban, minorText, currency, expiresText, ...rest] = subject.split("|");
1970
+ const reference = rest.join("|");
1971
+ const amountMinor = Number(minorText);
1972
+ const expiresAt = Number(expiresText);
1973
+ if (!iban || !currency || reference.length === 0) {
1105
1974
  return null;
1106
1975
  }
1107
- if (!Number.isInteger(minor) || minor <= 0) {
1976
+ if (!Number.isInteger(amountMinor) || amountMinor <= 0 || !Number.isSafeInteger(expiresAt)) {
1108
1977
  return null;
1109
1978
  }
1110
- return { reference, amountMinor: minor, currency, signature };
1979
+ return { iban, reference, amountMinor, currency, expiresAt };
1111
1980
  }
1112
1981
  function pays(credit, asked) {
1113
- return credit.amountMinor === asked.amountMinor && credit.currency.toUpperCase() === asked.currency.toUpperCase() && credit.reference.toUpperCase().includes(asked.reference.toUpperCase());
1982
+ return credit.amountMinor === asked.amountMinor && credit.currency.toUpperCase() === asked.currency.toUpperCase() && namesOnly(credit.reference, asked.reference);
1983
+ }
1984
+ function namesOnly(written, reference) {
1985
+ const wanted = reference.toUpperCase();
1986
+ const alike = [...written.toUpperCase().matchAll(wordsShapedLike(wanted))].map(([word]) => word);
1987
+ return alike.includes(wanted) && (!/\d/.test(wanted) || alike.every((word) => word === wanted));
1988
+ }
1989
+ function wordsShapedLike(reference) {
1990
+ const shape = [...reference].map((char) => {
1991
+ if (/\d/.test(char)) {
1992
+ return "\\d";
1993
+ }
1994
+ return /\p{L}/u.test(char) ? "\\p{L}" : char.replace(/[\^$\\.*+?()[\]{}|/]/g, "\\$&");
1995
+ }).join("");
1996
+ return new RegExp(`(?<![\\p{L}\\p{N}])${shape}(?![\\p{L}\\p{N}])`, "gu");
1114
1997
  }
1115
- function subjectOf(reference, amountMinor, currency) {
1116
- return `${reference}|${amountMinor}|${currency.toUpperCase()}`;
1998
+ function subjectOf({ iban, amountMinor, currency, expiresAt, reference }) {
1999
+ return `${iban}|${amountMinor}|${currency.toUpperCase()}|${expiresAt}|${reference}`;
1117
2000
  }
1118
2001
  function hashOf(preimage) {
1119
2002
  return bytesToHex(sha256(hexToBytes(preimage)));
1120
2003
  }
1121
- function shortPaymentDescriptor(params, currency) {
2004
+ function shortPaymentDescriptor(params, iban, currency) {
1122
2005
  const fields = [
1123
- `ACC:${params.iban}`,
2006
+ `ACC:${iban}`,
1124
2007
  `AM:${major(params.amountMinor, currency)}`,
1125
2008
  `CC:${currency.toUpperCase()}`,
1126
2009
  `MSG:${params.reference}`
@@ -1133,27 +2016,36 @@ function shortPaymentDescriptor(params, currency) {
1133
2016
  function major(amountMinor, currency) {
1134
2017
  return (amountMinor / minorScaleOf(currency)).toFixed(minorUnitsOf(currency));
1135
2018
  }
1136
- async function refuseAnOpenGateway(params) {
2019
+ async function refuseAnOpenGateway(gateway, params) {
1137
2020
  if (params.allowPublicGateway === true) {
1138
2021
  return;
1139
2022
  }
1140
- if (await params.gateway.refusesStrangers()) {
2023
+ if (await gateway.refusesStrangers()) {
1141
2024
  return;
1142
2025
  }
1143
2026
  throw new Error(
1144
- "this gateway serves callers with no token, so it is not yours, and its operator would read the amount and the reference off every verify URL. Point at one that answers 401 to a stranger, or say allowPublicGateway"
2027
+ "this gateway serves callers with no token, so it is not yours, and its operator would learn every watch you place. Point at one that answers 401 to a stranger, or say allowPublicGateway"
1145
2028
  );
1146
2029
  }
1147
- function refuseUnusable(params, currency) {
1148
- if (!IBAN.test(params.iban)) {
2030
+ function refuseUnusable(params, iban, currency) {
2031
+ refuseAWeakSecret(params.secret);
2032
+ if (params.answerBy !== "agent" && params.verifyUrl === void 0) {
2033
+ throw new Error("a transfer the gateway polls needs the verify url it is polled at");
2034
+ }
2035
+ if (!IBAN.test(iban)) {
1149
2036
  throw new Error(`${params.iban} is not an IBAN`);
1150
2037
  }
1151
- if (!Number.isInteger(params.amountMinor) || params.amountMinor <= 0) {
2038
+ if (!Number.isSafeInteger(params.amountMinor) || params.amountMinor <= 0) {
1152
2039
  throw new Error("amountMinor must be a whole number of minor units above zero");
1153
2040
  }
1154
2041
  if (params.reference.length === 0) {
1155
2042
  throw new Error("a transfer with no reference cannot be found");
1156
2043
  }
2044
+ if (params.reference.length > MAX_REFERENCE_CHARS) {
2045
+ throw new Error(
2046
+ `a reference is at most ${MAX_REFERENCE_CHARS} characters, as many as a QR payment's message carries and its sealed query hides`
2047
+ );
2048
+ }
1157
2049
  if (params.reference.includes(FORBIDDEN_IN_SPD)) {
1158
2050
  throw new Error("a reference cannot contain an asterisk, it separates the QR fields");
1159
2051
  }
@@ -1161,226 +2053,533 @@ function refuseUnusable(params, currency) {
1161
2053
  throw new Error("a variable symbol is up to ten digits");
1162
2054
  }
1163
2055
  }
2056
+ function accountOf(iban) {
2057
+ return iban.replace(/\s+/g, "").toUpperCase();
2058
+ }
1164
2059
  function unixNow() {
1165
2060
  return Math.floor(Date.now() / 1e3);
1166
2061
  }
1167
2062
 
1168
- // ../core/caller.ts
1169
- var KEY_HEADER = "x-client-key";
1170
- var SIGNATURE_HEADER2 = "x-signature";
1171
- var TIMESTAMP_HEADER2 = "x-timestamp";
1172
- var PREFIX = "ed25519=";
1173
- var SIGNING_LABEL = "request-signing";
1174
- async function callerKey(secret) {
1175
- return await signingKeyFromSeed(hexToBytes(await hmacHex(secret, SIGNING_LABEL)));
2063
+ // src/qr.ts
2064
+ import { encode } from "uqr";
2065
+ var SAFE_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$|^rgba?\([\d.,%/\s]+\)$|^[a-z]+$/i;
2066
+ function toLightningUri(destination) {
2067
+ const scanned = isLnAddress(destination) ? destination : destination.toUpperCase();
2068
+ return `LIGHTNING:${scanned}`;
1176
2069
  }
1177
- function paymentNamedBy(caller, paymentHash) {
1178
- return sha256Hex(`${caller}.payment-id.${paymentHash}`);
2070
+ function invoiceToSvg(destination, options) {
2071
+ return svgOf(toLightningUri(destination), options);
1179
2072
  }
1180
- async function signedAs(key, method, path, body) {
1181
- const timestamp = String(Math.floor(Date.now() / 1e3));
1182
- return {
1183
- [KEY_HEADER]: key.publicKeyHex,
1184
- [TIMESTAMP_HEADER2]: timestamp,
1185
- [SIGNATURE_HEADER2]: `${PREFIX}${await key.sign(spoken(timestamp, method, path, body))}`
2073
+ function svgOf(scanned, options) {
2074
+ const size = options?.size ?? 256;
2075
+ const color = options?.color ?? "#000";
2076
+ if (!SAFE_COLOR.test(color)) {
2077
+ throw new Error(`Invalid color: ${color}`);
2078
+ }
2079
+ return renderSvg(scanned, size, color);
2080
+ }
2081
+ function isLnAddress(destination) {
2082
+ const at = destination.indexOf("@");
2083
+ return at > 0 && destination.slice(at + 1).includes(".");
2084
+ }
2085
+ function renderSvg(content, size, color) {
2086
+ const { data } = encode(content);
2087
+ const modules = data.length;
2088
+ const cellSize = size / modules;
2089
+ let path = "";
2090
+ for (let y = 0; y < modules; y++) {
2091
+ for (let x = 0; x < modules; x++) {
2092
+ if (data[y][x]) {
2093
+ path += `M${x * cellSize},${y * cellSize}h${cellSize}v${cellSize}h-${cellSize}z`;
2094
+ }
2095
+ }
2096
+ }
2097
+ return [
2098
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">`,
2099
+ `<rect width="100%" height="100%" fill="white"/>`,
2100
+ `<path d="${path}" fill="${color}"/>`,
2101
+ `</svg>`
2102
+ ].join("");
2103
+ }
2104
+
2105
+ // src/relay.ts
2106
+ var DEFAULT_POLL_EVERY_SECS2 = 5;
2107
+ var WALLET = "w";
2108
+ function lightningVerifyEndpoint(config) {
2109
+ const paced = {
2110
+ "cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS2}`
2111
+ };
2112
+ return async (request) => {
2113
+ const consented = await answerVerifyChallenge(request);
2114
+ if (consented !== null) {
2115
+ return consented;
2116
+ }
2117
+ const sealed = new URL(request.url).searchParams.get(WALLET);
2118
+ if (sealed === null) {
2119
+ return Response.json({ settled: false }, { status: 400 });
2120
+ }
2121
+ const opened = await unseal(config.secret, sealed);
2122
+ const wallet = opened === null ? null : relayedFrom(opened);
2123
+ if (wallet === null) {
2124
+ return Response.json({ settled: false }, { status: 403 });
2125
+ }
2126
+ const asked = await checkSettled(
2127
+ config.send ?? pinnedToTheAddressWeVerified,
2128
+ wallet.url,
2129
+ wallet.hash
2130
+ ).catch(() => null);
2131
+ if (asked === null) {
2132
+ return Response.json({ settled: false }, { status: 502 });
2133
+ }
2134
+ return Response.json(
2135
+ { settled: asked.preimage !== null, preimage: asked.preimage },
2136
+ { headers: paced }
2137
+ );
1186
2138
  };
1187
2139
  }
1188
- function spoken(timestamp, method, path, body) {
1189
- return new TextEncoder().encode(
1190
- `${timestamp}.${method.toUpperCase()}.${path}.${sha256Hex(body)}`
1191
- );
2140
+ function relayedFrom(opened) {
2141
+ try {
2142
+ const said = JSON.parse(opened);
2143
+ return typeof said?.url === "string" && typeof said.hash === "string" ? { url: said.url, hash: said.hash } : null;
2144
+ } catch {
2145
+ return null;
2146
+ }
2147
+ }
2148
+ async function relayedVerifyUrl(endpoint, wallet, secret) {
2149
+ const relayed = new URL(endpoint);
2150
+ relayed.searchParams.set(WALLET, await seal(secret, JSON.stringify(wallet)));
2151
+ return relayed.toString();
1192
2152
  }
1193
2153
 
1194
- // src/errors.ts
1195
- var GatewayCheatError = class extends Error {
1196
- code;
1197
- paymentId;
1198
- constructor(code, paymentId) {
1199
- super(`gateway verification failed: ${code}`);
1200
- this.name = "GatewayCheatError";
1201
- this.code = code;
1202
- this.paymentId = paymentId;
2154
+ // src/rail.ts
2155
+ var BANK = "bank";
2156
+ var LIGHTNING = "lightning";
2157
+ function bankRail(gateway, config) {
2158
+ return async (order) => {
2159
+ const expiresAt = config.expiresAt(order);
2160
+ const transfer = await bankTransfer(gateway, {
2161
+ secret: config.secret,
2162
+ iban: config.iban,
2163
+ verifyUrl: config.verifyUrl,
2164
+ reference: order.reference,
2165
+ amountMinor: order.amountMinor,
2166
+ currency: order.currency,
2167
+ expiresAt,
2168
+ trigger: config.trigger,
2169
+ replay: config.replay,
2170
+ sealed: config.sealed && { secret: config.sealed.secret, data: config.sealed.data(order) },
2171
+ variableSymbol: config.variableSymbol?.(order),
2172
+ webhookUrl: config.webhookUrl,
2173
+ allowPublicGateway: config.allowPublicGateway
2174
+ });
2175
+ return {
2176
+ id: transfer.id,
2177
+ paymentHash: transfer.paymentHash,
2178
+ rail: config.name ?? BANK,
2179
+ scan: transfer.spd,
2180
+ qr: transfer.spd,
2181
+ expiresAt
2182
+ };
2183
+ };
2184
+ }
2185
+ function lightningRail(gateway, config) {
2186
+ return async (order) => {
2187
+ const payment = await gateway.mint(
2188
+ {
2189
+ paidTo: config.paidTo,
2190
+ amount: await msatForOrder(order, config.amount, config.rate),
2191
+ webhookUrl: config.webhookUrl
2192
+ },
2193
+ {
2194
+ idempotencyKey: config.idempotencyKey?.(order),
2195
+ trigger: config.trigger,
2196
+ replay: config.replay
2197
+ }
2198
+ );
2199
+ return {
2200
+ id: payment.id,
2201
+ paymentHash: payment.paymentHash,
2202
+ rail: config.name ?? LIGHTNING,
2203
+ scan: payment.bolt11,
2204
+ qr: toLightningUri(payment.bolt11),
2205
+ expiresAt: payment.expiresAt
2206
+ };
2207
+ };
2208
+ }
2209
+ function blindLightningRail(gateway, config) {
2210
+ return async (order) => {
2211
+ const resolved2 = await invoiceFrom(
2212
+ config.paidTo,
2213
+ await msatForOrder(order, config.amount, config.rate),
2214
+ config.send
2215
+ );
2216
+ const relay = config.relayThrough;
2217
+ const watched = await gateway.watch({
2218
+ paymentHash: resolved2.paymentHash,
2219
+ verifyUrl: relay ? await relayedVerifyUrl(
2220
+ relay.endpoint,
2221
+ { url: resolved2.verifyUrl, hash: resolved2.paymentHash },
2222
+ relay.secret
2223
+ ) : resolved2.verifyUrl,
2224
+ expiresAt: resolved2.expiresAt,
2225
+ trigger: config.trigger,
2226
+ replay: config.replay,
2227
+ sealed: config.sealed ? await seal(
2228
+ config.sealed.secret,
2229
+ JSON.stringify(config.sealed.data(order)),
2230
+ resolved2.paymentHash
2231
+ ) : void 0,
2232
+ webhookUrl: config.webhookUrl
2233
+ });
2234
+ return {
2235
+ id: watched.id,
2236
+ paymentHash: resolved2.paymentHash,
2237
+ rail: config.name ?? LIGHTNING,
2238
+ scan: resolved2.bolt11,
2239
+ qr: toLightningUri(resolved2.bolt11),
2240
+ expiresAt: resolved2.expiresAt
2241
+ };
2242
+ };
2243
+ }
2244
+ async function invoiceFrom(paidTo, amount, send = pinnedToTheAddressWeVerified) {
2245
+ const addresses = typeof paidTo === "string" ? [paidTo] : paidTo;
2246
+ try {
2247
+ return await resolve(send, addresses, await amountNow(amount));
2248
+ } catch (refused) {
2249
+ if (refused instanceof NoWalletAvailable) {
2250
+ throw new NoWalletAvailableError({ title: refused.message }, refused.wallets);
2251
+ }
2252
+ throw refused;
1203
2253
  }
1204
- };
1205
- var WrapRefusedError = class extends Error {
1206
- code;
1207
- constructor(code, detail) {
1208
- super(`wrapped invoice refused: ${code}, ${detail}`);
1209
- this.name = "WrapRefusedError";
1210
- this.code = code;
2254
+ }
2255
+ async function msatForOrder(order, amount, rate) {
2256
+ if (amount !== void 0) {
2257
+ return await amountNow(amount(order));
1211
2258
  }
1212
- };
1213
- var UnverifiedRecipientError = class extends Error {
1214
- lnAddress;
1215
- paymentId;
1216
- constructor(lnAddress, paymentId, cause) {
1217
- super(`could not reach ${lnAddress} to verify the invoice: ${String(cause)}`);
1218
- this.name = "UnverifiedRecipientError";
1219
- this.lnAddress = lnAddress;
1220
- this.paymentId = paymentId;
2259
+ return msat(msatFor(order.amountMinor, await (rate ?? medianOf())(order.currency)));
2260
+ }
2261
+ var Rails = class {
2262
+ constructor(gateway) {
2263
+ this.gateway = gateway;
1221
2264
  }
1222
- };
1223
- var ProblemError = class extends Error {
1224
- type;
1225
- title;
1226
- status;
1227
- detail;
1228
- constructor(problem) {
1229
- const title = problem.title ?? "Request failed";
1230
- super(problem.detail ? `${title}: ${problem.detail}` : title);
1231
- this.name = "ProblemError";
1232
- this.type = problem.type ?? "about:blank";
1233
- this.title = title;
1234
- this.status = problem.status ?? 0;
1235
- this.detail = problem.detail ?? null;
2265
+ gateway;
2266
+ /** Lightning, with the gateway minting against a priority list of addresses */
2267
+ lightning(config) {
2268
+ return lightningRail(this.gateway, config);
1236
2269
  }
1237
- };
1238
- function isProblemType(problem, type) {
1239
- return problem.type === type;
1240
- }
1241
- var NO_WALLET_AVAILABLE = "urn:problem-type:thunder-bridge:no-wallet-available";
1242
- var REQUEST_IN_FLIGHT = "urn:problem-type:thunder-bridge:request-in-flight";
1243
- var IDEMPOTENCY_KEY_REUSED = "urn:problem-type:thunder-bridge:idempotency-key-reused";
1244
- var PAYMENT_ALREADY_WATCHED = "urn:problem-type:thunder-bridge:payment-already-watched";
1245
- var IdempotencyConflictError = class extends ProblemError {
1246
- conflict;
1247
- constructor(problem, conflict) {
1248
- super(problem);
1249
- this.name = "IdempotencyConflictError";
1250
- this.conflict = conflict;
2270
+ /**
2271
+ * Lightning, with the invoice resolved here so the gateway is told neither the
2272
+ * address nor the amount
2273
+ */
2274
+ blindLightning(config) {
2275
+ return blindLightningRail(this.gateway, config);
1251
2276
  }
1252
- };
1253
- var NoWalletAvailableError = class extends ProblemError {
1254
- wallets;
1255
- constructor(problem, wallets) {
1256
- super({ ...problem, type: NO_WALLET_AVAILABLE });
1257
- this.name = "NoWalletAvailableError";
1258
- this.wallets = wallets;
2277
+ /** A bank transfer, proved the way a Lightning payment is */
2278
+ bank(config) {
2279
+ return bankRail(this.gateway, config);
2280
+ }
2281
+ /**
2282
+ * One bank transfer without building a rail first, for a shop that asks for
2283
+ * them one at a time rather than beside another payment method
2284
+ */
2285
+ transfer(params) {
2286
+ return bankTransfer(this.gateway, params);
1259
2287
  }
1260
2288
  };
1261
2289
 
1262
- // src/verify.ts
1263
- var HTTP_TIMEOUT_MS = 15e3;
1264
- var DEFAULT_WRAP_PROPORTION = 0.01;
1265
- var DEFAULT_WRAP_BASE_MSAT = 1e3;
1266
- async function proveOrigin(payment, request) {
1267
- const cheat = (code) => new GatewayCheatError(code, payment.id);
1268
- const listed = request.lnAddresses.find(
1269
- (address) => equalIgnoringCase(address, payment.lnAddress)
1270
- );
1271
- if (listed === void 0) {
1272
- throw cheat("address_not_requested");
1273
- }
1274
- if (payment.amountMsat !== request.amountMsat) {
1275
- throw cheat("amount_mismatch");
1276
- }
1277
- const invoice = decodeInvoice(payment.bolt11);
1278
- if (invoice.paymentHash === null || !equalIgnoringCase(invoice.paymentHash, payment.paymentHash)) {
1279
- throw cheat("hash_mismatch");
2290
+ // src/request.ts
2291
+ function paymentRequestOf(gateway, payment, options) {
2292
+ return {
2293
+ id: payment.id,
2294
+ bolt11: payment.bolt11,
2295
+ paymentHash: payment.paymentHash,
2296
+ lnAddress: payment.lnAddress,
2297
+ amountMsat: payment.amountMsat,
2298
+ expiresAt: payment.expiresAt,
2299
+ qr: invoiceToSvg(payment.bolt11, options?.qr),
2300
+ payment,
2301
+ paid: async (waiting) => paidOnly(await gateway.settled(payment, { ...waitingOf(options), ...waiting })),
2302
+ prove: () => proveSettlement(payment, { paidTo: [payment.lnAddress], amountMsat: payment.amountMsat }),
2303
+ onPaid: (arrived, failed) => {
2304
+ const stop = new AbortController();
2305
+ const waiting = waitingOf(options);
2306
+ const signal = waiting.signal ? AbortSignal.any([stop.signal, waiting.signal]) : stop.signal;
2307
+ gateway.settled(payment, { ...waiting, signal }).then((ended) => arrived(paidOnly(ended))).catch((reason) => {
2308
+ if (!stop.signal.aborted) {
2309
+ failed?.(reason);
2310
+ }
2311
+ });
2312
+ return () => stop.abort();
2313
+ }
2314
+ };
2315
+ }
2316
+ function paidOnly(ended) {
2317
+ if (ended.status !== "paid") {
2318
+ throw new Error(`payment ${ended.id} ended ${ended.status} rather than paid`);
1280
2319
  }
1281
- if (invoice.amountMsat !== request.amountMsat) {
1282
- throw cheat("amount_mismatch");
2320
+ if (ended.kind !== "minted") {
2321
+ throw new Error(`payment ${ended.id} came back without the invoice it was minted with`);
1283
2322
  }
1284
- const payRequest = await payRequestFor(listed, payment);
1285
- if (typeof payRequest.metadata !== "string" || typeof payRequest.callback !== "string") {
1286
- throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "no payRequest served");
2323
+ return ended;
2324
+ }
2325
+ function waitingOf(options) {
2326
+ return { signal: options?.signal, tickets: options?.tickets };
2327
+ }
2328
+
2329
+ // src/trigger.ts
2330
+ var NONCE_BYTES = 16;
2331
+ function lnurlPayEndpoint(gateway, config) {
2332
+ return async (request) => {
2333
+ const url = new URL(request.url);
2334
+ const asked = url.searchParams.get("to");
2335
+ try {
2336
+ return asked === null ? await offer(gateway, config, url) : await mint(gateway, config, url, asked);
2337
+ } catch (failure) {
2338
+ return refuse(failure instanceof Error ? failure.message : "the trigger could not be served");
2339
+ }
2340
+ };
2341
+ }
2342
+ function watchTicketEndpoint(gateway, config) {
2343
+ return async (request) => {
2344
+ const offered = await offeredSecret(request);
2345
+ if (!equalInConstantTime(sha256Hex(offered), sha256Hex(config.watchSecret))) {
2346
+ return Response.json({ reason: "not the watch secret" }, { status: 403 });
2347
+ }
2348
+ return await issue(gateway, config);
2349
+ };
2350
+ }
2351
+ function publicWatchTicketEndpoint(gateway, config) {
2352
+ return () => issue(gateway, config);
2353
+ }
2354
+ async function offer(gateway, config, url) {
2355
+ const { least, most } = await spread(config.amount);
2356
+ const winner = await quoted(gateway, config, least);
2357
+ const nonce = randomNonce();
2358
+ const callback = new URL(config.baseUrl ?? `${url.origin}${url.pathname}`);
2359
+ callback.searchParams.set("to", winner.address);
2360
+ callback.searchParams.set("least", String(least));
2361
+ callback.searchParams.set("most", String(most));
2362
+ callback.searchParams.set("n", nonce);
2363
+ callback.searchParams.set("sig", await sign(config.secret, winner.address, least, most, nonce));
2364
+ return Response.json({
2365
+ tag: "payRequest",
2366
+ callback: callback.toString(),
2367
+ metadata: winner.metadata,
2368
+ minSendable: least,
2369
+ maxSendable: most
2370
+ });
2371
+ }
2372
+ async function quoted(gateway, config, amountMsat) {
2373
+ if (config.blind) {
2374
+ const { won } = await quote(
2375
+ config.send ?? pinnedToTheAddressWeVerified,
2376
+ listed(config.paidTo),
2377
+ amountMsat
2378
+ );
2379
+ return won;
1287
2380
  }
1288
- if (invoice.descriptionHash !== sha256Hex(payRequest.metadata)) {
1289
- throw cheat("description_hash_mismatch");
2381
+ const { lnAddress, metadata } = await gateway.quote({
2382
+ paidTo: config.paidTo,
2383
+ amount: msat(amountMsat)
2384
+ });
2385
+ return { address: lnAddress, metadata };
2386
+ }
2387
+ async function spread(amount) {
2388
+ if (typeof amount === "number" || typeof amount === "function") {
2389
+ const pinned = await amountNow(amount);
2390
+ return { least: pinned, most: pinned };
2391
+ }
2392
+ const least = await amountNow(amount.least);
2393
+ const most = await amountNow(amount.most);
2394
+ if (most < least) {
2395
+ throw new Error(`a range cannot end at ${most} msat when it starts at ${least}`);
2396
+ }
2397
+ return { least, most };
2398
+ }
2399
+ async function mint(gateway, config, url, address) {
2400
+ const least = Number(url.searchParams.get("least"));
2401
+ const most = Number(url.searchParams.get("most"));
2402
+ const nonce = url.searchParams.get("n") ?? "";
2403
+ const signature = url.searchParams.get("sig") ?? "";
2404
+ const expected = await sign(config.secret, address, least, most, nonce);
2405
+ if (!equalInConstantTime(signature, expected)) {
2406
+ return refuse("this callback was not signed here");
2407
+ }
2408
+ if (!listed(config.paidTo).some((one) => one.toLowerCase() === address.toLowerCase())) {
2409
+ return refuse(`${address} is not paid through this trigger`);
2410
+ }
2411
+ const asked = url.searchParams.get("amount");
2412
+ const amountMsat = asked === null ? least : Number(asked);
2413
+ if (!Number.isSafeInteger(amountMsat) || amountMsat < least || amountMsat > most) {
2414
+ return refuse(
2415
+ least === most ? `this trigger costs exactly ${least} msat` : `this trigger takes between ${least} and ${most} msat`
2416
+ );
1290
2417
  }
1291
- if (!sameOrigin(payment.verifyUrl, payRequest.callback)) {
1292
- throw cheat("verify_url_foreign");
2418
+ const minted = config.blind ? await mintBlind(gateway, config, address, amountMsat) : await mintThroughGateway(gateway, config, address, amountMsat, nonce);
2419
+ return Response.json({
2420
+ status: "OK",
2421
+ pr: minted.bolt11,
2422
+ routes: [],
2423
+ verify: minted.verifyUrl
2424
+ });
2425
+ }
2426
+ async function mintThroughGateway(gateway, config, address, amountMsat, nonce) {
2427
+ const payment = await gateway.mint(
2428
+ { paidTo: address, amount: msat(amountMsat) },
2429
+ { idempotencyKey: nonce, trigger: config.watchSecret, replay: config.replay }
2430
+ );
2431
+ return { bolt11: payment.bolt11, verifyUrl: payment.verifyUrl };
2432
+ }
2433
+ async function mintBlind(gateway, config, address, amountMsat) {
2434
+ const resolved2 = await resolve(
2435
+ config.send ?? pinnedToTheAddressWeVerified,
2436
+ [address],
2437
+ amountMsat
2438
+ );
2439
+ const minted = { ...resolved2, amountMsat, lnAddress: resolved2.address };
2440
+ const locked = config.sealed;
2441
+ const relay = config.relayThrough;
2442
+ try {
2443
+ await gateway.watch({
2444
+ paymentHash: resolved2.paymentHash,
2445
+ verifyUrl: relay ? await relayedVerifyUrl(
2446
+ relay.endpoint,
2447
+ { url: resolved2.verifyUrl, hash: resolved2.paymentHash },
2448
+ relay.secret
2449
+ ) : resolved2.verifyUrl,
2450
+ expiresAt: resolved2.expiresAt,
2451
+ trigger: config.watchSecret,
2452
+ replay: config.replay,
2453
+ sealed: locked ? await seal(locked.secret, JSON.stringify(locked.data(minted)), minted.paymentHash) : void 0
2454
+ });
2455
+ } catch (refused) {
2456
+ if (!alreadyWatched(refused)) {
2457
+ throw refused;
2458
+ }
1293
2459
  }
1294
- const issued = await reachable(payment.verifyUrl, payment);
1295
- if (typeof issued.pr !== "string" || !equalIgnoringCase(issued.pr, payment.bolt11)) {
1296
- throw cheat("invoice_not_issued");
2460
+ return { bolt11: resolved2.bolt11, verifyUrl: resolved2.verifyUrl };
2461
+ }
2462
+ function listed(paidTo) {
2463
+ return typeof paidTo === "string" ? [paidTo] : paidTo;
2464
+ }
2465
+ function alreadyWatched(refused) {
2466
+ return refused instanceof ProblemError && isProblemType(refused, PAYMENT_ALREADY_WATCHED);
2467
+ }
2468
+ function sign(secret, address, least, most, nonce) {
2469
+ return hmacHex(secret, `${address}|${least}|${most}|${nonce}`);
2470
+ }
2471
+ function randomNonce() {
2472
+ const bytes = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
2473
+ let hex = "";
2474
+ for (const byte of bytes) {
2475
+ hex += byte.toString(16).padStart(2, "0");
1297
2476
  }
2477
+ return hex;
1298
2478
  }
1299
- async function proveSettlement(payment, request) {
1300
- await proveOrigin(payment, request);
1301
- const verified = await reachable(payment.verifyUrl, payment);
1302
- if (verified.settled !== true || typeof verified.preimage !== "string") {
1303
- return null;
2479
+ function refuse(reason) {
2480
+ return Response.json({ status: "ERROR", reason });
2481
+ }
2482
+ async function issue(gateway, config) {
2483
+ const issued = await gateway.ticket(config.watchSecret, { replay: config.replay });
2484
+ return Response.json({
2485
+ ticket: issued.ticket,
2486
+ expires_at: new Date(issued.expiresAt * 1e3).toISOString()
2487
+ });
2488
+ }
2489
+ async function offeredSecret(request) {
2490
+ const body = await request.json().catch(() => null);
2491
+ return typeof body?.secret === "string" ? body.secret : "";
2492
+ }
2493
+
2494
+ // src/serving.ts
2495
+ var Serve = class {
2496
+ constructor(gateway) {
2497
+ this.gateway = gateway;
1304
2498
  }
1305
- if (!preimageMatchesHash(verified.preimage, payment.paymentHash)) {
1306
- throw new GatewayCheatError("preimage_mismatch", payment.id);
2499
+ gateway;
2500
+ /**
2501
+ * An LNURL-pay endpoint of your own, standing in front of a priority list of
2502
+ * addresses, so a printed QR points at your domain and never expires
2503
+ */
2504
+ lnurlPay(config) {
2505
+ return lnurlPayEndpoint(this.gateway, config);
1307
2506
  }
1308
- return verified.preimage;
1309
- }
1310
- function isProvablyPaid(payment) {
1311
- if (payment.status !== "paid" || payment.preimage === null) {
1312
- return false;
2507
+ /** Trades the watch secret for a one minute socket ticket, refusing anyone without it */
2508
+ watchTicket(config) {
2509
+ return watchTicketEndpoint(this.gateway, config);
1313
2510
  }
1314
- const invoiceHash = decodeInvoice(payment.bolt11).paymentHash;
1315
- if (invoiceHash === null || !equalIgnoringCase(invoiceHash, payment.paymentHash)) {
1316
- return false;
2511
+ /**
2512
+ * Mints a socket ticket for anybody who asks, which makes the trigger's whole
2513
+ * stream public, preimages included. Only for a board where that is the point
2514
+ */
2515
+ publicWatchTicket(config) {
2516
+ return publicWatchTicketEndpoint(this.gateway, config);
1317
2517
  }
1318
- return preimageMatchesHash(payment.preimage, invoiceHash);
1319
- }
1320
- function wrapFeeCeiling(amountMsat, allowance) {
1321
- const proportional = amountMsat * (allowance?.proportion ?? DEFAULT_WRAP_PROPORTION);
1322
- return Math.max(allowance?.baseMsat ?? DEFAULT_WRAP_BASE_MSAT, Math.floor(proportional));
1323
- }
1324
- function proveWrapped(wrapped, recipient, allowance) {
1325
- const offered = decodeInvoice(wrapped);
1326
- const owed = decodeInvoice(recipient);
1327
- const refuse = (code, detail) => {
1328
- throw new WrapRefusedError(code, detail);
1329
- };
1330
- if (offered.paymentHash === null || offered.amountMsat === null || offered.expiresAt === null) {
1331
- refuse("undecodable", "the wrapped invoice carries no hash, amount or expiry");
2518
+ /**
2519
+ * A verify endpoint of your own that asks the recipient's wallet for you, so
2520
+ * the gateway polls you and never the wallet
2521
+ */
2522
+ verify(config) {
2523
+ return lightningVerifyEndpoint(config);
1332
2524
  }
1333
- if (owed.paymentHash === null || owed.amountMsat === null || owed.expiresAt === null) {
1334
- refuse("undecodable", "the recipient's invoice carries no hash, amount or expiry");
2525
+ /** The verify endpoint a bank rail is polled at, answering off your own statement */
2526
+ bankVerify(config) {
2527
+ return bankVerifyEndpoint(config);
1335
2528
  }
1336
- if (offered.paymentHash?.toLowerCase() !== owed.paymentHash?.toLowerCase()) {
1337
- refuse("hash_mismatch", "settling the wrap would not settle the recipient");
2529
+ /**
2530
+ * The whole webhook route: it answers the gateway's challenge, checks the
2531
+ * signature against the key the gateway publishes, refuses a settlement that
2532
+ * proves nothing, and calls you for the one that does.
2533
+ *
2534
+ * `export const POST = gateway.serve.webhook({ onSettled: fulfil })` is the
2535
+ * entire integration
2536
+ */
2537
+ webhook(handlers) {
2538
+ const options = { toleranceSecs: handlers.toleranceSecs };
2539
+ return async (request) => {
2540
+ const credential = await this.credential(handlers);
2541
+ const challenge = await answerWebhookChallenge(request, credential, options);
2542
+ if (challenge !== null) {
2543
+ return challenge;
2544
+ }
2545
+ const settlement = await readSettlement(request, credential, options);
2546
+ if (settlement !== null) {
2547
+ return await acted(settlement, handlers);
2548
+ }
2549
+ const payment = await readPayment(request, credential, options);
2550
+ if (payment !== null) {
2551
+ await handlers.onPayment?.(payment);
2552
+ return new Response("ok");
2553
+ }
2554
+ return new Response("bad signature", { status: 401 });
2555
+ };
1338
2556
  }
1339
- const asked = offered.amountMsat ?? 0;
1340
- const due = owed.amountMsat ?? 0;
1341
- if (asked < due) {
1342
- refuse("amount_below_recipient", `${asked} msat cannot forward ${due} msat`);
2557
+ /** Verify a delivery and read the settlement out of it, null when it is not believable */
2558
+ async readSettlement(request, options) {
2559
+ return await readSettlement(request, await this.credential({}), options);
1343
2560
  }
1344
- const ceiling = wrapFeeCeiling(due, allowance);
1345
- if (asked - due > ceiling) {
1346
- refuse("fee_above_allowance", `${asked - due} msat over ${due}, and ${ceiling} was allowed`);
2561
+ /** Verify a delivery and read the payment out of it, null when it is not believable */
2562
+ async readPayment(request, options) {
2563
+ return await readPayment(request, await this.credential({}), options);
1347
2564
  }
1348
- if ((owed.expiresAt ?? 0) <= (offered.expiresAt ?? 0)) {
1349
- refuse("recipient_expires_first", "the operator could be paid and find it cannot deliver");
2565
+ /** Answer the challenge the gateway sends before it will post to a webhook of yours */
2566
+ async answerWebhookChallenge(request, options) {
2567
+ return await answerWebhookChallenge(request, await this.credential({}), options);
1350
2568
  }
1351
- }
1352
- async function payRequestFor(listed, payment) {
1353
- const at = listed.indexOf("@");
1354
- const name = listed.slice(0, at);
1355
- const domain = listed.slice(at + 1).toLowerCase();
1356
- if (at < 1 || !publicHttps(`https://${domain}/`)) {
1357
- throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "not a usable address");
2569
+ async credential(handlers) {
2570
+ return handlers.credential ?? { publicKey: await this.gateway.webhookKey() };
1358
2571
  }
1359
- return reachable(`https://${domain}/.well-known/lnurlp/${name}`, payment);
1360
- }
1361
- async function reachable(url, payment) {
1362
- try {
1363
- if (!publicHttps(url)) {
1364
- throw new Error(`${url} is not a public https URL`);
1365
- }
1366
- const response = await fetch(url, {
1367
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
1368
- headers: { accept: "application/json" }
1369
- });
1370
- if (!response.ok) {
1371
- throw new Error(`answered ${response.status}`);
1372
- }
1373
- const body = await response.json();
1374
- if (body === null || typeof body !== "object") {
1375
- throw new Error("answered with no JSON object");
1376
- }
1377
- return body;
1378
- } catch (cause) {
1379
- throw new UnverifiedRecipientError(payment.lnAddress, payment.id, cause);
2572
+ };
2573
+ async function acted(settlement, handlers) {
2574
+ if (carriesProof(settlement)) {
2575
+ await handlers.onSettled?.(settlement);
2576
+ return new Response("ok");
1380
2577
  }
1381
- }
1382
- function equalIgnoringCase(one, other) {
1383
- return one.toLowerCase() === other.toLowerCase();
2578
+ if (handlers.onUnproven === void 0) {
2579
+ return new Response("the recipient released no preimage", { status: 202 });
2580
+ }
2581
+ await handlers.onUnproven(settlement);
2582
+ return new Response("ok");
1384
2583
  }
1385
2584
 
1386
2585
  // src/client.ts
@@ -1402,35 +2601,62 @@ function backoffMs(firstDelay, attempt) {
1402
2601
  const grown = Math.min(firstDelay * 2 ** (attempt - 1), RECONNECT_CAP_MS);
1403
2602
  return Math.round(grown * (0.5 + Math.random() / 2));
1404
2603
  }
2604
+ function askFromWire(raw) {
2605
+ let said;
2606
+ try {
2607
+ said = JSON.parse(raw);
2608
+ } catch {
2609
+ return null;
2610
+ }
2611
+ if (typeof said !== "object" || said === null) {
2612
+ return null;
2613
+ }
2614
+ const fields = said;
2615
+ const ask2 = fields["ask"];
2616
+ const paymentHash = fields["payment_hash"];
2617
+ return typeof ask2 === "string" && typeof paymentHash === "string" ? { ask: ask2, paymentHash } : null;
2618
+ }
2619
+ function gatewayAt(baseUrl) {
2620
+ const at = URL.canParse(baseUrl) ? new URL(baseUrl) : null;
2621
+ if (at === null || at.protocol !== "http:" && at.protocol !== "https:") {
2622
+ throw new Error(`${baseUrl} is not an http or https gateway url`);
2623
+ }
2624
+ return baseUrl.replace(/\/+$/, "");
2625
+ }
1405
2626
  var ThunderBridge = class {
1406
2627
  baseUrl;
1407
- verify;
1408
2628
  token;
2629
+ handedOut = /* @__PURE__ */ new Map();
1409
2630
  secret;
1410
2631
  strangers = null;
1411
2632
  speaks = null;
2633
+ published = null;
2634
+ /** Everything this gateway lets you mount, from an LNURL endpoint to a webhook route */
2635
+ serve;
2636
+ /** One call per sale, whatever the rail moves */
2637
+ rails;
1412
2638
  constructor(baseUrl, options) {
1413
- this.baseUrl = baseUrl.replace(/\/+$/, "");
1414
- this.verify = options?.verify ?? true;
2639
+ this.baseUrl = gatewayAt(baseUrl);
1415
2640
  this.token = options?.token ?? null;
1416
2641
  this.secret = options?.secret ?? null;
2642
+ this.serve = new Serve(this);
2643
+ this.rails = new Rails(this);
1417
2644
  }
1418
2645
  /**
1419
- * Whether a token was given, which is what makes an instance yours: a gateway
1420
- * started with `GATEWAY_TOKEN` answers nobody else, so anything you hand it
1421
- * stays between you and it
2646
+ * Whether a token was given to this instance, which is your side of the
2647
+ * arrangement and says nothing about the gateway's. `refusesStrangers` is the
2648
+ * one that asks the gateway, and it is the one to guard anything with
1422
2649
  */
1423
- get isPrivate() {
2650
+ get hasToken() {
1424
2651
  return this.token !== null;
1425
2652
  }
1426
2653
  /**
1427
2654
  * Whether the gateway turns away a caller carrying no token, asked by making
1428
- * one unauthenticated read it would have to refuse. `isPrivate` answers only
1429
- * whether you configured a token, which is your side of the arrangement and
1430
- * says nothing about the gateway's, so a made-up token against a public
1431
- * instance reads as private and is not. Asked once and remembered, because an
1432
- * instance does not change its mind. Anything other than a refusal counts as
1433
- * open, so an unreachable gateway fails closed
2655
+ * one unauthenticated read it would have to refuse. `hasToken` answers only
2656
+ * whether you configured one, so a made-up token against a public instance
2657
+ * reads as yours and is not. Asked once and remembered, because an instance
2658
+ * does not change its mind. Anything other than a refusal counts as open, so
2659
+ * an unreachable gateway fails closed
1434
2660
  */
1435
2661
  async refusesStrangers() {
1436
2662
  this.strangers ??= fetch(`${this.baseUrl}/incoming-payments/${STRANGER_PROBE}`, {
@@ -1438,14 +2664,25 @@ var ThunderBridge = class {
1438
2664
  }).then((answer) => answer.status === 401).catch(() => false);
1439
2665
  return await this.strangers;
1440
2666
  }
2667
+ /**
2668
+ * Ask to be paid for one thing. It mints the invoice, proves it came from the
2669
+ * address you asked for, draws the QR and hands back one object with a way to
2670
+ * wait for the money. This is `mint` plus the two things every caller does next
2671
+ */
2672
+ async requestPayment(asked) {
2673
+ return paymentRequestOf(this, await this.mint(asked, asked), asked);
2674
+ }
1441
2675
  /**
1442
2676
  * Ask the gateway for an invoice payable to the first address on your list
1443
2677
  * that can issue a provable one, throws `NoWalletAvailableError` when none can
1444
2678
  * and `GatewayCheatError` when what comes back is not what you asked for
1445
2679
  */
1446
- async createPayment(params, options) {
2680
+ async mint(charge, options) {
2681
+ const askedAt = Math.floor(Date.now() / 1e3);
2682
+ const asked = await priced(charge);
1447
2683
  const sent = createRequestBody(
1448
- params,
2684
+ asked,
2685
+ charge.webhookUrl,
1449
2686
  options?.trigger ? sha256Hex(unguessable(options.trigger)) : null,
1450
2687
  options?.replay
1451
2688
  );
@@ -1461,10 +2698,9 @@ var ThunderBridge = class {
1461
2698
  if (!response.ok) {
1462
2699
  throw await problemFrom(response);
1463
2700
  }
1464
- const payment = await paymentFrom(response);
1465
- if (this.verify) {
1466
- await proveOrigin(payment, params);
1467
- }
2701
+ const payment = await mintedFrom(response);
2702
+ await proveOrigin(payment, asked, askedAt);
2703
+ this.handOut(payment, options?.idempotencyKey ?? null);
1468
2704
  return payment;
1469
2705
  }
1470
2706
  /**
@@ -1474,8 +2710,9 @@ var ThunderBridge = class {
1474
2710
  * whether a wallet returns a provable invoice cannot be known without asking
1475
2711
  * it for one, and asking mints it
1476
2712
  */
1477
- async createQuote(params) {
1478
- const sent = quoteRequestBody(params);
2713
+ async quote(charge) {
2714
+ const asked = await priced(charge);
2715
+ const sent = quoteRequestBody(asked);
1479
2716
  const response = await fetch(`${this.baseUrl}/quotes`, {
1480
2717
  method: "POST",
1481
2718
  headers: await this.sending("/quotes", sent),
@@ -1484,22 +2721,31 @@ var ThunderBridge = class {
1484
2721
  if (!response.ok) {
1485
2722
  throw await problemFrom(response);
1486
2723
  }
1487
- const quote = quoteFromWire(await response.json().catch(() => null));
1488
- if (quote === null) {
2724
+ const quote2 = quoteFromWire(await response.json().catch(() => null));
2725
+ if (quote2 === null) {
1489
2726
  throw new ProblemError({
1490
2727
  status: response.status,
1491
2728
  title: "The gateway answered with something that is not a quote"
1492
2729
  });
1493
2730
  }
1494
- return quote;
2731
+ if (!asked.paidTo.some((address) => address.toLowerCase() === quote2.lnAddress.toLowerCase())) {
2732
+ throw new GatewayCheatError("address_not_requested", "");
2733
+ }
2734
+ return quote2;
1495
2735
  }
1496
- /** Read a payment back, null when the gateway has never heard of it */
1497
2736
  /**
1498
- * The key this gateway signs webhooks with when you registered none of your own,
1499
- * ready to hand to `parseWebhookRequest` as `{ publicKey }`. Fetch it once and
1500
- * keep it: it is the same for every instance in the cluster
2737
+ * The key this gateway signs webhooks with when you registered none of your own.
2738
+ * `serve.webhook` reads it for you. Asked once and kept, because it is the same
2739
+ * for every instance in the cluster
1501
2740
  */
1502
2741
  async webhookKey() {
2742
+ this.published ??= this.publishedKey().catch((refused) => {
2743
+ this.published = null;
2744
+ throw refused;
2745
+ });
2746
+ return await this.published;
2747
+ }
2748
+ async publishedKey() {
1503
2749
  const response = await fetch(`${this.baseUrl}/webhook-key`);
1504
2750
  if (!response.ok) {
1505
2751
  throw await problemFrom(response);
@@ -1513,26 +2759,13 @@ var ThunderBridge = class {
1513
2759
  }
1514
2760
  return body.public_key;
1515
2761
  }
1516
- async getPayment(id) {
1517
- const path = `/incoming-payments/${encodeURIComponent(id)}`;
1518
- const response = await fetch(`${this.baseUrl}${path}`, {
1519
- headers: await this.reading("GET", path)
1520
- });
1521
- if (response.status === 404) {
1522
- return null;
1523
- }
1524
- if (!response.ok) {
1525
- throw await problemFrom(response);
1526
- }
1527
- return this.checked(await paymentFrom(response));
1528
- }
1529
2762
  /**
1530
- * Read back a payment the gateway is only watching, null when it has never
1531
- * heard of it. A watched payment carries no address, amount or invoice, so
1532
- * `getPayment` refuses it and this reads the shape both rails share
2763
+ * Read a payment back, null when the gateway has never heard of it. One method
2764
+ * for both sorts: `kind` says whether the gateway minted it or was handed it,
2765
+ * and the address, amount and invoice are null on one it was never told
1533
2766
  */
1534
- async getWatched(id) {
1535
- const path = `/incoming-payments/${encodeURIComponent(id)}`;
2767
+ async payment(held) {
2768
+ const path = `/incoming-payments/${encodeURIComponent(held.id)}`;
1536
2769
  const response = await fetch(`${this.baseUrl}${path}`, {
1537
2770
  headers: await this.reading("GET", path)
1538
2771
  });
@@ -1542,14 +2775,7 @@ var ThunderBridge = class {
1542
2775
  if (!response.ok) {
1543
2776
  throw await problemFrom(response);
1544
2777
  }
1545
- const watched = triggerEventFromWire(await response.json().catch(() => null));
1546
- if (watched === null) {
1547
- throw new ProblemError({
1548
- status: response.status,
1549
- title: "The gateway answered with something that is not a payment"
1550
- });
1551
- }
1552
- return this.proven(watched);
2778
+ return this.proven(await paymentFrom(response), held);
1553
2779
  }
1554
2780
  /**
1555
2781
  * List what this gateway is watching, newest first. Only a gateway started
@@ -1560,7 +2786,7 @@ var ThunderBridge = class {
1560
2786
  * Anything older than that window is not in the answer, and the list does not
1561
2787
  * pretend otherwise
1562
2788
  */
1563
- async listPayments(limit) {
2789
+ async payments(limit) {
1564
2790
  const path = `/incoming-payments${limit === void 0 ? "" : `?limit=${limit}`}`;
1565
2791
  const response = await fetch(`${this.baseUrl}${path}`, {
1566
2792
  headers: await this.reading("GET", path)
@@ -1569,14 +2795,14 @@ var ThunderBridge = class {
1569
2795
  throw await problemFrom(response);
1570
2796
  }
1571
2797
  const body = await response.json().catch(() => null);
1572
- const listed = Array.isArray(body?.payments) ? body.payments.map(triggerEventFromWire).filter((one) => one !== null) : null;
1573
- if (listed === null || typeof body?.settled_scanned !== "number") {
2798
+ const listed2 = Array.isArray(body?.payments) ? body.payments.map(paymentFromWire).filter((one) => one !== null).filter((one) => one.status !== "paid" || carriesProof(one)) : null;
2799
+ if (listed2 === null || typeof body?.settled_scanned !== "number") {
1574
2800
  throw new ProblemError({
1575
2801
  status: response.status,
1576
2802
  title: "The gateway answered with something that is not a payment list"
1577
2803
  });
1578
2804
  }
1579
- return { payments: listed, scanned: body.settled_scanned };
2805
+ return { payments: listed2, scanned: body.settled_scanned };
1580
2806
  }
1581
2807
  /**
1582
2808
  * Follow a payment over WebSocket until it is paid or expired, reconnecting
@@ -1584,34 +2810,15 @@ var ThunderBridge = class {
1584
2810
  * one that has answered is followed until its own expiry, so the wait always
1585
2811
  * ends by itself
1586
2812
  */
1587
- async waitForPayment(id, options) {
1588
- const payment = paymentFromWire(await this.followed(id, options));
1589
- if (payment === null) {
1590
- throw new ProblemError({
1591
- status: 200,
1592
- title: `payment ${id} is watched rather than minted here, read it with waitForWatched`
1593
- });
1594
- }
1595
- return this.checked(payment);
1596
- }
1597
- /**
1598
- * Follow a payment the gateway is only watching, one it did not mint, until it
1599
- * is paid or expired.
1600
- *
1601
- * A watched payment carries no address, no amount and no invoice, because the
1602
- * gateway was told none of them, so it reads back as the shape a trigger
1603
- * streams rather than as a `Payment`. That is every bank transfer, and every
1604
- * Lightning invoice registered with `watchPayment` instead of `createPayment`.
1605
- */
1606
- async waitForWatched(id, options) {
1607
- const watched = triggerEventFromWire(await this.followed(id, options));
1608
- if (watched === null) {
2813
+ async settled(held, options) {
2814
+ const ended = paymentFromWire(await this.followed(held.id, options));
2815
+ if (ended === null) {
1609
2816
  throw new ProblemError({
1610
2817
  status: 200,
1611
2818
  title: "The gateway answered with something that is not a payment"
1612
2819
  });
1613
2820
  }
1614
- return this.proven(watched);
2821
+ return this.proven(ended, held);
1615
2822
  }
1616
2823
  followed(id, options) {
1617
2824
  const base = this.baseUrl.replace(/^http/, "ws");
@@ -1678,7 +2885,7 @@ var ThunderBridge = class {
1678
2885
  opened.onmessage = (event) => {
1679
2886
  try {
1680
2887
  const frame = JSON.parse(String(event.data));
1681
- const watched = triggerEventFromWire(frame);
2888
+ const watched = paymentFromWire(frame);
1682
2889
  if (watched === null) {
1683
2890
  return;
1684
2891
  }
@@ -1707,17 +2914,17 @@ var ThunderBridge = class {
1707
2914
  * what you want is the one that arrives and nothing further from the other.
1708
2915
  *
1709
2916
  * A leg that expires is a loser, not a winner, which is the whole reason this is
1710
- * not a race: `waitForPayment` ends on `paid` and on `expired` alike, and a
2917
+ * not a race: `settled` ends on `paid` and on `expired` alike, and a
1711
2918
  * Lightning invoice expires in an hour while a bank transfer takes days. `null`
1712
2919
  * means every leg ended without being paid.
1713
2920
  *
1714
2921
  * Stopping the wait is not revoking the invoice. Nobody can revoke one, because
1715
2922
  * the recipient's own wallet minted it, so a payer who pays the loser afterwards
1716
- * really does pay twice and that shows up on `followTrigger` as a second
1717
- * settlement to refund.
2923
+ * really does pay twice and that shows up on `follow` as a second settlement to
2924
+ * refund.
1718
2925
  */
1719
- async firstToSettle(ids, options) {
1720
- if (ids.length === 0) {
2926
+ async firstSettled(held, options) {
2927
+ if (held.length === 0) {
1721
2928
  return null;
1722
2929
  }
1723
2930
  const stopLosers = new AbortController();
@@ -1725,15 +2932,15 @@ var ThunderBridge = class {
1725
2932
  let refused = null;
1726
2933
  try {
1727
2934
  const winner = await new Promise((resolve2) => {
1728
- let waiting = ids.length;
2935
+ let waiting = held.length;
1729
2936
  const lost = () => {
1730
2937
  waiting -= 1;
1731
2938
  if (waiting === 0) {
1732
2939
  resolve2(null);
1733
2940
  }
1734
2941
  };
1735
- for (const id of ids) {
1736
- this.waitForWatched(id, { ...options, signal }).then((watched) => watched.status === "paid" ? resolve2(watched) : lost()).catch((failure) => {
2942
+ for (const leg of held) {
2943
+ this.settled(leg, { ...options, signal }).then((watched) => watched.status === "paid" ? resolve2(watched) : lost()).catch((failure) => {
1737
2944
  refused ??= failure;
1738
2945
  lost();
1739
2946
  });
@@ -1753,10 +2960,10 @@ var ThunderBridge = class {
1753
2960
  * rather than one recipient, which is what makes leaving it cheap. Anything
1754
2961
  * the watcher needs goes in `sealed`, which the gateway cannot read
1755
2962
  */
1756
- async watchPayment(params) {
2963
+ async watch(handover) {
1757
2964
  const sent = watchRequestBody(
1758
- params,
1759
- params.trigger ? sha256Hex(unguessable(params.trigger)) : null
2965
+ handover,
2966
+ handover.trigger ? sha256Hex(unguessable(handover.trigger)) : null
1760
2967
  );
1761
2968
  const response = await fetch(`${this.baseUrl}/watched-payments`, {
1762
2969
  method: "POST",
@@ -1766,17 +2973,20 @@ var ThunderBridge = class {
1766
2973
  if (!response.ok) {
1767
2974
  throw await problemFrom(response);
1768
2975
  }
1769
- const watched = triggerEventFromWire(await response.json().catch(() => null));
2976
+ const watched = paymentFromWire(await response.json().catch(() => null));
1770
2977
  if (watched === null) {
1771
2978
  throw new ProblemError({
1772
2979
  status: response.status,
1773
2980
  title: "The gateway answered with something that is not a watched payment"
1774
2981
  });
1775
2982
  }
1776
- const named = await this.nameFor(params.paymentHash);
2983
+ const named = await this.nameFor(handover.paymentHash);
1777
2984
  if (named !== null && watched.id !== named) {
1778
2985
  throw new GatewayCheatError("id_not_mine", watched.id);
1779
2986
  }
2987
+ if (watched.paymentHash.toLowerCase() !== handover.paymentHash.toLowerCase()) {
2988
+ throw new GatewayCheatError("hash_mismatch", watched.id);
2989
+ }
1780
2990
  return watched;
1781
2991
  }
1782
2992
  /**
@@ -1789,12 +2999,75 @@ var ThunderBridge = class {
1789
2999
  async nameFor(paymentHash) {
1790
3000
  return this.secret === null ? null : paymentNamedBy((await this.speaking()).publicKeyHex, paymentHash);
1791
3001
  }
3002
+ /**
3003
+ * Hold a socket open and answer what the gateway asks about this caller's own
3004
+ * payments, so a watch addressed to this caller settles without anybody
3005
+ * hosting a URL. Reconnects on its own until the returned function is called
3006
+ */
3007
+ attend(options) {
3008
+ const base = this.baseUrl.replace(/^http/, "ws");
3009
+ const firstDelay = options.reconnectDelayMs ?? RECONNECT_DELAY_MS;
3010
+ let socket = null;
3011
+ let retry;
3012
+ let stopped = false;
3013
+ let attempt = 0;
3014
+ const again = () => {
3015
+ if (stopped || options.reconnect === false) {
3016
+ return;
3017
+ }
3018
+ retry = setTimeout(() => void connect(), backoffMs(firstDelay, attempt));
3019
+ };
3020
+ const answered3 = async (opened, raw) => {
3021
+ const asked = askFromWire(raw);
3022
+ if (asked === null) {
3023
+ return;
3024
+ }
3025
+ const preimage = await options.answer(asked.paymentHash);
3026
+ opened.send(
3027
+ JSON.stringify(
3028
+ preimage === null ? { ask: asked.ask, settled: false } : { ask: asked.ask, settled: true, preimage }
3029
+ )
3030
+ );
3031
+ };
3032
+ const connect = async () => {
3033
+ attempt += 1;
3034
+ let url;
3035
+ try {
3036
+ url = `${base}/ws/tickets/${await this.wsTicket({ agent: true })}`;
3037
+ } catch (refused) {
3038
+ options.onError?.(refused);
3039
+ again();
3040
+ return;
3041
+ }
3042
+ if (stopped) {
3043
+ return;
3044
+ }
3045
+ const opened = new WebSocket(url);
3046
+ socket = opened;
3047
+ opened.onopen = () => {
3048
+ attempt = 1;
3049
+ };
3050
+ opened.onmessage = (event) => {
3051
+ void answered3(opened, String(event.data)).catch(
3052
+ (refused) => options.onError?.(refused)
3053
+ );
3054
+ };
3055
+ opened.onerror = () => options.onError?.(new Error(`could not attend ${base}`));
3056
+ opened.onclose = again;
3057
+ };
3058
+ void connect();
3059
+ return () => {
3060
+ stopped = true;
3061
+ clearTimeout(retry);
3062
+ socket?.close();
3063
+ };
3064
+ }
1792
3065
  /**
1793
3066
  * Follow every payment made to one trigger, replayed from the recent ones on
1794
3067
  * connect and then live, reconnecting on its own until the returned function
1795
3068
  * is called. A trigger has no terminal state, so this never resolves
1796
3069
  */
1797
- followTrigger(secret, options) {
3070
+ follow(secret, options) {
1798
3071
  const base = this.baseUrl.replace(/^http/, "ws");
1799
3072
  const asked = options.replay === void 0 ? "" : `?replay=${options.replay}`;
1800
3073
  const direct = `${base}/ws/triggers/${encodeURIComponent(unguessable(secret))}${asked}`;
@@ -1833,7 +3106,7 @@ var ThunderBridge = class {
1833
3106
  };
1834
3107
  socket.onmessage = (event) => {
1835
3108
  try {
1836
- const settled = triggerEventFromWire(JSON.parse(String(event.data)));
3109
+ const settled = paymentFromWire(JSON.parse(String(event.data)));
1837
3110
  if (settled !== null) {
1838
3111
  options.onPayment(this.proven(settled));
1839
3112
  }
@@ -1855,17 +3128,17 @@ var ThunderBridge = class {
1855
3128
  * A one minute pass onto one trigger's stream, for something that must hold
1856
3129
  * neither the token nor the trigger secret. Mint it in a handler and answer
1857
3130
  * with the ticket alone, because that is all a browser needs to connect and
1858
- * all it can do anything with. `watchTicketEndpoint` is this method already
3131
+ * all it can do anything with. `serve.watchTicket` is this method already
1859
3132
  * wrapped in a route
1860
3133
  */
1861
- async createSocketTicket(params) {
3134
+ async ticket(trigger, options) {
1862
3135
  return await this.mintedTicket({
1863
- trigger_secret: unguessable(params.trigger),
1864
- replay: params.replay
3136
+ trigger_secret: unguessable(trigger),
3137
+ replay: options?.replay
1865
3138
  });
1866
3139
  }
1867
3140
  needsTicket(asked) {
1868
- return asked === true || this.token !== null;
3141
+ return asked === true || this.token !== null || this.secret !== null;
1869
3142
  }
1870
3143
  async wsTicket(body) {
1871
3144
  return encodeURIComponent((await this.mintedTicket(body)).ticket);
@@ -1902,15 +3175,26 @@ var ThunderBridge = class {
1902
3175
  this.speaks ??= callerKey(this.secret ?? "");
1903
3176
  return await this.speaks;
1904
3177
  }
1905
- proven(settled) {
1906
- const unproven = settled.status === "paid" && (settled.preimage === null || !preimageMatchesHash(settled.preimage, settled.paymentHash));
1907
- if (this.verify && unproven) {
1908
- throw new GatewayCheatError("preimage_mismatch", settled.id);
3178
+ handOut(payment, key) {
3179
+ const now = Math.floor(Date.now() / 1e3);
3180
+ for (const [hash2, earlier2] of this.handedOut) {
3181
+ if (earlier2.expiresAt <= now) {
3182
+ this.handedOut.delete(hash2);
3183
+ }
3184
+ }
3185
+ const hash = payment.paymentHash.toLowerCase();
3186
+ const earlier = this.handedOut.get(hash);
3187
+ if (earlier !== void 0 && (earlier.key === null || earlier.key !== key)) {
3188
+ throw new GatewayCheatError("invoice_reused", payment.id);
1909
3189
  }
1910
- return settled;
3190
+ this.handedOut.set(hash, { key, expiresAt: payment.expiresAt });
1911
3191
  }
1912
- checked(payment) {
1913
- if (this.verify && payment.status === "paid" && !isProvablyPaid(payment)) {
3192
+ proven(payment, held) {
3193
+ const other = held !== void 0 && (payment.id !== held.id || payment.paymentHash.toLowerCase() !== held.paymentHash.toLowerCase());
3194
+ if (other) {
3195
+ throw new GatewayCheatError("hash_mismatch", payment.id);
3196
+ }
3197
+ if (payment.status === "paid" && !carriesProof(payment)) {
1914
3198
  throw new GatewayCheatError("preimage_mismatch", payment.id);
1915
3199
  }
1916
3200
  return payment;
@@ -1926,11 +3210,21 @@ async function paymentFrom(response) {
1926
3210
  }
1927
3211
  return payment;
1928
3212
  }
3213
+ async function mintedFrom(response) {
3214
+ const payment = mintedFromWire(await response.json().catch(() => null));
3215
+ if (payment === null) {
3216
+ throw new ProblemError({
3217
+ status: response.status,
3218
+ title: "The gateway answered with something that is not a minted payment"
3219
+ });
3220
+ }
3221
+ return payment;
3222
+ }
1929
3223
  async function problemFrom(response) {
1930
3224
  const problem = await response.json().catch(() => ({}));
1931
3225
  const document = { ...problem, status: response.status };
1932
3226
  if (isProblemType(problem, NO_WALLET_AVAILABLE)) {
1933
- return new NoWalletAvailableError(document, refusals(problem.wallets));
3227
+ return new NoWalletAvailableError(document, refusals2(problem.wallets));
1934
3228
  }
1935
3229
  if (isProblemType(problem, REQUEST_IN_FLIGHT)) {
1936
3230
  return new IdempotencyConflictError(document, "request-in-flight");
@@ -1940,7 +3234,7 @@ async function problemFrom(response) {
1940
3234
  }
1941
3235
  return new ProblemError(document);
1942
3236
  }
1943
- function refusals(wallets) {
3237
+ function refusals2(wallets) {
1944
3238
  if (!Array.isArray(wallets)) {
1945
3239
  return [];
1946
3240
  }
@@ -1949,118 +3243,6 @@ function refusals(wallets) {
1949
3243
  });
1950
3244
  }
1951
3245
 
1952
- // src/fio.ts
1953
- var BASE_URL = "https://fioapi.fio.cz/v1/rest";
1954
- var MINOR_UNITS = 100;
1955
- var MILLIS = 1e3;
1956
- var DATE_CHARS = "2026-08-04".length;
1957
- var DEFAULT_MIN_INTERVAL_SECS = 30;
1958
- var TOO_SOON = 409;
1959
- var BOOKED_AT = "column0";
1960
- var AMOUNT = "column1";
1961
- var CURRENCY = "column14";
1962
- var VARIABLE_SYMBOL = "column5";
1963
- var USER_IDENTIFICATION = "column7";
1964
- var MESSAGE_FOR_RECIPIENT = "column16";
1965
- var PAYER_REFERENCE = "column27";
1966
- var WHERE_A_PAYER_WRITES = [
1967
- MESSAGE_FOR_RECIPIENT,
1968
- VARIABLE_SYMBOL,
1969
- USER_IDENTIFICATION,
1970
- PAYER_REFERENCE
1971
- ];
1972
- var UTC_OFFSET = /^[+-]\d{4}$/;
1973
- function fioStatement(config) {
1974
- const named = typeof config.token === "string" ? [config.token] : config.token;
1975
- if (named.length === 0) {
1976
- throw new Error("fioStatement needs at least one token to read with");
1977
- }
1978
- const usedAt = new Map(named.map((token) => [token, Number.NEGATIVE_INFINITY]));
1979
- const tokenWindowMs = (config.minIntervalSecs ?? DEFAULT_MIN_INTERVAL_SECS) * MILLIS;
1980
- const paceMs = tokenWindowMs / usedAt.size;
1981
- let lastRead = Number.NEGATIVE_INFINITY;
1982
- let credits = [];
1983
- return async (sinceUnix) => {
1984
- const now = Date.now();
1985
- if (now - lastRead < paceMs) {
1986
- return credits;
1987
- }
1988
- const idlest = longestUnused(usedAt);
1989
- if (now - idlest.usedAt < tokenWindowMs) {
1990
- return credits;
1991
- }
1992
- usedAt.set(idlest.token, now);
1993
- lastRead = now;
1994
- const url = [
1995
- config.baseUrl ?? BASE_URL,
1996
- "periods",
1997
- idlest.token,
1998
- asDate(sinceUnix),
1999
- asDate(Math.floor(now / MILLIS)),
2000
- "transactions.json"
2001
- ].join("/");
2002
- const answer = await fetch(url, { headers: { accept: "application/json" } });
2003
- if (answer.status === TOO_SOON) {
2004
- throw new Error("fio refuses a second read of this token inside its 30 second window");
2005
- }
2006
- if (!answer.ok) {
2007
- throw new Error(`fio answered ${answer.status} reading the statement`);
2008
- }
2009
- const read = await answer.json();
2010
- credits = (read.accountStatement?.transactionList?.transaction ?? []).filter(isCredit).map(asCredit);
2011
- return credits;
2012
- };
2013
- }
2014
- function longestUnused(usedAt) {
2015
- let token = "";
2016
- let idleSince = Number.POSITIVE_INFINITY;
2017
- for (const [candidate, at] of usedAt) {
2018
- if (at < idleSince) {
2019
- token = candidate;
2020
- idleSince = at;
2021
- }
2022
- }
2023
- return { token, usedAt: idleSince };
2024
- }
2025
- function isCredit(transaction) {
2026
- return numberIn(transaction[AMOUNT]) > 0;
2027
- }
2028
- function asCredit(transaction) {
2029
- return {
2030
- amountMinor: Math.round(numberIn(transaction[AMOUNT]) * MINOR_UNITS),
2031
- currency: textIn(transaction[CURRENCY]),
2032
- reference: WHERE_A_PAYER_WRITES.map((column) => textIn(transaction[column])).filter((written) => written.length > 0).join(" "),
2033
- bookedAt: bookedAtIn(transaction[BOOKED_AT])
2034
- };
2035
- }
2036
- function bookedAtIn(cell) {
2037
- const value = cell?.value;
2038
- if (typeof value === "number") {
2039
- return Math.floor(value / MILLIS);
2040
- }
2041
- const at = Date.parse(asIso8601(textIn(cell)));
2042
- return Number.isFinite(at) ? Math.floor(at / MILLIS) : 0;
2043
- }
2044
- function asIso8601(booked) {
2045
- const day = booked.slice(0, DATE_CHARS);
2046
- const zone = booked.slice(DATE_CHARS);
2047
- if (!UTC_OFFSET.test(zone)) {
2048
- return `${day}T00:00:00Z`;
2049
- }
2050
- return `${day}T00:00:00${zone.slice(0, 3)}:${zone.slice(3)}`;
2051
- }
2052
- function numberIn(cell) {
2053
- const value = Number(cell?.value ?? Number.NaN);
2054
- return Number.isFinite(value) ? value : 0;
2055
- }
2056
- function textIn(cell) {
2057
- const value = cell?.value;
2058
- return value === null || value === void 0 ? "" : String(value).trim();
2059
- }
2060
- function asDate(unix) {
2061
- return new Date(unix * MILLIS).toISOString().slice(0, DATE_CHARS);
2062
- }
2063
-
2064
3246
  // src/gateways.ts
2065
3247
  var Gateways = class {
2066
3248
  each;
@@ -2084,10 +3266,8 @@ var Gateways = class {
2084
3266
  * carrying the first refusal, because one gateway that agreed is enough to be
2085
3267
  * watched
2086
3268
  */
2087
- async watchPayment(params) {
2088
- const asked = await Promise.allSettled(
2089
- this.each.map((gateway) => gateway.watchPayment(params))
2090
- );
3269
+ async watch(handover) {
3270
+ const asked = await Promise.allSettled(this.each.map((gateway) => gateway.watch(handover)));
2091
3271
  const taken = [];
2092
3272
  const refusals3 = [];
2093
3273
  for (const [at, answer] of asked.entries()) {
@@ -2109,7 +3289,7 @@ var Gateways = class {
2109
3289
  * saying so. When they all end without a payment, the first ending is the answer,
2110
3290
  * and when they all fail, the first failure is thrown
2111
3291
  */
2112
- async waitForWatched(id, options) {
3292
+ async settled(held, options) {
2113
3293
  const stopLosers = new AbortController();
2114
3294
  const signal = options?.signal ? AbortSignal.any([stopLosers.signal, options.signal]) : stopLosers.signal;
2115
3295
  const unpaid = [];
@@ -2124,7 +3304,7 @@ var Gateways = class {
2124
3304
  }
2125
3305
  };
2126
3306
  for (const gateway of this.each) {
2127
- gateway.waitForWatched(id, { ...options, signal }).then((watched) => {
3307
+ gateway.settled(held, { ...options, signal }).then((watched) => {
2128
3308
  if (watched.status === "paid") {
2129
3309
  return resolve2(watched);
2130
3310
  }
@@ -2148,314 +3328,29 @@ var Gateways = class {
2148
3328
  }
2149
3329
  }
2150
3330
  };
2151
-
2152
- // src/price.ts
2153
- var MSAT_PER_BTC2 = 1e11;
2154
- var BASIS_POINTS = 1e4;
2155
- var DEFAULT_HOLD_SECS = 60;
2156
- var DEFAULT_MIN_VENUES = 2;
2157
- var DEFAULT_MAX_SPREAD_BPS = 200;
2158
- var MILLIS2 = 1e3;
2159
- function coinbase(baseUrl = "https://api.coinbase.com") {
2160
- return async (currency) => {
2161
- const asked = currency.toUpperCase();
2162
- const answer = await asJson(`${baseUrl}/v2/prices/BTC-${asked}/spot`, "coinbase");
2163
- const quoted = answer.data;
2164
- if (quoted?.currency !== asked) {
2165
- throw new Error(`coinbase quoted ${quoted?.currency} when asked for ${asked}`);
2166
- }
2167
- return asMinor(quoted.amount, "coinbase", asked);
2168
- };
2169
- }
2170
- function kraken(baseUrl = "https://api.kraken.com") {
2171
- return async (currency) => {
2172
- const asked = currency.toUpperCase();
2173
- const answer = await asJson(`${baseUrl}/0/public/Ticker?pair=XBT${asked}`, "kraken");
2174
- if (answer.error?.length) {
2175
- throw new Error(`kraken refused XBT${asked}: ${answer.error[0]}`);
2176
- }
2177
- const pairs = Object.values(answer.result ?? {});
2178
- if (pairs.length !== 1) {
2179
- throw new Error(`kraken answered ${pairs.length} pairs for XBT${asked}`);
2180
- }
2181
- return asMinor(pairs[0]?.c?.[0], "kraken", asked);
2182
- };
2183
- }
2184
- function bitstamp(baseUrl = "https://www.bitstamp.net") {
2185
- return async (currency) => {
2186
- const pair = `btc${currency.toLowerCase()}`;
2187
- const answer = await asJson(`${baseUrl}/api/v2/ticker/${pair}/`, "bitstamp");
2188
- if (Array.isArray(answer)) {
2189
- throw new Error(`bitstamp has no ${pair} pair`);
2190
- }
2191
- return asMinor(answer.last, "bitstamp", currency);
2192
- };
2193
- }
2194
- function coinmate(baseUrl = "https://coinmate.io") {
2195
- return async (currency) => {
2196
- const pair = `BTC_${currency.toUpperCase()}`;
2197
- const answer = await asJson(`${baseUrl}/api/ticker?currencyPair=${pair}`, "coinmate");
2198
- if (answer.error !== false) {
2199
- throw new Error(`coinmate refused ${pair}: ${answer.errorMessage ?? "no reason"}`);
2200
- }
2201
- return asMinor(answer.data?.last, "coinmate", currency);
2202
- };
2203
- }
2204
- function medianOf(tickers = [coinbase(), kraken(), bitstamp(), coinmate()], options = {}) {
2205
- const holdFor = (options.holdForSecs ?? DEFAULT_HOLD_SECS) * MILLIS2;
2206
- const minVenues = options.minVenues ?? DEFAULT_MIN_VENUES;
2207
- const maxSpread = options.maxSpreadBps ?? DEFAULT_MAX_SPREAD_BPS;
2208
- const held = /* @__PURE__ */ new Map();
2209
- return async (currency) => {
2210
- const asked = currency.toUpperCase();
2211
- const remembered = held.get(asked);
2212
- if (remembered && Date.now() - remembered.at < holdFor) {
2213
- return remembered.price;
2214
- }
2215
- const answers = await Promise.allSettled(tickers.map((ticker) => ticker(asked)));
2216
- const quoted = answers.filter((answer) => answer.status === "fulfilled").map((answer) => answer.value).sort((one, other) => one - other);
2217
- if (quoted.length < minVenues) {
2218
- throw new Error(
2219
- `${quoted.length} of ${tickers.length} venues quoted ${asked}, ${minVenues} wanted: ${refusals2(answers)}`
2220
- );
2221
- }
2222
- const spread = spreadBpsOf(quoted);
2223
- if (spread > maxSpread) {
2224
- throw new Error(
2225
- `venues disagree on ${asked} by ${Math.round(spread)} bps, more than the ${maxSpread} allowed: ${quoted.join(", ")}`
2226
- );
2227
- }
2228
- const price = middleOf(quoted);
2229
- held.set(asked, { at: Date.now(), price });
2230
- return price;
2231
- };
2232
- }
2233
- function msatFor(amountMinor, priceMinorPerBtc, options = {}) {
2234
- if (!Number.isInteger(amountMinor) || amountMinor <= 0) {
2235
- throw new Error("amountMinor must be a whole number of minor units above zero");
2236
- }
2237
- if (!Number.isFinite(priceMinorPerBtc) || priceMinorPerBtc <= 0) {
2238
- throw new Error(`${priceMinorPerBtc} is not a usable price`);
2239
- }
2240
- const spreadBps = options.spreadBps ?? 0;
2241
- if (!Number.isInteger(spreadBps) || spreadBps < 0) {
2242
- throw new Error("spreadBps must be a whole number of basis points, none or more");
2243
- }
2244
- const wanted = BigInt(amountMinor) * BigInt(MSAT_PER_BTC2) * BigInt(BASIS_POINTS + spreadBps);
2245
- const per = BigInt(Math.round(priceMinorPerBtc)) * BigInt(BASIS_POINTS);
2246
- const msat = (wanted + per - 1n) / per;
2247
- if (msat > BigInt(Number.MAX_SAFE_INTEGER)) {
2248
- throw new Error(`${amountMinor} at ${priceMinorPerBtc} is more millisatoshi than exist`);
2249
- }
2250
- return Number(msat);
2251
- }
2252
- async function asJson(url, venue) {
2253
- const answer = await fetch(url, { headers: { accept: "application/json" } });
2254
- if (!answer.ok) {
2255
- throw new Error(`${venue} answered ${answer.status}`);
2256
- }
2257
- return answer.json();
2258
- }
2259
- function asMinor(quoted, venue, currency) {
2260
- const major2 = Number(quoted);
2261
- if (!Number.isFinite(major2) || major2 <= 0) {
2262
- throw new Error(`${venue} quoted ${String(quoted)} for ${currency.toUpperCase()}`);
2263
- }
2264
- return Math.round(major2 * minorScaleOf(currency));
2265
- }
2266
- function spreadBpsOf(sorted) {
2267
- const lowest = sorted[0] ?? 0;
2268
- const highest = sorted[sorted.length - 1] ?? 0;
2269
- return lowest === 0 ? Number.POSITIVE_INFINITY : (highest - lowest) / lowest * BASIS_POINTS;
2270
- }
2271
- function middleOf(sorted) {
2272
- const middle = Math.floor(sorted.length / 2);
2273
- if (sorted.length % 2 === 1) {
2274
- return sorted[middle] ?? 0;
2275
- }
2276
- return Math.round(((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2);
2277
- }
2278
- function refusals2(answers) {
2279
- return answers.filter((answer) => answer.status === "rejected").map(
2280
- (answer) => answer.reason instanceof Error ? answer.reason.message : String(answer.reason)
2281
- ).join("; ");
2282
- }
2283
-
2284
- // src/qr.ts
2285
- import { encode } from "uqr";
2286
- var SAFE_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$|^rgba?\([\d.,%/\s]+\)$|^[a-z]+$/i;
2287
- function encodeForQr(destination) {
2288
- const scanned = isLnAddress(destination) ? destination : destination.toUpperCase();
2289
- return `LIGHTNING:${scanned}`;
2290
- }
2291
- function invoiceToSvg(destination, options) {
2292
- return svgOf(encodeForQr(destination), options);
2293
- }
2294
- function invoiceToDataUrl(destination, options) {
2295
- return asDataUrl(invoiceToSvg(destination, options));
2296
- }
2297
- function lnurlToSvg(endpoint, options) {
2298
- return svgOf(toLnurl(endpoint), options);
2299
- }
2300
- function lnurlToDataUrl(endpoint, options) {
2301
- return asDataUrl(lnurlToSvg(endpoint, options));
2302
- }
2303
- function spdToSvg(spd, options) {
2304
- return svgOf(spd, options);
2305
- }
2306
- function spdToDataUrl(spd, options) {
2307
- return asDataUrl(spdToSvg(spd, options));
2308
- }
2309
- function qrToSvg(payload, options) {
2310
- return svgOf(payload, options);
2311
- }
2312
- function qrToDataUrl(payload, options) {
2313
- return asDataUrl(qrToSvg(payload, options));
2314
- }
2315
- function svgOf(scanned, options) {
2316
- const size = options?.size ?? 256;
2317
- const color = options?.color ?? "#000";
2318
- if (!SAFE_COLOR.test(color)) {
2319
- throw new Error(`Invalid color: ${color}`);
2320
- }
2321
- return renderSvg(scanned, size, color);
2322
- }
2323
- function asDataUrl(svg) {
2324
- return `data:image/svg+xml,${encodeURIComponent(svg)}`;
2325
- }
2326
- function isLnAddress(destination) {
2327
- const at = destination.indexOf("@");
2328
- return at > 0 && destination.slice(at + 1).includes(".");
2329
- }
2330
- function renderSvg(content, size, color) {
2331
- const { data } = encode(content);
2332
- const modules = data.length;
2333
- const cellSize = size / modules;
2334
- let path = "";
2335
- for (let y = 0; y < modules; y++) {
2336
- for (let x = 0; x < modules; x++) {
2337
- if (data[y][x]) {
2338
- path += `M${x * cellSize},${y * cellSize}h${cellSize}v${cellSize}h-${cellSize}z`;
2339
- }
2340
- }
2341
- }
2342
- return [
2343
- `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">`,
2344
- `<rect width="100%" height="100%" fill="white"/>`,
2345
- `<path d="${path}" fill="${color}"/>`,
2346
- `</svg>`
2347
- ].join("");
2348
- }
2349
-
2350
- // src/rail.ts
2351
- var BANK = "bank";
2352
- var LIGHTNING = "lightning";
2353
- function bankRail(config) {
2354
- return async (order) => {
2355
- const expiresAt = config.expiresAt(order);
2356
- const transfer = await bankTransfer({
2357
- gateway: config.gateway,
2358
- secret: config.secret,
2359
- iban: config.iban,
2360
- verifyUrl: config.verifyUrl,
2361
- reference: order.reference,
2362
- amountMinor: order.amountMinor,
2363
- currency: order.currency,
2364
- expiresAt,
2365
- trigger: config.trigger,
2366
- replay: config.replay,
2367
- sealed: await config.sealed?.(order),
2368
- variableSymbol: config.variableSymbol?.(order),
2369
- webhookUrl: config.webhookUrl,
2370
- allowPublicGateway: config.allowPublicGateway
2371
- });
2372
- return {
2373
- id: transfer.id,
2374
- rail: config.name ?? BANK,
2375
- scan: transfer.spd,
2376
- qr: transfer.spd,
2377
- expiresAt
2378
- };
2379
- };
2380
- }
2381
- function lightningRail(config) {
2382
- return async (order) => {
2383
- const payment = await config.gateway.createPayment(
2384
- {
2385
- lnAddresses: config.lnAddresses,
2386
- amountMsat: await config.amountMsat(order),
2387
- webhookUrl: config.webhookUrl
2388
- },
2389
- {
2390
- idempotencyKey: config.idempotencyKey?.(order),
2391
- trigger: config.trigger,
2392
- replay: config.replay
2393
- }
2394
- );
2395
- return {
2396
- id: payment.id,
2397
- rail: config.name ?? LIGHTNING,
2398
- scan: payment.bolt11,
2399
- qr: encodeForQr(payment.bolt11),
2400
- expiresAt: payment.expiresAt
2401
- };
2402
- };
2403
- }
2404
3331
  export {
3332
+ AmountError,
2405
3333
  GatewayCheatError,
2406
3334
  Gateways,
2407
- IDEMPOTENCY_KEY_REUSED,
2408
3335
  IdempotencyConflictError,
2409
- NO_WALLET_AVAILABLE,
2410
3336
  NoWalletAvailableError,
2411
- PAYMENT_ALREADY_WATCHED,
2412
3337
  ProblemError,
2413
- REQUEST_IN_FLIGHT,
2414
3338
  ThunderBridge,
2415
3339
  UnverifiedRecipientError,
2416
3340
  WrapRefusedError,
2417
3341
  answerVerifyChallenge,
2418
- answerVerifyChallengeRequest,
2419
- answerWebhookChallenge,
2420
- answerWebhookChallengeRequest,
2421
- bankRail,
2422
- bankTransfer,
2423
- bankVerifyEndpoint,
2424
- bitstamp,
2425
- coinbase,
2426
- coinmate,
3342
+ carriesProof,
2427
3343
  decodeInvoice,
2428
- fioStatement,
2429
- invoiceToDataUrl,
2430
- invoiceToSvg,
2431
- isProblemType,
2432
- isProvablyPaid,
2433
- isProvablySettled,
2434
- kraken,
2435
- lightningRail,
2436
- lnurlToDataUrl,
2437
- lnurlToSvg,
2438
- medianOf,
2439
- minorScaleOf,
2440
- minorUnitsOf,
2441
- msatFor,
2442
- parseSettlement,
2443
- parseSettlementRequest,
2444
- parseWatchedWebhook,
2445
- parseWatchedWebhookRequest,
2446
- parseWebhook,
2447
- parseWebhookRequest,
3344
+ fiat,
3345
+ invoiceFrom,
3346
+ msat,
2448
3347
  preimageMatchesHash,
2449
3348
  proveOrigin,
2450
3349
  proveSettlement,
2451
3350
  proveWrapped,
2452
- qrToDataUrl,
2453
- qrToSvg,
3351
+ relayedVerifyUrl,
3352
+ sats,
2454
3353
  seal,
2455
- spdToDataUrl,
2456
- spdToSvg,
2457
- toLnurl,
2458
3354
  unseal,
2459
- verifyWebhookSignature,
2460
3355
  wrapFeeCeiling
2461
3356
  };