@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 +74 -0
- package/dist/backends/lnd.js +20 -5
- package/dist/server.js +50 -8
- package/dist/store.d.ts +2 -0
- package/dist/store.js +29 -4
- package/package.json +1 -1
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
|
package/dist/backends/lnd.js
CHANGED
|
@@ -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
|
-
//
|
|
187
|
-
//
|
|
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
|
|
190
|
-
if (
|
|
191
|
-
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|