@forgesworn/moneyer 0.18.1 → 0.20.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 +43 -0
- package/README.md +14 -2
- package/dist/backends/cln.js +26 -0
- package/dist/backends/fake.d.ts +2 -0
- package/dist/backends/fake.js +58 -2
- package/dist/backends/lnd.js +64 -1
- package/dist/backends/types.d.ts +5 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +73 -5
- package/dist/store.d.ts +4 -0
- package/dist/store.js +25 -4
- package/dist/zap.d.ts +1 -1
- package/dist/zap.js +18 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.20.0 - 2026-10-07
|
|
4
|
+
|
|
5
|
+
- **A zap paid while the mint was down is no longer lost.** The expiry
|
|
6
|
+
sweep deleted any unsettled zap invoice an hour past expiry without
|
|
7
|
+
asking the funding source, so a zap paid while moneyer was down that long
|
|
8
|
+
stayed in the node and never became a note. It now asks first, as the
|
|
9
|
+
mint-invoice sweep has since 0.17.1: an unpaid one goes, a paid one stays
|
|
10
|
+
for the settle pass to mint, and no answer deletes nothing.
|
|
11
|
+
- **A paid mint invoice is credited the moment it settles.** moneyer now
|
|
12
|
+
watches the funding source's settled invoices (lnd's
|
|
13
|
+
`/v1/invoices/subscribe`, cln's `waitanyinvoice`) and mints the note as
|
|
14
|
+
soon as the payment lands, instead of when the wallet comes back to claim
|
|
15
|
+
or poll `verify`, or when the expiry sweep finds it. Liabilities and
|
|
16
|
+
coverage are therefore right straight away. A report is only a hint: the
|
|
17
|
+
invoice is checked against the node and credited through the same call
|
|
18
|
+
`verify` and the sweep make, and those remain the fallback while the
|
|
19
|
+
stream is down. The stream reconnects with backoff and resumes from the
|
|
20
|
+
node's settle index, kept in the database, so settles during a restart
|
|
21
|
+
are caught up. A settled zap invoice brings the next zap pass forward.
|
|
22
|
+
The existing lnd macaroon (`invoices:read`) already covers the
|
|
23
|
+
subscription. lnd sends no headers on it until the first event, so on a
|
|
24
|
+
quiet node Node's fetch ends it after five minutes; that counts as a
|
|
25
|
+
normal end and the stream reconnects without logging. The cln side is untested against a live node, like the
|
|
26
|
+
rest of that backend.
|
|
27
|
+
|
|
28
|
+
## 0.19.0 - 2026-10-07
|
|
29
|
+
|
|
30
|
+
- **A failed melt's invoice can be tried again.** Once the funding source
|
|
31
|
+
confirms a melt never paid, the note is restored as before, and now the
|
|
32
|
+
invoice is free again too: the payee is still waiting on it, so a wallet
|
|
33
|
+
can melt into it a second time, from the same note or a different one,
|
|
34
|
+
instead of asking for a fresh invoice. lnd sends a hash whose payment
|
|
35
|
+
failed again; a backend that refuses it restores the note, as for any
|
|
36
|
+
payment the node already holds. An invoice that paid, or whose melt is
|
|
37
|
+
still unresolved, is refused as before. The retry reuses the melt's row, so
|
|
38
|
+
`moneyer admin melts` and the restored count in the lifetime totals show
|
|
39
|
+
the latest attempt only.
|
|
40
|
+
- lnd's "invoice is already paid" refusal, sent when a shared node paid the
|
|
41
|
+
hash between the mint's pre-check and its send, is now read as a payment
|
|
42
|
+
the node already holds, like its other two refusals. It used to fall
|
|
43
|
+
through as an ambiguous failure, which the payment tracker would then
|
|
44
|
+
have confirmed as paid against somebody else's payment, burning the note.
|
|
45
|
+
|
|
3
46
|
## 0.18.1 - 2026-10-07
|
|
4
47
|
|
|
5
48
|
- **Bound mint receipts verify in current wallets again.** Since
|
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
|
|
|
@@ -349,6 +351,15 @@ preimage then buys nothing: it is an ordinary payment proof, which is why
|
|
|
349
351
|
there is no race left to run, and no window in which holding the invoice
|
|
350
352
|
is nearly holding the money.
|
|
351
353
|
|
|
354
|
+
On lnd and cln the mint watches the node's settled invoices (lnd's
|
|
355
|
+
invoice subscription, cln's `waitanyinvoice`), so the note exists from
|
|
356
|
+
the moment the invoice is paid, whether or not the wallet ever comes
|
|
357
|
+
back. Each report is checked against the node before anything is
|
|
358
|
+
credited, and the stream resumes from the last settle it handled after a
|
|
359
|
+
drop or a restart. Without it, a paid invoice is credited when the wallet
|
|
360
|
+
claims or polls `verify`, or at the latest when the expiry sweep finds it
|
|
361
|
+
paid.
|
|
362
|
+
|
|
352
363
|
### Every note is a taproot output key
|
|
353
364
|
|
|
354
365
|
LUD-25 makes every note a BIP-341 output key Q, written `cp1<Q>`, and that
|
|
@@ -471,7 +482,8 @@ at `h`" alone would let anyone holding a burned `k1` and any outstanding
|
|
|
471
482
|
note id draw a success out of the mint.
|
|
472
483
|
|
|
473
484
|
The melt path is untouched: melts are deduplicated by payment hash, which
|
|
474
|
-
is a different question with a different answer
|
|
485
|
+
is a different question with a different answer (a restored melt's
|
|
486
|
+
invoice excepted - see the melt discipline above).
|
|
475
487
|
|
|
476
488
|
This is moneyer's own behaviour. The LUD-25 draft says nothing about
|
|
477
489
|
retries yet; the suggested wording is on lnurl/luds#301 as a SHOULD.
|
package/dist/backends/cln.js
CHANGED
|
@@ -100,6 +100,32 @@ export const createClnBackend = (config) => {
|
|
|
100
100
|
}
|
|
101
101
|
return pays.some(pay => pay?.status === 'complete');
|
|
102
102
|
},
|
|
103
|
+
// waitanyinvoice answers with the first invoice paid after
|
|
104
|
+
// `lastpay_index`, or the next one paid at all without it. Its timeout
|
|
105
|
+
// (904) just means nothing was paid meanwhile.
|
|
106
|
+
async watchSettledInvoices({ fromIndex, signal, onSettled }) {
|
|
107
|
+
let lastIndex = fromIndex;
|
|
108
|
+
while (!signal.aborted) {
|
|
109
|
+
const res = await fetch(`${config.url}/v1/waitanyinvoice`, {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: { 'content-type': 'application/json', Rune: config.rune },
|
|
112
|
+
body: JSON.stringify({ ...(lastIndex > 0 ? { lastpay_index: lastIndex } : {}), timeout: 60 }),
|
|
113
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(90_000)])
|
|
114
|
+
});
|
|
115
|
+
const invoice = await res.json().catch(() => null);
|
|
116
|
+
if (!res.ok) {
|
|
117
|
+
if (invoice?.code === 904)
|
|
118
|
+
continue;
|
|
119
|
+
throw new Error(`cln waitanyinvoice failed (${res.status}).`);
|
|
120
|
+
}
|
|
121
|
+
const index = invoice?.pay_index;
|
|
122
|
+
if (invoice?.status !== 'paid' || typeof invoice.payment_hash !== 'string' || !Number.isSafeInteger(index)) {
|
|
123
|
+
throw new Error('cln waitanyinvoice returned no paid invoice.');
|
|
124
|
+
}
|
|
125
|
+
await onSettled(invoice.payment_hash, index);
|
|
126
|
+
lastIndex = index;
|
|
127
|
+
}
|
|
128
|
+
},
|
|
103
129
|
async isInvoiceSettled(paymentHashHex) {
|
|
104
130
|
const result = await mustCall('/v1/listinvoices', { payment_hash: paymentHashHex });
|
|
105
131
|
const invoices = result?.invoices ?? [];
|
package/dist/backends/fake.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export type FakeBackendOptions = {
|
|
|
5
5
|
autoSettle?: boolean;
|
|
6
6
|
nodeChoosesPreimage?: boolean;
|
|
7
7
|
autoSettleAfterMs?: number;
|
|
8
|
+
streamsSettles?: boolean;
|
|
8
9
|
};
|
|
9
10
|
export type FakeBackend = LightningBackend & {
|
|
10
11
|
control: {
|
|
@@ -21,6 +22,7 @@ export type FakeBackend = LightningBackend & {
|
|
|
21
22
|
} | undefined;
|
|
22
23
|
sentAmountMsat(paymentHashHex: string): number | null | undefined;
|
|
23
24
|
setLocalBalanceMsat(msat: number | undefined): void;
|
|
25
|
+
dropInvoiceStreams(): void;
|
|
24
26
|
};
|
|
25
27
|
};
|
|
26
28
|
export declare const FAKE_LOCAL_BALANCE_MSAT = 100000000000;
|
package/dist/backends/fake.js
CHANGED
|
@@ -33,9 +33,49 @@ export const createFakeBackend = (options = {}) => {
|
|
|
33
33
|
const knownPreimages = new Map();
|
|
34
34
|
let localBalanceMsat = FAKE_LOCAL_BALANCE_MSAT;
|
|
35
35
|
let payMode = 'succeed';
|
|
36
|
+
const settleLog = [];
|
|
37
|
+
const watchers = new Set();
|
|
38
|
+
const watchSettledInvoices = async ({ fromIndex, signal, onSettled }) => {
|
|
39
|
+
const queue = settleLog.filter(entry => fromIndex > 0 && entry.index > fromIndex);
|
|
40
|
+
let wake = null;
|
|
41
|
+
let ended = false;
|
|
42
|
+
const watcher = {
|
|
43
|
+
push: (entry) => {
|
|
44
|
+
queue.push(entry);
|
|
45
|
+
wake?.();
|
|
46
|
+
},
|
|
47
|
+
end: () => {
|
|
48
|
+
ended = true;
|
|
49
|
+
wake?.();
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const onAbort = () => watcher.end();
|
|
53
|
+
signal.addEventListener('abort', onAbort);
|
|
54
|
+
watchers.add(watcher);
|
|
55
|
+
try {
|
|
56
|
+
for (;;) {
|
|
57
|
+
const entry = queue.shift();
|
|
58
|
+
if (entry) {
|
|
59
|
+
await onSettled(entry.paymentHashHex, entry.index);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (ended)
|
|
63
|
+
return;
|
|
64
|
+
await new Promise(resolve => {
|
|
65
|
+
wake = resolve;
|
|
66
|
+
});
|
|
67
|
+
wake = null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
watchers.delete(watcher);
|
|
72
|
+
signal.removeEventListener('abort', onAbort);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
36
75
|
return {
|
|
37
76
|
name: 'fake',
|
|
38
77
|
acceptsInvoicePreimage: !nodeChoosesPreimage,
|
|
78
|
+
...(options.streamsSettles === true ? { watchSettledInvoices } : {}),
|
|
39
79
|
async createInvoice({ amountMsat, preimageHex, memo }) {
|
|
40
80
|
// A node that mints its own preimages never sees the caller's.
|
|
41
81
|
const chosen = nodeChoosesPreimage ? bytesToHex(randomBytes(32)) : preimageHex;
|
|
@@ -65,8 +105,10 @@ export const createFakeBackend = (options = {}) => {
|
|
|
65
105
|
if (!paymentHashHex)
|
|
66
106
|
throw new PaymentFailedError('That is not a decodable invoice.');
|
|
67
107
|
// Real nodes dedupe sends by payment hash: a hash this node already
|
|
68
|
-
//
|
|
69
|
-
|
|
108
|
+
// paid, or is still paying, is refused exactly as lnd and cln refuse
|
|
109
|
+
// it. One whose every attempt failed may be sent again, as on both.
|
|
110
|
+
const earlier = payments.get(paymentHashHex);
|
|
111
|
+
if (earlier && earlier.status !== 'failed') {
|
|
70
112
|
throw new PaymentAlreadyKnownError('this node already has a payment for that hash');
|
|
71
113
|
}
|
|
72
114
|
const preimageHex = knownPreimages.get(paymentHashHex) ?? null;
|
|
@@ -124,7 +166,13 @@ export const createFakeBackend = (options = {}) => {
|
|
|
124
166
|
const invoice = invoices.get(paymentHashHex);
|
|
125
167
|
if (!invoice)
|
|
126
168
|
throw new Error(`no fake invoice for ${paymentHashHex}`);
|
|
169
|
+
if (invoice.settled)
|
|
170
|
+
return;
|
|
127
171
|
invoice.settled = true;
|
|
172
|
+
const entry = { paymentHashHex, index: settleLog.length + 1 };
|
|
173
|
+
settleLog.push(entry);
|
|
174
|
+
for (const watcher of watchers)
|
|
175
|
+
watcher.push(entry);
|
|
128
176
|
},
|
|
129
177
|
setPayMode(mode) {
|
|
130
178
|
payMode = mode;
|
|
@@ -154,6 +202,14 @@ export const createFakeBackend = (options = {}) => {
|
|
|
154
202
|
setLocalBalanceMsat(msat) {
|
|
155
203
|
localBalanceMsat = msat;
|
|
156
204
|
},
|
|
205
|
+
dropInvoiceStreams() {
|
|
206
|
+
// gone at once, as a dropped connection is: a settle after this
|
|
207
|
+
// reaches the watcher only by its resuming from the index
|
|
208
|
+
for (const watcher of [...watchers]) {
|
|
209
|
+
watchers.delete(watcher);
|
|
210
|
+
watcher.end();
|
|
211
|
+
}
|
|
212
|
+
},
|
|
157
213
|
invoiceByHash(paymentHashHex) {
|
|
158
214
|
const invoice = invoices.get(paymentHashHex);
|
|
159
215
|
// `settled` here answers "is it paid now", so autoSettle's moment
|
package/dist/backends/lnd.js
CHANGED
|
@@ -19,6 +19,13 @@ const FAILURE_REASONS = {
|
|
|
19
19
|
FAILURE_REASON_INSUFFICIENT_BALANCE: 'Insufficient balance to pay this invoice.',
|
|
20
20
|
FAILURE_REASON_TIMEOUT: 'Timed out trying to find a route to pay this invoice.'
|
|
21
21
|
};
|
|
22
|
+
// Node's fetch gives up on a response idle for five minutes, headers or
|
|
23
|
+
// body. lnd sends no headers on an invoice subscription until its first
|
|
24
|
+
// event, so on a quiet node that is the normal end of one, not a fault.
|
|
25
|
+
const isIdleTimeout = (err) => {
|
|
26
|
+
const code = err?.cause?.code ?? err?.code;
|
|
27
|
+
return code === 'UND_ERR_HEADERS_TIMEOUT' || code === 'UND_ERR_BODY_TIMEOUT';
|
|
28
|
+
};
|
|
22
29
|
const hexToBase64 = (hex) => Buffer.from(hexToBytes(hex)).toString('base64');
|
|
23
30
|
const decodeHexOrBase64 = (value) => {
|
|
24
31
|
if (/^[0-9a-fA-F]{64}$/.test(value))
|
|
@@ -83,6 +90,47 @@ export const createLndBackend = (config) => {
|
|
|
83
90
|
reader.cancel().catch(() => { });
|
|
84
91
|
}
|
|
85
92
|
};
|
|
93
|
+
const readSettledInvoices = async ({ fromIndex, signal, onSettled }) => {
|
|
94
|
+
const res = await fetch(`${config.url}/v1/invoices/subscribe?settle_index=${fromIndex}`, { headers, signal });
|
|
95
|
+
if (!res.ok || !res.body)
|
|
96
|
+
throw new Error(`lnd refused the invoice subscription (${res.status}).`);
|
|
97
|
+
const reader = res.body.getReader();
|
|
98
|
+
const decoder = new TextDecoder();
|
|
99
|
+
let buffer = '';
|
|
100
|
+
try {
|
|
101
|
+
for (;;) {
|
|
102
|
+
const { done, value } = await reader.read();
|
|
103
|
+
if (done)
|
|
104
|
+
return;
|
|
105
|
+
buffer += decoder.decode(value, { stream: true });
|
|
106
|
+
const lines = buffer.split('\n');
|
|
107
|
+
buffer = lines.pop() ?? '';
|
|
108
|
+
for (const line of lines) {
|
|
109
|
+
if (!line.trim())
|
|
110
|
+
continue;
|
|
111
|
+
let event;
|
|
112
|
+
try {
|
|
113
|
+
event = JSON.parse(line);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (event?.error)
|
|
119
|
+
throw new Error(`lnd ended the invoice subscription: ${event.error.message ?? 'unknown'}`);
|
|
120
|
+
const invoice = event?.result ?? event;
|
|
121
|
+
if (invoice?.state !== 'SETTLED' || typeof invoice.r_hash !== 'string')
|
|
122
|
+
continue;
|
|
123
|
+
const index = Number(invoice.settle_index);
|
|
124
|
+
if (!Number.isSafeInteger(index) || index <= 0)
|
|
125
|
+
continue;
|
|
126
|
+
await onSettled(decodeHexOrBase64(invoice.r_hash), index);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
reader.cancel().catch(() => { });
|
|
132
|
+
}
|
|
133
|
+
};
|
|
86
134
|
return {
|
|
87
135
|
name: 'lnd',
|
|
88
136
|
acceptsInvoicePreimage: true,
|
|
@@ -122,7 +170,10 @@ export const createLndBackend = (config) => {
|
|
|
122
170
|
// lnd refuses a send whose payment hash it already holds - on a
|
|
123
171
|
// shared node that is somebody else's payment, and nothing went
|
|
124
172
|
// out for THIS call. Distinct from ambiguity: safely restorable.
|
|
125
|
-
|
|
173
|
+
// Its three refusals (lnd 0.20, payments/db/errors.go): one being
|
|
174
|
+
// created, one in flight, one that succeeded. A hash whose payment
|
|
175
|
+
// failed is not refused - lnd sends it again.
|
|
176
|
+
if (error && /already exists|AlreadyExists|payment is in transition|invoice is already paid/i.test(error)) {
|
|
126
177
|
throw new PaymentAlreadyKnownError('lnd already has a payment for this hash');
|
|
127
178
|
}
|
|
128
179
|
// The stream ended without a terminal status - genuinely ambiguous.
|
|
@@ -158,6 +209,18 @@ export const createLndBackend = (config) => {
|
|
|
158
209
|
throw new Error('lnd did not report a payment status.');
|
|
159
210
|
return result;
|
|
160
211
|
},
|
|
212
|
+
// lnd replays every settle after `settle_index` before going live, and
|
|
213
|
+
// none for 0. The stream is held open until lnd, the signal or Node's
|
|
214
|
+
// idle timeout ends it.
|
|
215
|
+
async watchSettledInvoices({ fromIndex, signal, onSettled }) {
|
|
216
|
+
try {
|
|
217
|
+
await readSettledInvoices({ fromIndex, signal, onSettled });
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
if (!isIdleTimeout(err))
|
|
221
|
+
throw err;
|
|
222
|
+
}
|
|
223
|
+
},
|
|
161
224
|
// 404 is lnd's answer for an invoice it does not hold, which can never
|
|
162
225
|
// settle. Any other refusal is no answer at all, and reading it as
|
|
163
226
|
// "unpaid" would let the expiry sweep delete a paid invoice's row.
|
package/dist/backends/types.d.ts
CHANGED
|
@@ -39,5 +39,10 @@ export interface LightningBackend {
|
|
|
39
39
|
invoicePreimage(paymentHashHex: string): Promise<string | null>;
|
|
40
40
|
paymentPreimage(paymentHashHex: string): Promise<string | null>;
|
|
41
41
|
nodeInfo?(): Promise<NodeInfo>;
|
|
42
|
+
watchSettledInvoices?(args: {
|
|
43
|
+
fromIndex: number;
|
|
44
|
+
signal: AbortSignal;
|
|
45
|
+
onSettled: (paymentHashHex: string, index: number) => Promise<void>;
|
|
46
|
+
}): Promise<void>;
|
|
42
47
|
close?(): void | Promise<void>;
|
|
43
48
|
}
|
package/dist/server.d.ts
CHANGED
package/dist/server.js
CHANGED
|
@@ -3,7 +3,7 @@ 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
8
|
import { bearerNoteId, decodeNote, decodeSpend, encodeCp1, spendDomainOf, verifySpend } from "./spend.js";
|
|
9
9
|
import { createKernelVerifier } from "./kernel.js";
|
|
@@ -1137,8 +1137,14 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
1137
1137
|
}
|
|
1138
1138
|
// The funding source dedupes payments by hash, so a second melt
|
|
1139
1139
|
// into the same invoice would be "confirmed" against the first
|
|
1140
|
-
// payment and burn its note without moving funds.
|
|
1141
|
-
|
|
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') {
|
|
1142
1148
|
return fail('Invoice already used by an earlier melt - use a fresh one.');
|
|
1143
1149
|
}
|
|
1144
1150
|
// A shared funding source is a supported deployment (DEPLOY.md),
|
|
@@ -1167,7 +1173,10 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
1167
1173
|
return fail('pending');
|
|
1168
1174
|
if (err instanceof NoteUnavailableError)
|
|
1169
1175
|
return fail(INVALID_K1);
|
|
1170
|
-
|
|
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.');
|
|
1171
1180
|
}
|
|
1172
1181
|
inFlight.add(paymentHash);
|
|
1173
1182
|
const melt = runMelt({
|
|
@@ -1289,7 +1298,7 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
1289
1298
|
// unref'd so it never keeps the process alive.
|
|
1290
1299
|
const housekeeping = async () => {
|
|
1291
1300
|
const { swept: mintSwept, settled } = await sweepExpiredMintInvoices(store, backend);
|
|
1292
|
-
const swept = mintSwept + (zap
|
|
1301
|
+
const swept = mintSwept + (zap ? await zap.sweep() : 0);
|
|
1293
1302
|
if (swept > 0)
|
|
1294
1303
|
log(`swept ${swept} expired mint invoice${swept === 1 ? '' : 's'}`);
|
|
1295
1304
|
if (settled > 0)
|
|
@@ -1326,6 +1335,63 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
1326
1335
|
};
|
|
1327
1336
|
const zapTimer = zap ? setInterval(() => void zapTick(), deps.zapPollMs ?? 5_000) : null;
|
|
1328
1337
|
zapTimer?.unref();
|
|
1338
|
+
// A funding source that streams its settles lets a paid mint invoice be
|
|
1339
|
+
// credited the moment it is paid, rather than when its wallet next asks
|
|
1340
|
+
// or the expiry sweep finds it. A report is only a hint: the invoice is
|
|
1341
|
+
// checked again and credited by the same call /verify and the sweep
|
|
1342
|
+
// make, and those stay as the fallback whenever the stream is down. A
|
|
1343
|
+
// settled zap invoice brings the next zap pass forward instead.
|
|
1344
|
+
const streamAbort = new AbortController();
|
|
1345
|
+
const streamIndexKey = `invoice_stream_index:${backend.name}`;
|
|
1346
|
+
const onSettled = async (paymentHash, index) => {
|
|
1347
|
+
try {
|
|
1348
|
+
const invoice = store.mintInvoiceByHash(paymentHash);
|
|
1349
|
+
if (invoice && !invoice.settled && (await backend.isInvoiceSettled(paymentHash))) {
|
|
1350
|
+
if (streamAbort.signal.aborted)
|
|
1351
|
+
return;
|
|
1352
|
+
store.settleMintInvoice(paymentHash);
|
|
1353
|
+
}
|
|
1354
|
+
else if (!invoice && zap && store.zapInvoiceByHash(paymentHash)) {
|
|
1355
|
+
void zapTick();
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
catch (err) {
|
|
1359
|
+
log(`invoice stream: could not settle a reported invoice (${err.message})`);
|
|
1360
|
+
}
|
|
1361
|
+
if (!streamAbort.signal.aborted)
|
|
1362
|
+
store.setMetaValue(streamIndexKey, String(index));
|
|
1363
|
+
};
|
|
1364
|
+
const streamInvoices = async (watch) => {
|
|
1365
|
+
const firstRetryMs = deps.invoiceStreamRetryMs ?? 1_000;
|
|
1366
|
+
let retryMs = firstRetryMs;
|
|
1367
|
+
while (!streamAbort.signal.aborted) {
|
|
1368
|
+
const startedAt = Date.now();
|
|
1369
|
+
try {
|
|
1370
|
+
await watch({ fromIndex: Number(store.metaValue(streamIndexKey) ?? 0), signal: streamAbort.signal, onSettled });
|
|
1371
|
+
}
|
|
1372
|
+
catch (err) {
|
|
1373
|
+
if (!streamAbort.signal.aborted)
|
|
1374
|
+
log(`invoice stream dropped: ${err.message}`);
|
|
1375
|
+
}
|
|
1376
|
+
if (streamAbort.signal.aborted)
|
|
1377
|
+
return;
|
|
1378
|
+
if (Date.now() - startedAt > 60_000)
|
|
1379
|
+
retryMs = firstRetryMs;
|
|
1380
|
+
await new Promise(resolve => {
|
|
1381
|
+
const done = () => {
|
|
1382
|
+
clearTimeout(timer);
|
|
1383
|
+
streamAbort.signal.removeEventListener('abort', done);
|
|
1384
|
+
resolve();
|
|
1385
|
+
};
|
|
1386
|
+
const timer = setTimeout(done, retryMs);
|
|
1387
|
+
streamAbort.signal.addEventListener('abort', done);
|
|
1388
|
+
});
|
|
1389
|
+
retryMs = Math.min(retryMs * 2, 60_000);
|
|
1390
|
+
}
|
|
1391
|
+
};
|
|
1392
|
+
const invoiceStream = backend.watchSettledInvoices
|
|
1393
|
+
? streamInvoices(backend.watchSettledInvoices.bind(backend))
|
|
1394
|
+
: Promise.resolve();
|
|
1329
1395
|
// One hourly pass to Nostr carries both: the signed liabilities snapshot
|
|
1330
1396
|
// and the mint's announcement of itself. Each re-checks its own switch,
|
|
1331
1397
|
// so an operator can have either, both, or neither.
|
|
@@ -1353,6 +1419,7 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
1353
1419
|
publishStats,
|
|
1354
1420
|
publishAnnouncement,
|
|
1355
1421
|
close: async () => {
|
|
1422
|
+
streamAbort.abort();
|
|
1356
1423
|
clearInterval(housekeepingTimer);
|
|
1357
1424
|
if (zapTimer)
|
|
1358
1425
|
clearInterval(zapTimer);
|
|
@@ -1361,6 +1428,7 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
1361
1428
|
nostr?.close();
|
|
1362
1429
|
kernel?.close();
|
|
1363
1430
|
await new Promise((resolve, reject) => server.close(err => (err ? reject(err) : resolve())));
|
|
1431
|
+
await invoiceStream;
|
|
1364
1432
|
await backend.close?.();
|
|
1365
1433
|
store.close();
|
|
1366
1434
|
}
|
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;
|
|
@@ -91,6 +93,8 @@ export declare class NoteStore {
|
|
|
91
93
|
private indexLegacyIds;
|
|
92
94
|
legacyIdOf(q: string): string | null;
|
|
93
95
|
noteByQ(q: string): NoteRow | null;
|
|
96
|
+
metaValue(key: string): string | null;
|
|
97
|
+
setMetaValue(key: string, value: string): void;
|
|
94
98
|
unnamedVerifyCutover(): number;
|
|
95
99
|
private tx;
|
|
96
100
|
noteById(id: string): NoteRow | null;
|
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;
|
|
@@ -212,6 +216,15 @@ export class NoteStore {
|
|
|
212
216
|
}
|
|
213
217
|
// Invoices quoted at or after this instant get no verify if they named no
|
|
214
218
|
// output. See the migration above for why it is not simply "now".
|
|
219
|
+
// Small operational values that must survive a restart, such as where
|
|
220
|
+
// the funding source's invoice stream should resume.
|
|
221
|
+
metaValue(key) {
|
|
222
|
+
const row = this.db.prepare('SELECT value FROM meta WHERE key = ?').get(key);
|
|
223
|
+
return row?.value ?? null;
|
|
224
|
+
}
|
|
225
|
+
setMetaValue(key, value) {
|
|
226
|
+
this.db.prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value').run(key, value);
|
|
227
|
+
}
|
|
215
228
|
unnamedVerifyCutover() {
|
|
216
229
|
const row = this.db
|
|
217
230
|
.prepare("SELECT value FROM meta WHERE key = 'unnamed_verify_cutover'")
|
|
@@ -329,14 +342,22 @@ export class NoteStore {
|
|
|
329
342
|
return parsed.map(([id, amountMsat]) => ({ id, amountMsat }));
|
|
330
343
|
}
|
|
331
344
|
// Reserves a note for a melt and records the melt, atomically. The melts
|
|
332
|
-
// row is keyed by the invoice's payment hash
|
|
333
|
-
//
|
|
345
|
+
// row is keyed by the invoice's payment hash. An earlier melt into the
|
|
346
|
+
// same invoice may be retried only once it was restored - the funding
|
|
347
|
+
// source confirmed nothing went out - and the retry takes over its row.
|
|
348
|
+
// One that paid, or is still unresolved, refuses the reservation.
|
|
334
349
|
markPending(noteId, paymentHash, pr, amountMsat) {
|
|
335
350
|
this.tx(() => {
|
|
336
351
|
this.assertOutstanding(noteId);
|
|
337
|
-
this.db
|
|
338
|
-
.prepare(
|
|
352
|
+
const written = this.db
|
|
353
|
+
.prepare(`INSERT INTO melts (payment_hash, note_id, pr, amount_msat, outcome, created_at) VALUES (?, ?, ?, ?, NULL, ?)
|
|
354
|
+
ON CONFLICT (payment_hash) DO UPDATE SET
|
|
355
|
+
note_id = excluded.note_id, pr = excluded.pr, amount_msat = excluded.amount_msat,
|
|
356
|
+
outcome = NULL, created_at = excluded.created_at, resolved_at = NULL
|
|
357
|
+
WHERE melts.outcome = 'restored'`)
|
|
339
358
|
.run(paymentHash, noteId, pr, amountMsat, Date.now());
|
|
359
|
+
if (written.changes !== 1)
|
|
360
|
+
throw new InvoiceUsedError('an earlier melt already used this invoice');
|
|
340
361
|
this.setNoteState(noteId, 'pending');
|
|
341
362
|
});
|
|
342
363
|
}
|
package/dist/zap.d.ts
CHANGED
|
@@ -48,6 +48,6 @@ export type ZapBridge = {
|
|
|
48
48
|
callback(name: string, amountMsat: number, nostrParam: string | null): Promise<ZapCallbackResult>;
|
|
49
49
|
settle(): Promise<number>;
|
|
50
50
|
publish(): Promise<number>;
|
|
51
|
-
sweep(nowMs?: number): number
|
|
51
|
+
sweep(nowMs?: number): Promise<number>;
|
|
52
52
|
};
|
|
53
53
|
export declare const createZapBridge: (deps: ZapBridgeDeps) => ZapBridge;
|
package/dist/zap.js
CHANGED
|
@@ -325,7 +325,10 @@ export const createZapBridge = (deps) => {
|
|
|
325
325
|
}
|
|
326
326
|
return published;
|
|
327
327
|
};
|
|
328
|
-
|
|
328
|
+
// As the mint-invoice sweep: an expired row goes only once the funding
|
|
329
|
+
// source confirms it unpaid. One paid while the mint was down stays for
|
|
330
|
+
// the next settle pass to mint, and no answer deletes nothing.
|
|
331
|
+
const sweep = async (nowMs = now()) => {
|
|
329
332
|
const stale = [];
|
|
330
333
|
for (const row of store.unsettledZapInvoices()) {
|
|
331
334
|
const decoded = tryDecodeBolt11(row.pr);
|
|
@@ -334,9 +337,21 @@ export const createZapBridge = (deps) => {
|
|
|
334
337
|
if (nowMs > (decoded.timestamp + decoded.expirySeconds) * 1000 + 3_600_000)
|
|
335
338
|
stale.push(row.paymentHash);
|
|
336
339
|
}
|
|
337
|
-
|
|
340
|
+
let swept = 0;
|
|
341
|
+
for (const hash of stale) {
|
|
342
|
+
let paid;
|
|
343
|
+
try {
|
|
344
|
+
paid = await backend.isInvoiceSettled(hash);
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (paid)
|
|
350
|
+
continue;
|
|
338
351
|
store.deleteUnsettledZapInvoice(hash);
|
|
339
|
-
|
|
352
|
+
swept++;
|
|
353
|
+
}
|
|
354
|
+
return swept;
|
|
340
355
|
};
|
|
341
356
|
return { pubkey, isZapName, payRequest, callback, settle, publish, sweep };
|
|
342
357
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgesworn/moneyer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.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",
|