@forgesworn/moneyer 0.6.1 → 0.7.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,79 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.0] - 2026-08-26
4
+
5
+ - **The published node capacity is the announced one.** `nodeCapacity` in
6
+ the discovery document was summed from lnd's `/v1/channels`, which is an
7
+ authenticated view of the node and counts private channels. That figure is
8
+ served to every visitor and goes out in the mint's announcement, so a
9
+ channel the operator chose not to announce was being sized in public
10
+ anyway. It now comes from this node's own entry in the public graph -
11
+ `total_capacity`, the same number any stranger on the network already
12
+ reads, converted from sats. A node with nothing announced reports zero
13
+ rather than omitting the field, because zero is the true answer there;
14
+ only a node that cannot be asked leaves it off. cln never reported
15
+ capacity and is unaffected.
16
+
17
+ Operators should expect the number to fall, and to fall to zero on a mint
18
+ running entirely on private channels. It was never the figure it claimed
19
+ to be.
20
+
21
+ - The web wallet prints a note to a PNG you can send. The plate existed only
22
+ as HTML over the artwork, which is no use in a message; the same portrait
23
+ plate composites onto a canvas and comes back as a file - the share sheet
24
+ where the browser offers one, a download everywhere else. The travelling
25
+ plate carries the bech32 LNURL rather than the claim link, because a note
26
+ that leaves in a message is scanned by whatever the recipient already has,
27
+ and that is usually a Lightning wallet.
28
+
29
+ - The bundled web wallet names its note on the plain path too, not only when
30
+ a signed receipt is on offer. It previously fell back to an unnamed mint
31
+ and then required `verify` to read the preimage - which, with the rule
32
+ above, a signer-less mint no longer answers. Naming needs no receipt, and
33
+ the page then claims the secret it chose rather than one the mint
34
+ publishes.
35
+
36
+ - **No LUD-21 `verify` on a mint payment that named no output.** LUD-25
37
+ forbids it, and the reason is concrete: on that path the note's `k1` IS the
38
+ payment preimage, and `verify` publishes the preimage at a URL anyone who
39
+ has seen the invoice can build from its payment hash. The note was only as
40
+ private as the QR it was paid from.
41
+
42
+ Both halves are needed. New unnamed invoices get no `verify` field, and
43
+ `/verify/<hash>` refuses them outright - not advertising a URL does not
44
+ stop anyone constructing it. A payer on this path still learns the preimage
45
+ the way any Lightning wallet does, by paying.
46
+
47
+ **Invoices quoted before this mint adopted the rule keep their `verify`.**
48
+ A wallet polling one of those did not pay the invoice itself - that is why
49
+ it is polling - so the preimage this mint holds is its only route to a note
50
+ it already owns. Refusing them would not close a hole, it would burn
51
+ somebody's money. The cutover is written to a new `meta` table the first
52
+ time a build carrying this code opens the database, and never moves after;
53
+ persisted rather than taken from process start, or a restart would walk the
54
+ line forward and strand a quote made minutes earlier under the same build.
55
+ Old rows drain and the hole closes for everything new.
56
+
57
+ - The mint accepts a LUD-12 `comment` carrying `hex(h)` as the name of the
58
+ note being bought, and advertises `commentAllowed: 64` on the payRequest.
59
+ This is how LUD-25 specifies it; `h` was this mint's own earlier spelling
60
+ and both are now honoured, so wallets on either keep working. A wallet
61
+ sending both must agree with itself - minting under one when the other is
62
+ being watched for would lose the note.
63
+
64
+ The two are deliberately not validated alike. Per LUD-25, a `comment` that
65
+ is not a bare 32-byte hex hash falls back to keying the note by the payment
66
+ preimage, exactly as no comment does, because a comment is free text in
67
+ LUD-12 and failing on every stray one would break ordinary payers. A
68
+ malformed `h` still fails loudly: that is a wallet that meant to name an
69
+ output and got it wrong.
70
+
71
+ This matters beyond conformance. A note minted with no named output has the
72
+ payment preimage as its spend secret, and until this release the mint
73
+ served that preimage on its LUD-21 `verify` URL, which anyone holding the
74
+ invoice can construct. Naming the output is what makes `verify` safe to
75
+ offer, and the entry above now gates it on exactly that.
76
+
3
77
  ## [0.6.1] - 2026-08-24
4
78
 
5
79
  - The bundled web wallet now accepts a bound mint quote anywhere inside
@@ -183,12 +183,27 @@ export const createLndBackend = (config) => {
183
183
  const color = typeof res.json?.color === 'string' ? `#${res.json.color.replace(/^#/, '')}` : undefined;
184
184
  const numChannels = Number(res.json?.num_active_channels);
185
185
  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.
186
+ // Announced capacity, read from this node's own entry in the public
187
+ // graph rather than from its channel list. `/v1/channels` is an
188
+ // authenticated view and counts private channels too; this figure is
189
+ // published in the discovery document, so summing that would tell the
190
+ // world what only the operator can see. The graph self-lookup returns
191
+ // the same `total_capacity` any stranger on the network already
192
+ // reads, in sats, so it is converted here to keep NodeInfo msat.
193
+ //
194
+ // Best-effort, in two flavours: a 404 is a node with nothing
195
+ // announced, which is a public capacity of zero rather than an
196
+ // unknown one, while any other failure leaves the field off - the
197
+ // macaroon may not carry info:read, and the discovery endpoint works
198
+ // fine without it.
188
199
  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);
200
+ const pubkey = res.json?.identity_pubkey;
201
+ if (typeof pubkey === 'string' && pubkey) {
202
+ const node = await json(`/v1/graph/node/${pubkey}`);
203
+ if (node.ok)
204
+ capacityMsat = Number(node.json?.total_capacity ?? 0) * 1000;
205
+ else if (node.status === 404)
206
+ capacityMsat = 0;
192
207
  }
193
208
  // Outbound liquidity, best-effort for the same reason as capacity:
194
209
  // the macaroon may not carry offchain:read.
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,33 @@ 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
+ if (outputId !== null && store.outputIdInUse(outputId)) {
631
+ return fail('Invalid or already spent k1.');
606
632
  }
607
633
  // The preimage is the future note's spend secret unless `h` named
608
634
  // one; its hash is the invoice's payment hash either way. Generated
@@ -660,7 +686,15 @@ export const createMoneyer = async (config, deps = {}) => {
660
686
  ...(outputId !== null && config.verify && signer
661
687
  ? { mint: { h: outputId, amount: net } }
662
688
  : {}),
663
- ...(config.verify ? { verify: `${origin}/verify/${paymentHash}` } : {})
689
+ // LUD-25: a SERVICE MUST NOT offer verify on a mint payment that
690
+ // named no output. There the note's k1 IS the preimage, and verify
691
+ // hands it to whoever holds the URL - which anyone who has seen the
692
+ // invoice can build from its payment hash. A wallet on this path
693
+ // learns the preimage from paying the invoice, the way any Lightning
694
+ // wallet already keeps it.
695
+ ...(config.verify && outputId !== null
696
+ ? { verify: `${origin}/verify/${paymentHash}` }
697
+ : {})
664
698
  });
665
699
  }
666
700
  // ---- LUD-21 verify: mint invoices and melt payments ----
@@ -671,6 +705,14 @@ export const createMoneyer = async (config, deps = {}) => {
671
705
  const paymentHash = verifyMatch[1].toLowerCase();
672
706
  const invoice = store.mintInvoiceByHash(paymentHash);
673
707
  if (invoice) {
708
+ // The other half of the rule above, and the half that matters: not
709
+ // advertising the URL does not stop anyone building it. Refused for
710
+ // an unnamed invoice quoted since this mint adopted the rule, and
711
+ // still honoured for one quoted before it, whose payer has no other
712
+ // way to reach a note they already own.
713
+ if (invoice.outputId === null && invoice.createdAt >= store.unnamedVerifyCutover()) {
714
+ return fail('Not found.', 404);
715
+ }
674
716
  if (!invoice.settled && (await backend.isInvoiceSettled(paymentHash))) {
675
717
  store.settleMintInvoice(paymentHash);
676
718
  }
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.7.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",