thunder-bridge 0.8.1 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,10 +12,11 @@ amount you asked for rather than the amount the gateway echoed back, and its
12
12
  settlement proof url belongs to the recipient. A gateway that substitutes an
13
13
  invoice is caught before a payer sees a QR code.
14
14
 
15
- Lightning is the rail it was built for, not the only one it can prove.
16
- `bankTransfer` derives a preimage and hands the gateway its hash, which puts money
17
- landing in a bank account behind the same watch and the same proof. `Statement` is
18
- the seam a bank plugs into and `fioStatement` is the first one.
15
+ Lightning is the rail it was built for, not the only one it can prove. A bank
16
+ transfer has no preimage, so `bankTransfer` derives one and hands the gateway its
17
+ hash, which puts money arriving in a bank account behind the same watch, the same
18
+ poll and the same proof. `Statement` is where a bank plugs in and `fioStatement`
19
+ is the first one.
19
20
 
20
21
  It touches only `fetch`, `crypto.subtle`, `URL` and `WebSocket`, so it runs in
21
22
  Node, Bun, Deno, Cloudflare Workers and the browser. The gateway it talks to is
@@ -36,7 +37,7 @@ CORS open, so the proof fetches work from a browser too.
36
37
  ```ts
37
38
  import { ThunderBridge, invoiceToSvg, type CreatePaymentParams } from "thunder-bridge";
38
39
 
39
- const gateway = new ThunderBridge("https://thunder-bridge-direct-production.up.railway.app");
40
+ const gateway = new ThunderBridge("https://thunder-bridge-production.up.railway.app");
40
41
 
41
42
  const request: CreatePaymentParams = {
42
43
  lnAddresses: ["alice@coinos.io", "alice@getalby.com"],
@@ -106,17 +107,38 @@ them and this table does not repeat them.
106
107
  | Export | What it does |
107
108
  |---|---|
108
109
  | `lnurlPayEndpoint(config)` | a whole LNURL-pay endpoint as one Fetch handler, so a static QR points at your domain |
109
- | `bankTransfer(params)` | register a Czech QR platba as a watched payment. Refuses a gateway with no token |
110
- | `bankVerifyEndpoint(config)` | the other half, the LUD-21 shape backed by your own statement |
111
- | `fioStatement(config)` | a `Statement` reading a Fio account, several tokens used strictly in turn |
112
110
  | `seal(secret, plaintext)`, `unseal` | the blob the gateway stores and cannot read |
113
111
  | `toLnurl(url)` | bech32-encode an endpoint url |
112
+ | `bankTransfer(params)` | register a Czech QR platba as a watched payment. Refuses a gateway that serves strangers |
113
+ | `bankVerifyEndpoint(config)` | the other half, the LUD-21 shape backed by your own statement |
114
+ | `fioStatement(config)` | a `Statement` reading a Fio account, several tokens used strictly in turn |
115
+
116
+ What your service answers once those handlers are mounted is written out in
117
+ [`openapi.yaml`](openapi.yaml), shipped with this package.
114
118
 
115
119
  `Statement` is a plain `(sinceUnix) => Promise<Credit[]>`, so another bank is
116
120
  another function of that shape and persistence wraps it from outside rather than
117
121
  living inside it. Nothing above it changes, and the package stays ignorant of
118
122
  whatever runtime you keep state in.
119
123
 
124
+ **One shape for every payment method** - [`src/rail.ts`](src/rail.ts)
125
+
126
+ | Export | What it does |
127
+ |---|---|
128
+ | `bankRail(config)` | a `Rail` selling for a bank transfer, reading back through any `Statement` |
129
+ | `lightningRail(config)` | a `Rail` where the gateway mints the invoice, so it learns the address and the amount |
130
+ | `blindLightningRail(config)` | a `Rail` that resolves the address itself and tells the gateway only a hash |
131
+
132
+ `Rail` is `(order: Order) => Promise<Leg>`. Everything that differs between rails
133
+ is bound once when the rail is built, so the only thing passed per sale is which
134
+ sale it is: a reference, an amount in minor units and a currency. A `Leg` reads
135
+ the same whichever rail made it, which is what lets `firstToSettle` take a mixed
136
+ list without being told what is in it.
137
+
138
+ The gateway is already indifferent to all of this. It holds a payment hash, polls
139
+ a verify URL and reports what came back, so a rail is an SDK-side arrangement of
140
+ calls the gateway already answers, not a plugin it has to load.
141
+
120
142
  **Pricing a fiat order** - [`src/price.ts`](src/price.ts), [`src/currency.ts`](src/currency.ts)
121
143
 
122
144
  | Export | What it does |
@@ -129,10 +151,12 @@ whatever runtime you keep state in.
129
151
  **QR codes** - [`src/qr.ts`](src/qr.ts)
130
152
 
131
153
  Every renderer returns a string, so they work on a server, in a worker and in a
132
- browser with no canvas involved. `invoiceToSvg` takes an invoice or a lightning
133
- address, `lnurlToSvg` takes your own endpoint url, `spdToSvg` takes the `spd` from
134
- `bankTransfer`. Each has a `…ToDataUrl` twin for an `<img>` `src`. A BOLT12 offer
135
- is not handled, because this gateway never returns one.
154
+ browser with no canvas involved. `qrToSvg` takes any rail's `Leg.qr` and needs to
155
+ know nothing else, because a rail states its own payload. Below it sit the
156
+ format-named ones for calling directly: `invoiceToSvg` takes an invoice or a
157
+ lightning address, `lnurlToSvg` takes your own endpoint url, `spdToSvg` takes a
158
+ Short Payment Descriptor. Each has a `…ToDataUrl` twin for an `<img>` `src`. A
159
+ BOLT12 offer is not handled, because this gateway never returns one.
136
160
 
137
161
  ```ts
138
162
  import { invoiceToSvg, lnurlToSvg } from "thunder-bridge";
package/dist/index.cjs CHANGED
@@ -30,9 +30,11 @@ __export(index_exports, {
30
30
  REQUEST_IN_FLIGHT: () => REQUEST_IN_FLIGHT,
31
31
  ThunderBridge: () => ThunderBridge,
32
32
  UnverifiedRecipientError: () => UnverifiedRecipientError,
33
+ bankRail: () => bankRail,
33
34
  bankTransfer: () => bankTransfer,
34
35
  bankVerifyEndpoint: () => bankVerifyEndpoint,
35
36
  bitstamp: () => bitstamp,
37
+ blindLightningRail: () => blindLightningRail,
36
38
  coinbase: () => coinbase,
37
39
  coinmate: () => coinmate,
38
40
  decodeInvoice: () => decodeInvoice,
@@ -42,6 +44,7 @@ __export(index_exports, {
42
44
  isProblemType: () => isProblemType,
43
45
  isProvablyPaid: () => isProvablyPaid,
44
46
  kraken: () => kraken,
47
+ lightningRail: () => lightningRail,
45
48
  lnurlPayEndpoint: () => lnurlPayEndpoint,
46
49
  lnurlToDataUrl: () => lnurlToDataUrl,
47
50
  lnurlToSvg: () => lnurlToSvg,
@@ -54,6 +57,8 @@ __export(index_exports, {
54
57
  preimageMatchesHash: () => preimageMatchesHash,
55
58
  proveOrigin: () => proveOrigin,
56
59
  proveSettlement: () => proveSettlement,
60
+ qrToDataUrl: () => qrToDataUrl,
61
+ qrToSvg: () => qrToSvg,
57
62
  seal: () => seal,
58
63
  spdToDataUrl: () => spdToDataUrl,
59
64
  spdToSvg: () => spdToSvg,
@@ -99,10 +104,8 @@ var ProblemError = class extends Error {
99
104
  this.detail = problem.detail ?? null;
100
105
  }
101
106
  };
102
- var NAMESPACE = "urn:problem-type:thunder-bridge:";
103
- var NAMESPACE_BEFORE_THE_RENAME = "urn:problem-type:thunder-bridge-direct:";
104
107
  function isProblemType(problem, type) {
105
- return problem.type === type || problem.type === type.replace(NAMESPACE, NAMESPACE_BEFORE_THE_RENAME);
108
+ return problem.type === type;
106
109
  }
107
110
  var NO_WALLET_AVAILABLE = "urn:problem-type:thunder-bridge:no-wallet-available";
108
111
  var REQUEST_IN_FLIGHT = "urn:problem-type:thunder-bridge:request-in-flight";
@@ -138,8 +141,8 @@ function hexToBytes(hex) {
138
141
  }
139
142
  return bytes;
140
143
  }
141
- function isHex(text2) {
142
- return /^[0-9a-f]+$/i.test(text2) && text2.length % 2 === 0;
144
+ function isHex(text3) {
145
+ return /^[0-9a-f]+$/i.test(text3) && text3.length % 2 === 0;
143
146
  }
144
147
 
145
148
  // ../core/sha256.ts
@@ -233,8 +236,8 @@ function sha256(bytes) {
233
236
  }
234
237
  return asBytes(state);
235
238
  }
236
- function sha256Hex(text2) {
237
- return bytesToHex(sha256(new TextEncoder().encode(text2)));
239
+ function sha256Hex(text3) {
240
+ return bytesToHex(sha256(new TextEncoder().encode(text3)));
238
241
  }
239
242
  function withPadding(bytes) {
240
243
  const padded = new Uint8Array(
@@ -413,10 +416,13 @@ function sameOrigin(one, other) {
413
416
  return false;
414
417
  }
415
418
  }
419
+ function publicAddress(literal) {
420
+ return literal.includes(":") ? ipv6IsGlobal(literal) : ipv4IsGlobal(literal);
421
+ }
416
422
  function hostIsPublic(host) {
417
- if (host.startsWith("[")) return ipv6IsGlobal(host.slice(1, -1));
423
+ if (host.startsWith("[")) return publicAddress(host.slice(1, -1));
418
424
  const name = host.replace(/\.$/, "");
419
- if (/^\d+\.\d+\.\d+\.\d+$/.test(name)) return ipv4IsGlobal(name);
425
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(name)) return publicAddress(name);
420
426
  const labels = name.split(".");
421
427
  if (labels.length < 2 || labels.some((label) => label === "")) return false;
422
428
  return !PRIVATE_SUFFIXES.includes(labels[labels.length - 1]);
@@ -681,6 +687,16 @@ var TERMINAL = /* @__PURE__ */ new Set(["paid", "expired"]);
681
687
  var RECONNECT_DELAY_MS = 3e3;
682
688
  var RECONNECT_CAP_MS = 3e4;
683
689
  var UNANSWERED_ATTEMPTS = 5;
690
+ var TRIGGER_MIN_CHARS = 16;
691
+ var STRANGER_PROBE = "is-this-gateway-yours";
692
+ function unguessable(trigger) {
693
+ if (trigger.length < TRIGGER_MIN_CHARS) {
694
+ throw new Error(
695
+ `a trigger secret is the only thing guarding its stream, and nothing rate limits a guess at it, so it has to be at least ${TRIGGER_MIN_CHARS} characters nobody can predict`
696
+ );
697
+ }
698
+ return trigger;
699
+ }
684
700
  function backoffMs(firstDelay, attempt) {
685
701
  const grown = Math.min(firstDelay * 2 ** (attempt - 1), RECONNECT_CAP_MS);
686
702
  return Math.round(grown * (0.5 + Math.random() / 2));
@@ -689,6 +705,7 @@ var ThunderBridge = class {
689
705
  baseUrl;
690
706
  verify;
691
707
  token;
708
+ strangers = null;
692
709
  constructor(baseUrl, options) {
693
710
  this.baseUrl = baseUrl.replace(/\/+$/, "");
694
711
  this.verify = options?.verify ?? true;
@@ -702,6 +719,21 @@ var ThunderBridge = class {
702
719
  get isPrivate() {
703
720
  return this.token !== null;
704
721
  }
722
+ /**
723
+ * Whether the gateway turns away a caller carrying no token, asked by making
724
+ * one unauthenticated read it would have to refuse. `isPrivate` answers only
725
+ * whether you configured a token, which is your side of the arrangement and
726
+ * says nothing about the gateway's, so a made-up token against a public
727
+ * instance reads as private and is not. Asked once and remembered, because an
728
+ * instance does not change its mind. Anything other than a refusal counts as
729
+ * open, so an unreachable gateway fails closed
730
+ */
731
+ async refusesStrangers() {
732
+ this.strangers ??= fetch(`${this.baseUrl}/incoming-payments/${STRANGER_PROBE}`, {
733
+ headers: { accept: "application/json" }
734
+ }).then((answer) => answer.status === 401).catch(() => false);
735
+ return await this.strangers;
736
+ }
705
737
  /**
706
738
  * Ask the gateway for an invoice payable to the first address on your list
707
739
  * that can issue a provable one, throws `NoWalletAvailableError` when none can
@@ -713,7 +745,10 @@ var ThunderBridge = class {
713
745
  const response = await fetch(`${this.baseUrl}/incoming-payments`, {
714
746
  method: "POST",
715
747
  headers,
716
- body: createRequestBody(params, options?.trigger ? sha256Hex(options.trigger) : null)
748
+ body: createRequestBody(
749
+ params,
750
+ options?.trigger ? sha256Hex(unguessable(options.trigger)) : null
751
+ )
717
752
  });
718
753
  if (!response.ok) throw await problemFrom(response);
719
754
  const payment = await paymentFrom(response);
@@ -955,7 +990,10 @@ var ThunderBridge = class {
955
990
  const response = await fetch(`${this.baseUrl}/watched-payments`, {
956
991
  method: "POST",
957
992
  headers: this.sending(),
958
- body: watchRequestBody(params, params.trigger ? sha256Hex(params.trigger) : null)
993
+ body: watchRequestBody(
994
+ params,
995
+ params.trigger ? sha256Hex(unguessable(params.trigger)) : null
996
+ )
959
997
  });
960
998
  if (!response.ok) throw await problemFrom(response);
961
999
  const watched = triggerEventFromWire(await response.json().catch(() => null));
@@ -974,7 +1012,7 @@ var ThunderBridge = class {
974
1012
  */
975
1013
  followTrigger(secret, options) {
976
1014
  const base = this.baseUrl.replace(/^http/, "ws");
977
- const direct = `${base}/ws/triggers/${encodeURIComponent(secret)}`;
1015
+ const direct = `${base}/ws/triggers/${encodeURIComponent(unguessable(secret))}`;
978
1016
  const firstDelay = options.reconnectDelayMs ?? RECONNECT_DELAY_MS;
979
1017
  let socket = null;
980
1018
  let retry;
@@ -1146,10 +1184,10 @@ function toBase64Url(bytes) {
1146
1184
  for (const byte of bytes) binary += String.fromCharCode(byte);
1147
1185
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1148
1186
  }
1149
- function fromBase64Url(text2) {
1150
- if (text2.length === 0 || !/^[A-Za-z0-9_-]+$/.test(text2)) return null;
1187
+ function fromBase64Url(text3) {
1188
+ if (text3.length === 0 || !/^[A-Za-z0-9_-]+$/.test(text3)) return null;
1151
1189
  try {
1152
- const binary = atob(text2.replace(/-/g, "+").replace(/_/g, "/"));
1190
+ const binary = atob(text3.replace(/-/g, "+").replace(/_/g, "/"));
1153
1191
  const bytes = new Uint8Array(binary.length);
1154
1192
  for (let at = 0; at < bytes.length; at++) bytes[at] = binary.charCodeAt(at);
1155
1193
  return bytes;
@@ -1181,6 +1219,87 @@ function asBuffer(bytes) {
1181
1219
  return bytes instanceof Uint8Array && bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
1182
1220
  }
1183
1221
 
1222
+ // ../core/outbound.ts
1223
+ var import_promises = require("dns/promises");
1224
+ var import_promises2 = require("timers/promises");
1225
+ var HTTP_TIMEOUT_MS2 = 15e3;
1226
+ var LOOKUP_TIMEOUT_MS = 5e3;
1227
+ var HOPS_FOLLOWED = 2;
1228
+ var REDIRECTS = [301, 302, 303, 307, 308];
1229
+ var KEEPS_THE_METHOD = [307, 308];
1230
+ var CREDENTIALS = ["authorization", "cookie", "proxy-authorization"];
1231
+ var BODY_LIMIT_BYTES = 262144;
1232
+ async function ask(url, sent = {}) {
1233
+ const capped = AbortSignal.timeout(HTTP_TIMEOUT_MS2);
1234
+ const signal = sent.deadline ? AbortSignal.any([sent.deadline, capped]) : capped;
1235
+ let target = url;
1236
+ let carried = sent;
1237
+ for (let hop = 0; hop <= HOPS_FOLLOWED; hop += 1) {
1238
+ await refuseUnlessPublic(target);
1239
+ const response = await fetch(target, {
1240
+ method: carried.method ?? "GET",
1241
+ headers: carried.headers ?? {},
1242
+ body: carried.body,
1243
+ redirect: "manual",
1244
+ signal
1245
+ });
1246
+ if (!REDIRECTS.includes(response.status)) return await answerOf(response);
1247
+ const location = response.headers.get("location");
1248
+ if (location === null) {
1249
+ throw new Error(`${target} answered ${response.status} without saying where to`);
1250
+ }
1251
+ await response.body?.cancel();
1252
+ const next = new URL(location, target).toString();
1253
+ carried = KEEPS_THE_METHOD.includes(response.status) ? carried : withoutBody(carried);
1254
+ carried = sameOrigin(target, next) ? carried : withoutCredentials(carried);
1255
+ target = next;
1256
+ }
1257
+ throw new Error(`${url} redirected more than ${HOPS_FOLLOWED} times`);
1258
+ }
1259
+ async function resolvesNothingPrivate(url) {
1260
+ const host = new URL(url).hostname.replace(/^\[|]$/g, "");
1261
+ const found = await Promise.race([
1262
+ (0, import_promises.lookup)(host, { all: true, verbatim: true }).catch(() => []),
1263
+ (0, import_promises2.setTimeout)(LOOKUP_TIMEOUT_MS, null, { ref: false })
1264
+ ]);
1265
+ return found !== null && found.every((one) => publicAddress(one.address));
1266
+ }
1267
+ async function refuseUnlessPublic(url) {
1268
+ if (!publicHttps(url)) throw new Error(`${url} is not a public https URL`);
1269
+ if (!await resolvesNothingPrivate(url)) throw new Error(`${url} resolves to an address we do not reach`);
1270
+ }
1271
+ async function answerOf(response) {
1272
+ const { status, ok } = response;
1273
+ const reader = response.body?.getReader();
1274
+ if (!reader) return { status, ok, body: "", truncated: false };
1275
+ const read = [];
1276
+ let bytes = 0;
1277
+ while (bytes <= BODY_LIMIT_BYTES) {
1278
+ const { done, value } = await reader.read();
1279
+ if (done) return { status, ok, body: text2(read), truncated: false };
1280
+ read.push(value);
1281
+ bytes += value.length;
1282
+ }
1283
+ await reader.cancel();
1284
+ return { status, ok, body: text2(read), truncated: true };
1285
+ }
1286
+ function withoutBody(sent) {
1287
+ const headers = { ...sent.headers };
1288
+ delete headers["content-type"];
1289
+ return { ...sent, method: "GET", headers, body: void 0 };
1290
+ }
1291
+ function withoutCredentials(sent) {
1292
+ const headers = { ...sent.headers };
1293
+ for (const named of CREDENTIALS) delete headers[named];
1294
+ return { ...sent, headers };
1295
+ }
1296
+ function text2(chunks) {
1297
+ const decoder = new TextDecoder();
1298
+ let decoded = "";
1299
+ for (const chunk of chunks) decoded += decoder.decode(chunk, { stream: true });
1300
+ return decoded + decoder.decode();
1301
+ }
1302
+
1184
1303
  // ../core/refusal.ts
1185
1304
  var WalletRefused = class extends Error {
1186
1305
  reason;
@@ -1199,7 +1318,6 @@ var NoWalletAvailable = class extends Error {
1199
1318
 
1200
1319
  // ../core/lnurl.ts
1201
1320
  var VERIFY_WITHOUT_PREIMAGE = ["zeuspay.com", "zeusnuts.com", "ecash.love"];
1202
- var HTTP_TIMEOUT_MS2 = 15e3;
1203
1321
  var BECH32_CHARSET2 = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
1204
1322
  var BECH32_GENERATOR = [996825010, 642813549, 513874426, 1027748829, 705979059];
1205
1323
  var CHECKSUM_SLOTS = [0, 1, 2, 3, 4, 5];
@@ -1364,14 +1482,12 @@ function splitAddress(address) {
1364
1482
  return [user, domain];
1365
1483
  }
1366
1484
  async function fetchJson(url, deadline) {
1367
- if (!publicHttps(url)) throw new Error(`${url} is not a public https URL`);
1368
- const capped = AbortSignal.timeout(HTTP_TIMEOUT_MS2);
1369
- const response = await fetch(url, {
1370
- signal: deadline ? AbortSignal.any([deadline, capped]) : capped,
1371
- headers: { accept: "application/json" }
1372
- });
1373
- if (!response.ok) throw new Error(`${url} answered ${response.status}`);
1374
- return await response.json();
1485
+ const answer = await ask(url, { headers: { accept: "application/json" }, deadline });
1486
+ if (!answer.ok) throw new Error(`${url} answered ${answer.status}`);
1487
+ if (answer.truncated) {
1488
+ throw new Error(`${url} answered with more than the ${BODY_LIMIT_BYTES} bytes we read`);
1489
+ }
1490
+ return JSON.parse(answer.body);
1375
1491
  }
1376
1492
 
1377
1493
  // src/trigger.ts
@@ -1515,6 +1631,7 @@ var FORBIDDEN_IN_SPD = "*";
1515
1631
  async function bankTransfer(params) {
1516
1632
  const currency = params.currency ?? DEFAULT_CURRENCY;
1517
1633
  refuseUnusable(params, currency);
1634
+ await refuseAnOpenGateway(params);
1518
1635
  const spd = shortPaymentDescriptor(params, currency);
1519
1636
  const subject = subjectOf(params.reference, params.amountMinor, currency);
1520
1637
  const polling = new URL(params.verifyUrl);
@@ -1581,12 +1698,14 @@ function shortPaymentDescriptor(params, currency) {
1581
1698
  function major(amountMinor, currency) {
1582
1699
  return (amountMinor / minorScaleOf(currency)).toFixed(minorUnitsOf(currency));
1583
1700
  }
1701
+ async function refuseAnOpenGateway(params) {
1702
+ if (params.allowPublicGateway === true) return;
1703
+ if (await params.gateway.refusesStrangers()) return;
1704
+ throw new Error(
1705
+ "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"
1706
+ );
1707
+ }
1584
1708
  function refuseUnusable(params, currency) {
1585
- if (!params.gateway.isPrivate && params.allowPublicGateway !== true) {
1586
- throw new Error(
1587
- "this gateway has no token, so it is not yours, and its operator would read the amount and the reference off every verify URL. Give it a token or say allowPublicGateway"
1588
- );
1589
- }
1590
1709
  if (!IBAN.test(params.iban)) throw new Error(`${params.iban} is not an IBAN`);
1591
1710
  if (!Number.isInteger(params.amountMinor) || params.amountMinor <= 0) {
1592
1711
  throw new Error("amountMinor must be a whole number of minor units above zero");
@@ -1625,6 +1744,7 @@ var WHERE_A_PAYER_WRITES = [
1625
1744
  USER_IDENTIFICATION,
1626
1745
  PAYER_REFERENCE
1627
1746
  ];
1747
+ var UTC_OFFSET = /^[+-]\d{4}$/;
1628
1748
  function fioStatement(config) {
1629
1749
  const named = typeof config.token === "string" ? [config.token] : config.token;
1630
1750
  if (named.length === 0) throw new Error("fioStatement needs at least one token to read with");
@@ -1677,9 +1797,21 @@ function asCredit(transaction) {
1677
1797
  amountMinor: Math.round(numberIn(transaction[AMOUNT]) * MINOR_UNITS),
1678
1798
  currency: textIn(transaction[CURRENCY]),
1679
1799
  reference: WHERE_A_PAYER_WRITES.map((column) => textIn(transaction[column])).filter((written) => written.length > 0).join(" "),
1680
- bookedAt: Math.floor(numberIn(transaction[BOOKED_AT]) / MILLIS)
1800
+ bookedAt: bookedAtIn(transaction[BOOKED_AT])
1681
1801
  };
1682
1802
  }
1803
+ function bookedAtIn(cell) {
1804
+ const value = cell?.value;
1805
+ if (typeof value === "number") return Math.floor(value / MILLIS);
1806
+ const at = Date.parse(asIso8601(textIn(cell)));
1807
+ return Number.isFinite(at) ? Math.floor(at / MILLIS) : 0;
1808
+ }
1809
+ function asIso8601(booked) {
1810
+ const day = booked.slice(0, DATE_CHARS);
1811
+ const zone = booked.slice(DATE_CHARS);
1812
+ if (!UTC_OFFSET.test(zone)) return `${day}T00:00:00Z`;
1813
+ return `${day}T00:00:00${zone.slice(0, 3)}:${zone.slice(3)}`;
1814
+ }
1683
1815
  function numberIn(cell) {
1684
1816
  const value = Number(cell?.value ?? Number.NaN);
1685
1817
  return Number.isFinite(value) ? value : 0;
@@ -1692,6 +1824,149 @@ function asDate(unix) {
1692
1824
  return new Date(unix * MILLIS).toISOString().slice(0, DATE_CHARS);
1693
1825
  }
1694
1826
 
1827
+ // src/qr.ts
1828
+ var import_uqr = require("uqr");
1829
+ var SAFE_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$|^rgba?\([\d.,%/\s]+\)$|^[a-z]+$/i;
1830
+ function encodeForQr(destination) {
1831
+ const scanned = isLnAddress(destination) ? destination : destination.toUpperCase();
1832
+ return `LIGHTNING:${scanned}`;
1833
+ }
1834
+ function invoiceToSvg(destination, options) {
1835
+ return svgOf(encodeForQr(destination), options);
1836
+ }
1837
+ function invoiceToDataUrl(destination, options) {
1838
+ return asDataUrl(invoiceToSvg(destination, options));
1839
+ }
1840
+ function lnurlToSvg(endpoint, options) {
1841
+ return svgOf(toLnurl(endpoint), options);
1842
+ }
1843
+ function lnurlToDataUrl(endpoint, options) {
1844
+ return asDataUrl(lnurlToSvg(endpoint, options));
1845
+ }
1846
+ function spdToSvg(spd, options) {
1847
+ return svgOf(spd, options);
1848
+ }
1849
+ function spdToDataUrl(spd, options) {
1850
+ return asDataUrl(spdToSvg(spd, options));
1851
+ }
1852
+ function qrToSvg(payload, options) {
1853
+ return svgOf(payload, options);
1854
+ }
1855
+ function qrToDataUrl(payload, options) {
1856
+ return asDataUrl(qrToSvg(payload, options));
1857
+ }
1858
+ function svgOf(scanned, options) {
1859
+ const size = options?.size ?? 256;
1860
+ const color = options?.color ?? "#000";
1861
+ if (!SAFE_COLOR.test(color)) throw new Error(`Invalid color: ${color}`);
1862
+ return renderSvg(scanned, size, color);
1863
+ }
1864
+ function asDataUrl(svg) {
1865
+ return `data:image/svg+xml,${encodeURIComponent(svg)}`;
1866
+ }
1867
+ function isLnAddress(destination) {
1868
+ const at = destination.indexOf("@");
1869
+ return at > 0 && destination.slice(at + 1).includes(".");
1870
+ }
1871
+ function renderSvg(content, size, color) {
1872
+ const { data } = (0, import_uqr.encode)(content);
1873
+ const modules = data.length;
1874
+ const cellSize = size / modules;
1875
+ let path = "";
1876
+ for (let y = 0; y < modules; y++) {
1877
+ for (let x = 0; x < modules; x++) {
1878
+ if (data[y][x]) {
1879
+ path += `M${x * cellSize},${y * cellSize}h${cellSize}v${cellSize}h-${cellSize}z`;
1880
+ }
1881
+ }
1882
+ }
1883
+ return [
1884
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">`,
1885
+ `<rect width="100%" height="100%" fill="white"/>`,
1886
+ `<path d="${path}" fill="${color}"/>`,
1887
+ `</svg>`
1888
+ ].join("");
1889
+ }
1890
+
1891
+ // src/rail.ts
1892
+ var BANK = "bank";
1893
+ var LIGHTNING = "lightning";
1894
+ function bankRail(config) {
1895
+ return async (order) => {
1896
+ const expiresAt = config.expiresAt(order);
1897
+ const transfer = await bankTransfer({
1898
+ gateway: config.gateway,
1899
+ secret: config.secret,
1900
+ iban: config.iban,
1901
+ verifyUrl: config.verifyUrl,
1902
+ reference: order.reference,
1903
+ amountMinor: order.amountMinor,
1904
+ currency: order.currency,
1905
+ expiresAt,
1906
+ trigger: config.trigger,
1907
+ sealed: await config.sealed?.(order),
1908
+ variableSymbol: config.variableSymbol?.(order),
1909
+ allowPublicGateway: config.allowPublicGateway
1910
+ });
1911
+ return {
1912
+ id: transfer.id,
1913
+ rail: config.name ?? BANK,
1914
+ scan: transfer.spd,
1915
+ qr: transfer.spd,
1916
+ expiresAt
1917
+ };
1918
+ };
1919
+ }
1920
+ function lightningRail(config) {
1921
+ return async (order) => {
1922
+ const payment = await config.gateway.createPayment(
1923
+ {
1924
+ lnAddresses: config.lnAddresses,
1925
+ amountMsat: await config.amountMsat(order),
1926
+ webhookUrl: config.webhookUrl,
1927
+ webhookSecret: config.webhookSecret
1928
+ },
1929
+ { idempotencyKey: config.idempotencyKey?.(order), trigger: config.trigger }
1930
+ );
1931
+ return {
1932
+ id: payment.id,
1933
+ rail: config.name ?? LIGHTNING,
1934
+ scan: payment.bolt11,
1935
+ qr: encodeForQr(payment.bolt11),
1936
+ expiresAt: payment.expiresAt
1937
+ };
1938
+ };
1939
+ }
1940
+ function blindLightningRail(config) {
1941
+ return async (order) => {
1942
+ const resolved = await invoiceFrom(config.lnAddresses, await config.amountMsat(order));
1943
+ const watched = await config.gateway.watchPayment({
1944
+ paymentHash: resolved.paymentHash,
1945
+ verifyUrl: resolved.verifyUrl,
1946
+ expiresAt: resolved.expiresAt,
1947
+ trigger: config.trigger,
1948
+ sealed: await config.sealed?.(order)
1949
+ });
1950
+ return {
1951
+ id: watched.id,
1952
+ rail: config.name ?? LIGHTNING,
1953
+ scan: resolved.bolt11,
1954
+ qr: encodeForQr(resolved.bolt11),
1955
+ expiresAt: resolved.expiresAt
1956
+ };
1957
+ };
1958
+ }
1959
+ async function invoiceFrom(lnAddresses, amountMsat) {
1960
+ try {
1961
+ return await resolve(lnAddresses, amountMsat);
1962
+ } catch (refused) {
1963
+ if (refused instanceof NoWalletAvailable) {
1964
+ throw new NoWalletAvailableError({ title: refused.message }, refused.wallets);
1965
+ }
1966
+ throw refused;
1967
+ }
1968
+ }
1969
+
1695
1970
  // src/price.ts
1696
1971
  var MSAT_PER_BTC2 = 1e11;
1697
1972
  var BASIS_POINTS = 1e4;
@@ -1814,64 +2089,6 @@ function refusals2(answers) {
1814
2089
  ).join("; ");
1815
2090
  }
1816
2091
 
1817
- // src/qr.ts
1818
- var import_uqr = require("uqr");
1819
- var SAFE_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$|^rgba?\([\d.,%/\s]+\)$|^[a-z]+$/i;
1820
- function encodeForQr(destination) {
1821
- const scanned = isLnAddress(destination) ? destination : destination.toUpperCase();
1822
- return `LIGHTNING:${scanned}`;
1823
- }
1824
- function invoiceToSvg(destination, options) {
1825
- return svgOf(encodeForQr(destination), options);
1826
- }
1827
- function invoiceToDataUrl(destination, options) {
1828
- return asDataUrl(invoiceToSvg(destination, options));
1829
- }
1830
- function lnurlToSvg(endpoint, options) {
1831
- return svgOf(toLnurl(endpoint), options);
1832
- }
1833
- function lnurlToDataUrl(endpoint, options) {
1834
- return asDataUrl(lnurlToSvg(endpoint, options));
1835
- }
1836
- function spdToSvg(spd, options) {
1837
- return svgOf(spd, options);
1838
- }
1839
- function spdToDataUrl(spd, options) {
1840
- return asDataUrl(spdToSvg(spd, options));
1841
- }
1842
- function svgOf(scanned, options) {
1843
- const size = options?.size ?? 256;
1844
- const color = options?.color ?? "#000";
1845
- if (!SAFE_COLOR.test(color)) throw new Error(`Invalid color: ${color}`);
1846
- return renderSvg(scanned, size, color);
1847
- }
1848
- function asDataUrl(svg) {
1849
- return `data:image/svg+xml,${encodeURIComponent(svg)}`;
1850
- }
1851
- function isLnAddress(destination) {
1852
- const at = destination.indexOf("@");
1853
- return at > 0 && destination.slice(at + 1).includes(".");
1854
- }
1855
- function renderSvg(content, size, color) {
1856
- const { data } = (0, import_uqr.encode)(content);
1857
- const modules = data.length;
1858
- const cellSize = size / modules;
1859
- let path = "";
1860
- for (let y = 0; y < modules; y++) {
1861
- for (let x = 0; x < modules; x++) {
1862
- if (data[y][x]) {
1863
- path += `M${x * cellSize},${y * cellSize}h${cellSize}v${cellSize}h-${cellSize}z`;
1864
- }
1865
- }
1866
- }
1867
- return [
1868
- `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">`,
1869
- `<rect width="100%" height="100%" fill="white"/>`,
1870
- `<path d="${path}" fill="${color}"/>`,
1871
- `</svg>`
1872
- ].join("");
1873
- }
1874
-
1875
2092
  // src/webhook.ts
1876
2093
  var SIGNATURE_HEADER = "x-signature";
1877
2094
  var TIMESTAMP_HEADER = "x-timestamp";
@@ -1884,9 +2101,9 @@ async function verifyWebhookSignature(body, signature, secret, timestamp, option
1884
2101
  }
1885
2102
  async function parseWebhook(body, signature, secret, timestamp, options = {}) {
1886
2103
  if (!await verifyWebhookSignature(body, signature, secret, timestamp, options)) return null;
1887
- const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
2104
+ const text3 = typeof body === "string" ? body : new TextDecoder().decode(body);
1888
2105
  try {
1889
- return paymentFromWire(JSON.parse(text2));
2106
+ return paymentFromWire(JSON.parse(text3));
1890
2107
  } catch {
1891
2108
  return null;
1892
2109
  }
@@ -1926,9 +2143,11 @@ function signed(timestamp, body) {
1926
2143
  REQUEST_IN_FLIGHT,
1927
2144
  ThunderBridge,
1928
2145
  UnverifiedRecipientError,
2146
+ bankRail,
1929
2147
  bankTransfer,
1930
2148
  bankVerifyEndpoint,
1931
2149
  bitstamp,
2150
+ blindLightningRail,
1932
2151
  coinbase,
1933
2152
  coinmate,
1934
2153
  decodeInvoice,
@@ -1938,6 +2157,7 @@ function signed(timestamp, body) {
1938
2157
  isProblemType,
1939
2158
  isProvablyPaid,
1940
2159
  kraken,
2160
+ lightningRail,
1941
2161
  lnurlPayEndpoint,
1942
2162
  lnurlToDataUrl,
1943
2163
  lnurlToSvg,
@@ -1950,6 +2170,8 @@ function signed(timestamp, body) {
1950
2170
  preimageMatchesHash,
1951
2171
  proveOrigin,
1952
2172
  proveSettlement,
2173
+ qrToDataUrl,
2174
+ qrToSvg,
1953
2175
  seal,
1954
2176
  spdToDataUrl,
1955
2177
  spdToSvg,