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