@forgesworn/moneyer 0.1.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/LICENSE +21 -0
  3. package/README.md +133 -0
  4. package/SECURITY.md +29 -0
  5. package/THREAT-MODEL.md +104 -0
  6. package/dist/backends/cln.d.ts +5 -0
  7. package/dist/backends/cln.js +130 -0
  8. package/dist/backends/fake-bolt11.d.ts +6 -0
  9. package/dist/backends/fake-bolt11.js +60 -0
  10. package/dist/backends/fake.d.ts +17 -0
  11. package/dist/backends/fake.js +104 -0
  12. package/dist/backends/lnd.d.ts +5 -0
  13. package/dist/backends/lnd.js +191 -0
  14. package/dist/backends/types.d.ts +38 -0
  15. package/dist/backends/types.js +25 -0
  16. package/dist/cli.d.ts +2 -0
  17. package/dist/cli.js +61 -0
  18. package/dist/config.d.ts +44 -0
  19. package/dist/config.js +100 -0
  20. package/dist/index.d.ts +10 -0
  21. package/dist/index.js +23 -0
  22. package/dist/landing.d.ts +8 -0
  23. package/dist/landing.js +63 -0
  24. package/dist/melt.d.ts +17 -0
  25. package/dist/melt.js +91 -0
  26. package/dist/server.d.ts +24 -0
  27. package/dist/server.js +502 -0
  28. package/dist/signing.d.ts +6 -0
  29. package/dist/signing.js +37 -0
  30. package/dist/store.d.ts +53 -0
  31. package/dist/store.js +245 -0
  32. package/dist/web-assets.d.ts +12 -0
  33. package/dist/web-assets.js +51 -0
  34. package/llms.txt +64 -0
  35. package/package.json +76 -0
  36. package/web/dist/art/plate-l.webp +0 -0
  37. package/web/dist/art/plate-p.webp +0 -0
  38. package/web/dist/assets/cinzel-latin-400-normal-C8jUSQqm.woff +0 -0
  39. package/web/dist/assets/cinzel-latin-400-normal-DnUIPmzd.woff2 +0 -0
  40. package/web/dist/assets/cinzel-latin-700-normal-C-gK7hA8.woff +0 -0
  41. package/web/dist/assets/cinzel-latin-700-normal-Dkw14w9r.woff2 +0 -0
  42. package/web/dist/assets/cinzel-latin-ext-400-normal-DJ0Lq8y-.woff +0 -0
  43. package/web/dist/assets/cinzel-latin-ext-400-normal-XQK_CSAr.woff2 +0 -0
  44. package/web/dist/assets/cinzel-latin-ext-700-normal-C24KFjuG.woff2 +0 -0
  45. package/web/dist/assets/cinzel-latin-ext-700-normal-CORa-yIv.woff +0 -0
  46. package/web/dist/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
  47. package/web/dist/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
  48. package/web/dist/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
  49. package/web/dist/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
  50. package/web/dist/assets/ibm-plex-mono-cyrillic-600-normal-CTOM6hUh.woff2 +0 -0
  51. package/web/dist/assets/ibm-plex-mono-cyrillic-600-normal-fLZuRloM.woff +0 -0
  52. package/web/dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
  53. package/web/dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
  54. package/web/dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
  55. package/web/dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
  56. package/web/dist/assets/ibm-plex-mono-cyrillic-ext-600-normal-9HEixskS.woff +0 -0
  57. package/web/dist/assets/ibm-plex-mono-cyrillic-ext-600-normal-V-xxqcpd.woff2 +0 -0
  58. package/web/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  59. package/web/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  60. package/web/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  61. package/web/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  62. package/web/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  63. package/web/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
  64. package/web/dist/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
  65. package/web/dist/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
  66. package/web/dist/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
  67. package/web/dist/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
  68. package/web/dist/assets/ibm-plex-mono-latin-ext-600-normal-D38SheWl.woff2 +0 -0
  69. package/web/dist/assets/ibm-plex-mono-latin-ext-600-normal-DmB0ttJJ.woff +0 -0
  70. package/web/dist/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
  71. package/web/dist/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
  72. package/web/dist/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
  73. package/web/dist/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
  74. package/web/dist/assets/ibm-plex-mono-vietnamese-600-normal-D2EvbN8M.woff2 +0 -0
  75. package/web/dist/assets/ibm-plex-mono-vietnamese-600-normal-iLQfcSjf.woff +0 -0
  76. package/web/dist/assets/index-BGYBQuiQ.css +1 -0
  77. package/web/dist/assets/index-CIuRQd9k.js +130 -0
  78. package/web/dist/assets/spectral-cyrillic-400-italic-C6o5RXh-.woff2 +0 -0
  79. package/web/dist/assets/spectral-cyrillic-400-italic-C95RxGpc.woff +0 -0
  80. package/web/dist/assets/spectral-cyrillic-400-normal-Bx9Tn3WZ.woff2 +0 -0
  81. package/web/dist/assets/spectral-cyrillic-400-normal-X3p2PSzL.woff +0 -0
  82. package/web/dist/assets/spectral-cyrillic-500-italic-BLpmaxIM.woff +0 -0
  83. package/web/dist/assets/spectral-cyrillic-500-italic-D14WfPba.woff2 +0 -0
  84. package/web/dist/assets/spectral-cyrillic-600-normal-Dl75bDKp.woff2 +0 -0
  85. package/web/dist/assets/spectral-cyrillic-600-normal-veT-ELBO.woff +0 -0
  86. package/web/dist/assets/spectral-cyrillic-ext-400-italic-C6Gmo4JY.woff +0 -0
  87. package/web/dist/assets/spectral-cyrillic-ext-400-italic-CZOVIkJA.woff2 +0 -0
  88. package/web/dist/assets/spectral-cyrillic-ext-400-normal--n_iQ4wr.woff +0 -0
  89. package/web/dist/assets/spectral-cyrillic-ext-400-normal-B4-YHs9i.woff2 +0 -0
  90. package/web/dist/assets/spectral-cyrillic-ext-500-italic-CxkamsWL.woff +0 -0
  91. package/web/dist/assets/spectral-cyrillic-ext-500-italic-DGZ5KH5h.woff2 +0 -0
  92. package/web/dist/assets/spectral-cyrillic-ext-600-normal-BVATWin6.woff +0 -0
  93. package/web/dist/assets/spectral-cyrillic-ext-600-normal-CvRHnPuy.woff2 +0 -0
  94. package/web/dist/assets/spectral-latin-400-italic-CDO25mTW.woff2 +0 -0
  95. package/web/dist/assets/spectral-latin-400-italic-Ds-NcNVC.woff +0 -0
  96. package/web/dist/assets/spectral-latin-400-normal-CTVgUekv.woff2 +0 -0
  97. package/web/dist/assets/spectral-latin-400-normal-D34IWA1h.woff +0 -0
  98. package/web/dist/assets/spectral-latin-500-italic-CqeZqSS8.woff2 +0 -0
  99. package/web/dist/assets/spectral-latin-500-italic-DxwFSvPk.woff +0 -0
  100. package/web/dist/assets/spectral-latin-600-normal-DE3OxvnQ.woff2 +0 -0
  101. package/web/dist/assets/spectral-latin-600-normal-DXnQJnYb.woff +0 -0
  102. package/web/dist/assets/spectral-latin-ext-400-italic-BcKU4A_a.woff +0 -0
  103. package/web/dist/assets/spectral-latin-ext-400-italic-BgTM1Mst.woff2 +0 -0
  104. package/web/dist/assets/spectral-latin-ext-400-normal-BHbqXr-7.woff +0 -0
  105. package/web/dist/assets/spectral-latin-ext-400-normal-XDLFwhqg.woff2 +0 -0
  106. package/web/dist/assets/spectral-latin-ext-500-italic-DMigwuN9.woff2 +0 -0
  107. package/web/dist/assets/spectral-latin-ext-500-italic-UnF3HUpx.woff +0 -0
  108. package/web/dist/assets/spectral-latin-ext-600-normal-DKlDBbgb.woff2 +0 -0
  109. package/web/dist/assets/spectral-latin-ext-600-normal-DnKmvInL.woff +0 -0
  110. package/web/dist/assets/spectral-vietnamese-400-italic-D8dhja4j.woff +0 -0
  111. package/web/dist/assets/spectral-vietnamese-400-italic-DO3bsfcj.woff2 +0 -0
  112. package/web/dist/assets/spectral-vietnamese-400-normal-C7CRHYqk.woff2 +0 -0
  113. package/web/dist/assets/spectral-vietnamese-400-normal-CWkMWCzb.woff +0 -0
  114. package/web/dist/assets/spectral-vietnamese-500-italic-e7VYE3f9.woff2 +0 -0
  115. package/web/dist/assets/spectral-vietnamese-500-italic-q7ilnnst.woff +0 -0
  116. package/web/dist/assets/spectral-vietnamese-600-normal-ZNiwCzjU.woff2 +0 -0
  117. package/web/dist/assets/spectral-vietnamese-600-normal-esmjhrFa.woff +0 -0
  118. package/web/dist/index.html +19 -0
package/dist/melt.js ADDED
@@ -0,0 +1,91 @@
1
+ import { verifyPreimage } from 'farrier-kit/preimage';
2
+ import { PaymentAlreadyKnownError, PaymentFailedError, PaymentPendingError } from "./backends/types.js";
3
+ const DEFAULT_CONFIRM_DELAYS_MS = [0, 2_000, 4_000, 9_000, 16_000];
4
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
5
+ const confirmThenSettle = async (job, deps) => {
6
+ const log = deps.log ?? (() => { });
7
+ for (const delay of deps.confirmDelaysMs ?? DEFAULT_CONFIRM_DELAYS_MS) {
8
+ if (delay > 0)
9
+ await sleep(delay);
10
+ try {
11
+ const complete = await deps.backend.isPaymentComplete(job.paymentHash);
12
+ if (complete) {
13
+ deps.store.finalizeMelt(job.paymentHash);
14
+ }
15
+ else {
16
+ log(`melt ${job.noteId}: confirmed not paid - restoring`);
17
+ deps.store.restoreMelt(job.paymentHash);
18
+ }
19
+ return;
20
+ }
21
+ catch (err) {
22
+ if (!(err instanceof PaymentPendingError)) {
23
+ log(`melt ${job.noteId}: could not confirm payment status (${err.message})`);
24
+ }
25
+ }
26
+ }
27
+ log(`melt ${job.noteId}: payment status unconfirmable - note left pending for reconciliation`);
28
+ };
29
+ export const runMelt = async (job, deps) => {
30
+ const log = deps.log ?? (() => { });
31
+ let outcome;
32
+ try {
33
+ outcome = await deps.backend.payInvoice({
34
+ pr: job.pr,
35
+ feeLimitMsat: deps.feeLimitMsat(job.amountMsat)
36
+ });
37
+ }
38
+ catch (err) {
39
+ if (err instanceof PaymentAlreadyKnownError) {
40
+ // The node's payment for this hash belongs to someone else - another
41
+ // mint sharing this funding source, or the operator. Nothing went
42
+ // out for THIS melt, and confirming by hash would confirm against
43
+ // that foreign payment: restore, never guess. The synchronous
44
+ // pre-check in the callback catches this before the note is even
45
+ // reserved; this branch closes the race where the foreign payment
46
+ // lands between that check and the send.
47
+ log(`melt ${job.noteId}: the funding source already knew this hash - restored (shared-node replay?)`);
48
+ deps.store.restoreMelt(job.paymentHash);
49
+ return;
50
+ }
51
+ if (!(err instanceof PaymentFailedError)) {
52
+ log(`melt ${job.noteId}: payment attempt failed ambiguously (${err.message})`);
53
+ }
54
+ await confirmThenSettle(job, deps);
55
+ return;
56
+ }
57
+ if (outcome.preimageHex !== null && !verifyPreimage(outcome.preimageHex, job.paymentHash)) {
58
+ // The backend claims success with a preimage that does not settle this
59
+ // invoice. That is a statement about the evidence, not the money - fall
60
+ // back to the tracker rather than trusting either way.
61
+ log(`melt ${job.noteId}: backend preimage does not settle this invoice - reconfirming`);
62
+ await confirmThenSettle(job, deps);
63
+ return;
64
+ }
65
+ deps.store.finalizeMelt(job.paymentHash);
66
+ };
67
+ // Resolves melts an earlier process left pending - a crash mid-melt, or an
68
+ // outcome that could not be confirmed at the time. Melts whose attempt is
69
+ // live in THIS process are skipped: their runMelt owns them, and the
70
+ // backend can momentarily report "no such payment" before the RPC lands.
71
+ export const reconcilePendingMelts = async (store, backend, inFlight, log = () => { }) => {
72
+ for (const melt of store.pendingMelts()) {
73
+ if (inFlight.has(melt.paymentHash))
74
+ continue;
75
+ try {
76
+ const complete = await backend.isPaymentComplete(melt.paymentHash);
77
+ if (complete) {
78
+ store.finalizeMelt(melt.paymentHash);
79
+ log(`reconcile: melt ${melt.noteId} confirmed paid - burned`);
80
+ }
81
+ else {
82
+ store.restoreMelt(melt.paymentHash);
83
+ log(`reconcile: melt ${melt.noteId} confirmed not paid - restored`);
84
+ }
85
+ }
86
+ catch {
87
+ // still unresolved - an operator can look, and the next reconcile
88
+ // will try again
89
+ }
90
+ }
91
+ };
@@ -0,0 +1,24 @@
1
+ import type { MoneyerConfig } from './config.ts';
2
+ import { NoteStore } from './store.ts';
3
+ import { type NoteSigner } from './signing.ts';
4
+ import { type LightningBackend } from './backends/types.ts';
5
+ import { type WebAssets } from './web-assets.ts';
6
+ export type Moneyer = {
7
+ url: string;
8
+ port: number;
9
+ config: MoneyerConfig;
10
+ store: NoteStore;
11
+ backend: LightningBackend;
12
+ signer: NoteSigner | null;
13
+ reconcile: () => Promise<void>;
14
+ close: () => Promise<void>;
15
+ };
16
+ export type MoneyerDeps = {
17
+ backend?: LightningBackend;
18
+ store?: NoteStore;
19
+ log?: (message: string) => void;
20
+ confirmDelaysMs?: number[];
21
+ webAssets?: WebAssets | null;
22
+ };
23
+ export declare const createMoneyer: (config: MoneyerConfig, deps?: MoneyerDeps) => Promise<Moneyer>;
24
+ export declare const sweepExpiredMintInvoices: (store: NoteStore, nowMs?: number) => number;
package/dist/server.js ADDED
@@ -0,0 +1,502 @@
1
+ import { createServer } from 'node:http';
2
+ import { bytesToHex, randomBytes } from '@noble/hashes/utils.js';
3
+ import { applyMintFee, grossUpForMintFee, hashK1 } from 'lnurlcash-kit';
4
+ import { tryDecodeBolt11 } from 'farrier-kit/bolt11';
5
+ import { NotePendingError, NoteStore, NoteUnavailableError } from "./store.js";
6
+ import { createNoteSigner } from "./signing.js";
7
+ import { createFakeBackend } from "./backends/fake.js";
8
+ import { createClnBackend } from "./backends/cln.js";
9
+ import { createLndBackend } from "./backends/lnd.js";
10
+ import { PaymentPendingError } from "./backends/types.js";
11
+ import { reconcilePendingMelts, runMelt } from "./melt.js";
12
+ import { landingPage } from "./landing.js";
13
+ import { CONFIG_TOKEN, loadWebAssets } from "./web-assets.js";
14
+ const HEX32 = /^[0-9a-f]{64}$/;
15
+ const backendFor = (config) => {
16
+ switch (config.backend.kind) {
17
+ case 'fake':
18
+ return createFakeBackend();
19
+ case 'cln':
20
+ return createClnBackend(config.backend);
21
+ case 'lnd':
22
+ return createLndBackend(config.backend);
23
+ }
24
+ };
25
+ export const createMoneyer = async (config, deps = {}) => {
26
+ const log = deps.log ?? (() => { });
27
+ const store = deps.store ?? new NoteStore(config.dbPath);
28
+ const backend = deps.backend ?? backendFor(config);
29
+ const signer = config.signingKey ? createNoteSigner(config.signingKey) : null;
30
+ const webAssets = deps.webAssets === undefined ? loadWebAssets() : deps.webAssets;
31
+ // Node identity for the discovery endpoint, fetched once, best-effort: a
32
+ // funding source that cannot answer does not stop the mint serving.
33
+ let nodeInfo = {};
34
+ try {
35
+ nodeInfo = (await backend.nodeInfo?.()) ?? {};
36
+ }
37
+ catch {
38
+ log('could not fetch funding-source node info - discovery will omit it');
39
+ }
40
+ // Melt payment hashes with a live attempt in this process. Reconcile
41
+ // skips these - see melt.ts.
42
+ const inFlight = new Set();
43
+ const mintFeeMsat = (grossMsat) => config.mintFee ? grossMsat - applyMintFee(grossMsat, config.mintFee) : 0;
44
+ // What the mint advertises as its minimum must survive its own fee: a
45
+ // payRequest whose minSendable nets below the dust floor invites a
46
+ // payment it would then refuse.
47
+ const effectiveMinSendableMsat = Math.max(config.minSendableMsat, config.mintFee ? grossUpForMintFee(config.minMintMsat, config.mintFee) : 0);
48
+ // The routing-fee budget for a melt. LUD-25 has the mint fee cover the
49
+ // eventual payout's routing cost, so the budget follows what this mint
50
+ // actually charges, floored at 0.5%-or-5000msat so fee-free mints still
51
+ // route.
52
+ const meltFeeLimitMsat = (amountMsat) => Math.max(Math.round(amountMsat * 0.005), 5_000, mintFeeMsat(amountMsat));
53
+ // A note whose id we do not know yet may be a settled mint invoice whose
54
+ // claim simply has not been observed: settle it lazily against the
55
+ // funding source, which is what makes paying an invoice mint the note.
56
+ const resolveNote = async (k1) => {
57
+ const id = hashK1(k1);
58
+ const note = store.noteById(id);
59
+ if (note)
60
+ return note;
61
+ const invoice = store.mintInvoiceByHash(id);
62
+ if (invoice && !invoice.settled && (await backend.isInvoiceSettled(id))) {
63
+ store.settleMintInvoice(id);
64
+ return store.noteById(id);
65
+ }
66
+ return null;
67
+ };
68
+ const handle = async (req, res) => {
69
+ const requestUrl = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);
70
+ const q = requestUrl.searchParams;
71
+ const origin = config.publicOrigin ?? `http://${req.headers.host ?? '127.0.0.1'}`;
72
+ const host = new URL(origin).host;
73
+ const send = (body, status = 200) => {
74
+ res.writeHead(status, {
75
+ 'content-type': 'application/json',
76
+ 'access-control-allow-origin': '*',
77
+ 'x-content-type-options': 'nosniff'
78
+ });
79
+ res.end(JSON.stringify(body));
80
+ };
81
+ const fail = (reason, status = 200) => send({ status: 'ERROR', reason }, status);
82
+ const knownUser = (user) => user === config.username || user === '_';
83
+ // ---- the mint's face ----
84
+ if (requestUrl.pathname === '/' && (req.method === 'GET' || req.method === 'HEAD')) {
85
+ if (webAssets) {
86
+ res.writeHead(200, {
87
+ 'content-type': 'text/html; charset=utf-8',
88
+ 'x-content-type-options': 'nosniff',
89
+ 'x-frame-options': 'DENY',
90
+ 'referrer-policy': 'no-referrer'
91
+ });
92
+ // Runtime identity the static build cannot know. `<` is escaped so
93
+ // no value can close the script element it is injected into.
94
+ const injected = JSON.stringify({
95
+ username: config.username,
96
+ ...(config.walletUrl ? { walletUrl: config.walletUrl } : {}),
97
+ ...(config.sunset ? { sunset: true } : {})
98
+ }).replace(/</g, '\\u003c');
99
+ res.end(webAssets.indexHtml.replace(CONFIG_TOKEN, `<script>window.__MINT__=${injected}</script>`));
100
+ }
101
+ else {
102
+ res.writeHead(200, {
103
+ 'content-type': 'text/html; charset=utf-8',
104
+ // The fallback page is self-contained: inline style only, no
105
+ // scripts, no external assets. The CSP says exactly that.
106
+ 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'",
107
+ 'x-content-type-options': 'nosniff',
108
+ 'x-frame-options': 'DENY',
109
+ 'referrer-policy': 'no-referrer'
110
+ });
111
+ res.end(landingPage({ config, host, mintPubkey: signer?.pubkey ?? null, nodeInfo }));
112
+ }
113
+ return;
114
+ }
115
+ if (webAssets && (req.method === 'GET' || req.method === 'HEAD')) {
116
+ const asset = webAssets.get(requestUrl.pathname);
117
+ if (asset) {
118
+ res.writeHead(200, {
119
+ 'content-type': asset.type,
120
+ 'x-content-type-options': 'nosniff',
121
+ 'cache-control': asset.immutable ? 'public, max-age=31536000, immutable' : 'public, max-age=300'
122
+ });
123
+ res.end(req.method === 'HEAD' ? undefined : asset.body);
124
+ return;
125
+ }
126
+ }
127
+ // Every LNURL endpoint below is a GET. The method matters: /w/cb
128
+ // mutates on whatever arrives, and an OPTIONS preflight or a stray
129
+ // retry carrying the same query string must never burn a note.
130
+ if (req.method !== 'GET')
131
+ return fail('Not found.', 404);
132
+ // ---- LUD-16 payRequest: paying this mints a note ----
133
+ const lnurlpMatch = requestUrl.pathname.match(/^\/\.well-known\/lnurlp\/(.+)$/);
134
+ if (lnurlpMatch) {
135
+ if (!knownUser(lnurlpMatch[1]))
136
+ return fail('Unknown user.', 404);
137
+ const metadata = [
138
+ ['text/plain', `Mint an LNURLcash bearer note at ${config.username}@${host}`],
139
+ ['text/identifier', `${lnurlpMatch[1]}@${host}`]
140
+ ];
141
+ if (config.mintFee) {
142
+ metadata.push(['text/plain', `Mint fees: ${config.mintFee.baseFeeMsat},${config.mintFee.feePpm}`]);
143
+ }
144
+ return send({
145
+ tag: 'payRequest',
146
+ callback: `${origin}/p/cb`,
147
+ minSendable: effectiveMinSendableMsat,
148
+ maxSendable: config.maxSendableMsat,
149
+ metadata: JSON.stringify(metadata),
150
+ withdrawLink: `lnurlw://${host}/w`,
151
+ disposable: false
152
+ });
153
+ }
154
+ // ---- LUD-25 mint address discovery (experimental) ----
155
+ const lnurlwMatch = requestUrl.pathname.match(/^\/\.well-known\/lnurlw\/(.+)$/);
156
+ if (lnurlwMatch) {
157
+ if (!knownUser(lnurlwMatch[1]))
158
+ return fail('Unknown user.', 404);
159
+ return send({
160
+ tag: 'withdrawRequest',
161
+ callback: `${origin}/w`,
162
+ minWithdrawable: config.minMintMsat,
163
+ maxWithdrawable: config.mintFee
164
+ ? applyMintFee(config.maxSendableMsat, config.mintFee)
165
+ : config.maxSendableMsat,
166
+ defaultDescription: config.description,
167
+ payLink: `${origin}/.well-known/lnurlp/${lnurlwMatch[1]}`,
168
+ ...(signer ? { mintPubkey: signer.pubkey } : {}),
169
+ ...(nodeInfo.alias ? { nodeAlias: nodeInfo.alias } : {}),
170
+ ...(nodeInfo.uri ? { nodeUri: nodeInfo.uri } : {}),
171
+ ...(nodeInfo.color ? { nodeColor: nodeInfo.color } : {}),
172
+ ...(nodeInfo.capacityMsat !== undefined ? { nodeCapacityMsat: nodeInfo.capacityMsat } : {}),
173
+ ...(nodeInfo.numChannels !== undefined ? { nodeNumChannels: nodeInfo.numChannels } : {}),
174
+ ...(nodeInfo.numPeers !== undefined ? { nodeNumPeers: nodeInfo.numPeers } : {})
175
+ });
176
+ }
177
+ // ---- LUD-06 pay callback: issue a mint invoice ----
178
+ if (requestUrl.pathname === '/p/cb') {
179
+ if (config.sunset)
180
+ return fail('This mint is sunsetting - minting is disabled.');
181
+ const amount = Number(q.get('amount'));
182
+ if (!Number.isSafeInteger(amount) || amount <= 0)
183
+ return fail('Invalid amount.');
184
+ if (amount < effectiveMinSendableMsat || amount > config.maxSendableMsat) {
185
+ return fail('Amount out of range.');
186
+ }
187
+ const net = config.mintFee ? applyMintFee(amount, config.mintFee) : amount;
188
+ if (net < config.minMintMsat)
189
+ return fail('Amount too small to mint a note.');
190
+ // The preimage is the future note's spend secret; its hash is the
191
+ // note id AND the invoice's payment hash. Generated here, handed to
192
+ // the funding source, never persisted - the store keeps hashes only.
193
+ let preimage = bytesToHex(randomBytes(32));
194
+ let paymentHash = hashK1(preimage);
195
+ while (store.noteById(paymentHash) || store.mintInvoiceByHash(paymentHash)) {
196
+ preimage = bytesToHex(randomBytes(32));
197
+ paymentHash = hashK1(preimage);
198
+ }
199
+ let pr;
200
+ try {
201
+ pr = (await backend.createInvoice({
202
+ amountMsat: amount,
203
+ preimageHex: preimage,
204
+ memo: `LNURLcash mint at ${host}`
205
+ })).pr;
206
+ }
207
+ catch (err) {
208
+ log(`create invoice failed: ${err.message}`);
209
+ return fail('Temporarily unable to issue an invoice.');
210
+ }
211
+ // Trust but verify: an invoice that does not commit to OUR payment
212
+ // hash would take the payer's money and mint a note they can never
213
+ // claim. Refuse to hand it out.
214
+ const decoded = tryDecodeBolt11(pr);
215
+ if (!decoded || decoded.paymentHashHex !== paymentHash || decoded.amountMsats !== BigInt(amount)) {
216
+ log('funding source returned an invoice that does not match the requested preimage/amount');
217
+ return fail('Temporarily unable to issue an invoice.');
218
+ }
219
+ store.recordMintInvoice(paymentHash, pr, amount, net);
220
+ return send({
221
+ pr,
222
+ disposable: false,
223
+ ...(config.verify ? { verify: `${origin}/verify/${paymentHash}` } : {})
224
+ });
225
+ }
226
+ // ---- LUD-21 verify: mint invoices and melt payments ----
227
+ const verifyMatch = requestUrl.pathname.match(/^\/verify\/([0-9a-fA-F]{64})$/);
228
+ if (verifyMatch) {
229
+ if (!config.verify)
230
+ return fail('Not found.', 404);
231
+ const paymentHash = verifyMatch[1].toLowerCase();
232
+ const invoice = store.mintInvoiceByHash(paymentHash);
233
+ if (invoice) {
234
+ if (!invoice.settled && (await backend.isInvoiceSettled(paymentHash))) {
235
+ store.settleMintInvoice(paymentHash);
236
+ }
237
+ const settled = store.mintInvoiceByHash(paymentHash).settled;
238
+ // The preimage IS the bearer secret. Served only once settled, and
239
+ // fetched live from the funding source - it is never stored here.
240
+ const preimageHex = settled ? await backend.invoicePreimage(paymentHash) : null;
241
+ return send({ status: 'OK', settled, preimage: preimageHex, pr: invoice.pr });
242
+ }
243
+ const melt = store.meltByHash(paymentHash);
244
+ if (melt) {
245
+ const settled = melt.outcome === 'paid';
246
+ const preimageHex = settled ? await backend.paymentPreimage(paymentHash) : null;
247
+ return send({ status: 'OK', settled, preimage: preimageHex, pr: melt.pr });
248
+ }
249
+ return fail('Unknown payment hash.');
250
+ }
251
+ // ---- LUD-03 informational GET ----
252
+ if (requestUrl.pathname === '/w') {
253
+ const k1 = q.get('k1')?.toLowerCase();
254
+ if (!k1 || !HEX32.test(k1))
255
+ return fail('Unknown note.');
256
+ const note = await resolveNote(k1);
257
+ if (!note)
258
+ return fail('Unknown note.');
259
+ if (note.state === 'burned')
260
+ return fail('Note already spent.');
261
+ return send({
262
+ tag: 'withdrawRequest',
263
+ callback: `${origin}/w/cb`,
264
+ k1,
265
+ minWithdrawable: 0,
266
+ maxWithdrawable: note.amountMsat,
267
+ defaultDescription: config.description,
268
+ ...(signer ? { mintPubkey: signer.pubkey } : {})
269
+ });
270
+ }
271
+ // ---- the mutating callback: melt / rotate / split / merge ----
272
+ if (requestUrl.pathname === '/w/cb') {
273
+ const k1s = q.getAll('k1').map(value => value.toLowerCase());
274
+ const pr = q.get('pr');
275
+ const amountRaw = q.get('amount');
276
+ const h = q.get('h')?.toLowerCase();
277
+ const h2 = q.get('h2')?.toLowerCase();
278
+ if (k1s.length === 0)
279
+ return fail('Missing k1.');
280
+ if (k1s.length > config.maxK1s)
281
+ return fail(`Too many k1s (max ${config.maxK1s}).`);
282
+ if (pr && (k1s.length > 1 || amountRaw !== null)) {
283
+ return fail('pr cannot be combined with multiple k1s or amount - merge or split first.');
284
+ }
285
+ // Hash parameters are checked before any note resolves, so an
286
+ // invalid or missing hash can never burn anything.
287
+ if (!pr) {
288
+ if (!h || !HEX32.test(h))
289
+ return fail('missing h');
290
+ if (amountRaw !== null && (!h2 || !HEX32.test(h2)))
291
+ return fail('missing h2');
292
+ if (h2 !== undefined && h2 === h)
293
+ return fail('h and h2 must differ.');
294
+ }
295
+ if (config.sunset && amountRaw !== null) {
296
+ return fail('This mint is sunsetting - splitting is disabled.');
297
+ }
298
+ // A repeated k1 would count one note's value twice on the way into a
299
+ // merge or split. Atomic refusal, same as an invalid one.
300
+ if (new Set(k1s).size !== k1s.length)
301
+ return fail('Invalid or already spent k1.');
302
+ const found = [];
303
+ for (const k1 of k1s) {
304
+ if (!HEX32.test(k1))
305
+ return fail('Invalid or already spent k1.');
306
+ const note = await resolveNote(k1);
307
+ if (!note || note.state === 'burned')
308
+ return fail('Invalid or already spent k1.');
309
+ if (note.state === 'pending')
310
+ return fail('pending');
311
+ found.push(note);
312
+ }
313
+ const totalMsat = found.reduce((sum, note) => sum + note.amountMsat, 0);
314
+ const inputIds = found.map(note => note.id);
315
+ // melt
316
+ if (pr) {
317
+ const decoded = tryDecodeBolt11(pr.trim());
318
+ if (!decoded)
319
+ return fail('Invalid invoice.');
320
+ if (decoded.amountMsats === null || decoded.amountMsats !== BigInt(totalMsat)) {
321
+ return fail(`Invoice must be for exactly ${totalMsat} msat.`);
322
+ }
323
+ const paymentHash = decoded.paymentHashHex;
324
+ // Paying an invoice this mint itself issued would route the funding
325
+ // source's money back at itself; refuse synchronously.
326
+ if (store.mintInvoiceByHash(paymentHash)) {
327
+ return fail('Cannot melt into an invoice this mint issued itself.');
328
+ }
329
+ // The funding source dedupes payments by hash, so a second melt
330
+ // into the same invoice would be "confirmed" against the first
331
+ // payment and burn its note without moving funds.
332
+ if (store.meltByHash(paymentHash)) {
333
+ return fail('Invoice already used by an earlier melt - use a fresh one.');
334
+ }
335
+ // A shared funding source is a supported deployment (DEPLOY.md),
336
+ // and the node's payment history is the only dedupe that spans
337
+ // every consumer of it. A hash the node ever paid - or is still
338
+ // paying - is refused here for the same reason as the local check
339
+ // above; runMelt's PaymentAlreadyKnownError branch closes the
340
+ // remaining race between this check and the send.
341
+ try {
342
+ if (await backend.isPaymentComplete(paymentHash)) {
343
+ return fail('Invoice already used by an earlier melt - use a fresh one.');
344
+ }
345
+ }
346
+ catch (err) {
347
+ if (err instanceof PaymentPendingError) {
348
+ return fail('Invoice already used by an earlier melt - use a fresh one.');
349
+ }
350
+ log(`melt pre-check failed: ${err.message}`);
351
+ return fail('Temporarily unable to melt - try again shortly.');
352
+ }
353
+ try {
354
+ store.markPending(inputIds[0], paymentHash, pr.trim(), totalMsat);
355
+ }
356
+ catch (err) {
357
+ if (err instanceof NotePendingError)
358
+ return fail('pending');
359
+ if (err instanceof NoteUnavailableError)
360
+ return fail('Invalid or already spent k1.');
361
+ return fail('Invoice already used by an earlier melt - use a fresh one.');
362
+ }
363
+ inFlight.add(paymentHash);
364
+ void runMelt({ paymentHash, noteId: inputIds[0], pr: pr.trim(), amountMsat: totalMsat }, { store, backend, feeLimitMsat: meltFeeLimitMsat, log, ...(deps.confirmDelaysMs ? { confirmDelaysMs: deps.confirmDelaysMs } : {}) })
365
+ .catch(err => log(`melt ${inputIds[0]}: ${err.message}`))
366
+ .finally(() => inFlight.delete(paymentHash));
367
+ // Replied before the payment resolves, per LUD-03: OK means the
368
+ // melt is requested and the note reserved, never that it settled.
369
+ return send({
370
+ status: 'OK',
371
+ ...(config.verify ? { pr: pr.trim(), verify: `${origin}/verify/${paymentHash}` } : {})
372
+ });
373
+ }
374
+ const baseFeeMsat = config.mintFee?.baseFeeMsat ?? 0;
375
+ // split
376
+ if (amountRaw !== null) {
377
+ const amount = Number(amountRaw);
378
+ if (!Number.isSafeInteger(amount) || amount <= 0)
379
+ return fail('Invalid amount.');
380
+ // Change must stay positive: an exact-total split is a rotate, and
381
+ // a zero note is a liability entry nobody can ever want.
382
+ if (amount >= totalMsat)
383
+ return fail('Invalid amount.');
384
+ // LUD-25: the flat base fee comes out of CHANGE on every split -
385
+ // never out of the requested amount - so a holder cannot dodge
386
+ // per-melt costs by splitting into dust and melting each piece.
387
+ // The proportional part is never reapplied; it was withheld once,
388
+ // at mint time.
389
+ const changeBeforeFeeMsat = totalMsat - amount;
390
+ if (changeBeforeFeeMsat < baseFeeMsat)
391
+ return fail('insufficient value');
392
+ const changeMsat = changeBeforeFeeMsat - baseFeeMsat;
393
+ if (changeMsat < 1)
394
+ return fail('insufficient value');
395
+ try {
396
+ store.swap(inputIds, [
397
+ { id: h, amountMsat: amount },
398
+ { id: h2, amountMsat: changeMsat }
399
+ ]);
400
+ }
401
+ catch (err) {
402
+ if (err instanceof NotePendingError)
403
+ return fail('pending');
404
+ // OutputCollisionError shares the dead-k1 reason on purpose:
405
+ // which table the id collided with is an oracle nobody is owed.
406
+ return fail('Invalid or already spent k1.');
407
+ }
408
+ return send({
409
+ status: 'OK',
410
+ ...(signer ? { sig: signer.sign(h, amount), sig2: signer.sign(h2, changeMsat) } : {})
411
+ });
412
+ }
413
+ // rotate (one k1) or merge (several) - the same swap either way.
414
+ // LUD-25: a merge of n notes refunds (n - 1) base fees, since this
415
+ // mint now faces one eventual melt instead of n. A rotate is a merge
416
+ // of one - the refund is exactly 0.
417
+ const mergedMsat = totalMsat + (inputIds.length - 1) * baseFeeMsat;
418
+ try {
419
+ store.swap(inputIds, [{ id: h, amountMsat: mergedMsat }]);
420
+ }
421
+ catch (err) {
422
+ if (err instanceof NotePendingError)
423
+ return fail('pending');
424
+ // same oracle-free reason for a collision as for a dead k1
425
+ return fail('Invalid or already spent k1.');
426
+ }
427
+ return send({ status: 'OK', ...(signer ? { sig: signer.sign(h, mergedMsat) } : {}) });
428
+ }
429
+ return fail('Not found.', 404);
430
+ };
431
+ const server = createServer((req, res) => {
432
+ handle(req, res).catch(err => {
433
+ log(`internal error: ${err.stack ?? err}`);
434
+ if (!res.headersSent) {
435
+ res.writeHead(200, {
436
+ 'content-type': 'application/json',
437
+ 'access-control-allow-origin': '*',
438
+ 'x-content-type-options': 'nosniff'
439
+ });
440
+ }
441
+ if (!res.writableEnded)
442
+ res.end(JSON.stringify({ status: 'ERROR', reason: 'Internal error.' }));
443
+ });
444
+ });
445
+ await new Promise(resolve => server.listen(config.port, config.host, resolve));
446
+ const address = server.address();
447
+ const port = typeof address === 'object' && address ? address.port : config.port;
448
+ // Housekeeping, at startup and every five minutes after: resolve melts an
449
+ // earlier process left pending, and sweep mint invoices whose bolt11
450
+ // expiry has passed. A melt whose outcome stays unconfirmable would
451
+ // otherwise park its note until the next restart; an unswept table would
452
+ // otherwise grow by one dead row per /p/cb call forever. The timer is
453
+ // unref'd so it never keeps the process alive.
454
+ const housekeeping = async () => {
455
+ const swept = sweepExpiredMintInvoices(store);
456
+ if (swept > 0)
457
+ log(`swept ${swept} expired mint invoice${swept === 1 ? '' : 's'}`);
458
+ await reconcilePendingMelts(store, backend, inFlight, log);
459
+ };
460
+ await housekeeping();
461
+ const housekeepingTimer = setInterval(() => {
462
+ housekeeping().catch(err => log(`housekeeping failed: ${err.message}`));
463
+ }, 300_000);
464
+ housekeepingTimer.unref();
465
+ return {
466
+ url: `http://${config.host}:${port}`,
467
+ port,
468
+ config,
469
+ store,
470
+ backend,
471
+ signer,
472
+ reconcile: () => reconcilePendingMelts(store, backend, inFlight, log),
473
+ close: async () => {
474
+ clearInterval(housekeepingTimer);
475
+ await new Promise((resolve, reject) => server.close(err => (err ? reject(err) : resolve())));
476
+ await backend.close?.();
477
+ store.close();
478
+ }
479
+ };
480
+ };
481
+ // Deletes unsettled mint invoices whose bolt11 expiry is comfortably past,
482
+ // returning the count swept. The invoice's own timestamp + expiry are the
483
+ // proof - a funding source refuses to settle an expired invoice, so the
484
+ // row can never mint a note again. The hour's margin keeps the delete
485
+ // unambiguously behind every implementation's reading of that expiry, and
486
+ // an undecodable invoice is kept: no proof, no delete. The delete itself is
487
+ // conditional on the row still being unsettled, so a settle racing the
488
+ // sweep always wins.
489
+ export const sweepExpiredMintInvoices = (store, nowMs = Date.now()) => {
490
+ const stale = [];
491
+ for (const invoice of store.unsettledMintInvoices()) {
492
+ const decoded = tryDecodeBolt11(invoice.pr);
493
+ if (!decoded)
494
+ continue;
495
+ const expiresAtMs = (decoded.timestamp + decoded.expirySeconds) * 1000;
496
+ if (nowMs > expiresAtMs + 3_600_000)
497
+ stale.push(invoice.paymentHash);
498
+ }
499
+ for (const paymentHash of stale)
500
+ store.deleteUnsettledMintInvoice(paymentHash);
501
+ return stale.length;
502
+ };
@@ -0,0 +1,6 @@
1
+ export declare const noteIdSignatureDigest: (noteId: string, amountMsat: number) => Uint8Array;
2
+ export type NoteSigner = {
3
+ pubkey: string;
4
+ sign: (noteId: string, amountMsat: number) => string;
5
+ };
6
+ export declare const createNoteSigner: (privateKeyHex: string) => NoteSigner;
@@ -0,0 +1,37 @@
1
+ import { sha256 } from '@noble/hashes/sha2.js';
2
+ import { secp256k1 } from '@noble/curves/secp256k1.js';
3
+ import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
4
+ // LUD-25 offline verification, from the SERVICE side. The construction is
5
+ // the standard "Lightning Signed Message" wrapping over
6
+ //
7
+ // "LNURLcash:" || amount_msat || ":" || note_id
8
+ //
9
+ // where note_id is hex(sha256(k1)). lnurlcash-kit's noteSignatureDigest
10
+ // takes the SECRET and hashes it itself, which a wallet holds and a mint
11
+ // never does - so the digest is rebuilt here from the id. The two must
12
+ // agree byte for byte; the test suite proves they do by verifying every
13
+ // signature this module makes with the kit's verifyNoteSignature.
14
+ export const noteIdSignatureDigest = (noteId, amountMsat) => sha256(sha256(utf8ToBytes(`Lightning Signed Message:LNURLcash:${amountMsat}:${noteId}`)));
15
+ // Signs with the mint's own dedicated key, in process. The reference mint
16
+ // signs via its node's signmessage RPC instead; either is valid, since the
17
+ // only key a wallet ever checks against is the mintPubkey the SERVICE
18
+ // advertises. An own key keeps the funding source swappable without
19
+ // invalidating every outstanding note's signature.
20
+ export const createNoteSigner = (privateKeyHex) => {
21
+ const priv = hexToBytes(privateKeyHex);
22
+ if (priv.length !== 32)
23
+ throw new Error('The signing key must be 32 bytes of hex.');
24
+ const pubkey = bytesToHex(secp256k1.getPublicKey(priv, true));
25
+ return {
26
+ pubkey,
27
+ sign: (noteId, amountMsat) => {
28
+ // noble v2 emits recovery_id || r || s; the LUD-25 wire layout is
29
+ // r || s || recovery_id, so the leading byte moves to the back.
30
+ const lead = secp256k1.sign(noteIdSignatureDigest(noteId, amountMsat), priv, {
31
+ format: 'recovered',
32
+ prehash: false
33
+ });
34
+ return bytesToHex(new Uint8Array([...lead.subarray(1), lead[0]]));
35
+ }
36
+ };
37
+ };