@forgesworn/moneyer 0.17.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.1 - 2026-10-06
4
+
5
+ - **The expiry sweep no longer deletes paid mint invoices.** A mint invoice
6
+ was recorded as paid only when a wallet came back to `/verify` or to
7
+ claim its note, and the sweep deleted every unrecorded one an hour past
8
+ its expiry. A payer whose wallet never came back lost the payment: the
9
+ funding source kept the money and the note's id went with the row. The
10
+ sweep, and `moneyer admin sweep`, now ask the funding source first. Paid,
11
+ the invoice is settled and its note minted; unpaid, the row goes; no
12
+ answer, the row stays.
13
+ - **lnd: a refused invoice lookup is no longer read as unpaid.** Only a 404
14
+ (an invoice lnd does not hold) now means unpaid; any other non-2xx throws.
15
+ A transient lnd error on `/verify` now returns an error instead of "not
16
+ paid yet".
17
+
3
18
  ## 0.17.0 - 2026-09-26
4
19
 
5
20
  LUD-25's unified taproot model (lnurl/luds 6e865b1). Every note is now a
package/README.md CHANGED
@@ -703,7 +703,7 @@ path should not silently create an empty database to answer from.
703
703
  | `note <id\|k1>` | one note; 64 hex that names no note is hashed and looked up as the secret |
704
704
  | `melts [--pending]` | list melts |
705
705
  | `reconcile` | one pending-melt reconcile pass, printing what changed |
706
- | `sweep` | delete mint invoices whose expiry is provably past |
706
+ | `sweep` | delete expired mint invoices the funding source confirms unpaid, and mint the notes of any it finds paid |
707
707
  | `snapshot <path>` | a consistent copy of the database, taken live, refusing to overwrite |
708
708
  | `names list\|add <name> <npub>\|rm <name>` | the lightning addresses this mint pays out as notes |
709
709
  | `keys rotate` | generate a signing key and print the two environment lines; writes nothing |
@@ -735,8 +735,9 @@ is not.
735
735
  Whether that is a channel imbalance or something worse, it is the one
736
736
  number a custodial mint must never be relaxed about.
737
737
  - **unsettled invoices growing.** Invoices are issued and never paid all
738
- day, and the sweep clears the expired ones. A count that climbs through
739
- a sweep means invoices are being issued that nobody can pay.
738
+ day, and the sweep clears the expired ones the funding source confirms
739
+ unpaid. A count that climbs through a sweep means invoices are being
740
+ issued that nobody can pay, or that the funding source is not answering.
740
741
 
741
742
  ## Testing
742
743
 
package/THREAT-MODEL.md CHANGED
@@ -147,9 +147,9 @@ is what notecase and lnurl-wallet do.
147
147
  - No rate limiting is built in; put it at the proxy. This matters most for
148
148
  `/p/cb`: each unauthenticated call creates a real invoice at the funding
149
149
  source. moneyer sweeps its own unsettled invoices once their bolt11
150
- expiry has passed, but the node's side of that growth is the operator's
151
- to bound (cln's autoclean, or an equivalent cron) - and a proxy limit is
152
- what keeps the RPC pile-up and the node database from growing at all.
150
+ expiry has passed and the funding source confirms them unpaid, but the
151
+ node's side of that growth is the operator's to bound (cln's autoclean,
152
+ or an equivalent cron) - and a proxy limit is what keeps the RPC pile-up and the node database from growing at all.
153
153
  - A fee-free configuration pays the melt routing-fee floor (0.5% of the
154
154
  amount or 5000 msat) out of its own channel balance, and mint-and-melt
155
155
  cycling costs a griefer nothing. Set `MONEYER_BASE_FEE_MSAT` to cover the
package/dist/admin.js CHANGED
@@ -18,7 +18,7 @@ const COMMANDS = [
18
18
  ['note <id|k1>', 'one note, by id or by the secret itself'],
19
19
  ['melts [--pending]', 'list melts, newest first'],
20
20
  ['reconcile', 'resolve melts left in flight, and say what changed'],
21
- ['sweep', 'delete mint invoices whose expiry is provably past'],
21
+ ['sweep', 'delete expired mint invoices the funding source confirms unpaid'],
22
22
  ['snapshot <path>', 'a consistent copy of the database, taken live'],
23
23
  ['names list|add <name> <npub>|rm <name>', 'the lightning addresses this mint pays out as notes'],
24
24
  ['keys rotate', 'generate a signing key and print the two env lines'],
@@ -271,8 +271,10 @@ export const runAdmin = async (argv, deps = {}) => {
271
271
  return 0;
272
272
  }
273
273
  case 'sweep': {
274
- const swept = sweepExpiredMintInvoices(store, now());
274
+ const { swept, settled } = await sweepExpiredMintInvoices(store, fundingSource(), now());
275
275
  out(`swept ${swept} expired mint invoice${swept === 1 ? '' : 's'}`);
276
+ if (settled > 0)
277
+ out(`found ${settled} paid after all - note${settled === 1 ? '' : 's'} minted`);
276
278
  return 0;
277
279
  }
278
280
  case 'snapshot': {
@@ -158,9 +158,16 @@ export const createLndBackend = (config) => {
158
158
  throw new Error('lnd did not report a payment status.');
159
159
  return result;
160
160
  },
161
+ // 404 is lnd's answer for an invoice it does not hold, which can never
162
+ // settle. Any other refusal is no answer at all, and reading it as
163
+ // "unpaid" would let the expiry sweep delete a paid invoice's row.
161
164
  async isInvoiceSettled(paymentHashHex) {
162
165
  const res = await json(`/v1/invoice/${paymentHashHex}`);
163
- return res.ok && Boolean(res.json?.settled);
166
+ if (res.status === 404)
167
+ return false;
168
+ if (!res.ok)
169
+ throw new Error(`lnd invoice lookup failed: HTTP ${res.status}`);
170
+ return Boolean(res.json?.settled);
164
171
  },
165
172
  async invoicePreimage(paymentHashHex) {
166
173
  const res = await json(`/v1/invoice/${paymentHashHex}`);
package/dist/server.d.ts CHANGED
@@ -33,4 +33,7 @@ export type MoneyerDeps = {
33
33
  };
34
34
  export { describeFee } from './fee-words.ts';
35
35
  export declare const createMoneyer: (config: MoneyerConfig, deps?: MoneyerDeps) => Promise<Moneyer>;
36
- export declare const sweepExpiredMintInvoices: (store: NoteStore, nowMs?: number) => number;
36
+ export declare const sweepExpiredMintInvoices: (store: NoteStore, backend: Pick<LightningBackend, "isInvoiceSettled">, nowMs?: number) => Promise<{
37
+ swept: number;
38
+ settled: number;
39
+ }>;
package/dist/server.js CHANGED
@@ -1262,9 +1262,12 @@ export const createMoneyer = async (config, deps = {}) => {
1262
1262
  // otherwise grow by one dead row per /p/cb call forever. The timer is
1263
1263
  // unref'd so it never keeps the process alive.
1264
1264
  const housekeeping = async () => {
1265
- const swept = sweepExpiredMintInvoices(store) + (zap?.sweep() ?? 0);
1265
+ const { swept: mintSwept, settled } = await sweepExpiredMintInvoices(store, backend);
1266
+ const swept = mintSwept + (zap?.sweep() ?? 0);
1266
1267
  if (swept > 0)
1267
1268
  log(`swept ${swept} expired mint invoice${swept === 1 ? '' : 's'}`);
1269
+ if (settled > 0)
1270
+ log(`found ${settled} expired mint invoice${settled === 1 ? '' : 's'} paid after all - note${settled === 1 ? '' : 's'} minted`);
1268
1271
  await reconcilePendingMelts(store, backend, inFlight, log);
1269
1272
  reconciledAt = Date.now();
1270
1273
  };
@@ -1337,15 +1340,25 @@ export const createMoneyer = async (config, deps = {}) => {
1337
1340
  }
1338
1341
  };
1339
1342
  };
1340
- // Deletes unsettled mint invoices whose bolt11 expiry is comfortably past,
1341
- // returning the count swept. The invoice's own timestamp + expiry are the
1342
- // proof - a funding source refuses to settle an expired invoice, so the
1343
- // row can never mint a note again. The hour's margin keeps the delete
1344
- // unambiguously behind every implementation's reading of that expiry, and
1345
- // an undecodable invoice is kept: no proof, no delete. The delete itself is
1346
- // conditional on the row still being unsettled, so a settle racing the
1347
- // sweep always wins.
1348
- export const sweepExpiredMintInvoices = (store, nowMs = Date.now()) => {
1343
+ // Clears mint invoices this mint has not seen paid once their bolt11 expiry
1344
+ // is comfortably past, returning how many it deleted and how many it found
1345
+ // paid after all.
1346
+ //
1347
+ // "Unsettled" here is only this mint's record. A payment is recorded when a
1348
+ // wallet comes back to /verify or to claim its note, so a payer whose wallet
1349
+ // never comes back - a closed tab, an invoice paid from another app - leaves
1350
+ // a row that reads unsettled while the funding source holds the money.
1351
+ // Deleting that row would strand the payment for good: the note's id lives
1352
+ // only here. So the funding source is asked first. Paid, the invoice is
1353
+ // settled and its note minted, exactly as a /verify would have. Unpaid, the
1354
+ // expiry is the proof it never can be, and the row goes. No answer, no
1355
+ // delete.
1356
+ //
1357
+ // The hour's margin keeps the delete unambiguously behind every
1358
+ // implementation's reading of that expiry, an undecodable invoice is kept,
1359
+ // and the delete is conditional on the row still being unsettled, so a
1360
+ // settle racing the sweep always wins.
1361
+ export const sweepExpiredMintInvoices = async (store, backend, nowMs = Date.now()) => {
1349
1362
  const stale = [];
1350
1363
  for (const invoice of store.unsettledMintInvoices()) {
1351
1364
  const decoded = tryDecodeBolt11(invoice.pr);
@@ -1355,7 +1368,24 @@ export const sweepExpiredMintInvoices = (store, nowMs = Date.now()) => {
1355
1368
  if (nowMs > expiresAtMs + 3_600_000)
1356
1369
  stale.push(invoice.paymentHash);
1357
1370
  }
1358
- for (const paymentHash of stale)
1359
- store.deleteUnsettledMintInvoice(paymentHash);
1360
- return stale.length;
1371
+ let swept = 0;
1372
+ let settled = 0;
1373
+ for (const paymentHash of stale) {
1374
+ let paid;
1375
+ try {
1376
+ paid = await backend.isInvoiceSettled(paymentHash);
1377
+ }
1378
+ catch {
1379
+ continue;
1380
+ }
1381
+ if (paid) {
1382
+ store.settleMintInvoice(paymentHash);
1383
+ settled++;
1384
+ }
1385
+ else {
1386
+ store.deleteUnsettledMintInvoice(paymentHash);
1387
+ swept++;
1388
+ }
1389
+ }
1390
+ return { swept, settled };
1361
1391
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgesworn/moneyer",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
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",