@forgesworn/moneyer 0.6.0 → 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,95 @@
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
+
77
+ ## [0.6.1] - 2026-08-24
78
+
79
+ - The bundled web wallet now accepts a bound mint quote anywhere inside
80
+ the mint's advertised fee band, then uses the committed amount for the
81
+ signed receipt and note. A mint that rounds its fee up to a whole sat -
82
+ including moneyer's production default - no longer makes the page
83
+ silently abandon the sealed-signer receipt path for a legacy invoice.
84
+ Browser coverage uses the production `5000 msat + 1000 ppm`, sat-rounded
85
+ policy and proves the staged quote survives through settlement.
86
+ - A resumable real-node bound-mint release check persists its bearer secret
87
+ with mode `0600` before requesting a quote, treats payer command output as
88
+ opaque, validates the settlement preimage and signed receipt, then melts
89
+ the whole test note to a fresh amountless refund invoice. Interrupted runs
90
+ resume from the same state file; successful runs scrub the secret and leave
91
+ no test-note liability behind.
92
+
3
93
  ## [0.6.0] - 2026-08-24
4
94
 
5
95
  - **Bound mint settlement receipts.** A pay callback asked to mint at a
@@ -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.
@@ -0,0 +1,48 @@
1
+ import { type InvoiceResult } from 'lnurlcash-kit';
2
+ export type LiveCheckStage = 'prepared' | 'quoted' | 'settled' | 'claimed' | 'retiring' | 'retired';
3
+ export type LiveCheckState = {
4
+ version: 1;
5
+ stage: LiveCheckStage;
6
+ payUrl: string;
7
+ grossMsat: number;
8
+ h: string;
9
+ secret?: string;
10
+ netMsat?: number;
11
+ mintPubkey?: string;
12
+ payCallback?: string;
13
+ withdrawLink?: string;
14
+ quote?: InvoiceResult;
15
+ noteCallback?: string;
16
+ refundPr?: string;
17
+ refundPaymentHash?: string;
18
+ meltVerify?: string;
19
+ paymentPreimageValidated?: true;
20
+ receiptSignatureValidated?: true;
21
+ refundSettled?: true;
22
+ completedAt?: string;
23
+ };
24
+ export type LiveBoundMintCheckOptions = {
25
+ payUrl: string;
26
+ grossMsat: number;
27
+ statePath: string;
28
+ payInvoice: (pr: string) => Promise<void>;
29
+ createRefundInvoice: () => Promise<string>;
30
+ timeoutMs?: number;
31
+ pollMs?: number;
32
+ log?: (message: string) => void;
33
+ };
34
+ export type LiveBoundMintCheckResult = {
35
+ stage: 'retired';
36
+ payUrl: string;
37
+ grossMsat: number;
38
+ netMsat: number;
39
+ h: string;
40
+ mintPubkey: string;
41
+ paymentPreimageValidated: true;
42
+ receiptSignatureValidated: true;
43
+ refundSettled: true;
44
+ completedAt: string;
45
+ };
46
+ export declare const readLiveCheckState: (path: string) => Promise<LiveCheckState>;
47
+ export declare const extractBolt11: (output: string) => string | null;
48
+ export declare const runLiveBoundMintCheck: (options: LiveBoundMintCheckOptions) => Promise<LiveBoundMintCheckResult>;
@@ -0,0 +1,378 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { lstat, open, readFile, rename, unlink } from 'node:fs/promises';
3
+ import { decodeBolt11 } from 'farrier-kit/bolt11';
4
+ import { NoteSpentError, PendingNoteError, buildNoteUrl, claimMintedNote, decodeBolt11AmountMsat, fetchInvoiceVerification, fetchPayRequest, hashK1, isBolt11Invoice, isPreimage, meltNote, probeBurnedNote, requestInvoice, requireBoundMintQuote, validateBoundMintReceipt, withinMintFeeBand } from 'lnurlcash-kit';
5
+ const stages = new Set(['prepared', 'quoted', 'settled', 'claimed', 'retiring', 'retired']);
6
+ const errno = (error) => error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' ? error.code : undefined;
7
+ const serialise = (state) => `${JSON.stringify(state, null, 2)}\n`;
8
+ const writeNewState = async (path, state) => {
9
+ const handle = await open(path, 'wx', 0o600);
10
+ try {
11
+ await handle.writeFile(serialise(state), 'utf8');
12
+ await handle.sync();
13
+ }
14
+ finally {
15
+ await handle.close();
16
+ }
17
+ };
18
+ const assertSecureStateFile = async (path) => {
19
+ const stat = await lstat(path);
20
+ if (!stat.isFile())
21
+ throw new Error(`Live-check state is not a regular file: ${path}`);
22
+ if (typeof process.getuid === 'function') {
23
+ if (stat.uid !== process.getuid())
24
+ throw new Error(`Live-check state is not owned by this user: ${path}`);
25
+ if ((stat.mode & 0o077) !== 0)
26
+ throw new Error(`Live-check state must have mode 0600: ${path}`);
27
+ }
28
+ };
29
+ const replaceState = async (path, state) => {
30
+ await assertSecureStateFile(path);
31
+ const temporary = `${path}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
32
+ try {
33
+ await writeNewState(temporary, state);
34
+ await rename(temporary, path);
35
+ }
36
+ catch (error) {
37
+ await unlink(temporary).catch(() => { });
38
+ throw error;
39
+ }
40
+ };
41
+ const validateState = (value) => {
42
+ if (!value || typeof value !== 'object')
43
+ throw new Error('Live-check state is not an object.');
44
+ const state = value;
45
+ if (state.version !== 1 || !state.stage || !stages.has(state.stage))
46
+ throw new Error('Unsupported live-check state.');
47
+ if (typeof state.payUrl !== 'string' ||
48
+ typeof state.grossMsat !== 'number' ||
49
+ !Number.isSafeInteger(state.grossMsat) ||
50
+ state.grossMsat <= 0 ||
51
+ typeof state.h !== 'string' ||
52
+ !/^[0-9a-f]{64}$/.test(state.h)) {
53
+ throw new Error('Live-check state is incomplete.');
54
+ }
55
+ if (state.stage !== 'retired') {
56
+ if (typeof state.secret !== 'string' || !isPreimage(state.secret) || hashK1(state.secret) !== state.h) {
57
+ throw new Error('Live-check state does not contain the secret committed by h.');
58
+ }
59
+ }
60
+ return state;
61
+ };
62
+ export const readLiveCheckState = async (path) => {
63
+ await assertSecureStateFile(path);
64
+ return validateState(JSON.parse(await readFile(path, 'utf8')));
65
+ };
66
+ const loadOrCreateState = async (options) => {
67
+ let state;
68
+ try {
69
+ state = await readLiveCheckState(options.statePath);
70
+ }
71
+ catch (error) {
72
+ if (errno(error) !== 'ENOENT')
73
+ throw error;
74
+ const secret = randomBytes(32).toString('hex');
75
+ state = {
76
+ version: 1,
77
+ stage: 'prepared',
78
+ payUrl: options.payUrl,
79
+ grossMsat: options.grossMsat,
80
+ secret,
81
+ h: hashK1(secret)
82
+ };
83
+ try {
84
+ // This fsync completes before a quote exists. A crash from here on
85
+ // can lose an index or an unpaid invoice, never the bearer secret.
86
+ await writeNewState(options.statePath, state);
87
+ }
88
+ catch (writeError) {
89
+ if (errno(writeError) !== 'EEXIST')
90
+ throw writeError;
91
+ state = await readLiveCheckState(options.statePath);
92
+ }
93
+ }
94
+ if (state.payUrl !== options.payUrl || state.grossMsat !== options.grossMsat) {
95
+ throw new Error('Existing live-check state belongs to a different mint or amount.');
96
+ }
97
+ return state;
98
+ };
99
+ const stringsIn = (value) => {
100
+ if (typeof value === 'string')
101
+ return [value];
102
+ if (Array.isArray(value))
103
+ return value.flatMap(stringsIn);
104
+ if (value && typeof value === 'object')
105
+ return Object.values(value).flatMap(stringsIn);
106
+ return [];
107
+ };
108
+ // `lncli` has emitted JSON in some versions and a display table in others.
109
+ // The release check only needs an invoice from the refund command; it does
110
+ // not treat either presentation as an API contract.
111
+ export const extractBolt11 = (output) => {
112
+ const trimmed = output.trim();
113
+ if (isBolt11Invoice(trimmed))
114
+ return trimmed;
115
+ try {
116
+ for (const candidate of stringsIn(JSON.parse(trimmed))) {
117
+ if (isBolt11Invoice(candidate))
118
+ return candidate.trim();
119
+ }
120
+ }
121
+ catch {
122
+ // Human-readable output is handled below.
123
+ }
124
+ for (const match of output.matchAll(/ln(?:bc|tb|bcrt|tbs|sb)[0-9]*[munp]?1[a-z0-9]+/gi)) {
125
+ if (isBolt11Invoice(match[0]))
126
+ return match[0].trim();
127
+ }
128
+ return null;
129
+ };
130
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
131
+ const waitForSettled = async (verifyUrl, timeoutMs, pollMs) => {
132
+ const deadline = Date.now() + timeoutMs;
133
+ let lastError;
134
+ while (Date.now() <= deadline) {
135
+ try {
136
+ const verification = await fetchInvoiceVerification(verifyUrl);
137
+ if (verification.settled)
138
+ return verification;
139
+ }
140
+ catch (error) {
141
+ lastError = error;
142
+ }
143
+ await sleep(pollMs);
144
+ }
145
+ const detail = lastError instanceof Error ? ` Last response: ${lastError.message}` : '';
146
+ throw new Error(`Timed out waiting for settlement.${detail}`);
147
+ };
148
+ const existingVerification = async (verifyUrl) => {
149
+ try {
150
+ return await fetchInvoiceVerification(verifyUrl);
151
+ }
152
+ catch {
153
+ return null;
154
+ }
155
+ };
156
+ const requireQuotedState = (state) => {
157
+ if (!state.secret ||
158
+ state.netMsat === undefined ||
159
+ !state.mintPubkey ||
160
+ !state.payCallback ||
161
+ !state.withdrawLink ||
162
+ !state.quote?.verify) {
163
+ throw new Error('Quoted live-check state is incomplete.');
164
+ }
165
+ const commitment = requireBoundMintQuote(state.quote, state.h, state.netMsat);
166
+ if (commitment.signature !== undefined)
167
+ throw new Error('The pre-payment commitment unexpectedly carries a signature.');
168
+ return {
169
+ secret: state.secret,
170
+ netMsat: state.netMsat,
171
+ mintPubkey: state.mintPubkey,
172
+ payCallback: state.payCallback,
173
+ withdrawLink: state.withdrawLink,
174
+ quote: state.quote
175
+ };
176
+ };
177
+ const retiredResult = (state) => {
178
+ if (state.stage !== 'retired' ||
179
+ state.netMsat === undefined ||
180
+ !state.mintPubkey ||
181
+ !state.completedAt ||
182
+ state.paymentPreimageValidated !== true ||
183
+ state.receiptSignatureValidated !== true ||
184
+ state.refundSettled !== true) {
185
+ throw new Error('Retired live-check state is incomplete.');
186
+ }
187
+ return {
188
+ stage: 'retired',
189
+ payUrl: state.payUrl,
190
+ grossMsat: state.grossMsat,
191
+ netMsat: state.netMsat,
192
+ h: state.h,
193
+ mintPubkey: state.mintPubkey,
194
+ paymentPreimageValidated: true,
195
+ receiptSignatureValidated: true,
196
+ refundSettled: true,
197
+ completedAt: state.completedAt
198
+ };
199
+ };
200
+ export const runLiveBoundMintCheck = async (options) => {
201
+ if (!Number.isSafeInteger(options.grossMsat) || options.grossMsat <= 0)
202
+ throw new Error('grossMsat must be a positive integer.');
203
+ new URL(options.payUrl);
204
+ const timeoutMs = options.timeoutMs ?? 60_000;
205
+ const pollMs = options.pollMs ?? 500;
206
+ const log = options.log ?? (() => { });
207
+ let state = await loadOrCreateState(options);
208
+ if (state.stage === 'retired')
209
+ return retiredResult(state);
210
+ if (state.stage === 'prepared') {
211
+ const pay = await fetchPayRequest(state.payUrl);
212
+ if (!pay.mintToHash || !pay.mintPubkey || !pay.withdrawLink) {
213
+ throw new Error('Mint does not advertise the bound-mint receipt capabilities required by this check.');
214
+ }
215
+ if (state.grossMsat < pay.minSendable || state.grossMsat > pay.maxSendable) {
216
+ throw new Error(`Test amount is outside the mint range ${pay.minSendable}-${pay.maxSendable} msat.`);
217
+ }
218
+ const quote = await requestInvoice(pay.callback, state.grossMsat, { h: state.h });
219
+ if (!quote.verify || !quote.mint)
220
+ throw new Error('Mint did not bind this quote to h and a verification URL.');
221
+ const netMsat = quote.mint.amountMsat;
222
+ if (!Number.isSafeInteger(netMsat) || netMsat <= 0)
223
+ throw new Error('Mint committed an invalid net note amount.');
224
+ const feeAccepted = pay.mintFee
225
+ ? withinMintFeeBand(state.grossMsat, netMsat, pay.mintFee)
226
+ : netMsat === state.grossMsat;
227
+ if (!feeAccepted)
228
+ throw new Error('Mint committed a net amount outside its advertised fee band.');
229
+ const commitment = requireBoundMintQuote(quote, state.h, netMsat);
230
+ if (commitment.signature !== undefined)
231
+ throw new Error('The pre-payment commitment unexpectedly carries a signature.');
232
+ state = {
233
+ ...state,
234
+ stage: 'quoted',
235
+ netMsat,
236
+ mintPubkey: pay.mintPubkey,
237
+ payCallback: pay.callback,
238
+ withdrawLink: pay.withdrawLink,
239
+ quote
240
+ };
241
+ await replaceState(options.statePath, state);
242
+ log(`quote committed ${netMsat} msat at the staged note hash`);
243
+ }
244
+ const quoted = requireQuotedState(state);
245
+ if (state.stage === 'quoted') {
246
+ let verification = await fetchInvoiceVerification(quoted.quote.verify);
247
+ if (!verification.settled) {
248
+ let payerError;
249
+ try {
250
+ // Stdout is deliberately outside this interface. Exit status says
251
+ // whether the command believes it paid; /verify supplies the proof.
252
+ await options.payInvoice(quoted.quote.pr);
253
+ }
254
+ catch (error) {
255
+ payerError = error;
256
+ }
257
+ try {
258
+ verification = await waitForSettled(quoted.quote.verify, timeoutMs, pollMs);
259
+ }
260
+ catch (error) {
261
+ if (payerError instanceof Error) {
262
+ throw new Error(`${error instanceof Error ? error.message : String(error)} Payer command: ${payerError.message}`);
263
+ }
264
+ throw error;
265
+ }
266
+ }
267
+ const receipt = validateBoundMintReceipt(quoted.quote, verification, state.h, quoted.netMsat, quoted.mintPubkey);
268
+ const paymentHash = decodeBolt11(quoted.quote.pr).paymentHashHex;
269
+ if (!verification.preimage || hashK1(verification.preimage) !== paymentHash) {
270
+ throw new Error('The settlement preimage does not prove the quoted invoice.');
271
+ }
272
+ if (!receipt.signature)
273
+ throw new Error('The settled receipt has no signature.');
274
+ state = { ...state, stage: 'settled' };
275
+ await replaceState(options.statePath, state);
276
+ log('settlement preimage and bound receipt signature validated');
277
+ }
278
+ if (state.stage === 'settled') {
279
+ const claim = await claimMintedNote(quoted.withdrawLink, quoted.secret);
280
+ if (claim.state !== 'minted' || claim.amountMsat !== quoted.netMsat || !claim.callback) {
281
+ throw new Error('The staged secret did not claim the committed note.');
282
+ }
283
+ state = { ...state, stage: 'claimed', noteCallback: claim.callback };
284
+ await replaceState(options.statePath, state);
285
+ log('the staged secret claimed the committed note');
286
+ }
287
+ if (state.stage === 'claimed') {
288
+ if (!state.noteCallback)
289
+ throw new Error('Claimed live-check state has no note callback.');
290
+ const refundOutput = await options.createRefundInvoice();
291
+ const refundPr = extractBolt11(refundOutput);
292
+ if (!refundPr)
293
+ throw new Error('Refund command did not emit a BOLT11 invoice.');
294
+ if (decodeBolt11AmountMsat(refundPr) !== null) {
295
+ throw new Error('Refund invoice must be amountless so the mint retires the entire test note.');
296
+ }
297
+ const refundPaymentHash = decodeBolt11(refundPr).paymentHashHex;
298
+ const meltVerify = new URL(`/verify/${refundPaymentHash}`, quoted.withdrawLink).toString();
299
+ // Persist the exact refund invoice before asking the mint to pay it.
300
+ // A crash after the callback can therefore resume without inventing a
301
+ // second payment target or losing the note secret.
302
+ state = { ...state, stage: 'retiring', refundPr, refundPaymentHash, meltVerify };
303
+ await replaceState(options.statePath, state);
304
+ }
305
+ if (state.stage === 'retiring') {
306
+ if (!state.noteCallback || !state.refundPr || !state.meltVerify) {
307
+ throw new Error('Retiring live-check state is incomplete.');
308
+ }
309
+ let verification = await existingVerification(state.meltVerify);
310
+ if (verification === null) {
311
+ try {
312
+ const result = await meltNote(state.noteCallback, quoted.secret, state.refundPr);
313
+ if (result.verify && result.verify !== state.meltVerify) {
314
+ throw new Error('Mint returned a different verification URL for the refund melt.');
315
+ }
316
+ }
317
+ catch (error) {
318
+ if (!(error instanceof PendingNoteError) && !(error instanceof NoteSpentError))
319
+ throw error;
320
+ }
321
+ verification = await existingVerification(state.meltVerify);
322
+ }
323
+ if (!verification?.settled) {
324
+ try {
325
+ verification = await waitForSettled(state.meltVerify, timeoutMs, pollMs);
326
+ }
327
+ catch (error) {
328
+ // A cleanly failed melt restores the note. Clear the used refund
329
+ // invoice but retain the secret, so the same command can retry with
330
+ // a fresh amountless invoice rather than stranding value.
331
+ const claim = await claimMintedNote(quoted.withdrawLink, quoted.secret).catch(() => null);
332
+ if (claim?.state === 'minted' && claim.callback) {
333
+ state = {
334
+ version: 1,
335
+ stage: 'claimed',
336
+ payUrl: state.payUrl,
337
+ grossMsat: state.grossMsat,
338
+ h: state.h,
339
+ secret: quoted.secret,
340
+ netMsat: quoted.netMsat,
341
+ mintPubkey: quoted.mintPubkey,
342
+ payCallback: quoted.payCallback,
343
+ withdrawLink: quoted.withdrawLink,
344
+ quote: quoted.quote,
345
+ noteCallback: claim.callback
346
+ };
347
+ await replaceState(options.statePath, state);
348
+ throw new Error(`Refund melt failed cleanly and the note was restored; rerun to use a fresh invoice. ${error instanceof Error ? error.message : ''}`);
349
+ }
350
+ throw error;
351
+ }
352
+ }
353
+ const noteUrl = buildNoteUrl(quoted.withdrawLink, quoted.secret, quoted.netMsat);
354
+ const deadline = Date.now() + timeoutMs;
355
+ while ((await probeBurnedNote(noteUrl)) !== 'gone') {
356
+ if (Date.now() > deadline)
357
+ throw new Error('Refund settled but the test note is not yet recorded as burned.');
358
+ await sleep(pollMs);
359
+ }
360
+ const completedAt = new Date().toISOString();
361
+ state = {
362
+ version: 1,
363
+ stage: 'retired',
364
+ payUrl: state.payUrl,
365
+ grossMsat: state.grossMsat,
366
+ netMsat: quoted.netMsat,
367
+ h: state.h,
368
+ mintPubkey: quoted.mintPubkey,
369
+ paymentPreimageValidated: true,
370
+ receiptSignatureValidated: true,
371
+ refundSettled: true,
372
+ completedAt
373
+ };
374
+ await replaceState(options.statePath, state);
375
+ log('refund settled and the test note was burned; bearer secret scrubbed from state');
376
+ }
377
+ return retiredResult(state);
378
+ };
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.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",
@@ -32,6 +32,7 @@
32
32
  "files": [
33
33
  "dist",
34
34
  "web/dist",
35
+ "scripts/live-bound-mint-check.mjs",
35
36
  "LICENSE",
36
37
  "README.md",
37
38
  "CHANGELOG.md",
@@ -51,7 +52,8 @@
51
52
  "web:dev": "vite web",
52
53
  "web:build": "vite build web",
53
54
  "web:preview": "vite preview web",
54
- "typecheck:web": "tsc -p web"
55
+ "typecheck:web": "tsc -p web",
56
+ "live:bound-mint": "npm run build && node scripts/live-bound-mint-check.mjs"
55
57
  },
56
58
  "dependencies": {
57
59
  "@noble/curves": "^2.3.0",
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ // A resumable, real-sats bound-mint release check.
3
+ //
4
+ // The payer command receives the mint invoice as its final argument. Its
5
+ // stdout is intentionally ignored: lnd has emitted JSON and tables across
6
+ // versions, while Moneyer's /verify response is the settlement proof that
7
+ // matters. The refund command receives no added arguments and must print an
8
+ // amountless BOLT11 invoice; Moneyer melts the whole test note back to it.
9
+ import {spawnSync} from 'node:child_process'
10
+ import {resolve} from 'node:path'
11
+ import {runLiveBoundMintCheck} from '../dist/live-check.js'
12
+
13
+ const usage = `usage:
14
+ npm run live:bound-mint -- \\
15
+ --pay-url https://mint.example/.well-known/lnurlp/mint \\
16
+ --amount-sat 56 \\
17
+ --state /secure/path/moneyer-live-check.json \\
18
+ --payer <command...> \\
19
+ --refund <command...>
20
+
21
+ The payer command gets the BOLT11 invoice as its final argument. The refund
22
+ command must emit a fresh amountless BOLT11 invoice on stdout. State is mode
23
+ 0600 and resumable; rerun the exact command after any interruption.`
24
+
25
+ const failUsage = message => {
26
+ if (message) console.error(message)
27
+ console.error(usage)
28
+ process.exit(2)
29
+ }
30
+
31
+ const args = process.argv.slice(2)
32
+ if (args.includes('--help') || args.includes('-h')) {
33
+ console.log(usage)
34
+ process.exit(0)
35
+ }
36
+
37
+ const payerAt = args.indexOf('--payer')
38
+ const refundAt = args.indexOf('--refund')
39
+ if (payerAt < 0 || refundAt < 0 || refundAt <= payerAt) failUsage('Both --payer and --refund commands are required.')
40
+
41
+ const optionArgs = args.slice(0, payerAt)
42
+ const payerArgv = args.slice(payerAt + 1, refundAt)
43
+ const refundArgv = args.slice(refundAt + 1)
44
+ if (payerArgv.length === 0 || refundArgv.length === 0) failUsage('Command markers may not be empty.')
45
+
46
+ const values = new Map()
47
+ for (let index = 0; index < optionArgs.length; index += 2) {
48
+ const name = optionArgs[index]
49
+ const value = optionArgs[index + 1]
50
+ if (!name?.startsWith('--') || value === undefined) failUsage(`Invalid option near ${name ?? '(end)'}.`)
51
+ if (!['--pay-url', '--amount-sat', '--state', '--timeout-seconds'].includes(name)) failUsage(`Unknown option ${name}.`)
52
+ values.set(name, value)
53
+ }
54
+
55
+ const payUrl = values.get('--pay-url')
56
+ const amountSat = Number(values.get('--amount-sat'))
57
+ const stateValue = values.get('--state')
58
+ const timeoutSeconds = values.has('--timeout-seconds') ? Number(values.get('--timeout-seconds')) : 60
59
+ if (!payUrl || !stateValue) failUsage('--pay-url, --amount-sat and --state are required.')
60
+ if (!Number.isSafeInteger(amountSat) || amountSat <= 0) failUsage('--amount-sat must be a positive whole number.')
61
+ if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) failUsage('--timeout-seconds must be positive.')
62
+
63
+ const runCommand = (argv, appended = []) => {
64
+ const [program, ...commandArgs] = argv
65
+ const result = spawnSync(program, [...commandArgs, ...appended], {
66
+ encoding: 'utf8',
67
+ maxBuffer: 4 * 1024 * 1024,
68
+ timeout: timeoutSeconds * 1000
69
+ })
70
+ if (result.error) throw result.error
71
+ if (result.status !== 0) {
72
+ const detail = result.stderr.trim().slice(0, 500)
73
+ throw new Error(`command exited ${result.status}${detail ? `: ${detail}` : ''}`)
74
+ }
75
+ return result.stdout
76
+ }
77
+
78
+ const statePath = resolve(stateValue)
79
+ try {
80
+ const result = await runLiveBoundMintCheck({
81
+ payUrl,
82
+ grossMsat: amountSat * 1000,
83
+ statePath,
84
+ timeoutMs: timeoutSeconds * 1000,
85
+ payInvoice: async pr => {
86
+ // Deliberately do not parse or print this output. Settlement is proved
87
+ // independently by the invoice preimage and signed mint receipt.
88
+ runCommand(payerArgv, [pr])
89
+ },
90
+ createRefundInvoice: async () => runCommand(refundArgv),
91
+ log: message => console.error(`[live-check] ${message}`)
92
+ })
93
+ console.log(JSON.stringify(result, null, 2))
94
+ } catch (error) {
95
+ console.error(`[live-check] ${error instanceof Error ? error.message : String(error)}`)
96
+ console.error(`[live-check] state retained at ${statePath}; rerun the exact command to resume`)
97
+ process.exitCode = 1
98
+ }