@forgesworn/moneyer 0.17.0 → 0.18.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,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.18.0 - 2026-10-07
4
+
5
+ - **A melt that cannot leave is answered with its reason.** The callback
6
+ used to reply `OK` the moment the note was reserved, so a melt with no
7
+ route looked like success until the wallet checked again. It now waits up
8
+ to two seconds for the payment: if the funding source confirms nothing
9
+ went out, the note is restored first and the answer is `ERROR` with the
10
+ reason (for lnd, "Could not find a route to pay this invoice."). A
11
+ payment still in flight, or one that settles, is `OK` as before. The
12
+ hodl-invoice rule stands: a failure report is acted on only once the
13
+ funding source confirms it. The invoice itself is still refused if tried
14
+ again, as it was after a background restore.
15
+
16
+ ## 0.17.1 - 2026-10-06
17
+
18
+ - **The expiry sweep no longer deletes paid mint invoices.** A mint invoice
19
+ was recorded as paid only when a wallet came back to `/verify` or to
20
+ claim its note, and the sweep deleted every unrecorded one an hour past
21
+ its expiry. A payer whose wallet never came back lost the payment: the
22
+ funding source kept the money and the note's id went with the row. The
23
+ sweep, and `moneyer admin sweep`, now ask the funding source first. Paid,
24
+ the invoice is settled and its note minted; unpaid, the row goes; no
25
+ answer, the row stays.
26
+ - **lnd: a refused invoice lookup is no longer read as unpaid.** Only a 404
27
+ (an invoice lnd does not hold) now means unpaid; any other non-2xx throws.
28
+ A transient lnd error on `/verify` now returns an error instead of "not
29
+ paid yet".
30
+
3
31
  ## 0.17.0 - 2026-09-26
4
32
 
5
33
  LUD-25's unified taproot model (lnurl/luds 6e865b1). Every note is now a
package/README.md CHANGED
@@ -37,10 +37,13 @@ lesson, that behaviour is kept deliberately and tested.
37
37
  from the start.
38
38
  - Signs every note it mints with its own mint key (secp256k1, the standard
39
39
  `Lightning Signed Message` construction) for LUD-25 offline verification.
40
- - The melt discipline: reply OK when the note is reserved, pay in the
41
- background, burn only on confirmed payment, restore only on confirmed
42
- non-payment, and park everything else as pending for reconciliation -
43
- which also runs at startup, so a crash mid-melt never guesses.
40
+ - The melt discipline: reserve the note, then give the payment up to two
41
+ seconds. A payment the funding source confirms never left (no route, say)
42
+ is answered `ERROR` with its reason and the note is outstanding again;
43
+ anything else is answered `OK` and finishes in the background. Burn only
44
+ on confirmed payment, restore only on confirmed non-payment, and park
45
+ everything else as pending for reconciliation - which also runs at
46
+ startup, so a crash mid-melt never guesses.
44
47
 
45
48
  ## Run
46
49
 
@@ -703,7 +706,7 @@ path should not silently create an empty database to answer from.
703
706
  | `note <id\|k1>` | one note; 64 hex that names no note is hashed and looked up as the secret |
704
707
  | `melts [--pending]` | list melts |
705
708
  | `reconcile` | one pending-melt reconcile pass, printing what changed |
706
- | `sweep` | delete mint invoices whose expiry is provably past |
709
+ | `sweep` | delete expired mint invoices the funding source confirms unpaid, and mint the notes of any it finds paid |
707
710
  | `snapshot <path>` | a consistent copy of the database, taken live, refusing to overwrite |
708
711
  | `names list\|add <name> <npub>\|rm <name>` | the lightning addresses this mint pays out as notes |
709
712
  | `keys rotate` | generate a signing key and print the two environment lines; writes nothing |
@@ -735,8 +738,9 @@ is not.
735
738
  Whether that is a channel imbalance or something worse, it is the one
736
739
  number a custodial mint must never be relaxed about.
737
740
  - **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.
741
+ day, and the sweep clears the expired ones the funding source confirms
742
+ unpaid. A count that climbs through a sweep means invoices are being
743
+ issued that nobody can pay, or that the funding source is not answering.
740
744
 
741
745
  ## Testing
742
746
 
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/melt.d.ts CHANGED
@@ -7,6 +7,14 @@ export type MeltJob = {
7
7
  amountMsat: number;
8
8
  payAmountMsat?: number;
9
9
  };
10
+ export type MeltOutcome = {
11
+ kind: 'paid';
12
+ } | {
13
+ kind: 'restored';
14
+ reason: string;
15
+ } | {
16
+ kind: 'unresolved';
17
+ };
10
18
  export type MeltDeps = {
11
19
  store: NoteStore;
12
20
  backend: LightningBackend;
@@ -14,5 +22,5 @@ export type MeltDeps = {
14
22
  confirmDelaysMs?: number[];
15
23
  log?: (message: string) => void;
16
24
  };
17
- export declare const runMelt: (job: MeltJob, deps: MeltDeps) => Promise<void>;
25
+ export declare const runMelt: (job: MeltJob, deps: MeltDeps) => Promise<MeltOutcome>;
18
26
  export declare const reconcilePendingMelts: (store: NoteStore, backend: LightningBackend, inFlight: ReadonlySet<string>, log?: (message: string) => void) => Promise<void>;
package/dist/melt.js CHANGED
@@ -2,7 +2,7 @@ import { verifyPreimage } from 'farrier-kit/preimage';
2
2
  import { PaymentAlreadyKnownError, PaymentFailedError, PaymentPendingError } from "./backends/types.js";
3
3
  const DEFAULT_CONFIRM_DELAYS_MS = [0, 2_000, 4_000, 9_000, 16_000];
4
4
  const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
5
- const confirmThenSettle = async (job, deps) => {
5
+ const confirmThenSettle = async (job, deps, reason) => {
6
6
  const log = deps.log ?? (() => { });
7
7
  for (const delay of deps.confirmDelaysMs ?? DEFAULT_CONFIRM_DELAYS_MS) {
8
8
  if (delay > 0)
@@ -11,12 +11,11 @@ const confirmThenSettle = async (job, deps) => {
11
11
  const complete = await deps.backend.isPaymentComplete(job.paymentHash);
12
12
  if (complete) {
13
13
  deps.store.finalizeMelt(job.paymentHash);
14
+ return { kind: 'paid' };
14
15
  }
15
- else {
16
- log(`melt ${job.noteId}: confirmed not paid - restoring`);
17
- deps.store.restoreMelt(job.paymentHash);
18
- }
19
- return;
16
+ log(`melt ${job.noteId}: confirmed not paid - restoring`);
17
+ deps.store.restoreMelt(job.paymentHash);
18
+ return { kind: 'restored', reason };
20
19
  }
21
20
  catch (err) {
22
21
  if (!(err instanceof PaymentPendingError)) {
@@ -25,6 +24,7 @@ const confirmThenSettle = async (job, deps) => {
25
24
  }
26
25
  }
27
26
  log(`melt ${job.noteId}: payment status unconfirmable - note left pending for reconciliation`);
27
+ return { kind: 'unresolved' };
28
28
  };
29
29
  export const runMelt = async (job, deps) => {
30
30
  const log = deps.log ?? (() => { });
@@ -47,23 +47,26 @@ export const runMelt = async (job, deps) => {
47
47
  // lands between that check and the send.
48
48
  log(`melt ${job.noteId}: the funding source already knew this hash - restored (shared-node replay?)`);
49
49
  deps.store.restoreMelt(job.paymentHash);
50
- return;
50
+ return { kind: 'restored', reason: 'Invoice already used by an earlier melt - use a fresh one.' };
51
51
  }
52
- if (!(err instanceof PaymentFailedError)) {
52
+ // A clean failure's message is the backend's reason, written for a
53
+ // wallet ("Could not find a route..."); an ambiguous one is transport
54
+ // detail, so a wallet gets a plain sentence instead.
55
+ const clean = err instanceof PaymentFailedError;
56
+ if (!clean) {
53
57
  log(`melt ${job.noteId}: payment attempt failed ambiguously (${err.message})`);
54
58
  }
55
- await confirmThenSettle(job, deps);
56
- return;
59
+ return confirmThenSettle(job, deps, clean ? err.message : 'Payment failed.');
57
60
  }
58
61
  if (outcome.preimageHex !== null && !verifyPreimage(outcome.preimageHex, job.paymentHash)) {
59
62
  // The backend claims success with a preimage that does not settle this
60
63
  // invoice. That is a statement about the evidence, not the money - fall
61
64
  // back to the tracker rather than trusting either way.
62
65
  log(`melt ${job.noteId}: backend preimage does not settle this invoice - reconfirming`);
63
- await confirmThenSettle(job, deps);
64
- return;
66
+ return confirmThenSettle(job, deps, 'Payment failed.');
65
67
  }
66
68
  deps.store.finalizeMelt(job.paymentHash);
69
+ return { kind: 'paid' };
67
70
  };
68
71
  // Resolves melts an earlier process left pending - a crash mid-melt, or an
69
72
  // outcome that could not be confirmed at the time. Melts whose attempt is
package/dist/server.d.ts CHANGED
@@ -25,6 +25,7 @@ export type MoneyerDeps = {
25
25
  store?: NoteStore;
26
26
  log?: (message: string) => void;
27
27
  confirmDelaysMs?: number[];
28
+ meltAnswerWindowMs?: number;
28
29
  webAssets?: WebAssets | null;
29
30
  nostr?: NostrTransport;
30
31
  zapPollMs?: number;
@@ -33,4 +34,7 @@ export type MoneyerDeps = {
33
34
  };
34
35
  export { describeFee } from './fee-words.ts';
35
36
  export declare const createMoneyer: (config: MoneyerConfig, deps?: MoneyerDeps) => Promise<Moneyer>;
36
- export declare const sweepExpiredMintInvoices: (store: NoteStore, nowMs?: number) => number;
37
+ export declare const sweepExpiredMintInvoices: (store: NoteStore, backend: Pick<LightningBackend, "isInvoiceSettled">, nowMs?: number) => Promise<{
38
+ swept: number;
39
+ settled: number;
40
+ }>;
package/dist/server.js CHANGED
@@ -45,6 +45,9 @@ const renamedRef = (q, name, legacy) => {
45
45
  // more than a name and a note, and an unbounded read on a public endpoint
46
46
  // is a way to run a mint out of memory.
47
47
  const MAX_BODY_BYTES = 8 * 1024;
48
+ // How long a melt callback waits for the payment's outcome before answering
49
+ // OK. Long enough for a no-route failure, short against a wallet's timeout.
50
+ const MELT_ANSWER_WINDOW_MS = 2_000;
48
51
  const readBody = async (req, limit = MAX_BODY_BYTES) => {
49
52
  const chunks = [];
50
53
  let size = 0;
@@ -1161,17 +1164,34 @@ export const createMoneyer = async (config, deps = {}) => {
1161
1164
  return fail('Invoice already used by an earlier melt - use a fresh one.');
1162
1165
  }
1163
1166
  inFlight.add(paymentHash);
1164
- void runMelt({
1167
+ const melt = runMelt({
1165
1168
  paymentHash,
1166
1169
  noteId: inputIds[0],
1167
1170
  pr: pr.trim(),
1168
1171
  amountMsat: totalMsat,
1169
1172
  ...(payAmountMsat !== null ? { payAmountMsat } : {})
1170
1173
  }, { store, backend, feeLimitMsat: meltFeeLimitMsat, log, ...(deps.confirmDelaysMs ? { confirmDelaysMs: deps.confirmDelaysMs } : {}) })
1171
- .catch(err => log(`melt ${inputIds[0]}: ${err.message}`))
1174
+ .catch((err) => {
1175
+ log(`melt ${inputIds[0]}: ${err.message}`);
1176
+ return { kind: 'unresolved' };
1177
+ })
1172
1178
  .finally(() => inFlight.delete(paymentHash));
1173
- // Replied before the payment resolves, per LUD-03: OK means the
1174
- // melt is requested and the note reserved, never that it settled.
1179
+ // A payment that cannot leave usually says so within a second (lnd
1180
+ // answers no-route before sending any HTLC), so wait briefly: a
1181
+ // melt the funding source has confirmed never went out is answered
1182
+ // with its reason, and the note is already outstanding again. Any
1183
+ // other outcome, or none yet, is OK as LUD-03 has it: the melt is
1184
+ // requested and the note reserved, never that it settled.
1185
+ let windowTimer;
1186
+ const outcome = await Promise.race([
1187
+ melt,
1188
+ new Promise(resolve => {
1189
+ windowTimer = setTimeout(() => resolve(null), deps.meltAnswerWindowMs ?? MELT_ANSWER_WINDOW_MS);
1190
+ })
1191
+ ]);
1192
+ clearTimeout(windowTimer);
1193
+ if (outcome?.kind === 'restored')
1194
+ return fail(outcome.reason);
1175
1195
  return send({
1176
1196
  status: 'OK',
1177
1197
  ...(config.verify ? { pr: pr.trim(), verify: `${origin}/verify/${paymentHash}` } : {})
@@ -1262,9 +1282,12 @@ export const createMoneyer = async (config, deps = {}) => {
1262
1282
  // otherwise grow by one dead row per /p/cb call forever. The timer is
1263
1283
  // unref'd so it never keeps the process alive.
1264
1284
  const housekeeping = async () => {
1265
- const swept = sweepExpiredMintInvoices(store) + (zap?.sweep() ?? 0);
1285
+ const { swept: mintSwept, settled } = await sweepExpiredMintInvoices(store, backend);
1286
+ const swept = mintSwept + (zap?.sweep() ?? 0);
1266
1287
  if (swept > 0)
1267
1288
  log(`swept ${swept} expired mint invoice${swept === 1 ? '' : 's'}`);
1289
+ if (settled > 0)
1290
+ log(`found ${settled} expired mint invoice${settled === 1 ? '' : 's'} paid after all - note${settled === 1 ? '' : 's'} minted`);
1268
1291
  await reconcilePendingMelts(store, backend, inFlight, log);
1269
1292
  reconciledAt = Date.now();
1270
1293
  };
@@ -1337,15 +1360,25 @@ export const createMoneyer = async (config, deps = {}) => {
1337
1360
  }
1338
1361
  };
1339
1362
  };
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()) => {
1363
+ // Clears mint invoices this mint has not seen paid once their bolt11 expiry
1364
+ // is comfortably past, returning how many it deleted and how many it found
1365
+ // paid after all.
1366
+ //
1367
+ // "Unsettled" here is only this mint's record. A payment is recorded when a
1368
+ // wallet comes back to /verify or to claim its note, so a payer whose wallet
1369
+ // never comes back - a closed tab, an invoice paid from another app - leaves
1370
+ // a row that reads unsettled while the funding source holds the money.
1371
+ // Deleting that row would strand the payment for good: the note's id lives
1372
+ // only here. So the funding source is asked first. Paid, the invoice is
1373
+ // settled and its note minted, exactly as a /verify would have. Unpaid, the
1374
+ // expiry is the proof it never can be, and the row goes. No answer, no
1375
+ // delete.
1376
+ //
1377
+ // The hour's margin keeps the delete unambiguously behind every
1378
+ // implementation's reading of that expiry, an undecodable invoice is kept,
1379
+ // and the delete is conditional on the row still being unsettled, so a
1380
+ // settle racing the sweep always wins.
1381
+ export const sweepExpiredMintInvoices = async (store, backend, nowMs = Date.now()) => {
1349
1382
  const stale = [];
1350
1383
  for (const invoice of store.unsettledMintInvoices()) {
1351
1384
  const decoded = tryDecodeBolt11(invoice.pr);
@@ -1355,7 +1388,24 @@ export const sweepExpiredMintInvoices = (store, nowMs = Date.now()) => {
1355
1388
  if (nowMs > expiresAtMs + 3_600_000)
1356
1389
  stale.push(invoice.paymentHash);
1357
1390
  }
1358
- for (const paymentHash of stale)
1359
- store.deleteUnsettledMintInvoice(paymentHash);
1360
- return stale.length;
1391
+ let swept = 0;
1392
+ let settled = 0;
1393
+ for (const paymentHash of stale) {
1394
+ let paid;
1395
+ try {
1396
+ paid = await backend.isInvoiceSettled(paymentHash);
1397
+ }
1398
+ catch {
1399
+ continue;
1400
+ }
1401
+ if (paid) {
1402
+ store.settleMintInvoice(paymentHash);
1403
+ settled++;
1404
+ }
1405
+ else {
1406
+ store.deleteUnsettledMintInvoice(paymentHash);
1407
+ swept++;
1408
+ }
1409
+ }
1410
+ return { swept, settled };
1361
1411
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgesworn/moneyer",
3
- "version": "0.17.0",
3
+ "version": "0.18.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",