@forgesworn/moneyer 0.1.2 → 0.2.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 +170 -0
- package/README.md +447 -11
- package/THREAT-MODEL.md +15 -6
- package/dist/admin.d.ts +12 -0
- package/dist/admin.js +410 -0
- package/dist/announce.d.ts +12 -0
- package/dist/announce.js +82 -0
- package/dist/backends/cln.js +30 -8
- package/dist/backends/fake-bolt11.d.ts +1 -1
- package/dist/backends/fake-bolt11.js +3 -1
- package/dist/backends/fake.d.ts +7 -1
- package/dist/backends/fake.js +27 -11
- package/dist/backends/lnd.js +30 -6
- package/dist/backends/types.d.ts +3 -0
- package/dist/cli.js +25 -3
- package/dist/config.d.ts +29 -0
- package/dist/config.js +194 -3
- package/dist/index.d.ts +5 -2
- package/dist/index.js +4 -1
- package/dist/landing.d.ts +2 -0
- package/dist/landing.js +42 -2
- package/dist/melt.d.ts +1 -0
- package/dist/melt.js +2 -1
- package/dist/names.d.ts +39 -0
- package/dist/names.js +172 -0
- package/dist/privacy.d.ts +2 -0
- package/dist/privacy.js +14 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +561 -41
- package/dist/signing.d.ts +2 -0
- package/dist/signing.js +34 -0
- package/dist/stats.d.ts +31 -0
- package/dist/stats.js +115 -0
- package/dist/store.d.ts +105 -3
- package/dist/store.js +355 -19
- package/dist/version.d.ts +1 -0
- package/dist/version.js +17 -0
- package/dist/zap.d.ts +52 -0
- package/dist/zap.js +294 -0
- package/llms.txt +25 -8
- package/package.json +4 -3
package/dist/server.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
|
-
import { bytesToHex, randomBytes } from '@noble/hashes/utils.js';
|
|
2
|
+
import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils.js';
|
|
3
|
+
import { finalizeEvent } from 'nostr-tools/pure';
|
|
3
4
|
import { applyMintFee, grossUpForMintFee, hashK1 } from 'lnurlcash-kit';
|
|
4
5
|
import { tryDecodeBolt11 } from 'farrier-kit/bolt11';
|
|
5
|
-
import { NotePendingError, NoteStore, NoteUnavailableError } from "./store.js";
|
|
6
|
+
import { NotePendingError, NoteStore, NoteUnavailableError, OutputCollisionError, swapFingerprint } from "./store.js";
|
|
6
7
|
import { createNoteSigner } from "./signing.js";
|
|
7
8
|
import { createFakeBackend } from "./backends/fake.js";
|
|
8
9
|
import { createClnBackend } from "./backends/cln.js";
|
|
@@ -10,12 +11,45 @@ import { createLndBackend } from "./backends/lnd.js";
|
|
|
10
11
|
import { PaymentPendingError } from "./backends/types.js";
|
|
11
12
|
import { reconcilePendingMelts, runMelt } from "./melt.js";
|
|
12
13
|
import { landingPage } from "./landing.js";
|
|
14
|
+
import { packageVersion } from "./version.js";
|
|
15
|
+
import { createZapBridge, poolTransport } from "./zap.js";
|
|
16
|
+
import { STATS_D_TAG, STATS_KIND, buildStats, statsSnapshotContent } from "./stats.js";
|
|
17
|
+
import { ANNOUNCE_D_TAG, ANNOUNCE_KIND, announcementContent } from "./announce.js";
|
|
18
|
+
import { isRefusal, registerName, validateNip98 } from "./names.js";
|
|
13
19
|
import { CONFIG_TOKEN, loadWebAssets } from "./web-assets.js";
|
|
14
20
|
const HEX32 = /^[0-9a-f]{64}$/;
|
|
21
|
+
// The one request body this mint reads. Capped hard: nothing here needs
|
|
22
|
+
// more than a name and a note, and an unbounded read on a public endpoint
|
|
23
|
+
// is a way to run a mint out of memory.
|
|
24
|
+
const MAX_BODY_BYTES = 8 * 1024;
|
|
25
|
+
const readBody = async (req, limit = MAX_BODY_BYTES) => {
|
|
26
|
+
const chunks = [];
|
|
27
|
+
let size = 0;
|
|
28
|
+
for await (const chunk of req) {
|
|
29
|
+
const buffer = chunk;
|
|
30
|
+
size += buffer.length;
|
|
31
|
+
if (size > limit)
|
|
32
|
+
return null;
|
|
33
|
+
chunks.push(buffer);
|
|
34
|
+
}
|
|
35
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
36
|
+
};
|
|
37
|
+
// "fee 5 sat + 0.1%" - what a payer sees in their wallet's description.
|
|
38
|
+
export const describeFee = (fee, roundedToSat) => {
|
|
39
|
+
const parts = [];
|
|
40
|
+
if (fee.baseFeeMsat > 0)
|
|
41
|
+
parts.push(`${fee.baseFeeMsat % 1000 === 0 ? fee.baseFeeMsat / 1000 : (fee.baseFeeMsat / 1000).toFixed(3)} sat`);
|
|
42
|
+
if (fee.feePpm > 0)
|
|
43
|
+
parts.push(`${(fee.feePpm / 10_000).toString()}%`);
|
|
44
|
+
const base = parts.length ? `fee ${parts.join(' + ')}` : 'no fee';
|
|
45
|
+
return roundedToSat && parts.length ? `${base}, rounded up to the sat` : base;
|
|
46
|
+
};
|
|
47
|
+
// A note's value rounded down to a whole sat; unchanged when already whole.
|
|
48
|
+
const wholeSatFloor = (msat) => Math.floor(msat / 1000) * 1000;
|
|
15
49
|
const backendFor = (config) => {
|
|
16
50
|
switch (config.backend.kind) {
|
|
17
51
|
case 'fake':
|
|
18
|
-
return createFakeBackend();
|
|
52
|
+
return createFakeBackend({ autoSettle: config.backend.autoSettle === true });
|
|
19
53
|
case 'cln':
|
|
20
54
|
return createClnBackend(config.backend);
|
|
21
55
|
case 'lnd':
|
|
@@ -37,6 +71,13 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
37
71
|
catch {
|
|
38
72
|
log('could not fetch funding-source node info - discovery will omit it');
|
|
39
73
|
}
|
|
74
|
+
// The operator's own lightning addresses, re-applied at every startup so
|
|
75
|
+
// the environment and the table cannot disagree. Self-service names
|
|
76
|
+
// live in the same table and are left alone.
|
|
77
|
+
if (config.zap) {
|
|
78
|
+
for (const [name, pubkey] of Object.entries(config.zap.names))
|
|
79
|
+
store.putOperatorZapName(name, pubkey);
|
|
80
|
+
}
|
|
40
81
|
// Melt payment hashes with a live attempt in this process. Reconcile
|
|
41
82
|
// skips these - see melt.ts.
|
|
42
83
|
const inFlight = new Set();
|
|
@@ -53,13 +94,46 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
53
94
|
const netAfterMintFee = (grossMsat) => Math.max(0, grossMsat - mintFeeMsat(grossMsat));
|
|
54
95
|
// What the mint advertises as its minimum must survive its own fee: a
|
|
55
96
|
// payRequest whose minSendable nets below the dust floor invites a
|
|
56
|
-
// payment it would then refuse.
|
|
57
|
-
|
|
97
|
+
// payment it would then refuse. grossUpForMintFee inverts the exact
|
|
98
|
+
// formula; with the fee rounded up to the sat the net can land just
|
|
99
|
+
// under the floor, so walk up until it clears. At most a sat of steps.
|
|
100
|
+
const effectiveMinSendableMsat = (() => {
|
|
101
|
+
let min = Math.max(config.minSendableMsat, config.mintFee ? grossUpForMintFee(config.minMintMsat, config.mintFee) : 0);
|
|
102
|
+
while (config.mintFee && netAfterMintFee(min) < config.minMintMsat)
|
|
103
|
+
min += 1;
|
|
104
|
+
return min;
|
|
105
|
+
})();
|
|
58
106
|
// The routing-fee budget for a melt. LUD-25 has the mint fee cover the
|
|
59
107
|
// eventual payout's routing cost, so the budget follows what this mint
|
|
60
108
|
// actually charges, floored at 0.5%-or-5000msat so fee-free mints still
|
|
61
109
|
// route.
|
|
62
110
|
const meltFeeLimitMsat = (amountMsat) => Math.max(Math.round(amountMsat * 0.005), 5_000, mintFeeMsat(amountMsat));
|
|
111
|
+
const mintFeeLine = config.mintFee ? `Mint fees: ${config.mintFee.baseFeeMsat},${config.mintFee.feePpm}` : null;
|
|
112
|
+
// The same fee for a person: "Mint fees: 5000,1000" is for wallets that
|
|
113
|
+
// parse LUD-25, and reads as nonsense to anyone who does not.
|
|
114
|
+
const feeInWords = config.mintFee ? describeFee(config.mintFee, config.roundFeeToSat === true) : null;
|
|
115
|
+
const nostr = config.zap ? (deps.nostr ?? poolTransport()) : null;
|
|
116
|
+
const zap = config.zap && nostr
|
|
117
|
+
? createZapBridge({
|
|
118
|
+
config: config.zap,
|
|
119
|
+
store,
|
|
120
|
+
backend,
|
|
121
|
+
transport: nostr,
|
|
122
|
+
netAfterMintFee,
|
|
123
|
+
minSendableMsat: effectiveMinSendableMsat,
|
|
124
|
+
maxSendableMsat: config.maxSendableMsat,
|
|
125
|
+
minMintMsat: config.minMintMsat,
|
|
126
|
+
mintFeeLine,
|
|
127
|
+
feeInWords,
|
|
128
|
+
verify: config.verify,
|
|
129
|
+
// configFromEnv guarantees this when zap is set; a caller
|
|
130
|
+
// building the config by hand gets the same rule.
|
|
131
|
+
origin: config.publicOrigin ?? (() => {
|
|
132
|
+
throw new Error('Zap-to-note needs publicOrigin.');
|
|
133
|
+
})(),
|
|
134
|
+
log
|
|
135
|
+
})
|
|
136
|
+
: null;
|
|
63
137
|
// A note whose id we do not know yet may be a settled mint invoice whose
|
|
64
138
|
// claim simply has not been observed: settle it lazily against the
|
|
65
139
|
// funding source, which is what makes paying an invoice mint the note.
|
|
@@ -68,13 +142,152 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
68
142
|
const note = store.noteById(id);
|
|
69
143
|
if (note)
|
|
70
144
|
return note;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
145
|
+
// Either the invoice whose payment hash is this id - the older
|
|
146
|
+
// arrangement, where the payment preimage is the spend secret - or the
|
|
147
|
+
// invoice a payer bound to this id by naming it with `h`. In the bound
|
|
148
|
+
// case the wallet needs nothing but its own secret to claim: no
|
|
149
|
+
// /verify poll, no preimage, and so no window in which knowing the
|
|
150
|
+
// invoice is knowing the money.
|
|
151
|
+
const invoice = store.mintInvoiceByHash(id) ?? store.mintInvoiceByOutputId(id);
|
|
152
|
+
if (invoice && !invoice.settled && (await backend.isInvoiceSettled(invoice.paymentHash))) {
|
|
153
|
+
store.settleMintInvoice(invoice.paymentHash);
|
|
154
|
+
// The note lands at the id its payer named. Looking it up by this id
|
|
155
|
+
// finds it when that id IS the name, and finds nothing when this is
|
|
156
|
+
// a bound invoice's payment hash - which is the point of binding.
|
|
74
157
|
return store.noteById(id);
|
|
75
158
|
}
|
|
76
159
|
return null;
|
|
77
160
|
};
|
|
161
|
+
// ---- transparency: what the mint owes, and what the node holds ----
|
|
162
|
+
//
|
|
163
|
+
// Cached for 30 seconds so a public endpoint cannot be turned into a
|
|
164
|
+
// load generator against the funding source, and so the landing page
|
|
165
|
+
// and /stats never disagree with each other by a request.
|
|
166
|
+
let localBalanceMsat = nodeInfo.localBalanceMsat;
|
|
167
|
+
let reconciledAt;
|
|
168
|
+
let statsCache = null;
|
|
169
|
+
const currentStats = async () => {
|
|
170
|
+
const now = Date.now();
|
|
171
|
+
if (statsCache && now - statsCache.builtAt < 30_000)
|
|
172
|
+
return statsCache.value;
|
|
173
|
+
try {
|
|
174
|
+
// An answer that omits the balance is the funding source saying it
|
|
175
|
+
// does not know, and coverage must disappear with it rather than be
|
|
176
|
+
// computed against a stale number. A THROWN error is different: the
|
|
177
|
+
// node is unreachable this minute, and the last known figure stands.
|
|
178
|
+
const fresh = await backend.nodeInfo?.();
|
|
179
|
+
if (fresh)
|
|
180
|
+
localBalanceMsat = fresh.localBalanceMsat;
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// unreachable - keep the last figure
|
|
184
|
+
}
|
|
185
|
+
const value = buildStats({
|
|
186
|
+
liabilities: store.liabilities(now),
|
|
187
|
+
localBalanceMsat,
|
|
188
|
+
reconciledAt,
|
|
189
|
+
at: now,
|
|
190
|
+
ratioOnly: config.statsRatioOnly === true
|
|
191
|
+
});
|
|
192
|
+
statsCache = { builtAt: now, value };
|
|
193
|
+
return value;
|
|
194
|
+
};
|
|
195
|
+
// ---- the mint address document ----
|
|
196
|
+
//
|
|
197
|
+
// LUD-25 discovery, built here rather than inline in its route because
|
|
198
|
+
// the mint also announces itself with it, and there must be one
|
|
199
|
+
// description of a mint rather than two that can drift apart.
|
|
200
|
+
const mintAddressDocument = (origin, user) => ({
|
|
201
|
+
tag: 'withdrawRequest',
|
|
202
|
+
callback: `${origin}/w`,
|
|
203
|
+
minWithdrawable: config.minMintMsat,
|
|
204
|
+
maxWithdrawable: config.mintFee
|
|
205
|
+
? netAfterMintFee(config.maxSendableMsat)
|
|
206
|
+
: config.maxSendableMsat,
|
|
207
|
+
defaultDescription: config.description,
|
|
208
|
+
payLink: `${origin}/.well-known/lnurlp/${user}`,
|
|
209
|
+
...(signer ? { mintPubkey: signer.pubkey } : {}),
|
|
210
|
+
// The human layer: who runs this, how to reach them, the terms,
|
|
211
|
+
// and today's message. Absent unless the operator set it.
|
|
212
|
+
...(config.name ? { name: config.name } : {}),
|
|
213
|
+
description: config.description,
|
|
214
|
+
...(config.contact ? { contact: config.contact } : {}),
|
|
215
|
+
...(config.tosUrl ? { tosUrl: config.tosUrl } : {}),
|
|
216
|
+
...(config.motd ? { motd: config.motd } : {}),
|
|
217
|
+
// The structured twin of the payRequest metadata's fee prose. Both
|
|
218
|
+
// stay: one is for a wallet that parses LUD-25, the other for a
|
|
219
|
+
// wallet that only reads a payRequest.
|
|
220
|
+
...(config.mintFee ? { fees: config.mintFee } : {}),
|
|
221
|
+
...(packageVersion ? { version: packageVersion } : {}),
|
|
222
|
+
// What a lightning address at this mint costs, when anyone can
|
|
223
|
+
// claim one. Absent means registration is closed.
|
|
224
|
+
...(config.namePriceMsat !== undefined ? { namePriceMsat: config.namePriceMsat } : {}),
|
|
225
|
+
// Keys this mint has signed under before, so a wallet pinned to an
|
|
226
|
+
// old one can tell a legitimate rotation from an impostor. Always
|
|
227
|
+
// present, empty included: "never rotated" and "does not
|
|
228
|
+
// implement the field" are different answers.
|
|
229
|
+
previousPubkeys: config.previousSigningPubkeys ?? [],
|
|
230
|
+
// This mint accepts `h` on the pay callback: the payer's wallet may
|
|
231
|
+
// name the note it is buying, and then the payment preimage is not
|
|
232
|
+
// a spend secret for it. A wallet learns this before it asks for an
|
|
233
|
+
// invoice rather than after it has paid one.
|
|
234
|
+
mintToHash: true,
|
|
235
|
+
...(nodeInfo.alias ? { nodeAlias: nodeInfo.alias } : {}),
|
|
236
|
+
...(nodeInfo.uri ? { nodeUri: nodeInfo.uri } : {}),
|
|
237
|
+
...(nodeInfo.color ? { nodeColor: nodeInfo.color } : {}),
|
|
238
|
+
// nodeCapacity is the name the reference mint, the conformance
|
|
239
|
+
// mock and lnurlcash-kit all use; nodeCapacityMsat was ours alone
|
|
240
|
+
// and only survived a round trip through the kit's rest-spread.
|
|
241
|
+
// Both go out for one release, then the old name goes.
|
|
242
|
+
...(nodeInfo.capacityMsat !== undefined
|
|
243
|
+
? { nodeCapacity: nodeInfo.capacityMsat, nodeCapacityMsat: nodeInfo.capacityMsat }
|
|
244
|
+
: {}),
|
|
245
|
+
...(nodeInfo.numChannels !== undefined ? { nodeNumChannels: nodeInfo.numChannels } : {}),
|
|
246
|
+
...(nodeInfo.numPeers !== undefined ? { nodeNumPeers: nodeInfo.numPeers } : {})
|
|
247
|
+
});
|
|
248
|
+
// An hourly signed snapshot, so the coverage history can be checked
|
|
249
|
+
// after the fact rather than taken on the operator's word for it today.
|
|
250
|
+
// Signed with the NOTE key: a holder already trusts that key for their
|
|
251
|
+
// own notes, so this adds nothing new to trust.
|
|
252
|
+
const publishStats = async () => {
|
|
253
|
+
if (config.statsPublish !== true || !signer || !config.signingKey || !config.zap || !nostr)
|
|
254
|
+
return;
|
|
255
|
+
const stats = await currentStats();
|
|
256
|
+
const event = finalizeEvent({
|
|
257
|
+
kind: STATS_KIND,
|
|
258
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
259
|
+
tags: [['d', STATS_D_TAG]],
|
|
260
|
+
content: statsSnapshotContent(stats, config.signingKey)
|
|
261
|
+
}, hexToBytes(config.zap.nostrKey));
|
|
262
|
+
const { ok, failed } = await nostr.publish(config.zap.relays, event);
|
|
263
|
+
log(`liabilities snapshot published to ${ok.length} relay${ok.length === 1 ? '' : 's'}${failed.length ? `, ${failed.length} refused` : ''}`);
|
|
264
|
+
};
|
|
265
|
+
// The mint saying where it is. A wallet otherwise only ever learns of a
|
|
266
|
+
// mint by being told its address, and nothing that might follow - a
|
|
267
|
+
// list, a recommendation, a review - can exist until a mint can be found
|
|
268
|
+
// at all.
|
|
269
|
+
//
|
|
270
|
+
// It reuses the replaceable kind the snapshot goes out under, with a `d`
|
|
271
|
+
// tag of its own: choosing a new event kind is a protocol decision, and
|
|
272
|
+
// Cashu's NIP-87 kinds are not ours to take. The content is the
|
|
273
|
+
// discovery document exactly as the endpoint serves it, plus a signature
|
|
274
|
+
// by the NOTE signing key, so a holder can check that the mint
|
|
275
|
+
// announcing itself is the mint their notes verify against.
|
|
276
|
+
//
|
|
277
|
+
// Off unless the operator asked for it: a mint that does not want to be
|
|
278
|
+
// listed says nothing.
|
|
279
|
+
const publishAnnouncement = async () => {
|
|
280
|
+
if (config.announce !== true || !config.zap || !nostr || !config.publicOrigin)
|
|
281
|
+
return;
|
|
282
|
+
const event = finalizeEvent({
|
|
283
|
+
kind: ANNOUNCE_KIND,
|
|
284
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
285
|
+
tags: [['d', ANNOUNCE_D_TAG]],
|
|
286
|
+
content: announcementContent(mintAddressDocument(config.publicOrigin, config.username), config.signingKey)
|
|
287
|
+
}, hexToBytes(config.zap.nostrKey));
|
|
288
|
+
const { ok, failed } = await nostr.publish(config.zap.relays, event);
|
|
289
|
+
log(`mint announced to ${ok.length} relay${ok.length === 1 ? '' : 's'}${failed.length ? `, ${failed.length} refused` : ''}`);
|
|
290
|
+
};
|
|
78
291
|
const handle = async (req, res) => {
|
|
79
292
|
const requestUrl = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);
|
|
80
293
|
const q = requestUrl.searchParams;
|
|
@@ -118,7 +331,13 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
118
331
|
'x-frame-options': 'DENY',
|
|
119
332
|
'referrer-policy': 'no-referrer'
|
|
120
333
|
});
|
|
121
|
-
res.end(landingPage({
|
|
334
|
+
res.end(landingPage({
|
|
335
|
+
config,
|
|
336
|
+
host,
|
|
337
|
+
mintPubkey: signer?.pubkey ?? null,
|
|
338
|
+
nodeInfo,
|
|
339
|
+
stats: config.stats === false ? null : await currentStats()
|
|
340
|
+
}));
|
|
122
341
|
}
|
|
123
342
|
return;
|
|
124
343
|
}
|
|
@@ -134,18 +353,147 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
134
353
|
return;
|
|
135
354
|
}
|
|
136
355
|
}
|
|
356
|
+
// ---- claiming a lightning address ----
|
|
357
|
+
//
|
|
358
|
+
// This sits ABOVE the GET-only gate on purpose. That gate protects
|
|
359
|
+
// the LNURL callbacks, where a retried GET carrying the same query
|
|
360
|
+
// string must never burn a note twice; this is not one of them. It is
|
|
361
|
+
// a POST because it creates something, and it is authenticated by
|
|
362
|
+
// NIP-98 rather than by anything this mint has to store.
|
|
363
|
+
if (requestUrl.pathname === '/names' && req.method === 'POST') {
|
|
364
|
+
if (config.namePriceMsat === undefined)
|
|
365
|
+
return fail('This mint is not registering names.', 404);
|
|
366
|
+
const body = await readBody(req);
|
|
367
|
+
if (body === null)
|
|
368
|
+
return fail('Request body too large.', 413);
|
|
369
|
+
const authorized = validateNip98(req.headers.authorization, {
|
|
370
|
+
url: `${origin}${requestUrl.pathname}`,
|
|
371
|
+
method: 'POST',
|
|
372
|
+
body
|
|
373
|
+
});
|
|
374
|
+
if ('reason' in authorized)
|
|
375
|
+
return fail(authorized.reason, 401);
|
|
376
|
+
let parsed;
|
|
377
|
+
try {
|
|
378
|
+
parsed = JSON.parse(body || '{}');
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
return fail('Body must be JSON.', 400);
|
|
382
|
+
}
|
|
383
|
+
const result = registerName({
|
|
384
|
+
store,
|
|
385
|
+
// The NIP-98 signer owns the name. No other identity is accepted:
|
|
386
|
+
// a pubkey in the body would let anyone register a name to
|
|
387
|
+
// somebody else's key.
|
|
388
|
+
pubkey: authorized.pubkey,
|
|
389
|
+
body: parsed,
|
|
390
|
+
priceMsat: config.namePriceMsat,
|
|
391
|
+
reserved: [config.username],
|
|
392
|
+
host
|
|
393
|
+
});
|
|
394
|
+
if (isRefusal(result))
|
|
395
|
+
return fail(result.reason, result.status);
|
|
396
|
+
log(`name ${result.name} registered to ${result.pubkey.slice(0, 8)} for ${result.paidMsat} msat`);
|
|
397
|
+
return send({
|
|
398
|
+
status: 'OK',
|
|
399
|
+
name: result.name,
|
|
400
|
+
pubkey: result.pubkey,
|
|
401
|
+
address: `${result.name}@${host}`,
|
|
402
|
+
priceMsat: config.namePriceMsat,
|
|
403
|
+
paidMsat: result.paidMsat
|
|
404
|
+
});
|
|
405
|
+
}
|
|
137
406
|
// Every LNURL endpoint below is a GET. The method matters: /w/cb
|
|
138
407
|
// mutates on whatever arrives, and an OPTIONS preflight or a stray
|
|
139
408
|
// retry carrying the same query string must never burn a note.
|
|
140
409
|
if (req.method !== 'GET')
|
|
141
410
|
return fail('Not found.', 404);
|
|
142
|
-
// ----
|
|
411
|
+
// ---- NIP-05 ----
|
|
412
|
+
// The same table, so a registered name resolves both as a lightning
|
|
413
|
+
// address and as a Nostr address. Only the name asked for is
|
|
414
|
+
// answered: the list of everyone here is not something to hand out.
|
|
415
|
+
if (requestUrl.pathname === '/.well-known/nostr.json') {
|
|
416
|
+
const wanted = q.get('name')?.toLowerCase();
|
|
417
|
+
const entry = wanted ? store.zapName(wanted) : null;
|
|
418
|
+
return send({ names: entry ? { [entry.name]: entry.pubkey } : {} });
|
|
419
|
+
}
|
|
420
|
+
// ---- machine-readable operating figures ----
|
|
421
|
+
// The OpenMetrics text format, for a scraper. Off unless asked for,
|
|
422
|
+
// and deliberately unauthenticated: the deployment notes restrict the
|
|
423
|
+
// path at the reverse proxy, which is where that decision belongs.
|
|
424
|
+
if (requestUrl.pathname === '/metrics') {
|
|
425
|
+
if (config.metrics !== true)
|
|
426
|
+
return fail('Not found.', 404);
|
|
427
|
+
const liabilities = store.liabilities();
|
|
428
|
+
const totals = store.totals();
|
|
429
|
+
// The balance rides along with the /stats cache rather than hitting
|
|
430
|
+
// the funding source on every scrape.
|
|
431
|
+
const snapshot = await currentStats();
|
|
432
|
+
const lines = [];
|
|
433
|
+
const metric = (name, help, type, samples) => {
|
|
434
|
+
lines.push(`# HELP ${name} ${help}`, `# TYPE ${name} ${type}`);
|
|
435
|
+
for (const [labels, value] of samples)
|
|
436
|
+
lines.push(`${name}${labels} ${value}`);
|
|
437
|
+
};
|
|
438
|
+
metric('moneyer_outstanding_msat', 'Value of every note this mint still owes.', 'gauge', [['', liabilities.outstandingMsat]]);
|
|
439
|
+
metric('moneyer_outstanding_notes', 'Number of notes this mint still owes.', 'gauge', [['', liabilities.outstandingNotes]]);
|
|
440
|
+
metric('moneyer_pending_melts', 'Melts reserved against a note and not yet resolved.', 'gauge', [['', liabilities.pendingMelts]]);
|
|
441
|
+
metric('moneyer_oldest_pending_melt_seconds', 'Age of the oldest unresolved melt.', 'gauge', [
|
|
442
|
+
['', liabilities.oldestPendingMeltAgeSecs]
|
|
443
|
+
]);
|
|
444
|
+
if (snapshot.localBalanceMsat !== undefined) {
|
|
445
|
+
metric('moneyer_local_balance_msat', 'Outbound balance reported by the funding source.', 'gauge', [
|
|
446
|
+
['', snapshot.localBalanceMsat]
|
|
447
|
+
]);
|
|
448
|
+
}
|
|
449
|
+
metric('moneyer_unsettled_mint_invoices', 'Mint invoices issued and not yet paid.', 'gauge', [
|
|
450
|
+
['', totals.unsettledMintInvoices]
|
|
451
|
+
]);
|
|
452
|
+
metric('moneyer_mints_total', 'Notes minted from a settled invoice.', 'counter', [['', totals.mints]]);
|
|
453
|
+
metric('moneyer_melts_total', 'Melts by outcome.', 'counter', [
|
|
454
|
+
['{outcome="paid"}', totals.melts.paid],
|
|
455
|
+
['{outcome="restored"}', totals.melts.restored],
|
|
456
|
+
['{outcome="pending"}', totals.melts.pending]
|
|
457
|
+
]);
|
|
458
|
+
metric('moneyer_zaps_total', 'Zaps that settled into a note.', 'counter', [['', totals.zaps]]);
|
|
459
|
+
res.writeHead(200, {
|
|
460
|
+
'content-type': 'text/plain; version=0.0.4; charset=utf-8',
|
|
461
|
+
'x-content-type-options': 'nosniff'
|
|
462
|
+
});
|
|
463
|
+
res.end(`${lines.join('\n')}\n`);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
// ---- what the mint owes ----
|
|
467
|
+
// Public by design, and never per-note: a mint that will not say its
|
|
468
|
+
// liabilities is asking for trust it has not earned, but a mint that
|
|
469
|
+
// listed its notes would be handing out an oracle.
|
|
470
|
+
if (requestUrl.pathname === '/stats') {
|
|
471
|
+
if (config.stats === false)
|
|
472
|
+
return fail('Not found.', 404);
|
|
473
|
+
return send(await currentStats());
|
|
474
|
+
}
|
|
475
|
+
// ---- Zap-to-note: a name that pays out as a note over Nostr ----
|
|
143
476
|
const lnurlpMatch = requestUrl.pathname.match(/^\/\.well-known\/lnurlp\/(.+)$/);
|
|
477
|
+
if (lnurlpMatch && zap && zap.isZapName(lnurlpMatch[1])) {
|
|
478
|
+
return send(zap.payRequest(lnurlpMatch[1]));
|
|
479
|
+
}
|
|
480
|
+
const zapCbMatch = requestUrl.pathname.match(/^\/z\/cb\/(.+)$/);
|
|
481
|
+
if (zapCbMatch) {
|
|
482
|
+
if (!zap || !zap.isZapName(zapCbMatch[1]))
|
|
483
|
+
return fail('Unknown user.', 404);
|
|
484
|
+
if (config.sunset)
|
|
485
|
+
return fail('This mint is sunsetting - minting is disabled.');
|
|
486
|
+
const result = await zap.callback(zapCbMatch[1], Number(q.get('amount')), q.get('nostr'));
|
|
487
|
+
if ('reason' in result)
|
|
488
|
+
return fail(result.reason);
|
|
489
|
+
return send({ ...result, disposable: false });
|
|
490
|
+
}
|
|
491
|
+
// ---- LUD-16 payRequest: paying this mints a note ----
|
|
144
492
|
if (lnurlpMatch) {
|
|
145
493
|
if (!knownUser(lnurlpMatch[1]))
|
|
146
494
|
return fail('Unknown user.', 404);
|
|
147
495
|
const metadata = [
|
|
148
|
-
['text/plain', `Mint an LNURLcash bearer note at ${config.username}@${host}`],
|
|
496
|
+
['text/plain', `Mint an LNURLcash bearer note at ${config.username}@${host}${feeInWords ? ` (${feeInWords})` : ''}`],
|
|
149
497
|
['text/identifier', `${lnurlpMatch[1]}@${host}`]
|
|
150
498
|
];
|
|
151
499
|
if (config.mintFee) {
|
|
@@ -161,6 +509,12 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
161
509
|
// LUD-17's lnurlw:// is the scheme a wallet puts on a QR, not a
|
|
162
510
|
// field in a JSON body; every other URL here is directly fetchable.
|
|
163
511
|
withdrawLink: `${origin}/w`,
|
|
512
|
+
// This mint takes `h` on the callback below, so a wallet can name
|
|
513
|
+
// the note it is buying. Advertised here as well as on the
|
|
514
|
+
// discovery document because a wallet handed nothing but a
|
|
515
|
+
// lightning address never reads that document, and this has to be
|
|
516
|
+
// known BEFORE paying, not after.
|
|
517
|
+
mintToHash: true,
|
|
164
518
|
disposable: false
|
|
165
519
|
});
|
|
166
520
|
}
|
|
@@ -169,23 +523,7 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
169
523
|
if (lnurlwMatch) {
|
|
170
524
|
if (!knownUser(lnurlwMatch[1]))
|
|
171
525
|
return fail('Unknown user.', 404);
|
|
172
|
-
return send(
|
|
173
|
-
tag: 'withdrawRequest',
|
|
174
|
-
callback: `${origin}/w`,
|
|
175
|
-
minWithdrawable: config.minMintMsat,
|
|
176
|
-
maxWithdrawable: config.mintFee
|
|
177
|
-
? netAfterMintFee(config.maxSendableMsat)
|
|
178
|
-
: config.maxSendableMsat,
|
|
179
|
-
defaultDescription: config.description,
|
|
180
|
-
payLink: `${origin}/.well-known/lnurlp/${lnurlwMatch[1]}`,
|
|
181
|
-
...(signer ? { mintPubkey: signer.pubkey } : {}),
|
|
182
|
-
...(nodeInfo.alias ? { nodeAlias: nodeInfo.alias } : {}),
|
|
183
|
-
...(nodeInfo.uri ? { nodeUri: nodeInfo.uri } : {}),
|
|
184
|
-
...(nodeInfo.color ? { nodeColor: nodeInfo.color } : {}),
|
|
185
|
-
...(nodeInfo.capacityMsat !== undefined ? { nodeCapacityMsat: nodeInfo.capacityMsat } : {}),
|
|
186
|
-
...(nodeInfo.numChannels !== undefined ? { nodeNumChannels: nodeInfo.numChannels } : {}),
|
|
187
|
-
...(nodeInfo.numPeers !== undefined ? { nodeNumPeers: nodeInfo.numPeers } : {})
|
|
188
|
-
});
|
|
526
|
+
return send(mintAddressDocument(origin, lnurlwMatch[1]));
|
|
189
527
|
}
|
|
190
528
|
// ---- LUD-06 pay callback: issue a mint invoice ----
|
|
191
529
|
if (requestUrl.pathname === '/p/cb') {
|
|
@@ -200,12 +538,43 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
200
538
|
const net = netAfterMintFee(amount);
|
|
201
539
|
if (net < config.minMintMsat)
|
|
202
540
|
return fail('Amount too small to mint a note.');
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
// the
|
|
541
|
+
// ---- naming the note being bought ----
|
|
542
|
+
//
|
|
543
|
+
// Optionally the payer's wallet chooses the note's spend secret
|
|
544
|
+
// itself and sends `h`, the sha256 of it, exactly as `h` means on
|
|
545
|
+
// the withdraw callback. The mint then credits the note at `h` on
|
|
546
|
+
// settlement, and the invoice's payment preimage buys nothing.
|
|
547
|
+
//
|
|
548
|
+
// That matters because a payment preimage is not a secret between
|
|
549
|
+
// two parties. It is known to the funding source, it is known to
|
|
550
|
+
// every node that forwarded the payment, and /verify hands it to
|
|
551
|
+
// anyone who can name the payment hash - which is written inside the
|
|
552
|
+
// invoice itself, on the QR the payer was shown. Where the preimage
|
|
553
|
+
// is the money, holding the invoice is nearly holding the money, and
|
|
554
|
+
// the wallet's only defence is to claim and rotate faster than
|
|
555
|
+
// anybody else. Naming the note removes the race instead of running
|
|
556
|
+
// it: the buyer is the only party who ever knew the secret.
|
|
557
|
+
//
|
|
558
|
+
// Both checks happen before an invoice exists, so a wallet is never
|
|
559
|
+
// left holding a quote the mint was always going to refuse. A
|
|
560
|
+
// collision gets the same reason a colliding output gets on the
|
|
561
|
+
// withdraw callback: which table an id already sits in is an oracle
|
|
562
|
+
// nobody is owed.
|
|
563
|
+
const askedOutputId = q.get('h');
|
|
564
|
+
const outputId = askedOutputId === null ? null : askedOutputId.toLowerCase();
|
|
565
|
+
if (outputId !== null) {
|
|
566
|
+
if (!HEX32.test(outputId))
|
|
567
|
+
return fail('missing h');
|
|
568
|
+
if (store.outputIdInUse(outputId))
|
|
569
|
+
return fail('Invalid or already spent k1.');
|
|
570
|
+
}
|
|
571
|
+
// The preimage is the future note's spend secret unless `h` named
|
|
572
|
+
// one; its hash is the invoice's payment hash either way. Generated
|
|
573
|
+
// here, handed to the funding source, never persisted - the store
|
|
574
|
+
// keeps hashes only.
|
|
206
575
|
let preimage = bytesToHex(randomBytes(32));
|
|
207
576
|
let paymentHash = hashK1(preimage);
|
|
208
|
-
while (store.
|
|
577
|
+
while (store.outputIdInUse(paymentHash) || paymentHash === outputId) {
|
|
209
578
|
preimage = bytesToHex(randomBytes(32));
|
|
210
579
|
paymentHash = hashK1(preimage);
|
|
211
580
|
}
|
|
@@ -229,10 +598,26 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
229
598
|
log('funding source returned an invoice that does not match the requested preimage/amount');
|
|
230
599
|
return fail('Temporarily unable to issue an invoice.');
|
|
231
600
|
}
|
|
232
|
-
|
|
601
|
+
try {
|
|
602
|
+
store.recordMintInvoice(paymentHash, pr, amount, net, outputId);
|
|
603
|
+
}
|
|
604
|
+
catch (err) {
|
|
605
|
+
// The read above already refused the collisions it could see; this
|
|
606
|
+
// closes the race where another request claimed the id in between.
|
|
607
|
+
// The invoice exists at the funding source but has been shown to
|
|
608
|
+
// nobody, so nothing can be paid against it.
|
|
609
|
+
if (err instanceof OutputCollisionError)
|
|
610
|
+
return fail('Invalid or already spent k1.');
|
|
611
|
+
throw err;
|
|
612
|
+
}
|
|
233
613
|
return send({
|
|
234
614
|
pr,
|
|
235
615
|
disposable: false,
|
|
616
|
+
// Confirmation that this invoice really is bound to the id the
|
|
617
|
+
// wallet named. A mint that ignored an unknown parameter would
|
|
618
|
+
// answer without it, and a wallet can tell the two apart before
|
|
619
|
+
// paying rather than by looking for a note afterwards.
|
|
620
|
+
...(outputId !== null ? { mintToHash: true } : {}),
|
|
236
621
|
...(config.verify ? { verify: `${origin}/verify/${paymentHash}` } : {})
|
|
237
622
|
});
|
|
238
623
|
}
|
|
@@ -259,6 +644,15 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
259
644
|
const preimageHex = settled ? await backend.paymentPreimage(paymentHash) : null;
|
|
260
645
|
return send({ status: 'OK', settled, preimage: preimageHex, pr: melt.pr });
|
|
261
646
|
}
|
|
647
|
+
// A zap invoice's preimage is a throwaway the payer already holds,
|
|
648
|
+
// so serving it is LUD-21 as written and leaks nothing: the note's
|
|
649
|
+
// secret is a different value, sealed to the recipient.
|
|
650
|
+
const zapInvoice = store.zapInvoiceByHash(paymentHash);
|
|
651
|
+
if (zapInvoice) {
|
|
652
|
+
const settled = zapInvoice.settled || (await backend.isInvoiceSettled(paymentHash));
|
|
653
|
+
const preimageHex = settled ? await backend.invoicePreimage(paymentHash) : null;
|
|
654
|
+
return send({ status: 'OK', settled, preimage: preimageHex, pr: zapInvoice.pr });
|
|
655
|
+
}
|
|
262
656
|
return fail('Unknown payment hash.');
|
|
263
657
|
}
|
|
264
658
|
// ---- LUD-03 informational GET ----
|
|
@@ -271,13 +665,24 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
271
665
|
return fail('Unknown note.');
|
|
272
666
|
if (note.state === 'burned')
|
|
273
667
|
return fail('Note already spent.');
|
|
668
|
+
// maxWithdrawable states the note's value, as the reference does.
|
|
669
|
+
// minWithdrawable is that value floored to a whole sat: most Lightning
|
|
670
|
+
// wallets can only invoice whole sats, and a note of 94.9 sat that
|
|
671
|
+
// insists on exactly 94,900 msat cannot be withdrawn by any of them.
|
|
672
|
+
// The sub-sat remainder of such a melt is dust the mint keeps.
|
|
274
673
|
return send({
|
|
275
674
|
tag: 'withdrawRequest',
|
|
276
675
|
callback: `${origin}/w/cb`,
|
|
277
676
|
k1,
|
|
278
|
-
minWithdrawable:
|
|
677
|
+
minWithdrawable: wholeSatFloor(note.amountMsat),
|
|
279
678
|
maxWithdrawable: note.amountMsat,
|
|
280
679
|
defaultDescription: config.description,
|
|
680
|
+
// The way home. A payRequest advertises `withdrawLink`; this is the
|
|
681
|
+
// other direction, so a holder who has nothing but a note can still
|
|
682
|
+
// find the document that publishes this mint's terms and its retired
|
|
683
|
+
// signing keys. Without it a wallet that only ever received notes
|
|
684
|
+
// cannot tell an announced key rotation from a substituted key.
|
|
685
|
+
payLink: `${origin}/.well-known/lnurlp/${config.username}`,
|
|
281
686
|
...(signer ? { mintPubkey: signer.pubkey } : {})
|
|
282
687
|
});
|
|
283
688
|
}
|
|
@@ -312,6 +717,49 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
312
717
|
// merge or split. Atomic refusal, same as an invalid one.
|
|
313
718
|
if (new Set(k1s).size !== k1s.length)
|
|
314
719
|
return fail('Invalid or already spent k1.');
|
|
720
|
+
// ---- the retried mutation ----
|
|
721
|
+
//
|
|
722
|
+
// A rotate, split or merge is a GET, and transports retry GETs.
|
|
723
|
+
// Go's net/http retries one that failed on a reused idle
|
|
724
|
+
// connection; the JDK's HttpClient retries idempotent methods with
|
|
725
|
+
// no switch to turn it off. The retry is byte-identical and arrives
|
|
726
|
+
// after the inputs are burned, so the honest-looking answer -
|
|
727
|
+
// "already spent" - tells a wallet to drop the only copy of a
|
|
728
|
+
// secret this mint really did mint a note against.
|
|
729
|
+
//
|
|
730
|
+
// So a request that already minted its outputs is answered with the
|
|
731
|
+
// same reply. This branch burns nothing, mints nothing and moves no
|
|
732
|
+
// balance: it is a read. The signature is deterministic over the
|
|
733
|
+
// output id and its amount, so it is recomputed rather than stored.
|
|
734
|
+
// Anything else naming a burned input is a double-spend attempt and
|
|
735
|
+
// still gets today's reason string, unchanged.
|
|
736
|
+
// Every k1 has to be hex before it can be hashed into a
|
|
737
|
+
// fingerprint; a malformed one falls through to the loop below and
|
|
738
|
+
// gets the same refusal it always did.
|
|
739
|
+
const fingerprint = pr !== null || !k1s.every(k1 => HEX32.test(k1))
|
|
740
|
+
? null
|
|
741
|
+
: swapFingerprint({
|
|
742
|
+
inputIds: k1s.map(hashK1),
|
|
743
|
+
h: h,
|
|
744
|
+
h2,
|
|
745
|
+
...(amountRaw !== null ? { amountMsat: Number(amountRaw) } : {})
|
|
746
|
+
});
|
|
747
|
+
const alreadyMinted = fingerprint === null ? null : store.swapByFingerprint(fingerprint);
|
|
748
|
+
if (alreadyMinted) {
|
|
749
|
+
const first = alreadyMinted.find(output => output.id === h);
|
|
750
|
+
const second = h2 === undefined ? undefined : alreadyMinted.find(output => output.id === h2);
|
|
751
|
+
if (first) {
|
|
752
|
+
return send({
|
|
753
|
+
status: 'OK',
|
|
754
|
+
...(signer
|
|
755
|
+
? {
|
|
756
|
+
sig: signer.sign(first.id, first.amountMsat),
|
|
757
|
+
...(second ? { sig2: signer.sign(second.id, second.amountMsat) } : {})
|
|
758
|
+
}
|
|
759
|
+
: {})
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
}
|
|
315
763
|
const found = [];
|
|
316
764
|
for (const k1 of k1s) {
|
|
317
765
|
if (!HEX32.test(k1))
|
|
@@ -330,8 +778,26 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
330
778
|
const decoded = tryDecodeBolt11(pr.trim());
|
|
331
779
|
if (!decoded)
|
|
332
780
|
return fail('Invalid invoice.');
|
|
333
|
-
|
|
334
|
-
|
|
781
|
+
// Exactly the notes' value, or the whole-sat floor of it when the
|
|
782
|
+
// value is not a whole sat (see the informational GET above). Never
|
|
783
|
+
// less than the floor: that would let a holder leave real value on
|
|
784
|
+
// the table by accident, and never more.
|
|
785
|
+
const floorMsat = wholeSatFloor(totalMsat);
|
|
786
|
+
// An invoice that states no amount is the easiest case a bearer
|
|
787
|
+
// note has: the note's value IS the amount, so the mint fills it in
|
|
788
|
+
// rather than refusing. It sends the whole-sat floor, which is the
|
|
789
|
+
// arithmetic a whole-value melt already does, and keeps the sub-sat
|
|
790
|
+
// remainder as the same dust it keeps when a wallet invoices the
|
|
791
|
+
// floor itself. A note worth less than a single sat has nothing
|
|
792
|
+
// left once that applies, and is refused as it always was.
|
|
793
|
+
const payAmountMsat = decoded.amountMsats === null ? floorMsat : null;
|
|
794
|
+
if (payAmountMsat === 0)
|
|
795
|
+
return fail('insufficient value');
|
|
796
|
+
if (decoded.amountMsats !== null &&
|
|
797
|
+
(decoded.amountMsats > BigInt(totalMsat) || decoded.amountMsats < BigInt(floorMsat))) {
|
|
798
|
+
return fail(floorMsat === totalMsat
|
|
799
|
+
? `Invoice must be for exactly ${totalMsat} msat.`
|
|
800
|
+
: `Invoice must be for ${totalMsat} msat, or ${floorMsat} msat (the whole-sat floor).`);
|
|
335
801
|
}
|
|
336
802
|
const paymentHash = decoded.paymentHashHex;
|
|
337
803
|
// Paying an invoice this mint itself issued would route the funding
|
|
@@ -374,7 +840,13 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
374
840
|
return fail('Invoice already used by an earlier melt - use a fresh one.');
|
|
375
841
|
}
|
|
376
842
|
inFlight.add(paymentHash);
|
|
377
|
-
void runMelt({
|
|
843
|
+
void runMelt({
|
|
844
|
+
paymentHash,
|
|
845
|
+
noteId: inputIds[0],
|
|
846
|
+
pr: pr.trim(),
|
|
847
|
+
amountMsat: totalMsat,
|
|
848
|
+
...(payAmountMsat !== null ? { payAmountMsat } : {})
|
|
849
|
+
}, { store, backend, feeLimitMsat: meltFeeLimitMsat, log, ...(deps.confirmDelaysMs ? { confirmDelaysMs: deps.confirmDelaysMs } : {}) })
|
|
378
850
|
.catch(err => log(`melt ${inputIds[0]}: ${err.message}`))
|
|
379
851
|
.finally(() => inFlight.delete(paymentHash));
|
|
380
852
|
// Replied before the payment resolves, per LUD-03: OK means the
|
|
@@ -409,7 +881,7 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
409
881
|
store.swap(inputIds, [
|
|
410
882
|
{ id: h, amountMsat: amount },
|
|
411
883
|
{ id: h2, amountMsat: changeMsat }
|
|
412
|
-
]);
|
|
884
|
+
], fingerprint ?? undefined);
|
|
413
885
|
}
|
|
414
886
|
catch (err) {
|
|
415
887
|
if (err instanceof NotePendingError)
|
|
@@ -429,7 +901,7 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
429
901
|
// of one - the refund is exactly 0.
|
|
430
902
|
const mergedMsat = totalMsat + (inputIds.length - 1) * baseFeeMsat;
|
|
431
903
|
try {
|
|
432
|
-
store.swap(inputIds, [{ id: h, amountMsat: mergedMsat }]);
|
|
904
|
+
store.swap(inputIds, [{ id: h, amountMsat: mergedMsat }], fingerprint ?? undefined);
|
|
433
905
|
}
|
|
434
906
|
catch (err) {
|
|
435
907
|
if (err instanceof NotePendingError)
|
|
@@ -465,16 +937,51 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
465
937
|
// otherwise grow by one dead row per /p/cb call forever. The timer is
|
|
466
938
|
// unref'd so it never keeps the process alive.
|
|
467
939
|
const housekeeping = async () => {
|
|
468
|
-
const swept = sweepExpiredMintInvoices(store);
|
|
940
|
+
const swept = sweepExpiredMintInvoices(store) + (zap?.sweep() ?? 0);
|
|
469
941
|
if (swept > 0)
|
|
470
942
|
log(`swept ${swept} expired mint invoice${swept === 1 ? '' : 's'}`);
|
|
471
943
|
await reconcilePendingMelts(store, backend, inFlight, log);
|
|
944
|
+
reconciledAt = Date.now();
|
|
472
945
|
};
|
|
473
946
|
await housekeeping();
|
|
474
947
|
const housekeepingTimer = setInterval(() => {
|
|
475
948
|
housekeeping().catch(err => log(`housekeeping failed: ${err.message}`));
|
|
476
949
|
}, 300_000);
|
|
477
950
|
housekeepingTimer.unref();
|
|
951
|
+
// A zap settles on the funding source's clock, not ours, so look often.
|
|
952
|
+
// One pass at a time: a slow relay must not stack passes.
|
|
953
|
+
let zapPass = null;
|
|
954
|
+
const zapTick = () => {
|
|
955
|
+
if (!zap)
|
|
956
|
+
return Promise.resolve();
|
|
957
|
+
if (zapPass)
|
|
958
|
+
return zapPass;
|
|
959
|
+
zapPass = (async () => {
|
|
960
|
+
try {
|
|
961
|
+
await zap.settle();
|
|
962
|
+
await zap.publish();
|
|
963
|
+
}
|
|
964
|
+
catch (err) {
|
|
965
|
+
log(`zap pass failed: ${err.message}`);
|
|
966
|
+
}
|
|
967
|
+
finally {
|
|
968
|
+
zapPass = null;
|
|
969
|
+
}
|
|
970
|
+
})();
|
|
971
|
+
return zapPass;
|
|
972
|
+
};
|
|
973
|
+
const zapTimer = zap ? setInterval(() => void zapTick(), deps.zapPollMs ?? 5_000) : null;
|
|
974
|
+
zapTimer?.unref();
|
|
975
|
+
// One hourly pass to Nostr carries both: the signed liabilities snapshot
|
|
976
|
+
// and the mint's announcement of itself. Each re-checks its own switch,
|
|
977
|
+
// so an operator can have either, both, or neither.
|
|
978
|
+
const statsTimer = (config.statsPublish === true && signer && config.zap) || (config.announce === true && config.zap)
|
|
979
|
+
? setInterval(() => {
|
|
980
|
+
publishStats().catch(err => log(`liabilities snapshot failed: ${err.message}`));
|
|
981
|
+
publishAnnouncement().catch(err => log(`mint announcement failed: ${err.message}`));
|
|
982
|
+
}, deps.statsPublishMs ?? 3_600_000)
|
|
983
|
+
: null;
|
|
984
|
+
statsTimer?.unref();
|
|
478
985
|
return {
|
|
479
986
|
url: `http://${config.host}:${port}`,
|
|
480
987
|
port,
|
|
@@ -482,9 +989,22 @@ export const createMoneyer = async (config, deps = {}) => {
|
|
|
482
989
|
store,
|
|
483
990
|
backend,
|
|
484
991
|
signer,
|
|
485
|
-
|
|
992
|
+
zap,
|
|
993
|
+
reconcile: async () => {
|
|
994
|
+
await reconcilePendingMelts(store, backend, inFlight, log);
|
|
995
|
+
reconciledAt = Date.now();
|
|
996
|
+
await zapTick();
|
|
997
|
+
},
|
|
998
|
+
stats: currentStats,
|
|
999
|
+
publishStats,
|
|
1000
|
+
publishAnnouncement,
|
|
486
1001
|
close: async () => {
|
|
487
1002
|
clearInterval(housekeepingTimer);
|
|
1003
|
+
if (zapTimer)
|
|
1004
|
+
clearInterval(zapTimer);
|
|
1005
|
+
if (statsTimer)
|
|
1006
|
+
clearInterval(statsTimer);
|
|
1007
|
+
nostr?.close();
|
|
488
1008
|
await new Promise((resolve, reject) => server.close(err => (err ? reject(err) : resolve())));
|
|
489
1009
|
await backend.close?.();
|
|
490
1010
|
store.close();
|