@forgesworn/moneyer 0.19.0 → 0.20.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.20.0 - 2026-10-07
4
+
5
+ - **A zap paid while the mint was down is no longer lost.** The expiry
6
+ sweep deleted any unsettled zap invoice an hour past expiry without
7
+ asking the funding source, so a zap paid while moneyer was down that long
8
+ stayed in the node and never became a note. It now asks first, as the
9
+ mint-invoice sweep has since 0.17.1: an unpaid one goes, a paid one stays
10
+ for the settle pass to mint, and no answer deletes nothing.
11
+ - **A paid mint invoice is credited the moment it settles.** moneyer now
12
+ watches the funding source's settled invoices (lnd's
13
+ `/v1/invoices/subscribe`, cln's `waitanyinvoice`) and mints the note as
14
+ soon as the payment lands, instead of when the wallet comes back to claim
15
+ or poll `verify`, or when the expiry sweep finds it. Liabilities and
16
+ coverage are therefore right straight away. A report is only a hint: the
17
+ invoice is checked against the node and credited through the same call
18
+ `verify` and the sweep make, and those remain the fallback while the
19
+ stream is down. The stream reconnects with backoff and resumes from the
20
+ node's settle index, kept in the database, so settles during a restart
21
+ are caught up. A settled zap invoice brings the next zap pass forward.
22
+ The existing lnd macaroon (`invoices:read`) already covers the
23
+ subscription. lnd sends no headers on it until the first event, so on a
24
+ quiet node Node's fetch ends it after five minutes; that counts as a
25
+ normal end and the stream reconnects without logging. The cln side is untested against a live node, like the
26
+ rest of that backend.
27
+
3
28
  ## 0.19.0 - 2026-10-07
4
29
 
5
30
  - **A failed melt's invoice can be tried again.** Once the funding source
package/README.md CHANGED
@@ -351,6 +351,15 @@ preimage then buys nothing: it is an ordinary payment proof, which is why
351
351
  there is no race left to run, and no window in which holding the invoice
352
352
  is nearly holding the money.
353
353
 
354
+ On lnd and cln the mint watches the node's settled invoices (lnd's
355
+ invoice subscription, cln's `waitanyinvoice`), so the note exists from
356
+ the moment the invoice is paid, whether or not the wallet ever comes
357
+ back. Each report is checked against the node before anything is
358
+ credited, and the stream resumes from the last settle it handled after a
359
+ drop or a restart. Without it, a paid invoice is credited when the wallet
360
+ claims or polls `verify`, or at the latest when the expiry sweep finds it
361
+ paid.
362
+
354
363
  ### Every note is a taproot output key
355
364
 
356
365
  LUD-25 makes every note a BIP-341 output key Q, written `cp1<Q>`, and that
@@ -100,6 +100,32 @@ export const createClnBackend = (config) => {
100
100
  }
101
101
  return pays.some(pay => pay?.status === 'complete');
102
102
  },
103
+ // waitanyinvoice answers with the first invoice paid after
104
+ // `lastpay_index`, or the next one paid at all without it. Its timeout
105
+ // (904) just means nothing was paid meanwhile.
106
+ async watchSettledInvoices({ fromIndex, signal, onSettled }) {
107
+ let lastIndex = fromIndex;
108
+ while (!signal.aborted) {
109
+ const res = await fetch(`${config.url}/v1/waitanyinvoice`, {
110
+ method: 'POST',
111
+ headers: { 'content-type': 'application/json', Rune: config.rune },
112
+ body: JSON.stringify({ ...(lastIndex > 0 ? { lastpay_index: lastIndex } : {}), timeout: 60 }),
113
+ signal: AbortSignal.any([signal, AbortSignal.timeout(90_000)])
114
+ });
115
+ const invoice = await res.json().catch(() => null);
116
+ if (!res.ok) {
117
+ if (invoice?.code === 904)
118
+ continue;
119
+ throw new Error(`cln waitanyinvoice failed (${res.status}).`);
120
+ }
121
+ const index = invoice?.pay_index;
122
+ if (invoice?.status !== 'paid' || typeof invoice.payment_hash !== 'string' || !Number.isSafeInteger(index)) {
123
+ throw new Error('cln waitanyinvoice returned no paid invoice.');
124
+ }
125
+ await onSettled(invoice.payment_hash, index);
126
+ lastIndex = index;
127
+ }
128
+ },
103
129
  async isInvoiceSettled(paymentHashHex) {
104
130
  const result = await mustCall('/v1/listinvoices', { payment_hash: paymentHashHex });
105
131
  const invoices = result?.invoices ?? [];
@@ -5,6 +5,7 @@ export type FakeBackendOptions = {
5
5
  autoSettle?: boolean;
6
6
  nodeChoosesPreimage?: boolean;
7
7
  autoSettleAfterMs?: number;
8
+ streamsSettles?: boolean;
8
9
  };
9
10
  export type FakeBackend = LightningBackend & {
10
11
  control: {
@@ -21,6 +22,7 @@ export type FakeBackend = LightningBackend & {
21
22
  } | undefined;
22
23
  sentAmountMsat(paymentHashHex: string): number | null | undefined;
23
24
  setLocalBalanceMsat(msat: number | undefined): void;
25
+ dropInvoiceStreams(): void;
24
26
  };
25
27
  };
26
28
  export declare const FAKE_LOCAL_BALANCE_MSAT = 100000000000;
@@ -33,9 +33,49 @@ export const createFakeBackend = (options = {}) => {
33
33
  const knownPreimages = new Map();
34
34
  let localBalanceMsat = FAKE_LOCAL_BALANCE_MSAT;
35
35
  let payMode = 'succeed';
36
+ const settleLog = [];
37
+ const watchers = new Set();
38
+ const watchSettledInvoices = async ({ fromIndex, signal, onSettled }) => {
39
+ const queue = settleLog.filter(entry => fromIndex > 0 && entry.index > fromIndex);
40
+ let wake = null;
41
+ let ended = false;
42
+ const watcher = {
43
+ push: (entry) => {
44
+ queue.push(entry);
45
+ wake?.();
46
+ },
47
+ end: () => {
48
+ ended = true;
49
+ wake?.();
50
+ }
51
+ };
52
+ const onAbort = () => watcher.end();
53
+ signal.addEventListener('abort', onAbort);
54
+ watchers.add(watcher);
55
+ try {
56
+ for (;;) {
57
+ const entry = queue.shift();
58
+ if (entry) {
59
+ await onSettled(entry.paymentHashHex, entry.index);
60
+ continue;
61
+ }
62
+ if (ended)
63
+ return;
64
+ await new Promise(resolve => {
65
+ wake = resolve;
66
+ });
67
+ wake = null;
68
+ }
69
+ }
70
+ finally {
71
+ watchers.delete(watcher);
72
+ signal.removeEventListener('abort', onAbort);
73
+ }
74
+ };
36
75
  return {
37
76
  name: 'fake',
38
77
  acceptsInvoicePreimage: !nodeChoosesPreimage,
78
+ ...(options.streamsSettles === true ? { watchSettledInvoices } : {}),
39
79
  async createInvoice({ amountMsat, preimageHex, memo }) {
40
80
  // A node that mints its own preimages never sees the caller's.
41
81
  const chosen = nodeChoosesPreimage ? bytesToHex(randomBytes(32)) : preimageHex;
@@ -126,7 +166,13 @@ export const createFakeBackend = (options = {}) => {
126
166
  const invoice = invoices.get(paymentHashHex);
127
167
  if (!invoice)
128
168
  throw new Error(`no fake invoice for ${paymentHashHex}`);
169
+ if (invoice.settled)
170
+ return;
129
171
  invoice.settled = true;
172
+ const entry = { paymentHashHex, index: settleLog.length + 1 };
173
+ settleLog.push(entry);
174
+ for (const watcher of watchers)
175
+ watcher.push(entry);
130
176
  },
131
177
  setPayMode(mode) {
132
178
  payMode = mode;
@@ -156,6 +202,14 @@ export const createFakeBackend = (options = {}) => {
156
202
  setLocalBalanceMsat(msat) {
157
203
  localBalanceMsat = msat;
158
204
  },
205
+ dropInvoiceStreams() {
206
+ // gone at once, as a dropped connection is: a settle after this
207
+ // reaches the watcher only by its resuming from the index
208
+ for (const watcher of [...watchers]) {
209
+ watchers.delete(watcher);
210
+ watcher.end();
211
+ }
212
+ },
159
213
  invoiceByHash(paymentHashHex) {
160
214
  const invoice = invoices.get(paymentHashHex);
161
215
  // `settled` here answers "is it paid now", so autoSettle's moment
@@ -19,6 +19,13 @@ const FAILURE_REASONS = {
19
19
  FAILURE_REASON_INSUFFICIENT_BALANCE: 'Insufficient balance to pay this invoice.',
20
20
  FAILURE_REASON_TIMEOUT: 'Timed out trying to find a route to pay this invoice.'
21
21
  };
22
+ // Node's fetch gives up on a response idle for five minutes, headers or
23
+ // body. lnd sends no headers on an invoice subscription until its first
24
+ // event, so on a quiet node that is the normal end of one, not a fault.
25
+ const isIdleTimeout = (err) => {
26
+ const code = err?.cause?.code ?? err?.code;
27
+ return code === 'UND_ERR_HEADERS_TIMEOUT' || code === 'UND_ERR_BODY_TIMEOUT';
28
+ };
22
29
  const hexToBase64 = (hex) => Buffer.from(hexToBytes(hex)).toString('base64');
23
30
  const decodeHexOrBase64 = (value) => {
24
31
  if (/^[0-9a-fA-F]{64}$/.test(value))
@@ -83,6 +90,47 @@ export const createLndBackend = (config) => {
83
90
  reader.cancel().catch(() => { });
84
91
  }
85
92
  };
93
+ const readSettledInvoices = async ({ fromIndex, signal, onSettled }) => {
94
+ const res = await fetch(`${config.url}/v1/invoices/subscribe?settle_index=${fromIndex}`, { headers, signal });
95
+ if (!res.ok || !res.body)
96
+ throw new Error(`lnd refused the invoice subscription (${res.status}).`);
97
+ const reader = res.body.getReader();
98
+ const decoder = new TextDecoder();
99
+ let buffer = '';
100
+ try {
101
+ for (;;) {
102
+ const { done, value } = await reader.read();
103
+ if (done)
104
+ return;
105
+ buffer += decoder.decode(value, { stream: true });
106
+ const lines = buffer.split('\n');
107
+ buffer = lines.pop() ?? '';
108
+ for (const line of lines) {
109
+ if (!line.trim())
110
+ continue;
111
+ let event;
112
+ try {
113
+ event = JSON.parse(line);
114
+ }
115
+ catch {
116
+ continue;
117
+ }
118
+ if (event?.error)
119
+ throw new Error(`lnd ended the invoice subscription: ${event.error.message ?? 'unknown'}`);
120
+ const invoice = event?.result ?? event;
121
+ if (invoice?.state !== 'SETTLED' || typeof invoice.r_hash !== 'string')
122
+ continue;
123
+ const index = Number(invoice.settle_index);
124
+ if (!Number.isSafeInteger(index) || index <= 0)
125
+ continue;
126
+ await onSettled(decodeHexOrBase64(invoice.r_hash), index);
127
+ }
128
+ }
129
+ }
130
+ finally {
131
+ reader.cancel().catch(() => { });
132
+ }
133
+ };
86
134
  return {
87
135
  name: 'lnd',
88
136
  acceptsInvoicePreimage: true,
@@ -161,6 +209,18 @@ export const createLndBackend = (config) => {
161
209
  throw new Error('lnd did not report a payment status.');
162
210
  return result;
163
211
  },
212
+ // lnd replays every settle after `settle_index` before going live, and
213
+ // none for 0. The stream is held open until lnd, the signal or Node's
214
+ // idle timeout ends it.
215
+ async watchSettledInvoices({ fromIndex, signal, onSettled }) {
216
+ try {
217
+ await readSettledInvoices({ fromIndex, signal, onSettled });
218
+ }
219
+ catch (err) {
220
+ if (!isIdleTimeout(err))
221
+ throw err;
222
+ }
223
+ },
164
224
  // 404 is lnd's answer for an invoice it does not hold, which can never
165
225
  // settle. Any other refusal is no answer at all, and reading it as
166
226
  // "unpaid" would let the expiry sweep delete a paid invoice's row.
@@ -39,5 +39,10 @@ export interface LightningBackend {
39
39
  invoicePreimage(paymentHashHex: string): Promise<string | null>;
40
40
  paymentPreimage(paymentHashHex: string): Promise<string | null>;
41
41
  nodeInfo?(): Promise<NodeInfo>;
42
+ watchSettledInvoices?(args: {
43
+ fromIndex: number;
44
+ signal: AbortSignal;
45
+ onSettled: (paymentHashHex: string, index: number) => Promise<void>;
46
+ }): Promise<void>;
42
47
  close?(): void | Promise<void>;
43
48
  }
package/dist/server.d.ts CHANGED
@@ -30,6 +30,7 @@ export type MoneyerDeps = {
30
30
  nostr?: NostrTransport;
31
31
  zapPollMs?: number;
32
32
  statsPublishMs?: number;
33
+ invoiceStreamRetryMs?: number;
33
34
  scriptVerifier?: ScriptVerifier;
34
35
  };
35
36
  export { describeFee } from './fee-words.ts';
package/dist/server.js CHANGED
@@ -1298,7 +1298,7 @@ export const createMoneyer = async (config, deps = {}) => {
1298
1298
  // unref'd so it never keeps the process alive.
1299
1299
  const housekeeping = async () => {
1300
1300
  const { swept: mintSwept, settled } = await sweepExpiredMintInvoices(store, backend);
1301
- const swept = mintSwept + (zap?.sweep() ?? 0);
1301
+ const swept = mintSwept + (zap ? await zap.sweep() : 0);
1302
1302
  if (swept > 0)
1303
1303
  log(`swept ${swept} expired mint invoice${swept === 1 ? '' : 's'}`);
1304
1304
  if (settled > 0)
@@ -1335,6 +1335,63 @@ export const createMoneyer = async (config, deps = {}) => {
1335
1335
  };
1336
1336
  const zapTimer = zap ? setInterval(() => void zapTick(), deps.zapPollMs ?? 5_000) : null;
1337
1337
  zapTimer?.unref();
1338
+ // A funding source that streams its settles lets a paid mint invoice be
1339
+ // credited the moment it is paid, rather than when its wallet next asks
1340
+ // or the expiry sweep finds it. A report is only a hint: the invoice is
1341
+ // checked again and credited by the same call /verify and the sweep
1342
+ // make, and those stay as the fallback whenever the stream is down. A
1343
+ // settled zap invoice brings the next zap pass forward instead.
1344
+ const streamAbort = new AbortController();
1345
+ const streamIndexKey = `invoice_stream_index:${backend.name}`;
1346
+ const onSettled = async (paymentHash, index) => {
1347
+ try {
1348
+ const invoice = store.mintInvoiceByHash(paymentHash);
1349
+ if (invoice && !invoice.settled && (await backend.isInvoiceSettled(paymentHash))) {
1350
+ if (streamAbort.signal.aborted)
1351
+ return;
1352
+ store.settleMintInvoice(paymentHash);
1353
+ }
1354
+ else if (!invoice && zap && store.zapInvoiceByHash(paymentHash)) {
1355
+ void zapTick();
1356
+ }
1357
+ }
1358
+ catch (err) {
1359
+ log(`invoice stream: could not settle a reported invoice (${err.message})`);
1360
+ }
1361
+ if (!streamAbort.signal.aborted)
1362
+ store.setMetaValue(streamIndexKey, String(index));
1363
+ };
1364
+ const streamInvoices = async (watch) => {
1365
+ const firstRetryMs = deps.invoiceStreamRetryMs ?? 1_000;
1366
+ let retryMs = firstRetryMs;
1367
+ while (!streamAbort.signal.aborted) {
1368
+ const startedAt = Date.now();
1369
+ try {
1370
+ await watch({ fromIndex: Number(store.metaValue(streamIndexKey) ?? 0), signal: streamAbort.signal, onSettled });
1371
+ }
1372
+ catch (err) {
1373
+ if (!streamAbort.signal.aborted)
1374
+ log(`invoice stream dropped: ${err.message}`);
1375
+ }
1376
+ if (streamAbort.signal.aborted)
1377
+ return;
1378
+ if (Date.now() - startedAt > 60_000)
1379
+ retryMs = firstRetryMs;
1380
+ await new Promise(resolve => {
1381
+ const done = () => {
1382
+ clearTimeout(timer);
1383
+ streamAbort.signal.removeEventListener('abort', done);
1384
+ resolve();
1385
+ };
1386
+ const timer = setTimeout(done, retryMs);
1387
+ streamAbort.signal.addEventListener('abort', done);
1388
+ });
1389
+ retryMs = Math.min(retryMs * 2, 60_000);
1390
+ }
1391
+ };
1392
+ const invoiceStream = backend.watchSettledInvoices
1393
+ ? streamInvoices(backend.watchSettledInvoices.bind(backend))
1394
+ : Promise.resolve();
1338
1395
  // One hourly pass to Nostr carries both: the signed liabilities snapshot
1339
1396
  // and the mint's announcement of itself. Each re-checks its own switch,
1340
1397
  // so an operator can have either, both, or neither.
@@ -1362,6 +1419,7 @@ export const createMoneyer = async (config, deps = {}) => {
1362
1419
  publishStats,
1363
1420
  publishAnnouncement,
1364
1421
  close: async () => {
1422
+ streamAbort.abort();
1365
1423
  clearInterval(housekeepingTimer);
1366
1424
  if (zapTimer)
1367
1425
  clearInterval(zapTimer);
@@ -1370,6 +1428,7 @@ export const createMoneyer = async (config, deps = {}) => {
1370
1428
  nostr?.close();
1371
1429
  kernel?.close();
1372
1430
  await new Promise((resolve, reject) => server.close(err => (err ? reject(err) : resolve())));
1431
+ await invoiceStream;
1373
1432
  await backend.close?.();
1374
1433
  store.close();
1375
1434
  }
package/dist/store.d.ts CHANGED
@@ -93,6 +93,8 @@ export declare class NoteStore {
93
93
  private indexLegacyIds;
94
94
  legacyIdOf(q: string): string | null;
95
95
  noteByQ(q: string): NoteRow | null;
96
+ metaValue(key: string): string | null;
97
+ setMetaValue(key: string, value: string): void;
96
98
  unnamedVerifyCutover(): number;
97
99
  private tx;
98
100
  noteById(id: string): NoteRow | null;
package/dist/store.js CHANGED
@@ -216,6 +216,15 @@ export class NoteStore {
216
216
  }
217
217
  // Invoices quoted at or after this instant get no verify if they named no
218
218
  // output. See the migration above for why it is not simply "now".
219
+ // Small operational values that must survive a restart, such as where
220
+ // the funding source's invoice stream should resume.
221
+ metaValue(key) {
222
+ const row = this.db.prepare('SELECT value FROM meta WHERE key = ?').get(key);
223
+ return row?.value ?? null;
224
+ }
225
+ setMetaValue(key, value) {
226
+ this.db.prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value').run(key, value);
227
+ }
219
228
  unnamedVerifyCutover() {
220
229
  const row = this.db
221
230
  .prepare("SELECT value FROM meta WHERE key = 'unnamed_verify_cutover'")
package/dist/zap.d.ts CHANGED
@@ -48,6 +48,6 @@ export type ZapBridge = {
48
48
  callback(name: string, amountMsat: number, nostrParam: string | null): Promise<ZapCallbackResult>;
49
49
  settle(): Promise<number>;
50
50
  publish(): Promise<number>;
51
- sweep(nowMs?: number): number;
51
+ sweep(nowMs?: number): Promise<number>;
52
52
  };
53
53
  export declare const createZapBridge: (deps: ZapBridgeDeps) => ZapBridge;
package/dist/zap.js CHANGED
@@ -325,7 +325,10 @@ export const createZapBridge = (deps) => {
325
325
  }
326
326
  return published;
327
327
  };
328
- const sweep = (nowMs = now()) => {
328
+ // As the mint-invoice sweep: an expired row goes only once the funding
329
+ // source confirms it unpaid. One paid while the mint was down stays for
330
+ // the next settle pass to mint, and no answer deletes nothing.
331
+ const sweep = async (nowMs = now()) => {
329
332
  const stale = [];
330
333
  for (const row of store.unsettledZapInvoices()) {
331
334
  const decoded = tryDecodeBolt11(row.pr);
@@ -334,9 +337,21 @@ export const createZapBridge = (deps) => {
334
337
  if (nowMs > (decoded.timestamp + decoded.expirySeconds) * 1000 + 3_600_000)
335
338
  stale.push(row.paymentHash);
336
339
  }
337
- for (const hash of stale)
340
+ let swept = 0;
341
+ for (const hash of stale) {
342
+ let paid;
343
+ try {
344
+ paid = await backend.isInvoiceSettled(hash);
345
+ }
346
+ catch {
347
+ continue;
348
+ }
349
+ if (paid)
350
+ continue;
338
351
  store.deleteUnsettledZapInvoice(hash);
339
- return stale.length;
352
+ swept++;
353
+ }
354
+ return swept;
340
355
  };
341
356
  return { pubkey, isZapName, payRequest, callback, settle, publish, sweep };
342
357
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgesworn/moneyer",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "An LNURLcash (LUD-25) mint - strikes Lightning bearer notes. Independent implementation, cln/lnd funding sources, SQLite, zero HTTP framework.",
5
5
  "author": "TheCryptoDonkey",
6
6
  "license": "MIT",