thunder-bridge 0.8.4 → 0.8.8
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 +105 -7
- package/dist/index.cjs +122 -17
- package/dist/index.d.cts +34 -8
- package/dist/index.d.ts +34 -8
- package/dist/index.js +120 -17
- package/dist/{rail-QZtUN1D-.d.cts → rail-J8QoYbSr.d.cts} +20 -1
- package/dist/{rail-QZtUN1D-.d.ts → rail-J8QoYbSr.d.ts} +20 -1
- package/dist/server.cjs +117 -8
- package/dist/server.d.cts +42 -3
- package/dist/server.d.ts +42 -3
- package/dist/server.js +114 -7
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -562,6 +562,7 @@ function triggerEventFromWire(body) {
|
|
|
562
562
|
if (sealed !== null && typeof sealed !== "string") return null;
|
|
563
563
|
return {
|
|
564
564
|
id,
|
|
565
|
+
kind: kindOf(wire),
|
|
565
566
|
paymentHash,
|
|
566
567
|
verifyUrl,
|
|
567
568
|
status,
|
|
@@ -573,6 +574,11 @@ function triggerEventFromWire(body) {
|
|
|
573
574
|
amountMsat: wire["incoming_amount"] === void 0 ? null : msatFrom(wire["incoming_amount"], 1)
|
|
574
575
|
};
|
|
575
576
|
}
|
|
577
|
+
function kindOf(wire) {
|
|
578
|
+
const said = wire["kind"];
|
|
579
|
+
if (said === "minted" || said === "watched") return said;
|
|
580
|
+
return text(wire["ln_address"]) === null ? "watched" : "minted";
|
|
581
|
+
}
|
|
576
582
|
function toAmount(msat) {
|
|
577
583
|
return { value: String(msat), asset_code: ASSET_CODE, asset_scale: ASSET_SCALE };
|
|
578
584
|
}
|
|
@@ -710,6 +716,23 @@ var ThunderBridge = class {
|
|
|
710
716
|
return quote;
|
|
711
717
|
}
|
|
712
718
|
/** Read a payment back, null when the gateway has never heard of it */
|
|
719
|
+
/**
|
|
720
|
+
* The key this gateway signs webhooks with when you registered none of your own,
|
|
721
|
+
* ready to hand to `parseWebhookRequest` as `{ publicKey }`. Fetch it once and
|
|
722
|
+
* keep it: it is the same for every instance in the cluster
|
|
723
|
+
*/
|
|
724
|
+
async webhookKey() {
|
|
725
|
+
const response = await fetch(`${this.baseUrl}/webhook-key`);
|
|
726
|
+
if (!response.ok) throw await problemFrom(response);
|
|
727
|
+
const body = await response.json().catch(() => null);
|
|
728
|
+
if (body?.algorithm !== "ed25519" || typeof body.public_key !== "string") {
|
|
729
|
+
throw new ProblemError({
|
|
730
|
+
status: response.status,
|
|
731
|
+
title: "The gateway published no ed25519 webhook key"
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
return body.public_key;
|
|
735
|
+
}
|
|
713
736
|
async getPayment(id) {
|
|
714
737
|
const response = await fetch(`${this.baseUrl}/incoming-payments/${encodeURIComponent(id)}`, {
|
|
715
738
|
headers: this.reading()
|
|
@@ -1195,6 +1218,7 @@ function minorScaleOf(currency) {
|
|
|
1195
1218
|
// src/bank.ts
|
|
1196
1219
|
var DEFAULT_CURRENCY = "CZK";
|
|
1197
1220
|
var DEFAULT_LOOK_BACK_SECS = 7 * 24 * 60 * 60;
|
|
1221
|
+
var DEFAULT_POLL_EVERY_SECS = 30;
|
|
1198
1222
|
var IBAN = /^[A-Z]{2}[0-9]{2}[0-9A-Z]{8,30}$/;
|
|
1199
1223
|
var FORBIDDEN_IN_SPD = "*";
|
|
1200
1224
|
async function bankTransfer(params) {
|
|
@@ -1221,6 +1245,9 @@ async function bankTransfer(params) {
|
|
|
1221
1245
|
return { id: watched.id, paymentHash: watched.paymentHash, verifyUrl, spd };
|
|
1222
1246
|
}
|
|
1223
1247
|
function bankVerifyEndpoint(config) {
|
|
1248
|
+
const paced = {
|
|
1249
|
+
"cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS}`
|
|
1250
|
+
};
|
|
1224
1251
|
return async (request) => {
|
|
1225
1252
|
const asked = readQuery(new URL(request.url));
|
|
1226
1253
|
if (asked === null) return Response.json({ settled: false }, { status: 400 });
|
|
@@ -1231,11 +1258,11 @@ function bankVerifyEndpoint(config) {
|
|
|
1231
1258
|
}
|
|
1232
1259
|
const since = unixNow() - (config.lookBackSecs ?? DEFAULT_LOOK_BACK_SECS);
|
|
1233
1260
|
const landed = (await config.statement(since)).some((credit) => pays(credit, asked));
|
|
1234
|
-
if (!landed) return Response.json({ settled: false });
|
|
1235
|
-
return Response.json(
|
|
1236
|
-
settled: true,
|
|
1237
|
-
|
|
1238
|
-
|
|
1261
|
+
if (!landed) return Response.json({ settled: false }, { headers: paced });
|
|
1262
|
+
return Response.json(
|
|
1263
|
+
{ settled: true, preimage: await hmacHex(config.secret, `preimage|${subject}`) },
|
|
1264
|
+
{ headers: paced }
|
|
1265
|
+
);
|
|
1239
1266
|
};
|
|
1240
1267
|
}
|
|
1241
1268
|
function readQuery(url) {
|
|
@@ -1693,18 +1720,59 @@ function refusals2(answers) {
|
|
|
1693
1720
|
).join("; ");
|
|
1694
1721
|
}
|
|
1695
1722
|
|
|
1723
|
+
// ../core/ed25519.ts
|
|
1724
|
+
var ALGORITHM = { name: "Ed25519" };
|
|
1725
|
+
var SIGNATURE_BYTES = 64;
|
|
1726
|
+
var PUBLIC_KEY_BYTES = 32;
|
|
1727
|
+
var PKCS8_HEADER = hexToBytes("302e020100300506032b657004220420");
|
|
1728
|
+
async function verifyHex(publicKeyHex, signatureHex, payload) {
|
|
1729
|
+
if (!isHex(publicKeyHex) || publicKeyHex.length !== PUBLIC_KEY_BYTES * 2) return false;
|
|
1730
|
+
if (!isHex(signatureHex) || signatureHex.length !== SIGNATURE_BYTES * 2) return false;
|
|
1731
|
+
try {
|
|
1732
|
+
const key = await crypto.subtle.importKey(
|
|
1733
|
+
"raw",
|
|
1734
|
+
asBuffer2(hexToBytes(publicKeyHex)),
|
|
1735
|
+
ALGORITHM,
|
|
1736
|
+
false,
|
|
1737
|
+
["verify"]
|
|
1738
|
+
);
|
|
1739
|
+
return await crypto.subtle.verify(
|
|
1740
|
+
ALGORITHM,
|
|
1741
|
+
key,
|
|
1742
|
+
asBuffer2(hexToBytes(signatureHex)),
|
|
1743
|
+
asBuffer2(payload)
|
|
1744
|
+
);
|
|
1745
|
+
} catch {
|
|
1746
|
+
return false;
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
function asBuffer2(bytes) {
|
|
1750
|
+
return bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1696
1753
|
// src/webhook.ts
|
|
1697
1754
|
var SIGNATURE_HEADER = "x-signature";
|
|
1698
1755
|
var TIMESTAMP_HEADER = "x-timestamp";
|
|
1699
|
-
var
|
|
1756
|
+
var SHARED_SECRET_PREFIX = "sha256=";
|
|
1757
|
+
var GATEWAY_KEY_PREFIX = "ed25519=";
|
|
1700
1758
|
var DEFAULT_TOLERANCE_SECS = 300;
|
|
1701
|
-
|
|
1759
|
+
var CHALLENGE = "webhook-challenge";
|
|
1760
|
+
async function verifyWebhookSignature(body, signature, credential, timestamp, options = {}) {
|
|
1702
1761
|
if (!recent(timestamp, options.toleranceSecs ?? DEFAULT_TOLERANCE_SECS)) return false;
|
|
1703
|
-
|
|
1704
|
-
|
|
1762
|
+
if (typeof credential !== "string") {
|
|
1763
|
+
if (!signature.startsWith(GATEWAY_KEY_PREFIX)) return false;
|
|
1764
|
+
return verifyHex(
|
|
1765
|
+
credential.publicKey.toLowerCase(),
|
|
1766
|
+
signature.slice(GATEWAY_KEY_PREFIX.length).toLowerCase(),
|
|
1767
|
+
signed(timestamp, body)
|
|
1768
|
+
);
|
|
1769
|
+
}
|
|
1770
|
+
if (signature.startsWith(GATEWAY_KEY_PREFIX)) return false;
|
|
1771
|
+
const received = signature.startsWith(SHARED_SECRET_PREFIX) ? signature.slice(SHARED_SECRET_PREFIX.length) : signature;
|
|
1772
|
+
return equalInConstantTime(await sign(credential, timestamp, body), received.toLowerCase());
|
|
1705
1773
|
}
|
|
1706
|
-
async function parseWebhook(body, signature,
|
|
1707
|
-
if (!await verifyWebhookSignature(body, signature,
|
|
1774
|
+
async function parseWebhook(body, signature, credential, timestamp, options = {}) {
|
|
1775
|
+
if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
|
|
1708
1776
|
const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
|
|
1709
1777
|
try {
|
|
1710
1778
|
return paymentFromWire(JSON.parse(text2));
|
|
@@ -1712,14 +1780,14 @@ async function parseWebhook(body, signature, secret, timestamp, options = {}) {
|
|
|
1712
1780
|
return null;
|
|
1713
1781
|
}
|
|
1714
1782
|
}
|
|
1715
|
-
async function parseWebhookRequest(request,
|
|
1783
|
+
async function parseWebhookRequest(request, credential, options = {}) {
|
|
1716
1784
|
const signature = request.headers.get(SIGNATURE_HEADER);
|
|
1717
1785
|
const timestamp = request.headers.get(TIMESTAMP_HEADER);
|
|
1718
1786
|
if (signature === null || timestamp === null) return null;
|
|
1719
|
-
return parseWebhook(await request.text(), signature,
|
|
1787
|
+
return parseWebhook(await request.text(), signature, credential, timestamp, options);
|
|
1720
1788
|
}
|
|
1721
|
-
async function parseWatchedWebhook(body, signature,
|
|
1722
|
-
if (!await verifyWebhookSignature(body, signature,
|
|
1789
|
+
async function parseWatchedWebhook(body, signature, credential, timestamp, options = {}) {
|
|
1790
|
+
if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
|
|
1723
1791
|
const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
|
|
1724
1792
|
try {
|
|
1725
1793
|
return triggerEventFromWire(JSON.parse(text2));
|
|
@@ -1727,11 +1795,44 @@ async function parseWatchedWebhook(body, signature, secret, timestamp, options =
|
|
|
1727
1795
|
return null;
|
|
1728
1796
|
}
|
|
1729
1797
|
}
|
|
1730
|
-
async function parseWatchedWebhookRequest(request,
|
|
1798
|
+
async function parseWatchedWebhookRequest(request, credential, options = {}) {
|
|
1731
1799
|
const signature = request.headers.get(SIGNATURE_HEADER);
|
|
1732
1800
|
const timestamp = request.headers.get(TIMESTAMP_HEADER);
|
|
1733
1801
|
if (signature === null || timestamp === null) return null;
|
|
1734
|
-
return parseWatchedWebhook(await request.text(), signature,
|
|
1802
|
+
return parseWatchedWebhook(await request.text(), signature, credential, timestamp, options);
|
|
1803
|
+
}
|
|
1804
|
+
async function answerWebhookChallenge(body, signature, credential, timestamp, options = {}) {
|
|
1805
|
+
if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
|
|
1806
|
+
const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
|
|
1807
|
+
const nonce = challenged(text2);
|
|
1808
|
+
if (nonce === null) return null;
|
|
1809
|
+
if (typeof credential !== "string") return JSON.stringify({ nonce });
|
|
1810
|
+
return JSON.stringify({
|
|
1811
|
+
nonce,
|
|
1812
|
+
signature: `${SHARED_SECRET_PREFIX}${await hmacHex(credential, nonce)}`
|
|
1813
|
+
});
|
|
1814
|
+
}
|
|
1815
|
+
async function answerWebhookChallengeRequest(request, credential, options = {}) {
|
|
1816
|
+
const signature = request.headers.get(SIGNATURE_HEADER);
|
|
1817
|
+
const timestamp = request.headers.get(TIMESTAMP_HEADER);
|
|
1818
|
+
if (signature === null || timestamp === null) return null;
|
|
1819
|
+
const answer = await answerWebhookChallenge(
|
|
1820
|
+
await request.clone().text(),
|
|
1821
|
+
signature,
|
|
1822
|
+
credential,
|
|
1823
|
+
timestamp,
|
|
1824
|
+
options
|
|
1825
|
+
);
|
|
1826
|
+
return answer === null ? null : new Response(answer, { headers: { "content-type": "application/json" } });
|
|
1827
|
+
}
|
|
1828
|
+
function challenged(text2) {
|
|
1829
|
+
try {
|
|
1830
|
+
const said = JSON.parse(text2);
|
|
1831
|
+
if (said["type"] !== CHALLENGE || typeof said["nonce"] !== "string") return null;
|
|
1832
|
+
return said["nonce"];
|
|
1833
|
+
} catch {
|
|
1834
|
+
return null;
|
|
1835
|
+
}
|
|
1735
1836
|
}
|
|
1736
1837
|
function recent(timestamp, toleranceSecs) {
|
|
1737
1838
|
const sent = Number(timestamp);
|
|
@@ -1761,6 +1862,8 @@ export {
|
|
|
1761
1862
|
REQUEST_IN_FLIGHT,
|
|
1762
1863
|
ThunderBridge,
|
|
1763
1864
|
UnverifiedRecipientError,
|
|
1865
|
+
answerWebhookChallenge,
|
|
1866
|
+
answerWebhookChallengeRequest,
|
|
1764
1867
|
bankRail,
|
|
1765
1868
|
bankTransfer,
|
|
1766
1869
|
bankVerifyEndpoint,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** Where a payment stands, `paid` is the only status that carries a preimage */
|
|
2
2
|
type PaymentStatus = "pending" | "paid" | "expired";
|
|
3
|
+
/** Whether the gateway resolved the address and got the invoice, or was handed one to watch */
|
|
4
|
+
type PaymentKind = "minted" | "watched";
|
|
3
5
|
/** A payment as the gateway reports it, every field is checkable against the recipient */
|
|
4
6
|
interface Payment {
|
|
5
7
|
id: string;
|
|
@@ -53,6 +55,7 @@ interface Quote {
|
|
|
53
55
|
*/
|
|
54
56
|
interface TriggerEvent {
|
|
55
57
|
id: string;
|
|
58
|
+
kind: PaymentKind;
|
|
56
59
|
paymentHash: string;
|
|
57
60
|
verifyUrl: string;
|
|
58
61
|
status: PaymentStatus;
|
|
@@ -189,6 +192,12 @@ declare class ThunderBridge {
|
|
|
189
192
|
*/
|
|
190
193
|
createQuote(params: CreateQuoteParams): Promise<Quote>;
|
|
191
194
|
/** Read a payment back, null when the gateway has never heard of it */
|
|
195
|
+
/**
|
|
196
|
+
* The key this gateway signs webhooks with when you registered none of your own,
|
|
197
|
+
* ready to hand to `parseWebhookRequest` as `{ publicKey }`. Fetch it once and
|
|
198
|
+
* keep it: it is the same for every instance in the cluster
|
|
199
|
+
*/
|
|
200
|
+
webhookKey(): Promise<string>;
|
|
192
201
|
getPayment(id: string): Promise<Payment | null>;
|
|
193
202
|
/**
|
|
194
203
|
* Read back a payment the gateway is only watching, null when it has never
|
|
@@ -353,6 +362,16 @@ interface BlindLightningRailConfig {
|
|
|
353
362
|
sealed?: (order: Order) => string | Promise<string>;
|
|
354
363
|
webhookUrl?: string;
|
|
355
364
|
webhookSecret?: string;
|
|
365
|
+
/**
|
|
366
|
+
* Where your own `lightningVerifyEndpoint` is mounted, and the secret it
|
|
367
|
+
* unseals with. Set both and the gateway is handed your URL rather than the
|
|
368
|
+
* wallet's, so it polls you, never a third party, and learns nothing about
|
|
369
|
+
* which provider the recipient uses. Leave them out and it polls the wallet
|
|
370
|
+
*/
|
|
371
|
+
relayVerifyThrough?: {
|
|
372
|
+
endpoint: string;
|
|
373
|
+
secret: string;
|
|
374
|
+
};
|
|
356
375
|
/** What `Leg.rail` reads, for a shop running more than one wallet */
|
|
357
376
|
name?: string;
|
|
358
377
|
}
|
|
@@ -378,4 +397,4 @@ declare function lightningRail(config: LightningRailConfig): Rail;
|
|
|
378
397
|
*/
|
|
379
398
|
declare function blindLightningRail(config: BlindLightningRailConfig): Rail;
|
|
380
399
|
|
|
381
|
-
export { type BankRailConfig as B, type CreatePaymentParams as C, type FollowOptions as F, type Leg as L, type Order as O, type Payment as P, type Quote as Q, type Rail as R, ThunderBridge as T, type WalletFailure as W, type TriggerEvent as a, type CreateOptions as b, type CreateQuoteParams as c, type LightningRailConfig as d, type
|
|
400
|
+
export { type BankRailConfig as B, type CreatePaymentParams as C, type FollowOptions as F, type Leg as L, type Order as O, type Payment as P, type Quote as Q, type Rail as R, ThunderBridge as T, type WalletFailure as W, type TriggerEvent as a, type CreateOptions as b, type CreateQuoteParams as c, type LightningRailConfig as d, type PaymentKind as e, type PaymentStatus as f, type ThunderBridgeOptions as g, type WaitOptions as h, type WalletReason as i, type WatchPaymentParams as j, bankRail as k, lightningRail as l, type BlindLightningRailConfig as m, blindLightningRail as n };
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** Where a payment stands, `paid` is the only status that carries a preimage */
|
|
2
2
|
type PaymentStatus = "pending" | "paid" | "expired";
|
|
3
|
+
/** Whether the gateway resolved the address and got the invoice, or was handed one to watch */
|
|
4
|
+
type PaymentKind = "minted" | "watched";
|
|
3
5
|
/** A payment as the gateway reports it, every field is checkable against the recipient */
|
|
4
6
|
interface Payment {
|
|
5
7
|
id: string;
|
|
@@ -53,6 +55,7 @@ interface Quote {
|
|
|
53
55
|
*/
|
|
54
56
|
interface TriggerEvent {
|
|
55
57
|
id: string;
|
|
58
|
+
kind: PaymentKind;
|
|
56
59
|
paymentHash: string;
|
|
57
60
|
verifyUrl: string;
|
|
58
61
|
status: PaymentStatus;
|
|
@@ -189,6 +192,12 @@ declare class ThunderBridge {
|
|
|
189
192
|
*/
|
|
190
193
|
createQuote(params: CreateQuoteParams): Promise<Quote>;
|
|
191
194
|
/** Read a payment back, null when the gateway has never heard of it */
|
|
195
|
+
/**
|
|
196
|
+
* The key this gateway signs webhooks with when you registered none of your own,
|
|
197
|
+
* ready to hand to `parseWebhookRequest` as `{ publicKey }`. Fetch it once and
|
|
198
|
+
* keep it: it is the same for every instance in the cluster
|
|
199
|
+
*/
|
|
200
|
+
webhookKey(): Promise<string>;
|
|
192
201
|
getPayment(id: string): Promise<Payment | null>;
|
|
193
202
|
/**
|
|
194
203
|
* Read back a payment the gateway is only watching, null when it has never
|
|
@@ -353,6 +362,16 @@ interface BlindLightningRailConfig {
|
|
|
353
362
|
sealed?: (order: Order) => string | Promise<string>;
|
|
354
363
|
webhookUrl?: string;
|
|
355
364
|
webhookSecret?: string;
|
|
365
|
+
/**
|
|
366
|
+
* Where your own `lightningVerifyEndpoint` is mounted, and the secret it
|
|
367
|
+
* unseals with. Set both and the gateway is handed your URL rather than the
|
|
368
|
+
* wallet's, so it polls you, never a third party, and learns nothing about
|
|
369
|
+
* which provider the recipient uses. Leave them out and it polls the wallet
|
|
370
|
+
*/
|
|
371
|
+
relayVerifyThrough?: {
|
|
372
|
+
endpoint: string;
|
|
373
|
+
secret: string;
|
|
374
|
+
};
|
|
356
375
|
/** What `Leg.rail` reads, for a shop running more than one wallet */
|
|
357
376
|
name?: string;
|
|
358
377
|
}
|
|
@@ -378,4 +397,4 @@ declare function lightningRail(config: LightningRailConfig): Rail;
|
|
|
378
397
|
*/
|
|
379
398
|
declare function blindLightningRail(config: BlindLightningRailConfig): Rail;
|
|
380
399
|
|
|
381
|
-
export { type BankRailConfig as B, type CreatePaymentParams as C, type FollowOptions as F, type Leg as L, type Order as O, type Payment as P, type Quote as Q, type Rail as R, ThunderBridge as T, type WalletFailure as W, type TriggerEvent as a, type CreateOptions as b, type CreateQuoteParams as c, type LightningRailConfig as d, type
|
|
400
|
+
export { type BankRailConfig as B, type CreatePaymentParams as C, type FollowOptions as F, type Leg as L, type Order as O, type Payment as P, type Quote as Q, type Rail as R, ThunderBridge as T, type WalletFailure as W, type TriggerEvent as a, type CreateOptions as b, type CreateQuoteParams as c, type LightningRailConfig as d, type PaymentKind as e, type PaymentStatus as f, type ThunderBridgeOptions as g, type WaitOptions as h, type WalletReason as i, type WatchPaymentParams as j, bankRail as k, lightningRail as l, type BlindLightningRailConfig as m, blindLightningRail as n };
|
package/dist/server.cjs
CHANGED
|
@@ -31,7 +31,9 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
31
31
|
var server_exports = {};
|
|
32
32
|
__export(server_exports, {
|
|
33
33
|
blindLightningRail: () => blindLightningRail,
|
|
34
|
-
|
|
34
|
+
lightningVerifyEndpoint: () => lightningVerifyEndpoint,
|
|
35
|
+
lnurlPayEndpoint: () => lnurlPayEndpoint,
|
|
36
|
+
relayedVerifyUrl: () => relayedVerifyUrl
|
|
35
37
|
});
|
|
36
38
|
module.exports = __toCommonJS(server_exports);
|
|
37
39
|
|
|
@@ -41,6 +43,16 @@ function bytesToHex(bytes) {
|
|
|
41
43
|
for (const byte of bytes) hex += byte.toString(16).padStart(2, "0");
|
|
42
44
|
return hex;
|
|
43
45
|
}
|
|
46
|
+
function hexToBytes(hex) {
|
|
47
|
+
const bytes = new Uint8Array(hex.length >> 1);
|
|
48
|
+
for (let at = 0; at < bytes.length; at++) {
|
|
49
|
+
bytes[at] = parseInt(hex.slice(at * 2, at * 2 + 2), 16);
|
|
50
|
+
}
|
|
51
|
+
return bytes;
|
|
52
|
+
}
|
|
53
|
+
function isHex(text2) {
|
|
54
|
+
return /^[0-9a-f]+$/i.test(text2) && text2.length % 2 === 0;
|
|
55
|
+
}
|
|
44
56
|
|
|
45
57
|
// ../core/hmac.ts
|
|
46
58
|
async function hmacHex(secret, payload) {
|
|
@@ -248,6 +260,10 @@ function decodeInvoice(bolt11) {
|
|
|
248
260
|
expiresAt: expiryOf(parts.words, tagged)
|
|
249
261
|
};
|
|
250
262
|
}
|
|
263
|
+
function preimageMatchesHash(preimage, paymentHash) {
|
|
264
|
+
if (!isHex(preimage)) return false;
|
|
265
|
+
return bytesToHex(sha256(hexToBytes(preimage))) === paymentHash.toLowerCase();
|
|
266
|
+
}
|
|
251
267
|
function splitBech32(bolt11) {
|
|
252
268
|
const lower = bolt11.toLowerCase();
|
|
253
269
|
if (isBolt12Encoding(lower)) return null;
|
|
@@ -428,19 +444,19 @@ async function refuseUnlessPublic(url) {
|
|
|
428
444
|
if (!await resolvesNothingPrivate(url)) throw new Error(`${url} resolves to an address we do not reach`);
|
|
429
445
|
}
|
|
430
446
|
async function answerOf(response) {
|
|
431
|
-
const { status, ok } = response;
|
|
447
|
+
const { status, ok, headers } = response;
|
|
432
448
|
const reader = response.body?.getReader();
|
|
433
|
-
if (!reader) return { status, ok, body: "", truncated: false };
|
|
449
|
+
if (!reader) return { status, ok, body: "", truncated: false, headers };
|
|
434
450
|
const read = [];
|
|
435
451
|
let bytes = 0;
|
|
436
452
|
while (bytes <= BODY_LIMIT_BYTES) {
|
|
437
453
|
const { done, value } = await reader.read();
|
|
438
|
-
if (done) return { status, ok, body: text(read), truncated: false };
|
|
454
|
+
if (done) return { status, ok, body: text(read), truncated: false, headers };
|
|
439
455
|
read.push(value);
|
|
440
456
|
bytes += value.length;
|
|
441
457
|
}
|
|
442
458
|
await reader.cancel();
|
|
443
|
-
return { status, ok, body: text(read), truncated: true };
|
|
459
|
+
return { status, ok, body: text(read), truncated: true, headers };
|
|
444
460
|
}
|
|
445
461
|
function withoutBody(sent) {
|
|
446
462
|
const headers = { ...sent.headers };
|
|
@@ -478,6 +494,10 @@ var NoWalletAvailable = class extends Error {
|
|
|
478
494
|
// ../core/lnurl.ts
|
|
479
495
|
var VERIFY_WITHOUT_PREIMAGE = ["zeuspay.com", "zeusnuts.com", "ecash.love"];
|
|
480
496
|
var RESOLVE_TIMEOUT_MS = 3e4;
|
|
497
|
+
var MIN_PACE_SECS = 1;
|
|
498
|
+
var MAX_PACE_SECS = 3600;
|
|
499
|
+
var MIN_PER_SECOND = 0.01;
|
|
500
|
+
var MAX_PER_SECOND = 100;
|
|
481
501
|
async function resolve(addresses, amountMsat) {
|
|
482
502
|
const served = await firstThatServes(
|
|
483
503
|
addresses,
|
|
@@ -565,6 +585,31 @@ function decodeIssued(address, bolt11) {
|
|
|
565
585
|
}
|
|
566
586
|
return { paymentHash, descriptionHash, amountMsat, expiresAt };
|
|
567
587
|
}
|
|
588
|
+
async function checkSettled(verifyUrl, paymentHash) {
|
|
589
|
+
const answer = await answeredJson(verifyUrl);
|
|
590
|
+
const asked = {
|
|
591
|
+
pace: paceAskedFor(answer.headers),
|
|
592
|
+
ceiling: ceilingAskedFor(answer.headers)
|
|
593
|
+
};
|
|
594
|
+
if (!answer.said.settled || !answer.said.preimage) return { preimage: null, ...asked };
|
|
595
|
+
if (!preimageMatchesHash(answer.said.preimage, paymentHash)) {
|
|
596
|
+
throw new Error(`verify returned a preimage that does not hash to ${paymentHash}`);
|
|
597
|
+
}
|
|
598
|
+
return { preimage: answer.said.preimage, ...asked };
|
|
599
|
+
}
|
|
600
|
+
function paceAskedFor(headers) {
|
|
601
|
+
const asked = /max-age\s*=\s*(\d+)/i.exec(headers.get("cache-control") ?? "");
|
|
602
|
+
if (!asked) return null;
|
|
603
|
+
return Math.min(Math.max(Number(asked[1]), MIN_PACE_SECS), MAX_PACE_SECS);
|
|
604
|
+
}
|
|
605
|
+
function ceilingAskedFor(headers) {
|
|
606
|
+
const asked = /^\s*(\d+)\s*(?:;\s*w\s*=\s*(\d+))?/i.exec(headers.get("ratelimit-limit") ?? "");
|
|
607
|
+
if (!asked) return null;
|
|
608
|
+
const perWindow = Number(asked[1]);
|
|
609
|
+
const window = asked[2] === void 0 ? 1 : Number(asked[2]);
|
|
610
|
+
if (perWindow === 0 || window === 0) return null;
|
|
611
|
+
return Math.min(Math.max(perWindow / window, MIN_PER_SECOND), MAX_PER_SECOND);
|
|
612
|
+
}
|
|
568
613
|
function cannotReleaseAPreimage(host) {
|
|
569
614
|
const lowered = host.toLowerCase();
|
|
570
615
|
return VERIFY_WITHOUT_PREIMAGE.some(
|
|
@@ -583,12 +628,15 @@ function splitAddress(address) {
|
|
|
583
628
|
return [user, domain];
|
|
584
629
|
}
|
|
585
630
|
async function fetchJson(url, deadline) {
|
|
631
|
+
return (await answeredJson(url, deadline)).said;
|
|
632
|
+
}
|
|
633
|
+
async function answeredJson(url, deadline) {
|
|
586
634
|
const answer = await ask(url, { headers: { accept: "application/json" }, deadline });
|
|
587
635
|
if (!answer.ok) throw new Error(`${url} answered ${answer.status}`);
|
|
588
636
|
if (answer.truncated) {
|
|
589
637
|
throw new Error(`${url} answered with more than the ${BODY_LIMIT_BYTES} bytes we read`);
|
|
590
638
|
}
|
|
591
|
-
return JSON.parse(answer.body);
|
|
639
|
+
return { said: JSON.parse(answer.body), headers: answer.headers };
|
|
592
640
|
}
|
|
593
641
|
|
|
594
642
|
// ../core/sealed.ts
|
|
@@ -610,6 +658,22 @@ async function seal(secret, plaintext) {
|
|
|
610
658
|
joined.set(cipher, iv.length);
|
|
611
659
|
return `${VERSION}.${toBase64Url(joined)}`;
|
|
612
660
|
}
|
|
661
|
+
async function unseal(secret, sealed) {
|
|
662
|
+
const key = await keyFor(secret);
|
|
663
|
+
if (!sealed.startsWith(`${VERSION}.`)) return null;
|
|
664
|
+
const joined = fromBase64Url(sealed.slice(VERSION.length + 1));
|
|
665
|
+
if (joined === null || joined.length <= IV_BYTES) return null;
|
|
666
|
+
try {
|
|
667
|
+
const body = await crypto.subtle.decrypt(
|
|
668
|
+
{ name: "AES-GCM", iv: joined.slice(0, IV_BYTES) },
|
|
669
|
+
key,
|
|
670
|
+
joined.slice(IV_BYTES)
|
|
671
|
+
);
|
|
672
|
+
return new TextDecoder().decode(body);
|
|
673
|
+
} catch {
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
613
677
|
async function keyFor(secret) {
|
|
614
678
|
if (secret.length < MIN_SECRET_CHARS) {
|
|
615
679
|
throw new Error(`the sealing secret needs ${MIN_SECRET_CHARS} characters of randomness`);
|
|
@@ -634,6 +698,17 @@ function toBase64Url(bytes) {
|
|
|
634
698
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
635
699
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
636
700
|
}
|
|
701
|
+
function fromBase64Url(text2) {
|
|
702
|
+
if (text2.length === 0 || !/^[A-Za-z0-9_-]+$/.test(text2)) return null;
|
|
703
|
+
try {
|
|
704
|
+
const binary = atob(text2.replace(/-/g, "+").replace(/_/g, "/"));
|
|
705
|
+
const bytes = new Uint8Array(binary.length);
|
|
706
|
+
for (let at = 0; at < bytes.length; at++) bytes[at] = binary.charCodeAt(at);
|
|
707
|
+
return bytes;
|
|
708
|
+
} catch {
|
|
709
|
+
return null;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
637
712
|
|
|
638
713
|
// src/errors.ts
|
|
639
714
|
var ProblemError = class extends Error {
|
|
@@ -767,14 +842,46 @@ function isLnAddress(destination) {
|
|
|
767
842
|
return at > 0 && destination.slice(at + 1).includes(".");
|
|
768
843
|
}
|
|
769
844
|
|
|
845
|
+
// src/relay.ts
|
|
846
|
+
var DEFAULT_POLL_EVERY_SECS = 5;
|
|
847
|
+
var WALLET = "w";
|
|
848
|
+
function lightningVerifyEndpoint(config) {
|
|
849
|
+
const paced = {
|
|
850
|
+
"cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS}`
|
|
851
|
+
};
|
|
852
|
+
return async (request) => {
|
|
853
|
+
const sealed = new URL(request.url).searchParams.get(WALLET);
|
|
854
|
+
if (sealed === null) return Response.json({ settled: false }, { status: 400 });
|
|
855
|
+
const opened = await unseal(config.secret, sealed);
|
|
856
|
+
if (opened === null) return Response.json({ settled: false }, { status: 403 });
|
|
857
|
+
const wallet = JSON.parse(opened);
|
|
858
|
+
const asked = await checkSettled(wallet.url, wallet.hash).catch(() => null);
|
|
859
|
+
if (asked === null) return Response.json({ settled: false }, { status: 502 });
|
|
860
|
+
return Response.json(
|
|
861
|
+
{ settled: asked.preimage !== null, preimage: asked.preimage },
|
|
862
|
+
{ headers: paced }
|
|
863
|
+
);
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
async function relayedVerifyUrl(endpoint, wallet, secret) {
|
|
867
|
+
const relayed = new URL(endpoint);
|
|
868
|
+
relayed.searchParams.set(WALLET, await seal(secret, JSON.stringify(wallet)));
|
|
869
|
+
return relayed.toString();
|
|
870
|
+
}
|
|
871
|
+
|
|
770
872
|
// src/rail.ts
|
|
771
873
|
var LIGHTNING = "lightning";
|
|
772
874
|
function blindLightningRail(config) {
|
|
773
875
|
return async (order) => {
|
|
774
876
|
const resolved = await invoiceFrom(config.lnAddresses, await config.amountMsat(order));
|
|
877
|
+
const relay = config.relayVerifyThrough;
|
|
775
878
|
const watched = await config.gateway.watchPayment({
|
|
776
879
|
paymentHash: resolved.paymentHash,
|
|
777
|
-
verifyUrl:
|
|
880
|
+
verifyUrl: relay ? await relayedVerifyUrl(
|
|
881
|
+
relay.endpoint,
|
|
882
|
+
{ url: resolved.verifyUrl, hash: resolved.paymentHash },
|
|
883
|
+
relay.secret
|
|
884
|
+
) : resolved.verifyUrl,
|
|
778
885
|
expiresAt: resolved.expiresAt,
|
|
779
886
|
trigger: config.trigger,
|
|
780
887
|
sealed: await config.sealed?.(order),
|
|
@@ -803,5 +910,7 @@ async function invoiceFrom(lnAddresses, amountMsat) {
|
|
|
803
910
|
// Annotate the CommonJS export names for ESM import in node:
|
|
804
911
|
0 && (module.exports = {
|
|
805
912
|
blindLightningRail,
|
|
806
|
-
|
|
913
|
+
lightningVerifyEndpoint,
|
|
914
|
+
lnurlPayEndpoint,
|
|
915
|
+
relayedVerifyUrl
|
|
807
916
|
});
|
package/dist/server.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { T as ThunderBridge } from './rail-
|
|
2
|
-
export {
|
|
1
|
+
import { T as ThunderBridge } from './rail-J8QoYbSr.cjs';
|
|
2
|
+
export { m as BlindLightningRailConfig, n as blindLightningRail } from './rail-J8QoYbSr.cjs';
|
|
3
3
|
|
|
4
4
|
interface TriggerConfig {
|
|
5
5
|
/** The gateway that quotes the addresses and mints the invoice */
|
|
@@ -61,4 +61,43 @@ interface Minted {
|
|
|
61
61
|
*/
|
|
62
62
|
declare function lnurlPayEndpoint(config: TriggerConfig): (request: Request) => Promise<Response>;
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
/** The wallet's own LUD-21 URL and the hash its preimage has to match */
|
|
65
|
+
interface Relayed {
|
|
66
|
+
url: string;
|
|
67
|
+
hash: string;
|
|
68
|
+
}
|
|
69
|
+
interface LightningVerifyConfig {
|
|
70
|
+
/** The secret the sealed wallet URL was made with, and nothing else uses it */
|
|
71
|
+
secret: string;
|
|
72
|
+
/**
|
|
73
|
+
* How often you want the gateway to ask, in seconds. It goes out as
|
|
74
|
+
* `Cache-Control: max-age`, so the pace is yours rather than the operator's.
|
|
75
|
+
* Five by default, which is what a Lightning checkout wants
|
|
76
|
+
*/
|
|
77
|
+
pollEverySecs?: number;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* A verify endpoint of your own that asks the recipient's wallet for you, so the
|
|
81
|
+
* gateway polls you and never the wallet.
|
|
82
|
+
*
|
|
83
|
+
* `relayedVerifyUrl` seals the wallet's own LUD-21 URL into the query with your
|
|
84
|
+
* secret, so what the gateway stores and replicates is opaque: not the wallet's
|
|
85
|
+
* host, not which provider the recipient uses, nothing but a blob it cannot read.
|
|
86
|
+
* This handler unseals it, asks the wallet, and answers the same LUD-21 shape.
|
|
87
|
+
*
|
|
88
|
+
* It relays rather than decides. The preimage still comes from the recipient's
|
|
89
|
+
* own server and still has to hash to the payment hash, so standing between the
|
|
90
|
+
* two buys privacy and pacing without becoming something anyone has to trust.
|
|
91
|
+
*
|
|
92
|
+
* A wallet it cannot reach answers `502` rather than "not settled", because those
|
|
93
|
+
* are different claims and only one of them is true. The gateway logs a failed
|
|
94
|
+
* poll and asks again, which is what a broken relay should look like.
|
|
95
|
+
*/
|
|
96
|
+
declare function lightningVerifyEndpoint(config: LightningVerifyConfig): (request: Request) => Promise<Response>;
|
|
97
|
+
/**
|
|
98
|
+
* The URL to hand the gateway instead of the wallet's own, with the wallet's
|
|
99
|
+
* sealed inside it. Point it at wherever `lightningVerifyEndpoint` is mounted
|
|
100
|
+
*/
|
|
101
|
+
declare function relayedVerifyUrl(endpoint: string, wallet: Relayed, secret: string): Promise<string>;
|
|
102
|
+
|
|
103
|
+
export { type LightningVerifyConfig, type Minted, type Relayed, type TriggerConfig, lightningVerifyEndpoint, lnurlPayEndpoint, relayedVerifyUrl };
|
package/dist/server.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { T as ThunderBridge } from './rail-
|
|
2
|
-
export {
|
|
1
|
+
import { T as ThunderBridge } from './rail-J8QoYbSr.js';
|
|
2
|
+
export { m as BlindLightningRailConfig, n as blindLightningRail } from './rail-J8QoYbSr.js';
|
|
3
3
|
|
|
4
4
|
interface TriggerConfig {
|
|
5
5
|
/** The gateway that quotes the addresses and mints the invoice */
|
|
@@ -61,4 +61,43 @@ interface Minted {
|
|
|
61
61
|
*/
|
|
62
62
|
declare function lnurlPayEndpoint(config: TriggerConfig): (request: Request) => Promise<Response>;
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
/** The wallet's own LUD-21 URL and the hash its preimage has to match */
|
|
65
|
+
interface Relayed {
|
|
66
|
+
url: string;
|
|
67
|
+
hash: string;
|
|
68
|
+
}
|
|
69
|
+
interface LightningVerifyConfig {
|
|
70
|
+
/** The secret the sealed wallet URL was made with, and nothing else uses it */
|
|
71
|
+
secret: string;
|
|
72
|
+
/**
|
|
73
|
+
* How often you want the gateway to ask, in seconds. It goes out as
|
|
74
|
+
* `Cache-Control: max-age`, so the pace is yours rather than the operator's.
|
|
75
|
+
* Five by default, which is what a Lightning checkout wants
|
|
76
|
+
*/
|
|
77
|
+
pollEverySecs?: number;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* A verify endpoint of your own that asks the recipient's wallet for you, so the
|
|
81
|
+
* gateway polls you and never the wallet.
|
|
82
|
+
*
|
|
83
|
+
* `relayedVerifyUrl` seals the wallet's own LUD-21 URL into the query with your
|
|
84
|
+
* secret, so what the gateway stores and replicates is opaque: not the wallet's
|
|
85
|
+
* host, not which provider the recipient uses, nothing but a blob it cannot read.
|
|
86
|
+
* This handler unseals it, asks the wallet, and answers the same LUD-21 shape.
|
|
87
|
+
*
|
|
88
|
+
* It relays rather than decides. The preimage still comes from the recipient's
|
|
89
|
+
* own server and still has to hash to the payment hash, so standing between the
|
|
90
|
+
* two buys privacy and pacing without becoming something anyone has to trust.
|
|
91
|
+
*
|
|
92
|
+
* A wallet it cannot reach answers `502` rather than "not settled", because those
|
|
93
|
+
* are different claims and only one of them is true. The gateway logs a failed
|
|
94
|
+
* poll and asks again, which is what a broken relay should look like.
|
|
95
|
+
*/
|
|
96
|
+
declare function lightningVerifyEndpoint(config: LightningVerifyConfig): (request: Request) => Promise<Response>;
|
|
97
|
+
/**
|
|
98
|
+
* The URL to hand the gateway instead of the wallet's own, with the wallet's
|
|
99
|
+
* sealed inside it. Point it at wherever `lightningVerifyEndpoint` is mounted
|
|
100
|
+
*/
|
|
101
|
+
declare function relayedVerifyUrl(endpoint: string, wallet: Relayed, secret: string): Promise<string>;
|
|
102
|
+
|
|
103
|
+
export { type LightningVerifyConfig, type Minted, type Relayed, type TriggerConfig, lightningVerifyEndpoint, lnurlPayEndpoint, relayedVerifyUrl };
|