@forgesworn/moneyer 0.18.0 → 0.19.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 +33 -0
- package/README.md +9 -5
- package/dist/admin.js +21 -5
- package/dist/backends/fake.js +4 -2
- package/dist/backends/lnd.js +4 -1
- package/dist/live-check.js +4 -2
- package/dist/server.js +28 -13
- package/dist/store.d.ts +2 -0
- package/dist/store.js +16 -4
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.19.0 - 2026-10-07
|
|
4
|
+
|
|
5
|
+
- **A failed melt's invoice can be tried again.** Once the funding source
|
|
6
|
+
confirms a melt never paid, the note is restored as before, and now the
|
|
7
|
+
invoice is free again too: the payee is still waiting on it, so a wallet
|
|
8
|
+
can melt into it a second time, from the same note or a different one,
|
|
9
|
+
instead of asking for a fresh invoice. lnd sends a hash whose payment
|
|
10
|
+
failed again; a backend that refuses it restores the note, as for any
|
|
11
|
+
payment the node already holds. An invoice that paid, or whose melt is
|
|
12
|
+
still unresolved, is refused as before. The retry reuses the melt's row, so
|
|
13
|
+
`moneyer admin melts` and the restored count in the lifetime totals show
|
|
14
|
+
the latest attempt only.
|
|
15
|
+
- lnd's "invoice is already paid" refusal, sent when a shared node paid the
|
|
16
|
+
hash between the mint's pre-check and its send, is now read as a payment
|
|
17
|
+
the node already holds, like its other two refusals. It used to fall
|
|
18
|
+
through as an ambiguous failure, which the payment tracker would then
|
|
19
|
+
have confirmed as paid against somebody else's payment, burning the note.
|
|
20
|
+
|
|
21
|
+
## 0.18.1 - 2026-10-07
|
|
22
|
+
|
|
23
|
+
- **Bound mint receipts verify in current wallets again.** Since
|
|
24
|
+
`@lnurlcash/kit` moved to taproot notes, a wallet checks a settled
|
|
25
|
+
`/verify` receipt's `mint.sig` as a `cs1` over the note's Q. moneyer still
|
|
26
|
+
sent a bare hex signature over the bearer `h`, which the kit drops as
|
|
27
|
+
unreadable, so a sealed signer could not confirm a note minted here. The
|
|
28
|
+
receipt now carries the note's ordinary certificate, and a quote or
|
|
29
|
+
receipt for a note named by its `cp1` repeats that `cp1` rather than the
|
|
30
|
+
hex of its Q, which a wallet would read as a bearer `h`.
|
|
31
|
+
- `@lnurlcash/kit` 0.20.2. `moneyer admin verify-note` checks a certificate
|
|
32
|
+
over the note's Q, or over `sha256(k1)` for a bearer note certified before
|
|
33
|
+
that, and still reads a pre-`cs1` signature from a note URL's `sig`. The
|
|
34
|
+
mint page and the live bound-mint check name a quote by its bare `h`.
|
|
35
|
+
|
|
3
36
|
## 0.18.0 - 2026-10-07
|
|
4
37
|
|
|
5
38
|
- **A melt that cannot leave is answered with its reason.** The callback
|
package/README.md
CHANGED
|
@@ -43,7 +43,9 @@ lesson, that behaviour is kept deliberately and tested.
|
|
|
43
43
|
anything else is answered `OK` and finishes in the background. Burn only
|
|
44
44
|
on confirmed payment, restore only on confirmed non-payment, and park
|
|
45
45
|
everything else as pending for reconciliation - which also runs at
|
|
46
|
-
startup, so a crash mid-melt never guesses.
|
|
46
|
+
startup, so a crash mid-melt never guesses. An invoice whose melt was
|
|
47
|
+
restored may be melted into again, from that note or another; one that
|
|
48
|
+
paid or is still unresolved is refused.
|
|
47
49
|
|
|
48
50
|
## Run
|
|
49
51
|
|
|
@@ -420,10 +422,11 @@ The rules:
|
|
|
420
422
|
true`, so a wallet knows this mint takes the parameter before it asks
|
|
421
423
|
rather than after it pays.
|
|
422
424
|
- When signing and LUD-21 verification are enabled, the quote also carries
|
|
423
|
-
`mint: {h, amount}`. This is the exact output
|
|
424
|
-
|
|
425
|
+
`mint: {h, amount}`. This is the exact output, in the spelling the wallet
|
|
426
|
+
named it (a bearer note's hex `h`, or its `cp1`), and the net millisatoshi
|
|
427
|
+
value the invoice will mint; it never carries `sig` before settlement.
|
|
425
428
|
- Once settled, `/verify` repeats the same `h` and `amount` and adds `sig`,
|
|
426
|
-
the ordinary LUD-25
|
|
429
|
+
the note's ordinary LUD-25 certificate: a `cs1` over its Q. A sealed
|
|
427
430
|
signer can verify that receipt against the pre-payment commitment and
|
|
428
431
|
pinned `mintPubkey`, then confirm its staged note without exporting `k1`.
|
|
429
432
|
- Claiming needs nothing else. `GET /w?k1=<the secret>` brings the note
|
|
@@ -470,7 +473,8 @@ at `h`" alone would let anyone holding a burned `k1` and any outstanding
|
|
|
470
473
|
note id draw a success out of the mint.
|
|
471
474
|
|
|
472
475
|
The melt path is untouched: melts are deduplicated by payment hash, which
|
|
473
|
-
is a different question with a different answer
|
|
476
|
+
is a different question with a different answer (a restored melt's
|
|
477
|
+
invoice excepted - see the melt discipline above).
|
|
474
478
|
|
|
475
479
|
This is moneyer's own behaviour. The LUD-25 draft says nothing about
|
|
476
480
|
retries yet; the suggested wording is on lnurl/luds#301 as a SHOULD.
|
package/dist/admin.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils.js';
|
|
3
3
|
import { secp256k1 } from '@noble/curves/secp256k1.js';
|
|
4
|
-
import { noteDeclaredAmount, noteK1, noteSignature,
|
|
4
|
+
import { encodeCs1WithAmount, hashK1, noteDeclaredAmount, noteK1, noteSignature, verifyNoteSignatureForKey } from '@lnurlcash/kit';
|
|
5
5
|
import { decodeNote, decodeSpend } from "./spend.js";
|
|
6
6
|
import { configFromEnv, pubkeyHex } from "./config.js";
|
|
7
7
|
import { NoteStore } from "./store.js";
|
|
@@ -374,7 +374,20 @@ export const runAdmin = async (argv, deps = {}) => {
|
|
|
374
374
|
}
|
|
375
375
|
const id = bytesToHex(spend.outputKey);
|
|
376
376
|
const declared = noteDeclaredAmount(url);
|
|
377
|
-
|
|
377
|
+
// `c` since the taproot unification; `sig` on a note URL from before.
|
|
378
|
+
let signature = noteSignature(url);
|
|
379
|
+
if (!signature) {
|
|
380
|
+
try {
|
|
381
|
+
signature = new URL(url).searchParams.get('sig');
|
|
382
|
+
}
|
|
383
|
+
catch { }
|
|
384
|
+
}
|
|
385
|
+
// The kit reads only a cs1 now. A certificate from before that was
|
|
386
|
+
// the bare 65-byte signature in hex, its amount on the URL beside
|
|
387
|
+
// it: put the amount back into a cs1 so it can still be checked.
|
|
388
|
+
if (signature && declared !== null && /^[0-9a-f]{130}$/i.test(signature)) {
|
|
389
|
+
signature = encodeCs1WithAmount(declared, hexToBytes(signature));
|
|
390
|
+
}
|
|
378
391
|
const current = signingPubkey(config);
|
|
379
392
|
const keys = [...(current ? [current] : []), ...(config.previousSigningPubkeys ?? [])];
|
|
380
393
|
out(`note id ${id}`);
|
|
@@ -387,9 +400,12 @@ export const runAdmin = async (argv, deps = {}) => {
|
|
|
387
400
|
out('signature present, but the URL declares no amount to check it against');
|
|
388
401
|
}
|
|
389
402
|
else {
|
|
390
|
-
// Over Q, as LUD-25 certifies every note now, or
|
|
391
|
-
//
|
|
392
|
-
|
|
403
|
+
// Over Q, as LUD-25 certifies every note now, or, for a bearer
|
|
404
|
+
// note certified before that, over its old id sha256(k1). A key
|
|
405
|
+
// note's old id was its key, which is Q already.
|
|
406
|
+
const legacyId = spend.kind === 'script' && /^[0-9a-f]{64}$/i.test(k1.trim()) ? hashK1(k1.trim()) : null;
|
|
407
|
+
const signedBy = keys.find(pubkey => verifyNoteSignatureForKey(id, declared, signature, pubkey) ||
|
|
408
|
+
(legacyId !== null && verifyNoteSignatureForKey(legacyId, declared, signature, pubkey)));
|
|
393
409
|
out(signedBy === undefined
|
|
394
410
|
? 'signature DOES NOT verify against this mint'
|
|
395
411
|
: `signature verifies against ${signedBy === current ? 'the current key' : `a previous key (${signedBy})`}`);
|
package/dist/backends/fake.js
CHANGED
|
@@ -65,8 +65,10 @@ export const createFakeBackend = (options = {}) => {
|
|
|
65
65
|
if (!paymentHashHex)
|
|
66
66
|
throw new PaymentFailedError('That is not a decodable invoice.');
|
|
67
67
|
// Real nodes dedupe sends by payment hash: a hash this node already
|
|
68
|
-
//
|
|
69
|
-
|
|
68
|
+
// paid, or is still paying, is refused exactly as lnd and cln refuse
|
|
69
|
+
// it. One whose every attempt failed may be sent again, as on both.
|
|
70
|
+
const earlier = payments.get(paymentHashHex);
|
|
71
|
+
if (earlier && earlier.status !== 'failed') {
|
|
70
72
|
throw new PaymentAlreadyKnownError('this node already has a payment for that hash');
|
|
71
73
|
}
|
|
72
74
|
const preimageHex = knownPreimages.get(paymentHashHex) ?? null;
|
package/dist/backends/lnd.js
CHANGED
|
@@ -122,7 +122,10 @@ export const createLndBackend = (config) => {
|
|
|
122
122
|
// lnd refuses a send whose payment hash it already holds - on a
|
|
123
123
|
// shared node that is somebody else's payment, and nothing went
|
|
124
124
|
// out for THIS call. Distinct from ambiguity: safely restorable.
|
|
125
|
-
|
|
125
|
+
// Its three refusals (lnd 0.20, payments/db/errors.go): one being
|
|
126
|
+
// created, one in flight, one that succeeded. A hash whose payment
|
|
127
|
+
// failed is not refused - lnd sends it again.
|
|
128
|
+
if (error && /already exists|AlreadyExists|payment is in transition|invoice is already paid/i.test(error)) {
|
|
126
129
|
throw new PaymentAlreadyKnownError('lnd already has a payment for this hash');
|
|
127
130
|
}
|
|
128
131
|
// The stream ended without a terminal status - genuinely ambiguous.
|
package/dist/live-check.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { lstat, open, readFile, rename, unlink } from 'node:fs/promises';
|
|
3
3
|
import { decodeBolt11 } from 'farrier-kit/bolt11';
|
|
4
|
-
import { NoteSpentError, PendingNoteError, buildNoteUrl, decodeBolt11AmountMsat, fetchInvoiceVerification, fetchPayRequest, hashK1, isBolt11Invoice, isPreimage, meltNote, probeBurnedNote,
|
|
4
|
+
import { NoteSpentError, PendingNoteError, buildNoteUrl, decodeBolt11AmountMsat, fetchInvoiceVerification, fetchPayRequest, hashK1, isBolt11Invoice, isPreimage, meltNote, probeBurnedNote, requestInvoiceShort, requireBoundMintQuote, validateBoundMintReceipt, withinMintFeeBand } from '@lnurlcash/kit';
|
|
5
5
|
import { claimMintedNote } from "./claim.js";
|
|
6
6
|
const stages = new Set(['prepared', 'quoted', 'settled', 'claimed', 'retiring', 'retired']);
|
|
7
7
|
const errno = (error) => error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' ? error.code : undefined;
|
|
@@ -216,7 +216,9 @@ export const runLiveBoundMintCheck = async (options) => {
|
|
|
216
216
|
if (state.grossMsat < pay.minSendable || state.grossMsat > pay.maxSendable) {
|
|
217
217
|
throw new Error(`Test amount is outside the mint range ${pay.minSendable}-${pay.maxSendable} msat.`);
|
|
218
218
|
}
|
|
219
|
-
|
|
219
|
+
// Named by the bare h, which the kit reads as a bearer note's h and
|
|
220
|
+
// normalises to its Q on both sides of the comparison below.
|
|
221
|
+
const quote = await requestInvoiceShort(pay.callback, state.grossMsat, state.h);
|
|
220
222
|
if (!quote.verify || !quote.mint)
|
|
221
223
|
throw new Error('Mint did not bind this quote to h and a verification URL.');
|
|
222
224
|
const netMsat = quote.mint.amountMsat;
|
package/dist/server.js
CHANGED
|
@@ -3,9 +3,9 @@ import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils.js';
|
|
|
3
3
|
import { finalizeEvent } from 'nostr-tools/pure';
|
|
4
4
|
import { applyMintFee, encodeCs1WithAmount, grossUpForMintFee, hashK1 } from '@lnurlcash/kit';
|
|
5
5
|
import { tryDecodeBolt11 } from 'farrier-kit/bolt11';
|
|
6
|
-
import { NotePendingError, NoteStore, NoteUnavailableError, OutputCollisionError, swapFingerprint } from "./store.js";
|
|
6
|
+
import { InvoiceUsedError, NotePendingError, NoteStore, NoteUnavailableError, OutputCollisionError, swapFingerprint } from "./store.js";
|
|
7
7
|
import { createNoteSigner } from "./signing.js";
|
|
8
|
-
import { bearerNoteId, decodeNote, decodeSpend, spendDomainOf, verifySpend } from "./spend.js";
|
|
8
|
+
import { bearerNoteId, decodeNote, decodeSpend, encodeCp1, spendDomainOf, verifySpend } from "./spend.js";
|
|
9
9
|
import { createKernelVerifier } from "./kernel.js";
|
|
10
10
|
import { describeFee } from "./fee-words.js";
|
|
11
11
|
import { createFakeBackend } from "./backends/fake.js";
|
|
@@ -25,6 +25,10 @@ const namedRef = (value) => {
|
|
|
25
25
|
const q = decodeNote(value);
|
|
26
26
|
return q ? { q, named: HEX32.test(value) ? value : q } : null;
|
|
27
27
|
};
|
|
28
|
+
// The name a quote or receipt repeats back, in the spelling the wallet used.
|
|
29
|
+
// A wallet reads bare hex there as a bearer note's h, so a note named by its
|
|
30
|
+
// cp1 has to come back as that cp1, never as the hex of its Q.
|
|
31
|
+
const echoedName = (q, named) => (named === q ? encodeCp1(hexToBytes(q)) : named);
|
|
28
32
|
const INVALID_K1 = 'Invalid or already spent k1.';
|
|
29
33
|
// LUD-25's own reason for an output that names a note already credited.
|
|
30
34
|
const IN_USE = 'already in use';
|
|
@@ -839,7 +843,7 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
839
843
|
// value this invoice will mint. It is only offered when /verify can
|
|
840
844
|
// later authenticate settlement with this mint's signing key.
|
|
841
845
|
...(config.verify && signer
|
|
842
|
-
? { mint: { h: commentRef.named, amount: net } }
|
|
846
|
+
? { mint: { h: echoedName(commentRef.q, commentRef.named), amount: net } }
|
|
843
847
|
: {}),
|
|
844
848
|
// Every current-draft mint quote is comment-bound, so the LUD-21
|
|
845
849
|
// preimage is ordinary settlement proof and safe to disclose after
|
|
@@ -879,19 +883,21 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
879
883
|
preimage: preimageHex,
|
|
880
884
|
pr: invoice.pr,
|
|
881
885
|
// Repeating h and amount binds this response to the quote. The
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
//
|
|
885
|
-
//
|
|
886
|
-
//
|
|
886
|
+
// signature appears only once value exists there, and it is the
|
|
887
|
+
// note's ordinary certificate: a cs1 over its Q, which is what a
|
|
888
|
+
// receipt validator derives from the repeated name and checks.
|
|
889
|
+
// A quote from before notes were keyed by Q stored the bearer h
|
|
890
|
+
// as its output and no separate name.
|
|
887
891
|
...(currentInvoice.outputId !== null && signer
|
|
888
892
|
? (() => {
|
|
893
|
+
const legacy = currentInvoice.outputNamed === null;
|
|
894
|
+
const q = legacy ? bearerNoteId(currentInvoice.outputId) : currentInvoice.outputId;
|
|
889
895
|
const named = currentInvoice.outputNamed ?? currentInvoice.outputId;
|
|
890
896
|
return {
|
|
891
897
|
mint: {
|
|
892
|
-
h: named,
|
|
898
|
+
h: echoedName(q, named),
|
|
893
899
|
amount: currentInvoice.netMsat,
|
|
894
|
-
...(settled ? { sig:
|
|
900
|
+
...(settled ? { sig: certify(q, currentInvoice.netMsat) } : {})
|
|
895
901
|
}
|
|
896
902
|
};
|
|
897
903
|
})()
|
|
@@ -1131,8 +1137,14 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
1131
1137
|
}
|
|
1132
1138
|
// The funding source dedupes payments by hash, so a second melt
|
|
1133
1139
|
// into the same invoice would be "confirmed" against the first
|
|
1134
|
-
// payment and burn its note without moving funds.
|
|
1135
|
-
|
|
1140
|
+
// payment and burn its note without moving funds. A melt that was
|
|
1141
|
+
// restored is the exception: the funding source confirmed it never
|
|
1142
|
+
// paid, lnd sends a hash whose payment failed again (it refuses
|
|
1143
|
+
// only one being created, in flight or paid), and the payee is
|
|
1144
|
+
// still waiting on that invoice. A backend that refuses anyway
|
|
1145
|
+
// throws PaymentAlreadyKnownError and the note restores.
|
|
1146
|
+
const earlier = store.meltByHash(paymentHash);
|
|
1147
|
+
if (earlier && earlier.outcome !== 'restored') {
|
|
1136
1148
|
return fail('Invoice already used by an earlier melt - use a fresh one.');
|
|
1137
1149
|
}
|
|
1138
1150
|
// A shared funding source is a supported deployment (DEPLOY.md),
|
|
@@ -1161,7 +1173,10 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
1161
1173
|
return fail('pending');
|
|
1162
1174
|
if (err instanceof NoteUnavailableError)
|
|
1163
1175
|
return fail(INVALID_K1);
|
|
1164
|
-
|
|
1176
|
+
if (err instanceof InvoiceUsedError)
|
|
1177
|
+
return fail('Invoice already used by an earlier melt - use a fresh one.');
|
|
1178
|
+
log(`melt reservation failed: ${err.message}`);
|
|
1179
|
+
return fail('Temporarily unable to melt - try again shortly.');
|
|
1165
1180
|
}
|
|
1166
1181
|
inFlight.add(paymentHash);
|
|
1167
1182
|
const melt = runMelt({
|
package/dist/store.d.ts
CHANGED
|
@@ -82,6 +82,8 @@ export declare class NoteUnavailableError extends Error {
|
|
|
82
82
|
}
|
|
83
83
|
export declare class OutputCollisionError extends Error {
|
|
84
84
|
}
|
|
85
|
+
export declare class InvoiceUsedError extends Error {
|
|
86
|
+
}
|
|
85
87
|
export declare class NoteStore {
|
|
86
88
|
private db;
|
|
87
89
|
readonly readOnly: boolean;
|
package/dist/store.js
CHANGED
|
@@ -30,6 +30,10 @@ export class NoteUnavailableError extends Error {
|
|
|
30
30
|
// can learn that id's preimage take the output.
|
|
31
31
|
export class OutputCollisionError extends Error {
|
|
32
32
|
}
|
|
33
|
+
// An earlier melt into this invoice paid it, or has not yet resolved. Only
|
|
34
|
+
// one the funding source confirmed never paid may be melted into again.
|
|
35
|
+
export class InvoiceUsedError extends Error {
|
|
36
|
+
}
|
|
33
37
|
export class NoteStore {
|
|
34
38
|
db;
|
|
35
39
|
readOnly;
|
|
@@ -329,14 +333,22 @@ export class NoteStore {
|
|
|
329
333
|
return parsed.map(([id, amountMsat]) => ({ id, amountMsat }));
|
|
330
334
|
}
|
|
331
335
|
// Reserves a note for a melt and records the melt, atomically. The melts
|
|
332
|
-
// row is keyed by the invoice's payment hash
|
|
333
|
-
//
|
|
336
|
+
// row is keyed by the invoice's payment hash. An earlier melt into the
|
|
337
|
+
// same invoice may be retried only once it was restored - the funding
|
|
338
|
+
// source confirmed nothing went out - and the retry takes over its row.
|
|
339
|
+
// One that paid, or is still unresolved, refuses the reservation.
|
|
334
340
|
markPending(noteId, paymentHash, pr, amountMsat) {
|
|
335
341
|
this.tx(() => {
|
|
336
342
|
this.assertOutstanding(noteId);
|
|
337
|
-
this.db
|
|
338
|
-
.prepare(
|
|
343
|
+
const written = this.db
|
|
344
|
+
.prepare(`INSERT INTO melts (payment_hash, note_id, pr, amount_msat, outcome, created_at) VALUES (?, ?, ?, ?, NULL, ?)
|
|
345
|
+
ON CONFLICT (payment_hash) DO UPDATE SET
|
|
346
|
+
note_id = excluded.note_id, pr = excluded.pr, amount_msat = excluded.amount_msat,
|
|
347
|
+
outcome = NULL, created_at = excluded.created_at, resolved_at = NULL
|
|
348
|
+
WHERE melts.outcome = 'restored'`)
|
|
339
349
|
.run(paymentHash, noteId, pr, amountMsat, Date.now());
|
|
350
|
+
if (written.changes !== 1)
|
|
351
|
+
throw new InvoiceUsedError('an earlier melt already used this invoice');
|
|
340
352
|
this.setNoteState(noteId, 'pending');
|
|
341
353
|
});
|
|
342
354
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgesworn/moneyer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.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",
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
"live:bound-mint": "npm run build && node scripts/live-bound-mint-check.mjs"
|
|
75
75
|
},
|
|
76
76
|
"dependencies": {
|
|
77
|
-
"@lnurlcash/kit": "0.
|
|
77
|
+
"@lnurlcash/kit": "0.20.2",
|
|
78
78
|
"@noble/curves": "^2.3.0",
|
|
79
79
|
"@noble/hashes": "^2.3.0",
|
|
80
80
|
"@scure/base": "^1.2.4",
|