@forgesworn/moneyer 0.17.1 → 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,18 @@
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
+
3
16
  ## 0.17.1 - 2026-10-06
4
17
 
5
18
  - **The expiry sweep no longer deletes paid mint invoices.** A mint invoice
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
 
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;
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}` } : {})
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgesworn/moneyer",
3
- "version": "0.17.1",
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",