@forgesworn/moneyer 0.19.0 → 0.20.1
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 +44 -0
- package/README.md +12 -3
- package/dist/backends/cln.js +26 -0
- package/dist/backends/fake.d.ts +2 -0
- package/dist/backends/fake.js +54 -0
- package/dist/backends/lnd.js +60 -0
- package/dist/backends/types.d.ts +5 -0
- package/dist/names.js +2 -2
- package/dist/server.d.ts +1 -0
- package/dist/server.js +63 -3
- package/dist/store.d.ts +2 -0
- package/dist/store.js +9 -0
- package/dist/zap.d.ts +2 -2
- package/dist/zap.js +44 -10
- package/llms.txt +4 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,49 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.20.1 - 2026-10-13
|
|
4
|
+
|
|
5
|
+
- **A name with a cx1 issues invoices its payer's wallet will accept.** Its
|
|
6
|
+
payRequest metadata carries `text/cpub` with the next free index, but the
|
|
7
|
+
invoice's description hash was taken over the metadata without it, so it
|
|
8
|
+
never matched `sha256(metadata)` as LUD-06 requires. A wallet that checks
|
|
9
|
+
refused to pay the name by Lightning. The callback is now
|
|
10
|
+
`/z/cb/<name>/<index>`, the index the payRequest advertised, and the
|
|
11
|
+
invoice commits to exactly the metadata the payer read, even when a
|
|
12
|
+
settled payment has moved the next free index on in between. The index
|
|
13
|
+
feeds only the metadata: the note still lands on the next free key at
|
|
14
|
+
settlement. An index that is not a non-hardened one is refused. Zaps were
|
|
15
|
+
unaffected (their hash is over the zap request), and so is a name without
|
|
16
|
+
a cx1, whose callback stays `/z/cb/<name>`; that form still answers for
|
|
17
|
+
every name.
|
|
18
|
+
- The derivation, address proof and auto-mint text moved from LUD-25 to
|
|
19
|
+
LUD-26 in the draft (lnurl/luds 2f17578, editorial); the README and
|
|
20
|
+
comments now point there.
|
|
21
|
+
|
|
22
|
+
## 0.20.0 - 2026-10-07
|
|
23
|
+
|
|
24
|
+
- **A zap paid while the mint was down is no longer lost.** The expiry
|
|
25
|
+
sweep deleted any unsettled zap invoice an hour past expiry without
|
|
26
|
+
asking the funding source, so a zap paid while moneyer was down that long
|
|
27
|
+
stayed in the node and never became a note. It now asks first, as the
|
|
28
|
+
mint-invoice sweep has since 0.17.1: an unpaid one goes, a paid one stays
|
|
29
|
+
for the settle pass to mint, and no answer deletes nothing.
|
|
30
|
+
- **A paid mint invoice is credited the moment it settles.** moneyer now
|
|
31
|
+
watches the funding source's settled invoices (lnd's
|
|
32
|
+
`/v1/invoices/subscribe`, cln's `waitanyinvoice`) and mints the note as
|
|
33
|
+
soon as the payment lands, instead of when the wallet comes back to claim
|
|
34
|
+
or poll `verify`, or when the expiry sweep finds it. Liabilities and
|
|
35
|
+
coverage are therefore right straight away. A report is only a hint: the
|
|
36
|
+
invoice is checked against the node and credited through the same call
|
|
37
|
+
`verify` and the sweep make, and those remain the fallback while the
|
|
38
|
+
stream is down. The stream reconnects with backoff and resumes from the
|
|
39
|
+
node's settle index, kept in the database, so settles during a restart
|
|
40
|
+
are caught up. A settled zap invoice brings the next zap pass forward.
|
|
41
|
+
The existing lnd macaroon (`invoices:read`) already covers the
|
|
42
|
+
subscription. lnd sends no headers on it until the first event, so on a
|
|
43
|
+
quiet node Node's fetch ends it after five minutes; that counts as a
|
|
44
|
+
normal end and the stream reconnects without logging. The cln side is untested against a live node, like the
|
|
45
|
+
rest of that backend.
|
|
46
|
+
|
|
3
47
|
## 0.19.0 - 2026-10-07
|
|
4
48
|
|
|
5
49
|
- **A failed melt's invoice can be tried again.** Once the funding source
|
package/README.md
CHANGED
|
@@ -351,6 +351,15 @@ preimage then buys nothing: it is an ordinary payment proof, which is why
|
|
|
351
351
|
there is no race left to run, and no window in which holding the invoice
|
|
352
352
|
is nearly holding the money.
|
|
353
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
|
+
|
|
354
363
|
### Every note is a taproot output key
|
|
355
364
|
|
|
356
365
|
LUD-25 makes every note a BIP-341 output key Q, written `cp1<Q>`, and that
|
|
@@ -575,7 +584,7 @@ A registered name resolves on both rails at once:
|
|
|
575
584
|
The discovery endpoint advertises `namePriceMsat` while registration is
|
|
576
585
|
open, so a wallet can offer the flow without asking.
|
|
577
586
|
|
|
578
|
-
### A name that pays to your own keys (LUD-
|
|
587
|
+
### A name that pays to your own keys (LUD-26 auto-mint)
|
|
579
588
|
|
|
580
589
|
Add `"cx1": "cx1..."` to the registration body, or send `{"name", "cx1"}`
|
|
581
590
|
again later from the key that owns the name, and payments to it stop being
|
|
@@ -583,7 +592,7 @@ custodial. A `cx1` is a watch-only branch: from it the mint can work out each
|
|
|
583
592
|
of the holder's note keys in turn, but never spend one.
|
|
584
593
|
|
|
585
594
|
A `cx1` is public, so pointing a name at one has to be proven by the branch
|
|
586
|
-
itself, as LUD-
|
|
595
|
+
itself, as LUD-26 requires: the body also carries `"sig"`, a BIP-340
|
|
587
596
|
signature by the branch's purpose-0 index-0 key over
|
|
588
597
|
`sha256("LNURLcash:register:<domain>:<name>")`, `<domain>` being this mint's
|
|
589
598
|
hostname. Changing the branch later is proven by the branch on file, not the
|
|
@@ -681,7 +690,7 @@ clearnet.
|
|
|
681
690
|
| `/.well-known/lnurlp/<zap name>` | NIP-57 payRequest; paying mints a note *to the name's pubkey* |
|
|
682
691
|
| `/.well-known/lnurlw/<user>` | LUD-25 mint address discovery (experimental) |
|
|
683
692
|
| `/p/cb` | LUD-06 pay callback; issues the mint invoice for the note the `comment` names, a hash or a `cp1` key (`h` is an older spelling for a hash) |
|
|
684
|
-
| `/z/cb/<zap name>` | the zap callback; validates the kind 9734 and issues the invoice |
|
|
693
|
+
| `/z/cb/<zap name>` | the zap callback; validates the kind 9734 and issues the invoice. A name with a `cx1` calls back at `/z/cb/<zap name>/<index>`, the index its payRequest advertised, so the invoice commits to exactly the metadata the payer was shown |
|
|
685
694
|
| `/verify/<hash>` | LUD-21 verify, for mint invoices and melt payments |
|
|
686
695
|
| `/w` | LUD-03 informational GET; accepts `k1` (a preimage, `ck1` or `cw1`, verified in full) or the non-disclosing LUD-25 `p` check (a bearer note's h or any `cp1`; `h` is its older name, still accepted); a live note carries `payLink` and its `cs1` as `sig` |
|
|
687
696
|
| `/w/cb` | the mutating callback: melt, rotate, split, merge; inputs as preimages, `ck1`s or `cw1`s, outputs as `p1`/`p2` (a bearer note's h or a `cp1`), or their older names `h`/`h2` |
|
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;
|
|
@@ -126,7 +166,13 @@ export const createFakeBackend = (options = {}) => {
|
|
|
126
166
|
const invoice = invoices.get(paymentHashHex);
|
|
127
167
|
if (!invoice)
|
|
128
168
|
throw new Error(`no fake invoice for ${paymentHashHex}`);
|
|
169
|
+
if (invoice.settled)
|
|
170
|
+
return;
|
|
129
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);
|
|
130
176
|
},
|
|
131
177
|
setPayMode(mode) {
|
|
132
178
|
payMode = mode;
|
|
@@ -156,6 +202,14 @@ export const createFakeBackend = (options = {}) => {
|
|
|
156
202
|
setLocalBalanceMsat(msat) {
|
|
157
203
|
localBalanceMsat = msat;
|
|
158
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
|
+
},
|
|
159
213
|
invoiceByHash(paymentHashHex) {
|
|
160
214
|
const invoice = invoices.get(paymentHashHex);
|
|
161
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,
|
|
@@ -161,6 +209,18 @@ export const createLndBackend = (config) => {
|
|
|
161
209
|
throw new Error('lnd did not report a payment status.');
|
|
162
210
|
return result;
|
|
163
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
|
+
},
|
|
164
224
|
// 404 is lnd's answer for an invoice it does not hold, which can never
|
|
165
225
|
// settle. Any other refusal is no answer at all, and reading it as
|
|
166
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/names.js
CHANGED
|
@@ -87,7 +87,7 @@ export const validateNip98 = (authorization, request) => {
|
|
|
87
87
|
}
|
|
88
88
|
return { pubkey: event.pubkey };
|
|
89
89
|
};
|
|
90
|
-
// LUD-
|
|
90
|
+
// LUD-26's address proof. A cx1 is public, so pointing a name at one, or
|
|
91
91
|
// away from one, has to be proven by the branch itself: a BIP-340
|
|
92
92
|
// signature by its index-0 key over sha256("LNURLcash:<action>:<domain>:
|
|
93
93
|
// <name>"). The domain is this mint's own, so a proof one mint has seen
|
|
@@ -132,7 +132,7 @@ export const registerName = async (args) => {
|
|
|
132
132
|
if ([...ALWAYS_RESERVED, ...args.reserved.map(name => name.toLowerCase())].includes(raw)) {
|
|
133
133
|
return refuse('That name is reserved.', 403);
|
|
134
134
|
}
|
|
135
|
-
// LUD-
|
|
135
|
+
// LUD-26 auto-mint: a watch-only branch, so payments to the name are minted
|
|
136
136
|
// to the holder's own keys. Absent leaves it as it is; null clears it.
|
|
137
137
|
let cx1;
|
|
138
138
|
if (args.body.cx1 === null) {
|
package/dist/server.d.ts
CHANGED
package/dist/server.js
CHANGED
|
@@ -615,13 +615,14 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
615
615
|
if (lnurlpMatch && zap && zap.isZapName(lnurlpMatch[1])) {
|
|
616
616
|
return send(zap.payRequest(lnurlpMatch[1]));
|
|
617
617
|
}
|
|
618
|
-
|
|
618
|
+
// `/z/cb/<name>`, or `/z/cb/<name>/<index>` for a name advertising a cx1.
|
|
619
|
+
const zapCbMatch = requestUrl.pathname.match(/^\/z\/cb\/([^/]+)(?:\/([^/]+))?$/);
|
|
619
620
|
if (zapCbMatch) {
|
|
620
621
|
if (!zap || !zap.isZapName(zapCbMatch[1]))
|
|
621
622
|
return fail('Unknown user.', 404);
|
|
622
623
|
if (config.sunset)
|
|
623
624
|
return fail('This mint is sunsetting - minting is disabled.');
|
|
624
|
-
const result = await zap.callback(zapCbMatch[1], Number(q.get('amount')), q.get('nostr'));
|
|
625
|
+
const result = await zap.callback(zapCbMatch[1], Number(q.get('amount')), q.get('nostr'), zapCbMatch[2] ?? null);
|
|
625
626
|
if ('reason' in result)
|
|
626
627
|
return fail(result.reason);
|
|
627
628
|
return send({ ...result, disposable: false });
|
|
@@ -1298,7 +1299,7 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
1298
1299
|
// unref'd so it never keeps the process alive.
|
|
1299
1300
|
const housekeeping = async () => {
|
|
1300
1301
|
const { swept: mintSwept, settled } = await sweepExpiredMintInvoices(store, backend);
|
|
1301
|
-
const swept = mintSwept + (zap
|
|
1302
|
+
const swept = mintSwept + (zap ? await zap.sweep() : 0);
|
|
1302
1303
|
if (swept > 0)
|
|
1303
1304
|
log(`swept ${swept} expired mint invoice${swept === 1 ? '' : 's'}`);
|
|
1304
1305
|
if (settled > 0)
|
|
@@ -1335,6 +1336,63 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
1335
1336
|
};
|
|
1336
1337
|
const zapTimer = zap ? setInterval(() => void zapTick(), deps.zapPollMs ?? 5_000) : null;
|
|
1337
1338
|
zapTimer?.unref();
|
|
1339
|
+
// A funding source that streams its settles lets a paid mint invoice be
|
|
1340
|
+
// credited the moment it is paid, rather than when its wallet next asks
|
|
1341
|
+
// or the expiry sweep finds it. A report is only a hint: the invoice is
|
|
1342
|
+
// checked again and credited by the same call /verify and the sweep
|
|
1343
|
+
// make, and those stay as the fallback whenever the stream is down. A
|
|
1344
|
+
// settled zap invoice brings the next zap pass forward instead.
|
|
1345
|
+
const streamAbort = new AbortController();
|
|
1346
|
+
const streamIndexKey = `invoice_stream_index:${backend.name}`;
|
|
1347
|
+
const onSettled = async (paymentHash, index) => {
|
|
1348
|
+
try {
|
|
1349
|
+
const invoice = store.mintInvoiceByHash(paymentHash);
|
|
1350
|
+
if (invoice && !invoice.settled && (await backend.isInvoiceSettled(paymentHash))) {
|
|
1351
|
+
if (streamAbort.signal.aborted)
|
|
1352
|
+
return;
|
|
1353
|
+
store.settleMintInvoice(paymentHash);
|
|
1354
|
+
}
|
|
1355
|
+
else if (!invoice && zap && store.zapInvoiceByHash(paymentHash)) {
|
|
1356
|
+
void zapTick();
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
catch (err) {
|
|
1360
|
+
log(`invoice stream: could not settle a reported invoice (${err.message})`);
|
|
1361
|
+
}
|
|
1362
|
+
if (!streamAbort.signal.aborted)
|
|
1363
|
+
store.setMetaValue(streamIndexKey, String(index));
|
|
1364
|
+
};
|
|
1365
|
+
const streamInvoices = async (watch) => {
|
|
1366
|
+
const firstRetryMs = deps.invoiceStreamRetryMs ?? 1_000;
|
|
1367
|
+
let retryMs = firstRetryMs;
|
|
1368
|
+
while (!streamAbort.signal.aborted) {
|
|
1369
|
+
const startedAt = Date.now();
|
|
1370
|
+
try {
|
|
1371
|
+
await watch({ fromIndex: Number(store.metaValue(streamIndexKey) ?? 0), signal: streamAbort.signal, onSettled });
|
|
1372
|
+
}
|
|
1373
|
+
catch (err) {
|
|
1374
|
+
if (!streamAbort.signal.aborted)
|
|
1375
|
+
log(`invoice stream dropped: ${err.message}`);
|
|
1376
|
+
}
|
|
1377
|
+
if (streamAbort.signal.aborted)
|
|
1378
|
+
return;
|
|
1379
|
+
if (Date.now() - startedAt > 60_000)
|
|
1380
|
+
retryMs = firstRetryMs;
|
|
1381
|
+
await new Promise(resolve => {
|
|
1382
|
+
const done = () => {
|
|
1383
|
+
clearTimeout(timer);
|
|
1384
|
+
streamAbort.signal.removeEventListener('abort', done);
|
|
1385
|
+
resolve();
|
|
1386
|
+
};
|
|
1387
|
+
const timer = setTimeout(done, retryMs);
|
|
1388
|
+
streamAbort.signal.addEventListener('abort', done);
|
|
1389
|
+
});
|
|
1390
|
+
retryMs = Math.min(retryMs * 2, 60_000);
|
|
1391
|
+
}
|
|
1392
|
+
};
|
|
1393
|
+
const invoiceStream = backend.watchSettledInvoices
|
|
1394
|
+
? streamInvoices(backend.watchSettledInvoices.bind(backend))
|
|
1395
|
+
: Promise.resolve();
|
|
1338
1396
|
// One hourly pass to Nostr carries both: the signed liabilities snapshot
|
|
1339
1397
|
// and the mint's announcement of itself. Each re-checks its own switch,
|
|
1340
1398
|
// so an operator can have either, both, or neither.
|
|
@@ -1362,6 +1420,7 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
1362
1420
|
publishStats,
|
|
1363
1421
|
publishAnnouncement,
|
|
1364
1422
|
close: async () => {
|
|
1423
|
+
streamAbort.abort();
|
|
1365
1424
|
clearInterval(housekeepingTimer);
|
|
1366
1425
|
if (zapTimer)
|
|
1367
1426
|
clearInterval(zapTimer);
|
|
@@ -1370,6 +1429,7 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
1370
1429
|
nostr?.close();
|
|
1371
1430
|
kernel?.close();
|
|
1372
1431
|
await new Promise((resolve, reject) => server.close(err => (err ? reject(err) : resolve())));
|
|
1432
|
+
await invoiceStream;
|
|
1373
1433
|
await backend.close?.();
|
|
1374
1434
|
store.close();
|
|
1375
1435
|
}
|
package/dist/store.d.ts
CHANGED
|
@@ -93,6 +93,8 @@ export declare class NoteStore {
|
|
|
93
93
|
private indexLegacyIds;
|
|
94
94
|
legacyIdOf(q: string): string | null;
|
|
95
95
|
noteByQ(q: string): NoteRow | null;
|
|
96
|
+
metaValue(key: string): string | null;
|
|
97
|
+
setMetaValue(key: string, value: string): void;
|
|
96
98
|
unnamedVerifyCutover(): number;
|
|
97
99
|
private tx;
|
|
98
100
|
noteById(id: string): NoteRow | null;
|
package/dist/store.js
CHANGED
|
@@ -216,6 +216,15 @@ export class NoteStore {
|
|
|
216
216
|
}
|
|
217
217
|
// Invoices quoted at or after this instant get no verify if they named no
|
|
218
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
|
+
}
|
|
219
228
|
unnamedVerifyCutover() {
|
|
220
229
|
const row = this.db
|
|
221
230
|
.prepare("SELECT value FROM meta WHERE key = 'unnamed_verify_cutover'")
|
package/dist/zap.d.ts
CHANGED
|
@@ -45,9 +45,9 @@ export type ZapBridge = {
|
|
|
45
45
|
pubkey: string;
|
|
46
46
|
isZapName(name: string): boolean;
|
|
47
47
|
payRequest(name: string): Record<string, unknown> | null;
|
|
48
|
-
callback(name: string, amountMsat: number, nostrParam: string | null): Promise<ZapCallbackResult>;
|
|
48
|
+
callback(name: string, amountMsat: number, nostrParam: string | null, advertisedIndex?: 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
|
@@ -111,7 +111,7 @@ export const createZapBridge = (deps) => {
|
|
|
111
111
|
const registered = store.zapName(lowered);
|
|
112
112
|
if (!registered)
|
|
113
113
|
return null;
|
|
114
|
-
let
|
|
114
|
+
let advertisedIndex;
|
|
115
115
|
const branch = decodeCx1(registered.cx1 ?? '');
|
|
116
116
|
if (branch) {
|
|
117
117
|
let index = registered.nextIndex;
|
|
@@ -119,7 +119,7 @@ export const createZapBridge = (deps) => {
|
|
|
119
119
|
try {
|
|
120
120
|
const noteId = bytesToHex(deriveNotePubkey(branch.pubkeyXOnly, branch.chainCode, NOTE_PURPOSE_LIGHTNING_ADDRESS, index));
|
|
121
121
|
if (!store.outputIdInUse(noteId)) {
|
|
122
|
-
|
|
122
|
+
advertisedIndex = index;
|
|
123
123
|
break;
|
|
124
124
|
}
|
|
125
125
|
}
|
|
@@ -128,9 +128,15 @@ export const createZapBridge = (deps) => {
|
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
|
+
const internalTransfer = advertisedIndex === undefined ? undefined : `${registered.cx1}:${advertisedIndex}`;
|
|
131
132
|
return {
|
|
132
133
|
tag: 'payRequest',
|
|
133
|
-
callback
|
|
134
|
+
// The index rides in the callback path so the invoice can commit to
|
|
135
|
+
// this exact metadata (LUD-06): by the time the payer calls back, a
|
|
136
|
+
// settled payment may have moved the next unused index on. A path
|
|
137
|
+
// segment rather than a query parameter, because some zap clients
|
|
138
|
+
// append `?amount=` without looking for an existing query.
|
|
139
|
+
callback: `${origin}/z/cb/${lowered}${advertisedIndex === undefined ? '' : `/${advertisedIndex}`}`,
|
|
134
140
|
minSendable: deps.minSendableMsat,
|
|
135
141
|
maxSendable: deps.maxSendableMsat,
|
|
136
142
|
metadata: metadataFor(lowered, host, deps.mintFeeLine, deps.feeInWords, internalTransfer),
|
|
@@ -141,11 +147,16 @@ export const createZapBridge = (deps) => {
|
|
|
141
147
|
// is one.
|
|
142
148
|
};
|
|
143
149
|
};
|
|
144
|
-
const callback = async (name, amountMsat, nostrParam) => {
|
|
150
|
+
const callback = async (name, amountMsat, nostrParam, advertisedIndex = null) => {
|
|
145
151
|
const lowered = name.toLowerCase();
|
|
146
|
-
const
|
|
147
|
-
|
|
152
|
+
const registered = store.zapName(lowered);
|
|
153
|
+
const recipient = registered?.pubkey;
|
|
154
|
+
if (!registered || !recipient)
|
|
148
155
|
return { reason: 'Unknown user.' };
|
|
156
|
+
// Below 2^31: a non-hardened index, as the derivation's ser32 needs.
|
|
157
|
+
if (advertisedIndex !== null && (!/^\d{1,10}$/.test(advertisedIndex) || Number(advertisedIndex) > 0x7fffffff)) {
|
|
158
|
+
return { reason: 'Invalid callback.' };
|
|
159
|
+
}
|
|
149
160
|
if (!Number.isSafeInteger(amountMsat) || amountMsat <= 0)
|
|
150
161
|
return { reason: 'Invalid amount.' };
|
|
151
162
|
if (amountMsat < deps.minSendableMsat || amountMsat > deps.maxSendableMsat)
|
|
@@ -174,13 +185,21 @@ export const createZapBridge = (deps) => {
|
|
|
174
185
|
// nothing to them. The note's secret is minted at settlement.
|
|
175
186
|
const preimage = bytesToHex(randomBytes(32));
|
|
176
187
|
const paymentHash = hashK1(preimage);
|
|
188
|
+
// A zap commits to its zap request (NIP-57); any other payment to the
|
|
189
|
+
// metadata the payer was shown (LUD-06), rebuilt from the index the
|
|
190
|
+
// payRequest advertised. That index is only a hint and feeds nothing
|
|
191
|
+
// but this string: settlement derives the note's key itself, so a
|
|
192
|
+
// forged one changes no more than the forger's own invoice. A cx1
|
|
193
|
+
// changed or cleared between the two requests still mismatches, and
|
|
194
|
+
// the payer's wallet fetches the payRequest again.
|
|
195
|
+
const internalTransfer = advertisedIndex !== null && registered.cx1 ? `${registered.cx1}:${Number(advertisedIndex)}` : undefined;
|
|
177
196
|
let pr;
|
|
178
197
|
try {
|
|
179
198
|
pr = (await backend.createInvoice({
|
|
180
199
|
amountMsat,
|
|
181
200
|
preimageHex: preimage,
|
|
182
201
|
memo: `Zap ${lowered}@${host}`,
|
|
183
|
-
descriptionForHash: zapRequest ?? metadataFor(lowered, host, deps.mintFeeLine, deps.feeInWords)
|
|
202
|
+
descriptionForHash: zapRequest ?? metadataFor(lowered, host, deps.mintFeeLine, deps.feeInWords, internalTransfer)
|
|
184
203
|
})).pr;
|
|
185
204
|
}
|
|
186
205
|
catch (err) {
|
|
@@ -325,7 +344,10 @@ export const createZapBridge = (deps) => {
|
|
|
325
344
|
}
|
|
326
345
|
return published;
|
|
327
346
|
};
|
|
328
|
-
|
|
347
|
+
// As the mint-invoice sweep: an expired row goes only once the funding
|
|
348
|
+
// source confirms it unpaid. One paid while the mint was down stays for
|
|
349
|
+
// the next settle pass to mint, and no answer deletes nothing.
|
|
350
|
+
const sweep = async (nowMs = now()) => {
|
|
329
351
|
const stale = [];
|
|
330
352
|
for (const row of store.unsettledZapInvoices()) {
|
|
331
353
|
const decoded = tryDecodeBolt11(row.pr);
|
|
@@ -334,9 +356,21 @@ export const createZapBridge = (deps) => {
|
|
|
334
356
|
if (nowMs > (decoded.timestamp + decoded.expirySeconds) * 1000 + 3_600_000)
|
|
335
357
|
stale.push(row.paymentHash);
|
|
336
358
|
}
|
|
337
|
-
|
|
359
|
+
let swept = 0;
|
|
360
|
+
for (const hash of stale) {
|
|
361
|
+
let paid;
|
|
362
|
+
try {
|
|
363
|
+
paid = await backend.isInvoiceSettled(hash);
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (paid)
|
|
369
|
+
continue;
|
|
338
370
|
store.deleteUnsettledZapInvoice(hash);
|
|
339
|
-
|
|
371
|
+
swept++;
|
|
372
|
+
}
|
|
373
|
+
return swept;
|
|
340
374
|
};
|
|
341
375
|
return { pubkey, isZapName, payRequest, callback, settle, publish, sweep };
|
|
342
376
|
};
|
package/llms.txt
CHANGED
|
@@ -131,4 +131,7 @@ POST /names claim a name (NIP-98; the signer owns it); body {nam
|
|
|
131
131
|
/w?p=<cp1>&c=<amount-bearing-cs1>&i=<index> plus an `i` tag. The index is
|
|
132
132
|
taken at settlement, never at the invoice, and occupied keys are skipped
|
|
133
133
|
Its payRequest metadata publishes text/cpub = cx1:<next-free-purpose-2-index> for
|
|
134
|
-
same-mint rotate/split/merge transfers; a raced key returns "already in use"
|
|
134
|
+
same-mint rotate/split/merge transfers; a raced key returns "already in use".
|
|
135
|
+
Such a name's callback is /z/cb/{name}/{index}: the index it advertised, so a
|
|
136
|
+
non-zap invoice's description hash is sha256 of that exact metadata (a zap's
|
|
137
|
+
is of its kind 9734). The index feeds only the metadata, never the note's key
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgesworn/moneyer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.1",
|
|
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",
|