@forgesworn/moneyer 0.6.1 → 0.8.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,92 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0 - 2026-08-30
4
+
5
+ **`MONEYER_REQUIRE_COMMENT` (default off).** Refuses a mint quote that names
6
+ no output, rather than falling back to a note keyed by the payment preimage.
7
+ Off by default because LUD-25 line 80 still asks for that fallback; on, the
8
+ refusal happens before any invoice is issued, so a wallet never pays for a
9
+ quote the mint was always going to reject. A quote naming its output by
10
+ either spelling (`comment` or `h`) is unaffected.
11
+
12
+ Worth knowing: `lnurlcash-conformance` now grades the mandate as required, so
13
+ moneyer fails that suite in its default configuration and passes with the
14
+ flag on. See that repo's `docs/COMMENT-IS-MANDATORY.md`.
15
+
16
+ ## [0.7.0] - 2026-08-26
17
+
18
+ - **The published node capacity is the announced one.** `nodeCapacity` in
19
+ the discovery document was summed from lnd's `/v1/channels`, which is an
20
+ authenticated view of the node and counts private channels. That figure is
21
+ served to every visitor and goes out in the mint's announcement, so a
22
+ channel the operator chose not to announce was being sized in public
23
+ anyway. It now comes from this node's own entry in the public graph -
24
+ `total_capacity`, the same number any stranger on the network already
25
+ reads, converted from sats. A node with nothing announced reports zero
26
+ rather than omitting the field, because zero is the true answer there;
27
+ only a node that cannot be asked leaves it off. cln never reported
28
+ capacity and is unaffected.
29
+
30
+ Operators should expect the number to fall, and to fall to zero on a mint
31
+ running entirely on private channels. It was never the figure it claimed
32
+ to be.
33
+
34
+ - The web wallet prints a note to a PNG you can send. The plate existed only
35
+ as HTML over the artwork, which is no use in a message; the same portrait
36
+ plate composites onto a canvas and comes back as a file - the share sheet
37
+ where the browser offers one, a download everywhere else. The travelling
38
+ plate carries the bech32 LNURL rather than the claim link, because a note
39
+ that leaves in a message is scanned by whatever the recipient already has,
40
+ and that is usually a Lightning wallet.
41
+
42
+ - The bundled web wallet names its note on the plain path too, not only when
43
+ a signed receipt is on offer. It previously fell back to an unnamed mint
44
+ and then required `verify` to read the preimage - which, with the rule
45
+ above, a signer-less mint no longer answers. Naming needs no receipt, and
46
+ the page then claims the secret it chose rather than one the mint
47
+ publishes.
48
+
49
+ - **No LUD-21 `verify` on a mint payment that named no output.** LUD-25
50
+ forbids it, and the reason is concrete: on that path the note's `k1` IS the
51
+ payment preimage, and `verify` publishes the preimage at a URL anyone who
52
+ has seen the invoice can build from its payment hash. The note was only as
53
+ private as the QR it was paid from.
54
+
55
+ Both halves are needed. New unnamed invoices get no `verify` field, and
56
+ `/verify/<hash>` refuses them outright - not advertising a URL does not
57
+ stop anyone constructing it. A payer on this path still learns the preimage
58
+ the way any Lightning wallet does, by paying.
59
+
60
+ **Invoices quoted before this mint adopted the rule keep their `verify`.**
61
+ A wallet polling one of those did not pay the invoice itself - that is why
62
+ it is polling - so the preimage this mint holds is its only route to a note
63
+ it already owns. Refusing them would not close a hole, it would burn
64
+ somebody's money. The cutover is written to a new `meta` table the first
65
+ time a build carrying this code opens the database, and never moves after;
66
+ persisted rather than taken from process start, or a restart would walk the
67
+ line forward and strand a quote made minutes earlier under the same build.
68
+ Old rows drain and the hole closes for everything new.
69
+
70
+ - The mint accepts a LUD-12 `comment` carrying `hex(h)` as the name of the
71
+ note being bought, and advertises `commentAllowed: 64` on the payRequest.
72
+ This is how LUD-25 specifies it; `h` was this mint's own earlier spelling
73
+ and both are now honoured, so wallets on either keep working. A wallet
74
+ sending both must agree with itself - minting under one when the other is
75
+ being watched for would lose the note.
76
+
77
+ The two are deliberately not validated alike. Per LUD-25, a `comment` that
78
+ is not a bare 32-byte hex hash falls back to keying the note by the payment
79
+ preimage, exactly as no comment does, because a comment is free text in
80
+ LUD-12 and failing on every stray one would break ordinary payers. A
81
+ malformed `h` still fails loudly: that is a wallet that meant to name an
82
+ output and got it wrong.
83
+
84
+ This matters beyond conformance. A note minted with no named output has the
85
+ payment preimage as its spend secret, and until this release the mint
86
+ served that preimage on its LUD-21 `verify` URL, which anyone holding the
87
+ invoice can construct. Naming the output is what makes `verify` safe to
88
+ offer, and the entry above now gates it on exactly that.
89
+
3
90
  ## [0.6.1] - 2026-08-24
4
91
 
5
92
  - The bundled web wallet now accepts a bound mint quote anywhere inside
package/README.md CHANGED
@@ -105,6 +105,7 @@ default, and a variable set to an empty string counts as unset.
105
105
  | `MONEYER_MIN_SENDABLE_MSAT` | `1000` | smallest payment the mint advertises |
106
106
  | `MONEYER_MAX_SENDABLE_MSAT` | `100000000` | largest payment the mint advertises |
107
107
  | `MONEYER_MIN_MINT_MSAT` | `1000` | dust floor: the smallest note the mint will strike |
108
+ | `MONEYER_REQUIRE_COMMENT` | `false` | refuse a mint quote that names no output, instead of falling back to a note keyed by the payment preimage. See [comment protection](#comment-protection) |
108
109
  | `MONEYER_MAX_K1S` | `21` | most notes one callback may name |
109
110
  | `MONEYER_VERIFY` | `true` | the LUD-21 `verify` endpoint. Off means 404 |
110
111
  | `MONEYER_WALLET_URL` | | a companion web wallet the mint's site links notes into |
@@ -667,3 +668,34 @@ in [awesome-lnurlcash](https://github.com/TheCryptoDonkey/awesome-lnurlcash).
667
668
  ## Licence
668
669
 
669
670
  MIT.
671
+
672
+
673
+ ## Comment protection
674
+
675
+ A LUD-25 mint quote may carry a LUD-12 `comment` holding
676
+ `hex(sha256(secret))`, naming the note the payment will mint. The note is
677
+ then keyed by the wallet's own `secret`, and the payment preimage redeems
678
+ nothing.
679
+
680
+ With no such comment, LUD-25 line 80 says the mint MUST fall back to keying
681
+ the note by the payment preimage itself. That fallback is the default here,
682
+ and it is what the draft currently requires.
683
+
684
+ `MONEYER_REQUIRE_COMMENT=true` refuses an unnamed quote instead, before any
685
+ invoice is issued. Two reasons to want it:
686
+
687
+ - A preimage-keyed note is only as safe as the discretion of every routing
688
+ hop on the payment. Each one learns the preimage as it settles its own
689
+ HTLC, often before the payer has finished processing the payment.
690
+ - A funding source that settles without producing a preimage - a Spark
691
+ backend, say - has nothing to key a fallback note by at all.
692
+
693
+ The cost is backward compatibility: a wallet that has never heard of
694
+ LNURLcash sends a bare LUD-06 request and gets an error rather than an
695
+ invoice. `dni/lnurl-mint` made this behaviour unconditional in `b257d58`;
696
+ the draft has not yet followed, which is why it is opt-in here. See
697
+ `lnurlcash-conformance/docs/COMMENT-IS-MANDATORY.md`.
698
+
699
+ Note that `lnurlcash-conformance` now grades the mandate as required, so
700
+ this mint **fails that suite in its default configuration** and passes with
701
+ `MONEYER_REQUIRE_COMMENT=true`.
@@ -6,8 +6,11 @@ import { PaymentAlreadyKnownError, PaymentFailedError, PaymentPendingError } fro
6
6
  // one (r_preimage), which is what lets it back a mint. Payment send/track
7
7
  // are chunked NDJSON streams read line by line to a terminal status.
8
8
  //
9
- // Not yet exercised against a live node - the semantics are a direct port
10
- // of the reference mint's lnd backend, which is.
9
+ // This is what backs the public mint, so the invoice and payment paths run
10
+ // against a live node continuously; the semantics started as a direct port
11
+ // of the reference mint's lnd backend. The exception is nodeInfo's graph
12
+ // self-lookup, which is newer than the running deployment - it is covered
13
+ // by test/lnd-node-info.test.ts and by nothing else yet.
11
14
  //
12
15
  // TLS: for lnd's self-signed cert, point NODE_EXTRA_CA_CERTS at it.
13
16
  const FAILURE_REASONS = {
@@ -183,12 +186,27 @@ export const createLndBackend = (config) => {
183
186
  const color = typeof res.json?.color === 'string' ? `#${res.json.color.replace(/^#/, '')}` : undefined;
184
187
  const numChannels = Number(res.json?.num_active_channels);
185
188
  const numPeers = Number(res.json?.num_peers);
186
- // Total public capacity, best-effort: the macaroon may not carry
187
- // offchain:read, and the discovery endpoint works fine without it.
189
+ // Announced capacity, read from this node's own entry in the public
190
+ // graph rather than from its channel list. `/v1/channels` is an
191
+ // authenticated view and counts private channels too; this figure is
192
+ // published in the discovery document, so summing that would tell the
193
+ // world what only the operator can see. The graph self-lookup returns
194
+ // the same `total_capacity` any stranger on the network already
195
+ // reads, in sats, so it is converted here to keep NodeInfo msat.
196
+ //
197
+ // Best-effort, in two flavours: a 404 is a node with nothing
198
+ // announced, which is a public capacity of zero rather than an
199
+ // unknown one, while any other failure leaves the field off - the
200
+ // macaroon may not carry info:read, and the discovery endpoint works
201
+ // fine without it.
188
202
  let capacityMsat;
189
- const channels = await json('/v1/channels');
190
- if (channels.ok && Array.isArray(channels.json?.channels)) {
191
- capacityMsat = channels.json.channels.reduce((sum, channel) => sum + Number(channel.capacity ?? 0) * 1000, 0);
203
+ const pubkey = res.json?.identity_pubkey;
204
+ if (typeof pubkey === 'string' && pubkey) {
205
+ const node = await json(`/v1/graph/node/${pubkey}`);
206
+ if (node.ok)
207
+ capacityMsat = Number(node.json?.total_capacity ?? 0) * 1000;
208
+ else if (node.status === 404)
209
+ capacityMsat = 0;
192
210
  }
193
211
  // Outbound liquidity, best-effort for the same reason as capacity:
194
212
  // the macaroon may not carry offchain:read.
package/dist/config.d.ts CHANGED
@@ -32,6 +32,7 @@ export type MoneyerConfig = {
32
32
  maxSendableMsat: number;
33
33
  minMintMsat: number;
34
34
  mintFee: MintFee | null;
35
+ requireComment: boolean;
35
36
  roundFeeToSat?: boolean;
36
37
  signingKey?: string;
37
38
  previousSigningPubkeys?: string[];
package/dist/config.js CHANGED
@@ -84,6 +84,7 @@ const contactFromEnv = (env) => {
84
84
  // that starts with a half-understood configuration is holding other
85
85
  // people's money on a misunderstanding.
86
86
  export const configFromEnv = (env = process.env) => {
87
+ const requireComment = flag(env.MONEYER_REQUIRE_COMMENT, false);
87
88
  const baseFeeMsat = int(env.MONEYER_BASE_FEE_MSAT, 0);
88
89
  const feePpm = int(env.MONEYER_FEE_PPM, 0);
89
90
  if (feePpm >= 1_000_000) {
@@ -211,6 +212,7 @@ export const configFromEnv = (env = process.env) => {
211
212
  maxSendableMsat,
212
213
  minMintMsat: int(env.MONEYER_MIN_MINT_MSAT, DEFAULTS.minMintMsat),
213
214
  mintFee: baseFeeMsat === 0 && feePpm === 0 ? null : { baseFeeMsat, feePpm },
215
+ requireComment,
214
216
  roundFeeToSat: flag(env.MONEYER_ROUND_FEE_TO_SAT, DEFAULTS.roundFeeToSat),
215
217
  ...(signingKey ? { signingKey: signingKey.toLowerCase() } : {}),
216
218
  ...(previousSigningPubkeys.length ? { previousSigningPubkeys } : {}),
package/dist/server.js CHANGED
@@ -551,6 +551,12 @@ export const createMoneyer = async (config, deps = {}) => {
551
551
  // lightning address never reads that document, and this has to be
552
552
  // known BEFORE paying, not after.
553
553
  mintToHash: true,
554
+ // The same capability in the spelling LUD-25 specifies: the output
555
+ // hash rides in a LUD-12 comment, so 64 characters is exactly what
556
+ // a hex-encoded 32-byte hash needs. Advertised alongside
557
+ // `mintToHash` rather than instead of it, so wallets on either
558
+ // spelling can name a note.
559
+ commentAllowed: 64,
554
560
  disposable: false
555
561
  });
556
562
  }
@@ -596,13 +602,43 @@ export const createMoneyer = async (config, deps = {}) => {
596
602
  // collision gets the same reason a colliding output gets on the
597
603
  // withdraw callback: which table an id already sits in is an oracle
598
604
  // nobody is owed.
599
- const askedOutputId = q.get('h');
600
- const outputId = askedOutputId === null ? null : askedOutputId.toLowerCase();
601
- if (outputId !== null) {
602
- if (!HEX32.test(outputId))
603
- return fail('missing h');
604
- if (store.outputIdInUse(outputId))
605
- return fail('Invalid or already spent k1.');
605
+ // Two spellings of one thing. LUD-25 puts the output hash in a LUD-12
606
+ // `comment`; `h` is this mint's own earlier name for it, kept so the
607
+ // wallets that adopted it keep working.
608
+ //
609
+ // They are NOT validated the same way, and that asymmetry is the
610
+ // spec's. A malformed `comment` MUST fall back to keying the note by
611
+ // the preimage, exactly as no comment at all does - a comment is a
612
+ // free-text field in LUD-12 and a mint cannot treat every stray one
613
+ // as a failed mint. A malformed `h` is a wallet that meant to name an
614
+ // output and got it wrong, so it still fails loudly rather than
615
+ // quietly minting a note the wallet is not expecting.
616
+ const askedComment = q.get('comment')?.trim().toLowerCase() ?? null;
617
+ const commentOutputId = askedComment !== null && HEX32.test(askedComment) ? askedComment : null;
618
+ const askedOutputId = q.get('h')?.trim().toLowerCase() ?? null;
619
+ if (askedOutputId !== null && !HEX32.test(askedOutputId))
620
+ return fail('missing h');
621
+ // A wallet sending both should send the same hash in both; ours does.
622
+ // Disagreement is a bug in the caller, and picking a winner would
623
+ // mint a note under a hash one half of it is not watching for.
624
+ if (commentOutputId !== null &&
625
+ askedOutputId !== null &&
626
+ commentOutputId !== askedOutputId) {
627
+ return fail('comment and h name different outputs');
628
+ }
629
+ const outputId = commentOutputId ?? askedOutputId;
630
+ // MONEYER_REQUIRE_COMMENT: refuse a quote that names no output at all,
631
+ // rather than minting a note keyed by the payment preimage. Off by
632
+ // default - LUD-25 line 80 still asks for that fallback - but the
633
+ // fallback note is only as safe as every routing hop's discretion, and
634
+ // a funding source that settles without a preimage has nothing to key
635
+ // one by. Refused here, before any invoice exists, so a wallet never
636
+ // pays for a quote this mint was always going to reject.
637
+ if (config.requireComment && outputId === null) {
638
+ return fail('a mint quote must name its output with a LUD-12 comment carrying hex(sha256(secret))');
639
+ }
640
+ if (outputId !== null && store.outputIdInUse(outputId)) {
641
+ return fail('Invalid or already spent k1.');
606
642
  }
607
643
  // The preimage is the future note's spend secret unless `h` named
608
644
  // one; its hash is the invoice's payment hash either way. Generated
@@ -660,7 +696,15 @@ export const createMoneyer = async (config, deps = {}) => {
660
696
  ...(outputId !== null && config.verify && signer
661
697
  ? { mint: { h: outputId, amount: net } }
662
698
  : {}),
663
- ...(config.verify ? { verify: `${origin}/verify/${paymentHash}` } : {})
699
+ // LUD-25: a SERVICE MUST NOT offer verify on a mint payment that
700
+ // named no output. There the note's k1 IS the preimage, and verify
701
+ // hands it to whoever holds the URL - which anyone who has seen the
702
+ // invoice can build from its payment hash. A wallet on this path
703
+ // learns the preimage from paying the invoice, the way any Lightning
704
+ // wallet already keeps it.
705
+ ...(config.verify && outputId !== null
706
+ ? { verify: `${origin}/verify/${paymentHash}` }
707
+ : {})
664
708
  });
665
709
  }
666
710
  // ---- LUD-21 verify: mint invoices and melt payments ----
@@ -671,6 +715,14 @@ export const createMoneyer = async (config, deps = {}) => {
671
715
  const paymentHash = verifyMatch[1].toLowerCase();
672
716
  const invoice = store.mintInvoiceByHash(paymentHash);
673
717
  if (invoice) {
718
+ // The other half of the rule above, and the half that matters: not
719
+ // advertising the URL does not stop anyone building it. Refused for
720
+ // an unnamed invoice quoted since this mint adopted the rule, and
721
+ // still honoured for one quoted before it, whose payer has no other
722
+ // way to reach a note they already own.
723
+ if (invoice.outputId === null && invoice.createdAt >= store.unnamedVerifyCutover()) {
724
+ return fail('Not found.', 404);
725
+ }
674
726
  if (!invoice.settled && (await backend.isInvoiceSettled(paymentHash))) {
675
727
  store.settleMintInvoice(paymentHash);
676
728
  }
package/dist/store.d.ts CHANGED
@@ -11,6 +11,7 @@ export type MintInvoiceRow = {
11
11
  netMsat: number;
12
12
  settled: boolean;
13
13
  outputId: string | null;
14
+ createdAt: number;
14
15
  };
15
16
  export type MeltRow = {
16
17
  paymentHash: string;
@@ -83,6 +84,7 @@ export declare class NoteStore {
83
84
  constructor(path: string, options?: {
84
85
  readOnly?: boolean;
85
86
  });
87
+ unnamedVerifyCutover(): number;
86
88
  private tx;
87
89
  noteById(id: string): NoteRow | null;
88
90
  private insertNote;
package/dist/store.js CHANGED
@@ -117,6 +117,29 @@ export class NoteStore {
117
117
  // Two invoices may not name the same note. SQLite counts NULLs as
118
118
  // distinct, so every unbound invoice still fits.
119
119
  this.db.exec('CREATE UNIQUE INDEX IF NOT EXISTS mint_invoices_output_id ON mint_invoices (output_id)');
120
+ this.db.exec('CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);');
121
+ // The moment this mint stopped publishing preimages for notes nobody
122
+ // named. Written once, the first time a build carrying this code opens
123
+ // the database, and never moved after.
124
+ //
125
+ // It has to be persisted rather than taken from process start, or a
126
+ // restart would walk the line forward and strand a quote made minutes
127
+ // earlier under the same build. Invoices older than it keep their
128
+ // verify: a wallet polling one of those did not pay the invoice itself
129
+ // - that is why it is polling - so the preimage this mint holds is its
130
+ // only route to a note it already owns. Refusing those retroactively
131
+ // would not close a hole, it would burn somebody's money. They drain.
132
+ this.db
133
+ .prepare("INSERT OR IGNORE INTO meta (key, value) VALUES ('unnamed_verify_cutover', ?)")
134
+ .run(String(Date.now()));
135
+ }
136
+ // Invoices quoted at or after this instant get no verify if they named no
137
+ // output. See the migration above for why it is not simply "now".
138
+ unnamedVerifyCutover() {
139
+ const row = this.db
140
+ .prepare("SELECT value FROM meta WHERE key = 'unnamed_verify_cutover'")
141
+ .get();
142
+ return row ? Number(row.value) : 0;
120
143
  }
121
144
  tx(fn) {
122
145
  this.db.exec('BEGIN IMMEDIATE');
@@ -294,7 +317,7 @@ export class NoteStore {
294
317
  }
295
318
  mintInvoiceByHash(paymentHash) {
296
319
  const row = this.db
297
- .prepare('SELECT payment_hash, pr, gross_msat, net_msat, settled, output_id FROM mint_invoices WHERE payment_hash = ?')
320
+ .prepare('SELECT payment_hash, pr, gross_msat, net_msat, settled, output_id, created_at FROM mint_invoices WHERE payment_hash = ?')
298
321
  .get(paymentHash);
299
322
  if (!row)
300
323
  return null;
@@ -304,7 +327,8 @@ export class NoteStore {
304
327
  grossMsat: row.gross_msat,
305
328
  netMsat: row.net_msat,
306
329
  settled: row.settled === 1,
307
- outputId: row.output_id
330
+ outputId: row.output_id,
331
+ createdAt: row.created_at
308
332
  };
309
333
  }
310
334
  // The invoice a payer bound to this note id, if any. The lookup a claim
@@ -320,7 +344,7 @@ export class NoteStore {
320
344
  // expired invoices - so it is dead weight, and every /p/cb call adds one.
321
345
  unsettledMintInvoices() {
322
346
  const rows = this.db
323
- .prepare('SELECT payment_hash, pr, gross_msat, net_msat, settled, output_id FROM mint_invoices WHERE settled = 0')
347
+ .prepare('SELECT payment_hash, pr, gross_msat, net_msat, settled, output_id, created_at FROM mint_invoices WHERE settled = 0')
324
348
  .all();
325
349
  return rows.map(row => ({
326
350
  paymentHash: row.payment_hash,
@@ -328,7 +352,8 @@ export class NoteStore {
328
352
  grossMsat: row.gross_msat,
329
353
  netMsat: row.net_msat,
330
354
  settled: false,
331
- outputId: row.output_id
355
+ outputId: row.output_id,
356
+ createdAt: row.created_at
332
357
  }));
333
358
  }
334
359
  // Conditional on STILL unsettled: a settle landing between the sweep's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgesworn/moneyer",
3
- "version": "0.6.1",
3
+ "version": "0.8.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",