@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/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
- const effectiveMinSendableMsat = Math.max(config.minSendableMsat, config.mintFee ? grossUpForMintFee(config.minMintMsat, config.mintFee) : 0);
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
- const invoice = store.mintInvoiceByHash(id);
72
- if (invoice && !invoice.settled && (await backend.isInvoiceSettled(id))) {
73
- store.settleMintInvoice(id);
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({ config, host, mintPubkey: signer?.pubkey ?? null, nodeInfo }));
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
- // ---- LUD-16 payRequest: paying this mints a note ----
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
- // The preimage is the future note's spend secret; its hash is the
204
- // note id AND the invoice's payment hash. Generated here, handed to
205
- // the funding source, never persisted - the store keeps hashes only.
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.noteById(paymentHash) || store.mintInvoiceByHash(paymentHash)) {
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
- store.recordMintInvoice(paymentHash, pr, amount, net);
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: 0,
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
- if (decoded.amountMsats === null || decoded.amountMsats !== BigInt(totalMsat)) {
334
- return fail(`Invoice must be for exactly ${totalMsat} msat.`);
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({ paymentHash, noteId: inputIds[0], pr: pr.trim(), amountMsat: totalMsat }, { store, backend, feeLimitMsat: meltFeeLimitMsat, log, ...(deps.confirmDelaysMs ? { confirmDelaysMs: deps.confirmDelaysMs } : {}) })
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
- reconcile: () => reconcilePendingMelts(store, backend, inFlight, log),
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();