thunder-bridge 2.1.1 → 2.2.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
@@ -98,11 +98,11 @@ var BLOCK_BYTES = 64;
98
98
  var LENGTH_BYTES = 8;
99
99
  var SCHEDULE_WORDS = 64;
100
100
  function sha256(bytes) {
101
- const padded = withPadding(bytes);
102
- const reader = new DataView(padded.buffer);
101
+ const padded2 = withPadding(bytes);
102
+ const reader = new DataView(padded2.buffer);
103
103
  const state = Uint32Array.from(INITIAL_STATE);
104
104
  const schedule = new Uint32Array(SCHEDULE_WORDS);
105
- for (let block = 0; block < padded.length; block += BLOCK_BYTES) {
105
+ for (let block = 0; block < padded2.length; block += BLOCK_BYTES) {
106
106
  fillSchedule(schedule, reader, block);
107
107
  compress(state, schedule);
108
108
  }
@@ -112,13 +112,13 @@ function sha256Hex(text3) {
112
112
  return bytesToHex(sha256(new TextEncoder().encode(text3)));
113
113
  }
114
114
  function withPadding(bytes) {
115
- const padded = new Uint8Array(
115
+ const padded2 = new Uint8Array(
116
116
  bytes.length + 1 + LENGTH_BYTES + BLOCK_BYTES - 1 & ~(BLOCK_BYTES - 1)
117
117
  );
118
- padded.set(bytes);
119
- padded[bytes.length] = 128;
120
- new DataView(padded.buffer).setUint32(padded.length - 4, bytes.length * 8, false);
121
- return padded;
118
+ padded2.set(bytes);
119
+ padded2[bytes.length] = 128;
120
+ new DataView(padded2.buffer).setUint32(padded2.length - 4, bytes.length * 8, false);
121
+ return padded2;
122
122
  }
123
123
  function fillSchedule(schedule, reader, block) {
124
124
  for (let at = 0; at < 16; at++) {
@@ -355,63 +355,86 @@ function asBuffer(bytes) {
355
355
  // ../core/sealed.ts
356
356
  var VERSION = "v2";
357
357
  var UNPADDED_VERSION = "v1";
358
+ var PURPOSE_VERSION = "v3";
358
359
  var IV_BYTES = 12;
359
360
  var MIN_SECRET_CHARS = 32;
360
361
  var MAX_PLAIN_BYTES = 3e3;
361
362
  var SIZE_CLASSES = [256, 1024, MAX_PLAIN_BYTES];
362
363
  var END_OF_TEXT = 128;
363
- var INFO = new TextEncoder().encode("thunder-bridge/sealed");
364
+ var INFO = "thunder-bridge/sealed";
364
365
  async function seal(secret, plaintext, paymentHash) {
365
366
  return await sealUnder(
366
- secret,
367
+ await keyFor(secret, INFO),
367
368
  plaintext,
368
369
  crypto.getRandomValues(new Uint8Array(IV_BYTES)),
370
+ VERSION,
369
371
  boundTo(paymentHash)
370
372
  );
371
373
  }
372
- async function sealStable(secret, plaintext) {
374
+ async function sealFor(purpose, secret, plaintext, { stable = false } = {}) {
375
+ return await sealUnder(
376
+ await keyFor(secret, `${INFO}/${purpose}`),
377
+ plaintext,
378
+ stable ? await nonceOf(secret, plaintext) : crypto.getRandomValues(new Uint8Array(IV_BYTES)),
379
+ PURPOSE_VERSION,
380
+ new TextEncoder().encode(PURPOSE_VERSION)
381
+ );
382
+ }
383
+ async function unsealFor(purpose, secret, sealed) {
384
+ if (!sealed.startsWith(`${PURPOSE_VERSION}.`)) {
385
+ return await unseal(secret, sealed);
386
+ }
387
+ return await opened(
388
+ await keyFor(secret, `${INFO}/${purpose}`),
389
+ sealed.slice(PURPOSE_VERSION.length + 1),
390
+ new TextEncoder().encode(PURPOSE_VERSION)
391
+ );
392
+ }
393
+ async function nonceOf(secret, plaintext) {
373
394
  const derived = await hmacHex(secret, `sealed-nonce|${plaintext}`);
374
395
  const nonce = new Uint8Array(IV_BYTES);
375
396
  for (let at = 0; at < IV_BYTES; at++) {
376
397
  nonce[at] = Number.parseInt(derived.slice(at * 2, at * 2 + 2), 16);
377
398
  }
378
- return await sealUnder(secret, plaintext, nonce, boundTo(void 0));
399
+ return nonce;
379
400
  }
380
- async function sealUnder(secret, plaintext, iv, additionalData) {
401
+ async function sealUnder(key, plaintext, iv, version, additionalData) {
381
402
  const body = padToClass(plaintext);
382
- const key = await keyFor(secret);
383
403
  const cipher = new Uint8Array(
384
404
  await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData }, key, body)
385
405
  );
386
- const joined = new Uint8Array(iv.length + cipher.length);
387
- joined.set(iv);
388
- joined.set(cipher, iv.length);
389
- return `${VERSION}.${toBase64Url(joined)}`;
406
+ const joined2 = new Uint8Array(iv.length + cipher.length);
407
+ joined2.set(iv);
408
+ joined2.set(cipher, iv.length);
409
+ return `${version}.${toBase64Url(joined2)}`;
390
410
  }
391
411
  async function unseal(secret, sealed, paymentHash) {
392
- const key = await keyFor(secret);
393
- const padded = sealed.startsWith(`${VERSION}.`);
394
- const unbound = !padded && sealed.startsWith(`${UNPADDED_VERSION}.`);
395
- if (!padded && (!unbound || paymentHash !== void 0)) {
412
+ const key = await keyFor(secret, INFO);
413
+ const padded2 = sealed.startsWith(`${VERSION}.`);
414
+ const unbound = !padded2 && sealed.startsWith(`${UNPADDED_VERSION}.`);
415
+ if (!padded2 && (!unbound || paymentHash !== void 0)) {
396
416
  return null;
397
417
  }
398
- const joined = fromBase64Url(sealed.slice(VERSION.length + 1));
399
- if (joined === null || joined.length <= IV_BYTES) {
418
+ return await opened(
419
+ key,
420
+ sealed.slice(VERSION.length + 1),
421
+ padded2 ? boundTo(paymentHash) : void 0
422
+ );
423
+ }
424
+ async function opened(key, encoded, additionalData) {
425
+ const joined2 = fromBase64Url(encoded);
426
+ if (joined2 === null || joined2.length <= IV_BYTES) {
400
427
  return null;
401
428
  }
402
429
  try {
403
430
  const body = new Uint8Array(
404
431
  await crypto.subtle.decrypt(
405
- {
406
- name: "AES-GCM",
407
- iv: joined.slice(0, IV_BYTES),
408
- additionalData: padded ? boundTo(paymentHash) : void 0
409
- },
432
+ { name: "AES-GCM", iv: joined2.slice(0, IV_BYTES), additionalData },
410
433
  key,
411
- joined.slice(IV_BYTES)
434
+ joined2.slice(IV_BYTES)
412
435
  )
413
436
  );
414
- const text3 = padded ? unpad(body) : body;
437
+ const text3 = additionalData === void 0 ? body : unpad(body);
415
438
  return text3 === null ? null : new TextDecoder().decode(text3);
416
439
  } catch {
417
440
  return null;
@@ -442,7 +465,7 @@ function unpad(body) {
442
465
  const end = body.lastIndexOf(END_OF_TEXT);
443
466
  return end === -1 ? null : body.subarray(0, end);
444
467
  }
445
- async function keyFor(secret) {
468
+ async function keyFor(secret, info) {
446
469
  refuseAWeakSecret(secret);
447
470
  const material = await crypto.subtle.importKey(
448
471
  "raw",
@@ -452,7 +475,12 @@ async function keyFor(secret) {
452
475
  ["deriveKey"]
453
476
  );
454
477
  return crypto.subtle.deriveKey(
455
- { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: INFO },
478
+ {
479
+ name: "HKDF",
480
+ hash: "SHA-256",
481
+ salt: new Uint8Array(0),
482
+ info: new TextEncoder().encode(info)
483
+ },
456
484
  material,
457
485
  { name: "AES-GCM", length: 256 },
458
486
  false,
@@ -885,8 +913,8 @@ function hasSmallOrder(publicKeyHex) {
885
913
  return SMALL_ORDER.includes(bytesToHex(point));
886
914
  }
887
915
  function fromBase64Url2(text3) {
888
- const padded = text3.replace(/-/g, "+").replace(/_/g, "/");
889
- const raw = atob(padded + "=".repeat((4 - padded.length % 4) % 4));
916
+ const padded2 = text3.replace(/-/g, "+").replace(/_/g, "/");
917
+ const raw = atob(padded2 + "=".repeat((4 - padded2.length % 4) % 4));
890
918
  const bytes = new Uint8Array(raw.length);
891
919
  for (let at = 0; at < raw.length; at++) {
892
920
  bytes[at] = raw.charCodeAt(at);
@@ -901,7 +929,9 @@ function asBuffer2(bytes) {
901
929
  var KEY_HEADER = "x-client-key";
902
930
  var SIGNATURE_HEADER = "x-signature";
903
931
  var TIMESTAMP_HEADER = "x-timestamp";
932
+ var NONCE_HEADER = "x-nonce";
904
933
  var PREFIX = "ed25519=";
934
+ var NONCE_BYTES = 16;
905
935
  var SIGNING_LABEL = "request-signing";
906
936
  async function callerKey(secret) {
907
937
  return await signingKeyFromSeed(hexToBytes(await hmacHex(secret, SIGNING_LABEL)));
@@ -909,17 +939,21 @@ async function callerKey(secret) {
909
939
  function paymentNamedBy(caller, paymentHash) {
910
940
  return sha256Hex(`${caller}.payment-id.${paymentHash}`);
911
941
  }
912
- async function signedAs(key, method, path, body) {
942
+ async function signedAs(key, method, path, body, host) {
913
943
  const timestamp = String(Math.floor(Date.now() / 1e3));
944
+ const nonce = bytesToHex(crypto.getRandomValues(new Uint8Array(NONCE_BYTES)));
914
945
  return {
915
946
  [KEY_HEADER]: key.publicKeyHex,
916
947
  [TIMESTAMP_HEADER]: timestamp,
917
- [SIGNATURE_HEADER]: `${PREFIX}${await key.sign(spoken(timestamp, method, path, body))}`
948
+ [NONCE_HEADER]: nonce,
949
+ [SIGNATURE_HEADER]: `${PREFIX}${await key.sign(spokenTo(host, timestamp, nonce, method, path, body))}`
918
950
  };
919
951
  }
920
- function spoken(timestamp, method, path, body) {
952
+ function spokenTo(host, timestamp, nonce, method, path, body) {
921
953
  return new TextEncoder().encode(
922
- `${timestamp}.${method.toUpperCase()}.${path}.${sha256Hex(body)}`
954
+ ["v2", host.toLowerCase(), timestamp, nonce, method.toUpperCase(), path, sha256Hex(body)].join(
955
+ "\n"
956
+ )
923
957
  );
924
958
  }
925
959
 
@@ -1101,14 +1135,39 @@ async function addressesToReach(url) {
1101
1135
  return found;
1102
1136
  }
1103
1137
  async function resolved(url) {
1104
- const { lookup } = await import("dns/promises");
1105
- const { setTimeout: sleep } = await import("timers/promises");
1138
+ const { isIP } = await import("net");
1106
1139
  const host = new URL(url).hostname.replace(/^\[|]$/g, "");
1140
+ const literal = isIP(host);
1141
+ if (literal !== 0) {
1142
+ return [{ address: host, family: literal }];
1143
+ }
1144
+ const { Resolver } = await import("dns/promises");
1145
+ const { setTimeout: sleep } = await import("timers/promises");
1146
+ const resolver = new Resolver({ timeout: LOOKUP_TIMEOUT_MS, tries: 1 });
1107
1147
  const found = await Promise.race([
1108
- lookup(host, { all: true, verbatim: true }).catch(() => []),
1148
+ Promise.all([
1149
+ resolver.resolve4(host).catch(() => []),
1150
+ resolver.resolve6(host).catch(() => [])
1151
+ ]),
1109
1152
  sleep(LOOKUP_TIMEOUT_MS, null, { ref: false })
1110
1153
  ]);
1111
- return found ?? [];
1154
+ cancelled(resolver);
1155
+ if (found === null) {
1156
+ return [];
1157
+ }
1158
+ const [four, six] = found;
1159
+ return [
1160
+ ...four.map((address) => ({ address, family: 4 })),
1161
+ ...six.map((address) => ({ address, family: 6 }))
1162
+ ];
1163
+ }
1164
+ function cancelled(resolver) {
1165
+ try {
1166
+ resolver.cancel();
1167
+ return true;
1168
+ } catch {
1169
+ return false;
1170
+ }
1112
1171
  }
1113
1172
  async function answerOf(response) {
1114
1173
  const { status, ok, headers } = response;
@@ -1437,6 +1496,14 @@ function headersOf(raw) {
1437
1496
  return headers;
1438
1497
  }
1439
1498
 
1499
+ // ../core/delivery.ts
1500
+ function deliverySigned(url, timestamp, body) {
1501
+ const endpoint = new URL(url);
1502
+ return new TextEncoder().encode(
1503
+ ["v2", `${endpoint.origin}${endpoint.pathname}`, timestamp, body].join("\n")
1504
+ );
1505
+ }
1506
+
1440
1507
  // src/verify.ts
1441
1508
  var HTTP_TIMEOUT_MS2 = 15e3;
1442
1509
  var ISSUED_SKEW_SECS = 300;
@@ -1508,7 +1575,7 @@ async function proveSettlement(payment, asked) {
1508
1575
  }
1509
1576
  return verified.preimage;
1510
1577
  }
1511
- function carriesProof(report) {
1578
+ function agreesWithItself(report) {
1512
1579
  if (report.status !== "paid" || report.preimage === null) {
1513
1580
  return false;
1514
1581
  }
@@ -1520,6 +1587,7 @@ function carriesProof(report) {
1520
1587
  }
1521
1588
  return preimageMatchesHash(report.preimage, report.paymentHash);
1522
1589
  }
1590
+ var carriesProof = agreesWithItself;
1523
1591
  function wrapFeeCeiling(amountMsat, allowance) {
1524
1592
  const proportional = amountMsat * (allowance?.proportion ?? DEFAULT_WRAP_PROPORTION);
1525
1593
  return msat(Math.max(allowance?.baseMsat ?? DEFAULT_WRAP_BASE_MSAT, Math.floor(proportional)));
@@ -1801,7 +1869,7 @@ function isStatus(value) {
1801
1869
  }
1802
1870
 
1803
1871
  // src/webhook.ts
1804
- var SIGNATURE_HEADER2 = "x-signature";
1872
+ var SIGNATURE_HEADER2 = "x-signature-v2";
1805
1873
  var TIMESTAMP_HEADER2 = "x-timestamp";
1806
1874
  var GATEWAY_KEY_PREFIX = "ed25519=";
1807
1875
  var DEFAULT_TOLERANCE_SECS = 300;
@@ -1817,14 +1885,14 @@ async function readSettlement(request, credential, options = {}) {
1817
1885
  }
1818
1886
  async function answerWebhookChallenge(request, credential, options = {}) {
1819
1887
  const body = await believable(request, credential, options);
1820
- const nonce = body === null ? null : nonceOf(body, CHALLENGE);
1888
+ const nonce = body === null ? null : nonceOf2(body, CHALLENGE);
1821
1889
  return nonce === null ? null : answered2(nonce);
1822
1890
  }
1823
1891
  async function answerVerifyChallenge(request) {
1824
1892
  if (request.method !== "POST") {
1825
1893
  return null;
1826
1894
  }
1827
- const nonce = nonceOf(await request.clone().text(), VERIFY_CHALLENGE);
1895
+ const nonce = nonceOf2(await request.clone().text(), VERIFY_CHALLENGE);
1828
1896
  return nonce === null ? null : answered2(nonce);
1829
1897
  }
1830
1898
  async function believable(request, credential, options) {
@@ -1843,12 +1911,12 @@ async function believable(request, credential, options) {
1843
1911
  const signed = await verifyHex(
1844
1912
  credential.publicKey.toLowerCase(),
1845
1913
  signature.slice(GATEWAY_KEY_PREFIX.length).toLowerCase(),
1846
- stamped(timestamp, body)
1914
+ deliverySigned(options.url ?? request.url, timestamp, body)
1847
1915
  );
1848
1916
  return signed ? body : null;
1849
1917
  }
1850
1918
  function proved(read) {
1851
- return read !== null && read.status === "paid" && !carriesProof(read) ? null : read;
1919
+ return read !== null && read.status === "paid" && !agreesWithItself(read) ? null : read;
1852
1920
  }
1853
1921
  function decoded(body, from) {
1854
1922
  try {
@@ -1862,7 +1930,7 @@ function answered2(nonce) {
1862
1930
  headers: { "content-type": "application/json" }
1863
1931
  });
1864
1932
  }
1865
- function nonceOf(text3, type) {
1933
+ function nonceOf2(text3, type) {
1866
1934
  try {
1867
1935
  const said = JSON.parse(text3);
1868
1936
  if (said["type"] !== type || typeof said["nonce"] !== "string") {
@@ -1880,9 +1948,6 @@ function recent(timestamp, toleranceSecs) {
1880
1948
  }
1881
1949
  return Math.abs(Math.floor(Date.now() / 1e3) - sent) <= toleranceSecs;
1882
1950
  }
1883
- function stamped(timestamp, body) {
1884
- return new TextEncoder().encode(`${timestamp}.${body}`);
1885
- }
1886
1951
 
1887
1952
  // src/bank.ts
1888
1953
  var DEFAULT_CURRENCY = "CZK";
@@ -1924,7 +1989,7 @@ function bankVerifyEndpoint(config) {
1924
1989
  throw new Error(`${config.iban} is not an IBAN, so this endpoint answers for no account`);
1925
1990
  }
1926
1991
  const paced = {
1927
- "cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS}`
1992
+ "cache-control": `max-age=${config.pollEverySecs ?? config.statement.freshEverySecs ?? DEFAULT_POLL_EVERY_SECS}`
1928
1993
  };
1929
1994
  return async (request) => {
1930
1995
  const consented = await answerVerifyChallenge(request);
@@ -1935,7 +2000,7 @@ function bankVerifyEndpoint(config) {
1935
2000
  if (sealed === null) {
1936
2001
  return Response.json({ settled: false }, { status: 400 });
1937
2002
  }
1938
- const subject = await unseal(config.secret, sealed);
2003
+ const subject = await unsealFor("bank-verify", config.secret, sealed);
1939
2004
  const asked = subject === null ? null : askedFrom(subject);
1940
2005
  if (asked === null || asked.iban !== answersFor) {
1941
2006
  return Response.json({ settled: false }, { status: 403 });
@@ -1954,7 +2019,10 @@ async function answeredAt(params, subject) {
1954
2019
  return `agent:${(await callerKey(params.secret)).publicKeyHex}`;
1955
2020
  }
1956
2021
  const polling = new URL(params.verifyUrl ?? "");
1957
- polling.searchParams.set("q", await sealStable(params.secret, subject));
2022
+ polling.searchParams.set(
2023
+ "q",
2024
+ await sealFor("bank-verify", params.secret, subject, { stable: true })
2025
+ );
1958
2026
  return polling.toString();
1959
2027
  }
1960
2028
  async function creditedPreimage(params) {
@@ -2060,6 +2128,161 @@ function unixNow() {
2060
2128
  return Math.floor(Date.now() / 1e3);
2061
2129
  }
2062
2130
 
2131
+ // src/nostr.ts
2132
+ import { chacha20 } from "@noble/ciphers/chacha.js";
2133
+ import { schnorr, secp256k1 } from "@noble/curves/secp256k1.js";
2134
+ import { expand, extract } from "@noble/hashes/hkdf.js";
2135
+ import { hmac } from "@noble/hashes/hmac.js";
2136
+ import { sha256 as sha2562 } from "@noble/hashes/sha2.js";
2137
+ var NIP44_SALT = new TextEncoder().encode("nip44-v2");
2138
+ var NIP44_VERSION = 2;
2139
+ var NONCE_BYTES2 = 32;
2140
+ var MAC_BYTES = 32;
2141
+ var MESSAGE_KEYS_BYTES = 76;
2142
+ var CHACHA_KEY_END = 32;
2143
+ var CHACHA_NONCE_END = 44;
2144
+ var LENGTH_PREFIX_BYTES = 2;
2145
+ var SMALLEST_PAD = 32;
2146
+ var CHUNKS_STAY_SMALL_TO = 256;
2147
+ var MAX_PLAINTEXT_BYTES = 65535;
2148
+ function publicKeyFor(privateKeyHex) {
2149
+ return bytesToHex(schnorr.getPublicKey(hexToBytes(privateKeyHex)));
2150
+ }
2151
+ function conversationKeyFor(privateKeyHex, publicKeyHex) {
2152
+ const shared = secp256k1.getSharedSecret(
2153
+ hexToBytes(privateKeyHex),
2154
+ hexToBytes(`02${publicKeyHex}`)
2155
+ );
2156
+ return extract(sha2562, shared.subarray(1, 33), NIP44_SALT);
2157
+ }
2158
+ function signEvent(privateKeyHex, draft) {
2159
+ const pubkey = publicKeyFor(privateKeyHex);
2160
+ const id = idOf(pubkey, draft);
2161
+ return {
2162
+ id,
2163
+ pubkey,
2164
+ created_at: draft.created_at,
2165
+ kind: draft.kind,
2166
+ tags: draft.tags,
2167
+ content: draft.content,
2168
+ sig: bytesToHex(schnorr.sign(hexToBytes(id), hexToBytes(privateKeyHex)))
2169
+ };
2170
+ }
2171
+ function verifyEvent(event) {
2172
+ try {
2173
+ return idOf(event.pubkey, event) === event.id && schnorr.verify(hexToBytes(event.sig), hexToBytes(event.id), hexToBytes(event.pubkey));
2174
+ } catch {
2175
+ return false;
2176
+ }
2177
+ }
2178
+ function idOf(pubkey, draft) {
2179
+ return bytesToHex(
2180
+ sha2562(
2181
+ new TextEncoder().encode(
2182
+ JSON.stringify([0, pubkey, draft.created_at, draft.kind, draft.tags, draft.content])
2183
+ )
2184
+ )
2185
+ );
2186
+ }
2187
+ function encryptNip44(conversationKey, plaintext) {
2188
+ return sealNip44(conversationKey, plaintext, crypto.getRandomValues(new Uint8Array(NONCE_BYTES2)));
2189
+ }
2190
+ function sealNip44(conversationKey, plaintext, nonce) {
2191
+ const keys = messageKeysFrom(conversationKey, nonce);
2192
+ const ciphertext = chacha20(keys.chachaKey, keys.chachaNonce, padded(plaintext));
2193
+ const mac = hmac(sha2562, keys.hmacKey, joined(nonce, ciphertext));
2194
+ return toBase64(joined(Uint8Array.of(NIP44_VERSION), nonce, ciphertext, mac));
2195
+ }
2196
+ function decryptNip44(conversationKey, payload) {
2197
+ if (payload.startsWith("#")) {
2198
+ throw new Error("nip44 payload announces an encryption version we do not speak");
2199
+ }
2200
+ const raw = fromBase64(payload);
2201
+ if (raw.length < 1 + NONCE_BYTES2 + MAC_BYTES) {
2202
+ throw new Error(`nip44 payload is ${raw.length} bytes, too short to hold a nonce and a mac`);
2203
+ }
2204
+ if (raw[0] !== NIP44_VERSION) {
2205
+ throw new Error(`nip44 payload is version ${raw[0]}, not ${NIP44_VERSION}`);
2206
+ }
2207
+ const nonce = raw.subarray(1, 1 + NONCE_BYTES2);
2208
+ const ciphertext = raw.subarray(1 + NONCE_BYTES2, raw.length - MAC_BYTES);
2209
+ const mac = raw.subarray(raw.length - MAC_BYTES);
2210
+ const keys = messageKeysFrom(conversationKey, nonce);
2211
+ if (!sameBytes(hmac(sha2562, keys.hmacKey, joined(nonce, ciphertext)), mac)) {
2212
+ throw new Error("nip44 payload failed its mac, so it was not written with this key");
2213
+ }
2214
+ return unpadded(chacha20(keys.chachaKey, keys.chachaNonce, ciphertext));
2215
+ }
2216
+ function messageKeysFrom(conversationKey, nonce) {
2217
+ if (conversationKey.length !== NONCE_BYTES2 || nonce.length !== NONCE_BYTES2) {
2218
+ throw new Error("a nip44 conversation key and nonce are both 32 bytes");
2219
+ }
2220
+ const keys = expand(sha2562, conversationKey, nonce, MESSAGE_KEYS_BYTES);
2221
+ return {
2222
+ chachaKey: keys.subarray(0, CHACHA_KEY_END),
2223
+ chachaNonce: keys.subarray(CHACHA_KEY_END, CHACHA_NONCE_END),
2224
+ hmacKey: keys.subarray(CHACHA_NONCE_END)
2225
+ };
2226
+ }
2227
+ function paddedLengthFor(unpadded2) {
2228
+ if (unpadded2 <= SMALLEST_PAD) {
2229
+ return SMALLEST_PAD;
2230
+ }
2231
+ const nextPower = 1 << Math.floor(Math.log2(unpadded2 - 1)) + 1;
2232
+ const chunk = nextPower <= CHUNKS_STAY_SMALL_TO ? SMALLEST_PAD : nextPower / 8;
2233
+ return chunk * (Math.floor((unpadded2 - 1) / chunk) + 1);
2234
+ }
2235
+ function padded(plaintext) {
2236
+ const body = new TextEncoder().encode(plaintext);
2237
+ if (body.length === 0 || body.length > MAX_PLAINTEXT_BYTES) {
2238
+ throw new Error(`nip44 takes 1 to ${MAX_PLAINTEXT_BYTES} bytes, this was ${body.length}`);
2239
+ }
2240
+ const buffer = new Uint8Array(LENGTH_PREFIX_BYTES + paddedLengthFor(body.length));
2241
+ buffer.set(Uint8Array.of(body.length >>> 8, body.length & 255));
2242
+ buffer.set(body, LENGTH_PREFIX_BYTES);
2243
+ return buffer;
2244
+ }
2245
+ function unpadded(buffer) {
2246
+ const length = (buffer[0] ?? 0) << 8 | (buffer[1] ?? 0);
2247
+ if (length === 0 || buffer.length !== LENGTH_PREFIX_BYTES + paddedLengthFor(length)) {
2248
+ throw new Error("nip44 plaintext does not match the length its own padding declares");
2249
+ }
2250
+ return new TextDecoder().decode(
2251
+ buffer.subarray(LENGTH_PREFIX_BYTES, LENGTH_PREFIX_BYTES + length)
2252
+ );
2253
+ }
2254
+ function joined(...parts) {
2255
+ const whole = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
2256
+ let at = 0;
2257
+ for (const part of parts) {
2258
+ whole.set(part, at);
2259
+ at += part.length;
2260
+ }
2261
+ return whole;
2262
+ }
2263
+ function sameBytes(left, right) {
2264
+ let different = left.length ^ right.length;
2265
+ for (let at = 0; at < left.length && at < right.length; at++) {
2266
+ different |= (left[at] ?? 0) ^ (right[at] ?? 0);
2267
+ }
2268
+ return different === 0;
2269
+ }
2270
+ function toBase64(bytes) {
2271
+ let binary = "";
2272
+ for (const byte of bytes) {
2273
+ binary += String.fromCharCode(byte);
2274
+ }
2275
+ return btoa(binary);
2276
+ }
2277
+ function fromBase64(text3) {
2278
+ const binary = atob(text3);
2279
+ const bytes = new Uint8Array(binary.length);
2280
+ for (let at = 0; at < bytes.length; at++) {
2281
+ bytes[at] = binary.charCodeAt(at);
2282
+ }
2283
+ return bytes;
2284
+ }
2285
+
2063
2286
  // src/qr.ts
2064
2287
  import { encode } from "uqr";
2065
2288
  var SAFE_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$|^rgba?\([\d.,%/\s]+\)$|^[a-z]+$/i;
@@ -2102,12 +2325,244 @@ function renderSvg(content, size, color) {
2102
2325
  ].join("");
2103
2326
  }
2104
2327
 
2105
- // src/relay.ts
2328
+ // src/nwc.ts
2329
+ var REQUEST_KIND = 23194;
2330
+ var RESPONSE_KIND = 23195;
2331
+ var NIP44_V2 = "nip44_v2";
2332
+ var DEFAULT_ASK_TIMEOUT_MS = 1e4;
2106
2333
  var DEFAULT_POLL_EVERY_SECS2 = 5;
2334
+ var HASH = "h";
2335
+ var REFUSAL_BY_CODE = {
2336
+ INSUFFICIENT_BALANCE: "amount-not-accepted",
2337
+ QUOTA_EXCEEDED: "amount-not-accepted",
2338
+ RESTRICTED: "address-unusable",
2339
+ UNAUTHORIZED: "address-unusable",
2340
+ UNSUPPORTED_ENCRYPTION: "address-unusable"
2341
+ };
2342
+ async function nwcInvoice(connection, amountMsat, description, timeoutMs = DEFAULT_ASK_TIMEOUT_MS) {
2343
+ const answered3 = await askWallet(
2344
+ connection,
2345
+ "make_invoice",
2346
+ { amount: amountMsat, description },
2347
+ timeoutMs
2348
+ );
2349
+ const bolt11 = String(answered3.invoice ?? "");
2350
+ const issued = decodeInvoice(bolt11);
2351
+ if (issued.paymentHash === null || issued.expiresAt === null) {
2352
+ throw new WalletRefused("invoice-refused", "the nwc wallet issued an invoice we cannot decode");
2353
+ }
2354
+ if (issued.amountMsat !== amountMsat) {
2355
+ throw new WalletRefused(
2356
+ "invoice-refused",
2357
+ `the nwc wallet issued an invoice for ${issued.amountMsat} msat, not ${amountMsat}`
2358
+ );
2359
+ }
2360
+ return { bolt11, paymentHash: issued.paymentHash, expiresAt: issued.expiresAt };
2361
+ }
2362
+ async function nwcSettlement(connection, paymentHash, timeoutMs = DEFAULT_ASK_TIMEOUT_MS) {
2363
+ const answered3 = await askWallet(
2364
+ connection,
2365
+ "lookup_invoice",
2366
+ { payment_hash: paymentHash },
2367
+ timeoutMs
2368
+ );
2369
+ const preimage = answered3.preimage;
2370
+ if (typeof preimage !== "string" || preimage.length === 0) {
2371
+ return null;
2372
+ }
2373
+ if (!preimageMatchesHash(preimage, paymentHash)) {
2374
+ throw new Error(`the nwc wallet returned a preimage that does not hash to ${paymentHash}`);
2375
+ }
2376
+ return preimage;
2377
+ }
2378
+ function nwcVerifyEndpoint(config) {
2379
+ const paced = {
2380
+ "cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS2}`
2381
+ };
2382
+ return async (request) => {
2383
+ const consented = await answerVerifyChallenge(request);
2384
+ if (consented !== null) {
2385
+ return consented;
2386
+ }
2387
+ const sealed = new URL(request.url).searchParams.get(HASH);
2388
+ if (sealed === null) {
2389
+ return Response.json({ settled: false }, { status: 400 });
2390
+ }
2391
+ const paymentHash = await unsealFor("nwc-verify", config.secret, sealed);
2392
+ if (paymentHash === null) {
2393
+ return Response.json({ settled: false }, { status: 403 });
2394
+ }
2395
+ const preimage = await nwcSettlement(config.connection, paymentHash, config.askTimeoutMs).catch(
2396
+ () => void 0
2397
+ );
2398
+ if (preimage === void 0) {
2399
+ return Response.json({ settled: false }, { status: 502 });
2400
+ }
2401
+ return Response.json({ settled: preimage !== null, preimage }, { headers: paced });
2402
+ };
2403
+ }
2404
+ async function nwcVerifyUrl(endpoint, paymentHash, secret) {
2405
+ const sealed = new URL(endpoint);
2406
+ sealed.searchParams.set(HASH, await sealFor("nwc-verify", secret, paymentHash));
2407
+ return sealed.toString();
2408
+ }
2409
+ async function askWallet(connection, method, params, timeoutMs = DEFAULT_ASK_TIMEOUT_MS) {
2410
+ const key = conversationKeyFor(connection.secret, connection.walletPubkey);
2411
+ const request = signEvent(connection.secret, {
2412
+ kind: REQUEST_KIND,
2413
+ tags: [
2414
+ ["p", connection.walletPubkey],
2415
+ ["encryption", NIP44_V2]
2416
+ ],
2417
+ content: encryptNip44(key, JSON.stringify({ method, params })),
2418
+ created_at: Math.floor(Date.now() / 1e3)
2419
+ });
2420
+ const answer = await overFirstRelayThatAnswers(connection, request, timeoutMs);
2421
+ return resultOf(JSON.parse(decryptNip44(key, answer.content)), method);
2422
+ }
2423
+ function resultOf(said, method) {
2424
+ const failure = said.error;
2425
+ if (failure) {
2426
+ throw new WalletRefused(
2427
+ REFUSAL_BY_CODE[failure.code ?? ""] ?? "invoice-refused",
2428
+ `the nwc wallet refused ${method}: ${failure.code ?? "unknown"} ${failure.message ?? ""}`.trim()
2429
+ );
2430
+ }
2431
+ const result = said.result;
2432
+ if (result === null || typeof result !== "object") {
2433
+ throw new WalletRefused("invoice-refused", `the nwc wallet answered ${method} with no result`);
2434
+ }
2435
+ return result;
2436
+ }
2437
+ async function overFirstRelayThatAnswers(connection, request, timeoutMs) {
2438
+ const deadline = AbortSignal.timeout(timeoutMs);
2439
+ let lastFailure = null;
2440
+ for (const relay of connection.relays) {
2441
+ if (deadline.aborted) {
2442
+ break;
2443
+ }
2444
+ try {
2445
+ return await overRelay(relay, connection, request, deadline);
2446
+ } catch (failure) {
2447
+ lastFailure = failure;
2448
+ }
2449
+ }
2450
+ throw new WalletRefused("unreachable", `no nwc relay answered: ${String(lastFailure)}`);
2451
+ }
2452
+ function overRelay(relay, connection, request, deadline) {
2453
+ return new Promise((resolve2, reject) => {
2454
+ const socket = new WebSocket(relay);
2455
+ const subscription = request.id.slice(0, 16);
2456
+ const expired = () => {
2457
+ close();
2458
+ reject(new Error(`${relay} did not answer before the deadline`));
2459
+ };
2460
+ deadline.addEventListener("abort", expired);
2461
+ function close() {
2462
+ deadline.removeEventListener("abort", expired);
2463
+ socket.close();
2464
+ }
2465
+ socket.addEventListener("open", () => {
2466
+ socket.send(
2467
+ JSON.stringify([
2468
+ "REQ",
2469
+ subscription,
2470
+ {
2471
+ kinds: [RESPONSE_KIND],
2472
+ authors: [connection.walletPubkey],
2473
+ "#p": [publicKeyFor(connection.secret)],
2474
+ "#e": [request.id]
2475
+ }
2476
+ ])
2477
+ );
2478
+ socket.send(JSON.stringify(["EVENT", request]));
2479
+ });
2480
+ socket.addEventListener("error", () => {
2481
+ close();
2482
+ reject(new Error(`${relay} could not be reached`));
2483
+ });
2484
+ socket.addEventListener("message", (delivered) => {
2485
+ const frame = String(delivered.data);
2486
+ const rejection = rejectionOf(frame, request.id);
2487
+ if (rejection !== null) {
2488
+ close();
2489
+ reject(new Error(`${relay} refused the request: ${rejection}`));
2490
+ return;
2491
+ }
2492
+ const answer = answeredEvent(frame, subscription, connection, request.id);
2493
+ if (answer === null) {
2494
+ return;
2495
+ }
2496
+ close();
2497
+ resolve2(answer);
2498
+ });
2499
+ });
2500
+ }
2501
+ function rejectionOf(frame, requestId) {
2502
+ const parsed = parsedFrame(frame);
2503
+ if (parsed === null || parsed[0] !== "OK" || parsed[1] !== requestId || parsed[2] !== false) {
2504
+ return null;
2505
+ }
2506
+ return typeof parsed[3] === "string" && parsed[3].length > 0 ? parsed[3] : "no reason given";
2507
+ }
2508
+ function answeredEvent(frame, subscription, connection, requestId) {
2509
+ const parsed = parsedFrame(frame);
2510
+ if (parsed === null || parsed[0] !== "EVENT" || parsed[1] !== subscription) {
2511
+ return null;
2512
+ }
2513
+ const event = parsed[2];
2514
+ const answers = event?.kind === RESPONSE_KIND && event.pubkey === connection.walletPubkey && Array.isArray(event.tags) && event.tags.some((tag) => tag?.[0] === "e" && tag[1] === requestId) && verifyEvent(event);
2515
+ return answers ? event : null;
2516
+ }
2517
+ function parsedFrame(frame) {
2518
+ try {
2519
+ const parsed = JSON.parse(frame);
2520
+ return Array.isArray(parsed) ? parsed : null;
2521
+ } catch {
2522
+ return null;
2523
+ }
2524
+ }
2525
+ function nwcRail(gateway, config) {
2526
+ return async (order) => {
2527
+ const invoice = await nwcInvoice(
2528
+ config.connection,
2529
+ await msatForOrder(order, config.amount, config.rate),
2530
+ config.description?.(order) ?? order.reference
2531
+ );
2532
+ const watched = await gateway.watch({
2533
+ paymentHash: invoice.paymentHash,
2534
+ verifyUrl: await nwcVerifyUrl(
2535
+ config.verifyThrough.endpoint,
2536
+ invoice.paymentHash,
2537
+ config.verifyThrough.secret
2538
+ ),
2539
+ expiresAt: invoice.expiresAt,
2540
+ trigger: config.trigger,
2541
+ replay: config.replay,
2542
+ sealed: config.sealed ? await seal(
2543
+ config.sealed.secret,
2544
+ JSON.stringify(config.sealed.data(order)),
2545
+ invoice.paymentHash
2546
+ ) : void 0,
2547
+ webhookUrl: config.webhookUrl
2548
+ });
2549
+ return {
2550
+ id: watched.id,
2551
+ paymentHash: invoice.paymentHash,
2552
+ rail: config.name ?? "lightning",
2553
+ scan: invoice.bolt11,
2554
+ qr: toLightningUri(invoice.bolt11),
2555
+ expiresAt: invoice.expiresAt
2556
+ };
2557
+ };
2558
+ }
2559
+
2560
+ // src/relay.ts
2561
+ var DEFAULT_POLL_EVERY_SECS3 = 5;
2107
2562
  var WALLET = "w";
2108
2563
  function lightningVerifyEndpoint(config) {
2109
2564
  const paced = {
2110
- "cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS2}`
2565
+ "cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS3}`
2111
2566
  };
2112
2567
  return async (request) => {
2113
2568
  const consented = await answerVerifyChallenge(request);
@@ -2118,8 +2573,8 @@ function lightningVerifyEndpoint(config) {
2118
2573
  if (sealed === null) {
2119
2574
  return Response.json({ settled: false }, { status: 400 });
2120
2575
  }
2121
- const opened = await unseal(config.secret, sealed);
2122
- const wallet = opened === null ? null : relayedFrom(opened);
2576
+ const opened2 = await unsealFor("relay", config.secret, sealed);
2577
+ const wallet = opened2 === null ? null : relayedFrom(opened2);
2123
2578
  if (wallet === null) {
2124
2579
  return Response.json({ settled: false }, { status: 403 });
2125
2580
  }
@@ -2137,9 +2592,9 @@ function lightningVerifyEndpoint(config) {
2137
2592
  );
2138
2593
  };
2139
2594
  }
2140
- function relayedFrom(opened) {
2595
+ function relayedFrom(opened2) {
2141
2596
  try {
2142
- const said = JSON.parse(opened);
2597
+ const said = JSON.parse(opened2);
2143
2598
  return typeof said?.url === "string" && typeof said.hash === "string" ? { url: said.url, hash: said.hash } : null;
2144
2599
  } catch {
2145
2600
  return null;
@@ -2147,7 +2602,7 @@ function relayedFrom(opened) {
2147
2602
  }
2148
2603
  async function relayedVerifyUrl(endpoint, wallet, secret) {
2149
2604
  const relayed = new URL(endpoint);
2150
- relayed.searchParams.set(WALLET, await seal(secret, JSON.stringify(wallet)));
2605
+ relayed.searchParams.set(WALLET, await sealFor("relay", secret, JSON.stringify(wallet)));
2151
2606
  return relayed.toString();
2152
2607
  }
2153
2608
 
@@ -2278,6 +2733,15 @@ var Rails = class {
2278
2733
  bank(config) {
2279
2734
  return bankRail(this.gateway, config);
2280
2735
  }
2736
+ /**
2737
+ * Lightning against a wallet of your own over NIP-47, for a wallet that has no
2738
+ * LUD-21 address to be watched at. Your node mints the invoice and releases the
2739
+ * preimage, so the proof comes from one hop nearer than any hosted address can
2740
+ * manage, and the gateway sees a hash and a URL of yours
2741
+ */
2742
+ nwc(config) {
2743
+ return nwcRail(this.gateway, config);
2744
+ }
2281
2745
  /**
2282
2746
  * One bank transfer without building a rail first, for a shop that asks for
2283
2747
  * them one at a time rather than beside another payment method
@@ -2327,7 +2791,7 @@ function waitingOf(options) {
2327
2791
  }
2328
2792
 
2329
2793
  // src/trigger.ts
2330
- var NONCE_BYTES = 16;
2794
+ var NONCE_BYTES3 = 16;
2331
2795
  function lnurlPayEndpoint(gateway, config) {
2332
2796
  return async (request) => {
2333
2797
  const url = new URL(request.url);
@@ -2466,10 +2930,10 @@ function alreadyWatched(refused) {
2466
2930
  return refused instanceof ProblemError && isProblemType(refused, PAYMENT_ALREADY_WATCHED);
2467
2931
  }
2468
2932
  function sign(secret, address, least, most, nonce) {
2469
- return hmacHex(secret, `${address}|${least}|${most}|${nonce}`);
2933
+ return hmacHex(secret, `lnurl-callback|${address}|${least}|${most}|${nonce}`);
2470
2934
  }
2471
2935
  function randomNonce() {
2472
- const bytes = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
2936
+ const bytes = crypto.getRandomValues(new Uint8Array(NONCE_BYTES3));
2473
2937
  let hex = "";
2474
2938
  for (const byte of bytes) {
2475
2939
  hex += byte.toString(16).padStart(2, "0");
@@ -2519,23 +2983,39 @@ var Serve = class {
2519
2983
  * A verify endpoint of your own that asks the recipient's wallet for you, so
2520
2984
  * the gateway polls you and never the wallet
2521
2985
  */
2522
- verify(config) {
2986
+ lightningVerify(config) {
2523
2987
  return lightningVerifyEndpoint(config);
2524
2988
  }
2989
+ /**
2990
+ * What `lightningVerify` was called before 2.2.0
2991
+ *
2992
+ * @deprecated Use `lightningVerify`, beside `bankVerify` and `nwcVerify`
2993
+ */
2994
+ verify(config) {
2995
+ return this.lightningVerify(config);
2996
+ }
2525
2997
  /** The verify endpoint a bank rail is polled at, answering off your own statement */
2526
2998
  bankVerify(config) {
2527
2999
  return bankVerifyEndpoint(config);
2528
3000
  }
3001
+ /**
3002
+ * The verify endpoint an NWC rail is polled at, asking your own wallet over
3003
+ * NIP-47, so the gateway never learns the connection, the relay or the wallet
3004
+ */
3005
+ nwcVerify(config) {
3006
+ return nwcVerifyEndpoint(config);
3007
+ }
2529
3008
  /**
2530
3009
  * The whole webhook route: it answers the gateway's challenge, checks the
2531
3010
  * signature against the key the gateway publishes, refuses a settlement that
2532
- * proves nothing, and calls you for the one that does.
3011
+ * proves nothing, and calls you once for the one that does.
2533
3012
  *
2534
3013
  * `export const POST = gateway.serve.webhook({ onSettled: fulfil })` is the
2535
3014
  * entire integration
2536
3015
  */
2537
3016
  webhook(handlers) {
2538
- const options = { toleranceSecs: handlers.toleranceSecs };
3017
+ const options = { toleranceSecs: handlers.toleranceSecs, url: handlers.url };
3018
+ const once = onceWhileReplayable(handlers.toleranceSecs ?? DEFAULT_TOLERANCE_SECS);
2539
3019
  return async (request) => {
2540
3020
  const credential = await this.credential(handlers);
2541
3021
  const challenge = await answerWebhookChallenge(request, credential, options);
@@ -2544,12 +3024,17 @@ var Serve = class {
2544
3024
  }
2545
3025
  const settlement = await readSettlement(request, credential, options);
2546
3026
  if (settlement !== null) {
2547
- return await acted(settlement, handlers);
3027
+ return await once(
3028
+ `${settlement.id}:${settlement.status}`,
3029
+ () => acted(settlement, handlers)
3030
+ );
2548
3031
  }
2549
3032
  const payment = await readPayment(request, credential, options);
2550
3033
  if (payment !== null) {
2551
- await handlers.onPayment?.(payment);
2552
- return new Response("ok");
3034
+ return await once(`${payment.id}:${payment.status}`, async () => {
3035
+ await handlers.onPayment?.(payment);
3036
+ return new Response("ok");
3037
+ });
2553
3038
  }
2554
3039
  return new Response("bad signature", { status: 401 });
2555
3040
  };
@@ -2570,8 +3055,34 @@ var Serve = class {
2570
3055
  return handlers.credential ?? { publicKey: await this.gateway.webhookKey() };
2571
3056
  }
2572
3057
  };
3058
+ function onceWhileReplayable(toleranceSecs) {
3059
+ const acting = /* @__PURE__ */ new Map();
3060
+ const replayableForMs = 2 * toleranceSecs * 1e3;
3061
+ return async (delivery, act) => {
3062
+ const now = Date.now();
3063
+ for (const [earlier2, { forgetAt }] of acting) {
3064
+ if (forgetAt > now) {
3065
+ break;
3066
+ }
3067
+ acting.delete(earlier2);
3068
+ }
3069
+ const earlier = acting.get(delivery);
3070
+ if (earlier !== void 0) {
3071
+ await earlier.done;
3072
+ return new Response("ok");
3073
+ }
3074
+ const done = act();
3075
+ acting.set(delivery, { forgetAt: now + replayableForMs, done });
3076
+ try {
3077
+ return await done;
3078
+ } catch (failure) {
3079
+ acting.delete(delivery);
3080
+ throw failure;
3081
+ }
3082
+ };
3083
+ }
2573
3084
  async function acted(settlement, handlers) {
2574
- if (carriesProof(settlement)) {
3085
+ if (agreesWithItself(settlement)) {
2575
3086
  await handlers.onSettled?.(settlement);
2576
3087
  return new Response("ok");
2577
3088
  }
@@ -2795,7 +3306,7 @@ var ThunderBridge = class {
2795
3306
  throw await problemFrom(response);
2796
3307
  }
2797
3308
  const body = await response.json().catch(() => null);
2798
- const listed2 = Array.isArray(body?.payments) ? body.payments.map(paymentFromWire).filter((one) => one !== null).filter((one) => one.status !== "paid" || carriesProof(one)) : null;
3309
+ const listed2 = Array.isArray(body?.payments) ? body.payments.map(paymentFromWire).filter((one) => one !== null).filter((one) => one.status !== "paid" || agreesWithItself(one)) : null;
2799
3310
  if (listed2 === null || typeof body?.settled_scanned !== "number") {
2800
3311
  throw new ProblemError({
2801
3312
  status: response.status,
@@ -2880,9 +3391,9 @@ var ThunderBridge = class {
2880
3391
  if (settled) {
2881
3392
  return;
2882
3393
  }
2883
- const opened = new WebSocket(url);
2884
- socket = opened;
2885
- opened.onmessage = (event) => {
3394
+ const opened2 = new WebSocket(url);
3395
+ socket = opened2;
3396
+ opened2.onmessage = (event) => {
2886
3397
  try {
2887
3398
  const frame = JSON.parse(String(event.data));
2888
3399
  const watched = paymentFromWire(frame);
@@ -2899,7 +3410,7 @@ var ThunderBridge = class {
2899
3410
  settle(() => reject(refused));
2900
3411
  }
2901
3412
  };
2902
- opened.onclose = again;
3413
+ opened2.onclose = again;
2903
3414
  };
2904
3415
  options?.signal?.addEventListener("abort", abort, { once: true });
2905
3416
  void connect();
@@ -2922,6 +3433,10 @@ var ThunderBridge = class {
2922
3433
  * the recipient's own wallet minted it, so a payer who pays the loser afterwards
2923
3434
  * really does pay twice and that shows up on `follow` as a second settlement to
2924
3435
  * refund.
3436
+ *
3437
+ * A leg the gateway is caught lying about before any leg is paid ends the wait
3438
+ * with its `GatewayCheatError`, even when another leg is paid after it, so a
3439
+ * detected cheat is never traded for a later win.
2925
3440
  */
2926
3441
  async firstSettled(held, options) {
2927
3442
  if (held.length === 0) {
@@ -2931,7 +3446,7 @@ var ThunderBridge = class {
2931
3446
  const signal = options?.signal ? AbortSignal.any([stopLosers.signal, options.signal]) : stopLosers.signal;
2932
3447
  let refused = null;
2933
3448
  try {
2934
- const winner = await new Promise((resolve2) => {
3449
+ const winner = await new Promise((resolve2, caught) => {
2935
3450
  let waiting = held.length;
2936
3451
  const lost = () => {
2937
3452
  waiting -= 1;
@@ -2941,6 +3456,10 @@ var ThunderBridge = class {
2941
3456
  };
2942
3457
  for (const leg of held) {
2943
3458
  this.settled(leg, { ...options, signal }).then((watched) => watched.status === "paid" ? resolve2(watched) : lost()).catch((failure) => {
3459
+ if (failure instanceof GatewayCheatError) {
3460
+ caught(failure);
3461
+ return;
3462
+ }
2944
3463
  refused ??= failure;
2945
3464
  lost();
2946
3465
  });
@@ -3017,13 +3536,13 @@ var ThunderBridge = class {
3017
3536
  }
3018
3537
  retry = setTimeout(() => void connect(), backoffMs(firstDelay, attempt));
3019
3538
  };
3020
- const answered3 = async (opened, raw) => {
3539
+ const answered3 = async (opened2, raw) => {
3021
3540
  const asked = askFromWire(raw);
3022
3541
  if (asked === null) {
3023
3542
  return;
3024
3543
  }
3025
3544
  const preimage = await options.answer(asked.paymentHash);
3026
- opened.send(
3545
+ opened2.send(
3027
3546
  JSON.stringify(
3028
3547
  preimage === null ? { ask: asked.ask, settled: false } : { ask: asked.ask, settled: true, preimage }
3029
3548
  )
@@ -3042,18 +3561,18 @@ var ThunderBridge = class {
3042
3561
  if (stopped) {
3043
3562
  return;
3044
3563
  }
3045
- const opened = new WebSocket(url);
3046
- socket = opened;
3047
- opened.onopen = () => {
3564
+ const opened2 = new WebSocket(url);
3565
+ socket = opened2;
3566
+ opened2.onopen = () => {
3048
3567
  attempt = 1;
3049
3568
  };
3050
- opened.onmessage = (event) => {
3051
- void answered3(opened, String(event.data)).catch(
3569
+ opened2.onmessage = (event) => {
3570
+ void answered3(opened2, String(event.data)).catch(
3052
3571
  (refused) => options.onError?.(refused)
3053
3572
  );
3054
3573
  };
3055
- opened.onerror = () => options.onError?.(new Error(`could not attend ${base}`));
3056
- opened.onclose = again;
3574
+ opened2.onerror = () => options.onError?.(new Error(`could not attend ${base}`));
3575
+ opened2.onclose = again;
3057
3576
  };
3058
3577
  void connect();
3059
3578
  return () => {
@@ -3168,7 +3687,7 @@ var ThunderBridge = class {
3168
3687
  async reading(method, path, body = "") {
3169
3688
  return {
3170
3689
  ...this.token === null ? {} : { authorization: `Bearer ${this.token}` },
3171
- ...this.secret === null ? {} : await signedAs(await this.speaking(), method, path, body)
3690
+ ...this.secret === null ? {} : await signedAs(await this.speaking(), method, path, body, new URL(this.baseUrl).host)
3172
3691
  };
3173
3692
  }
3174
3693
  async speaking() {
@@ -3194,7 +3713,7 @@ var ThunderBridge = class {
3194
3713
  if (other) {
3195
3714
  throw new GatewayCheatError("hash_mismatch", payment.id);
3196
3715
  }
3197
- if (payment.status === "paid" && !carriesProof(payment)) {
3716
+ if (payment.status === "paid" && !agreesWithItself(payment)) {
3198
3717
  throw new GatewayCheatError("preimage_mismatch", payment.id);
3199
3718
  }
3200
3719
  return payment;
@@ -3248,6 +3767,7 @@ var Gateways = class {
3248
3767
  each;
3249
3768
  urls;
3250
3769
  onRefused;
3770
+ onCaught;
3251
3771
  constructor(baseUrls, options) {
3252
3772
  if (baseUrls.length === 0) {
3253
3773
  throw new Error("name at least one gateway to watch at");
@@ -3256,6 +3776,8 @@ var Gateways = class {
3256
3776
  this.each = baseUrls.map((baseUrl) => new ThunderBridge(baseUrl, options));
3257
3777
  this.onRefused = options?.onRefused ?? (() => {
3258
3778
  });
3779
+ this.onCaught = options?.onCaught ?? (() => {
3780
+ });
3259
3781
  }
3260
3782
  /** What this payment is called, which every gateway here will agree on */
3261
3783
  async nameFor(paymentHash) {
@@ -3303,7 +3825,7 @@ var Gateways = class {
3303
3825
  resolve2(null);
3304
3826
  }
3305
3827
  };
3306
- for (const gateway of this.each) {
3828
+ for (const [at, gateway] of this.each.entries()) {
3307
3829
  gateway.settled(held, { ...options, signal }).then((watched) => {
3308
3830
  if (watched.status === "paid") {
3309
3831
  return resolve2(watched);
@@ -3311,6 +3833,9 @@ var Gateways = class {
3311
3833
  unpaid.push(watched);
3312
3834
  lost();
3313
3835
  }).catch((failure) => {
3836
+ if (failure instanceof GatewayCheatError) {
3837
+ this.onCaught(this.urls[at], failure);
3838
+ }
3314
3839
  refused ??= failure;
3315
3840
  lost();
3316
3841
  });
@@ -3338,6 +3863,7 @@ export {
3338
3863
  ThunderBridge,
3339
3864
  UnverifiedRecipientError,
3340
3865
  WrapRefusedError,
3866
+ agreesWithItself,
3341
3867
  answerVerifyChallenge,
3342
3868
  carriesProof,
3343
3869
  decodeInvoice,