@forgesworn/moneyer 0.18.1 → 0.19.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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.19.0 - 2026-10-07
4
+
5
+ - **A failed melt's invoice can be tried again.** Once the funding source
6
+ confirms a melt never paid, the note is restored as before, and now the
7
+ invoice is free again too: the payee is still waiting on it, so a wallet
8
+ can melt into it a second time, from the same note or a different one,
9
+ instead of asking for a fresh invoice. lnd sends a hash whose payment
10
+ failed again; a backend that refuses it restores the note, as for any
11
+ payment the node already holds. An invoice that paid, or whose melt is
12
+ still unresolved, is refused as before. The retry reuses the melt's row, so
13
+ `moneyer admin melts` and the restored count in the lifetime totals show
14
+ the latest attempt only.
15
+ - lnd's "invoice is already paid" refusal, sent when a shared node paid the
16
+ hash between the mint's pre-check and its send, is now read as a payment
17
+ the node already holds, like its other two refusals. It used to fall
18
+ through as an ambiguous failure, which the payment tracker would then
19
+ have confirmed as paid against somebody else's payment, burning the note.
20
+
3
21
  ## 0.18.1 - 2026-10-07
4
22
 
5
23
  - **Bound mint receipts verify in current wallets again.** Since
package/README.md CHANGED
@@ -43,7 +43,9 @@ lesson, that behaviour is kept deliberately and tested.
43
43
  anything else is answered `OK` and finishes in the background. Burn only
44
44
  on confirmed payment, restore only on confirmed non-payment, and park
45
45
  everything else as pending for reconciliation - which also runs at
46
- startup, so a crash mid-melt never guesses.
46
+ startup, so a crash mid-melt never guesses. An invoice whose melt was
47
+ restored may be melted into again, from that note or another; one that
48
+ paid or is still unresolved is refused.
47
49
 
48
50
  ## Run
49
51
 
@@ -471,7 +473,8 @@ at `h`" alone would let anyone holding a burned `k1` and any outstanding
471
473
  note id draw a success out of the mint.
472
474
 
473
475
  The melt path is untouched: melts are deduplicated by payment hash, which
474
- is a different question with a different answer.
476
+ is a different question with a different answer (a restored melt's
477
+ invoice excepted - see the melt discipline above).
475
478
 
476
479
  This is moneyer's own behaviour. The LUD-25 draft says nothing about
477
480
  retries yet; the suggested wording is on lnurl/luds#301 as a SHOULD.
@@ -65,8 +65,10 @@ export const createFakeBackend = (options = {}) => {
65
65
  if (!paymentHashHex)
66
66
  throw new PaymentFailedError('That is not a decodable invoice.');
67
67
  // Real nodes dedupe sends by payment hash: a hash this node already
68
- // holds a payment for is refused, exactly as lnd and cln refuse it.
69
- if (payments.has(paymentHashHex)) {
68
+ // paid, or is still paying, is refused exactly as lnd and cln refuse
69
+ // it. One whose every attempt failed may be sent again, as on both.
70
+ const earlier = payments.get(paymentHashHex);
71
+ if (earlier && earlier.status !== 'failed') {
70
72
  throw new PaymentAlreadyKnownError('this node already has a payment for that hash');
71
73
  }
72
74
  const preimageHex = knownPreimages.get(paymentHashHex) ?? null;
@@ -122,7 +122,10 @@ export const createLndBackend = (config) => {
122
122
  // lnd refuses a send whose payment hash it already holds - on a
123
123
  // shared node that is somebody else's payment, and nothing went
124
124
  // out for THIS call. Distinct from ambiguity: safely restorable.
125
- if (error && /already exists|AlreadyExists|payment is in transition/i.test(error)) {
125
+ // Its three refusals (lnd 0.20, payments/db/errors.go): one being
126
+ // created, one in flight, one that succeeded. A hash whose payment
127
+ // failed is not refused - lnd sends it again.
128
+ if (error && /already exists|AlreadyExists|payment is in transition|invoice is already paid/i.test(error)) {
126
129
  throw new PaymentAlreadyKnownError('lnd already has a payment for this hash');
127
130
  }
128
131
  // The stream ended without a terminal status - genuinely ambiguous.
package/dist/server.js CHANGED
@@ -3,7 +3,7 @@ import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils.js';
3
3
  import { finalizeEvent } from 'nostr-tools/pure';
4
4
  import { applyMintFee, encodeCs1WithAmount, grossUpForMintFee, hashK1 } from '@lnurlcash/kit';
5
5
  import { tryDecodeBolt11 } from 'farrier-kit/bolt11';
6
- import { NotePendingError, NoteStore, NoteUnavailableError, OutputCollisionError, swapFingerprint } from "./store.js";
6
+ import { InvoiceUsedError, NotePendingError, NoteStore, NoteUnavailableError, OutputCollisionError, swapFingerprint } from "./store.js";
7
7
  import { createNoteSigner } from "./signing.js";
8
8
  import { bearerNoteId, decodeNote, decodeSpend, encodeCp1, spendDomainOf, verifySpend } from "./spend.js";
9
9
  import { createKernelVerifier } from "./kernel.js";
@@ -1137,8 +1137,14 @@ export const createMoneyer = async (config, deps = {}) => {
1137
1137
  }
1138
1138
  // The funding source dedupes payments by hash, so a second melt
1139
1139
  // into the same invoice would be "confirmed" against the first
1140
- // payment and burn its note without moving funds.
1141
- if (store.meltByHash(paymentHash)) {
1140
+ // payment and burn its note without moving funds. A melt that was
1141
+ // restored is the exception: the funding source confirmed it never
1142
+ // paid, lnd sends a hash whose payment failed again (it refuses
1143
+ // only one being created, in flight or paid), and the payee is
1144
+ // still waiting on that invoice. A backend that refuses anyway
1145
+ // throws PaymentAlreadyKnownError and the note restores.
1146
+ const earlier = store.meltByHash(paymentHash);
1147
+ if (earlier && earlier.outcome !== 'restored') {
1142
1148
  return fail('Invoice already used by an earlier melt - use a fresh one.');
1143
1149
  }
1144
1150
  // A shared funding source is a supported deployment (DEPLOY.md),
@@ -1167,7 +1173,10 @@ export const createMoneyer = async (config, deps = {}) => {
1167
1173
  return fail('pending');
1168
1174
  if (err instanceof NoteUnavailableError)
1169
1175
  return fail(INVALID_K1);
1170
- return fail('Invoice already used by an earlier melt - use a fresh one.');
1176
+ if (err instanceof InvoiceUsedError)
1177
+ return fail('Invoice already used by an earlier melt - use a fresh one.');
1178
+ log(`melt reservation failed: ${err.message}`);
1179
+ return fail('Temporarily unable to melt - try again shortly.');
1171
1180
  }
1172
1181
  inFlight.add(paymentHash);
1173
1182
  const melt = runMelt({
package/dist/store.d.ts CHANGED
@@ -82,6 +82,8 @@ export declare class NoteUnavailableError extends Error {
82
82
  }
83
83
  export declare class OutputCollisionError extends Error {
84
84
  }
85
+ export declare class InvoiceUsedError extends Error {
86
+ }
85
87
  export declare class NoteStore {
86
88
  private db;
87
89
  readonly readOnly: boolean;
package/dist/store.js CHANGED
@@ -30,6 +30,10 @@ export class NoteUnavailableError extends Error {
30
30
  // can learn that id's preimage take the output.
31
31
  export class OutputCollisionError extends Error {
32
32
  }
33
+ // An earlier melt into this invoice paid it, or has not yet resolved. Only
34
+ // one the funding source confirmed never paid may be melted into again.
35
+ export class InvoiceUsedError extends Error {
36
+ }
33
37
  export class NoteStore {
34
38
  db;
35
39
  readOnly;
@@ -329,14 +333,22 @@ export class NoteStore {
329
333
  return parsed.map(([id, amountMsat]) => ({ id, amountMsat }));
330
334
  }
331
335
  // Reserves a note for a melt and records the melt, atomically. The melts
332
- // row is keyed by the invoice's payment hash; a duplicate hash means an
333
- // earlier melt already used this invoice and the INSERT itself refuses.
336
+ // row is keyed by the invoice's payment hash. An earlier melt into the
337
+ // same invoice may be retried only once it was restored - the funding
338
+ // source confirmed nothing went out - and the retry takes over its row.
339
+ // One that paid, or is still unresolved, refuses the reservation.
334
340
  markPending(noteId, paymentHash, pr, amountMsat) {
335
341
  this.tx(() => {
336
342
  this.assertOutstanding(noteId);
337
- this.db
338
- .prepare('INSERT INTO melts (payment_hash, note_id, pr, amount_msat, outcome, created_at) VALUES (?, ?, ?, ?, NULL, ?)')
343
+ const written = this.db
344
+ .prepare(`INSERT INTO melts (payment_hash, note_id, pr, amount_msat, outcome, created_at) VALUES (?, ?, ?, ?, NULL, ?)
345
+ ON CONFLICT (payment_hash) DO UPDATE SET
346
+ note_id = excluded.note_id, pr = excluded.pr, amount_msat = excluded.amount_msat,
347
+ outcome = NULL, created_at = excluded.created_at, resolved_at = NULL
348
+ WHERE melts.outcome = 'restored'`)
339
349
  .run(paymentHash, noteId, pr, amountMsat, Date.now());
350
+ if (written.changes !== 1)
351
+ throw new InvoiceUsedError('an earlier melt already used this invoice');
340
352
  this.setNoteState(noteId, 'pending');
341
353
  });
342
354
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgesworn/moneyer",
3
- "version": "0.18.1",
3
+ "version": "0.19.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",