@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/store.js CHANGED
@@ -1,4 +1,20 @@
1
1
  import { DatabaseSync } from 'node:sqlite';
2
+ import { sha256 } from '@noble/hashes/sha2.js';
3
+ import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
4
+ // What identifies one mutation request, so a retry of it can be answered
5
+ // with the same reply instead of "already spent". Everything the WALLET
6
+ // chose goes in: the notes it named (by id, never by secret - the store
7
+ // holds no secrets), the output ids it asked for, and the split amount.
8
+ // Anything else naming a burned input is a different request and is still
9
+ // refused, so no oracle appears. Input order is not part of it: a
10
+ // reordered retry is the same operation.
11
+ export const swapFingerprint = (args) => bytesToHex(sha256(utf8ToBytes([[...args.inputIds].sort().join(','), args.h, args.h2 ?? '', args.amountMsat === undefined ? '' : String(args.amountMsat)].join('|'))));
12
+ // A lightning address this mint pays out as a note. `source` is how it
13
+ // got here: 'env' for one the operator set, 'self' for one somebody
14
+ // registered and paid for.
15
+ // How long a connection waits for a lock before giving up. Long enough
16
+ // for a swap to commit, short enough that a wedged process is obvious.
17
+ const BUSY_TIMEOUT_MS = 5_000;
2
18
  // A note named by the request is reserved by an in-flight melt. The wire
3
19
  // reply for this is the exact reason string "pending".
4
20
  export class NotePendingError extends Error {
@@ -15,9 +31,27 @@ export class OutputCollisionError extends Error {
15
31
  }
16
32
  export class NoteStore {
17
33
  db;
18
- constructor(path) {
34
+ readOnly;
35
+ // `readOnly` is for the operator CLI: a command that only reads should
36
+ // not be able to write, and should not create a database file at a
37
+ // mistyped path either. It skips the schema statements for the same
38
+ // reason - there is nothing to migrate when nothing can be written.
39
+ constructor(path, options = {}) {
40
+ this.readOnly = options.readOnly === true;
41
+ if (this.readOnly) {
42
+ this.db = new DatabaseSync(path, { readOnly: true });
43
+ // A reader still meets a locked shared-memory index for a moment
44
+ // during WAL recovery, so it waits too rather than throwing.
45
+ this.db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
46
+ return;
47
+ }
19
48
  this.db = new DatabaseSync(path);
20
49
  this.db.exec('PRAGMA journal_mode = WAL');
50
+ // node:sqlite opens with no busy timeout at all, so a second writer
51
+ // gets `database is locked` the instant it meets the first one. The
52
+ // operator CLI is that second writer while the mint is running, and
53
+ // the moments you reach for it are the busy ones. Wait, do not throw.
54
+ this.db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
21
55
  this.db.exec('PRAGMA foreign_keys = ON');
22
56
  this.db.exec(`
23
57
  CREATE TABLE IF NOT EXISTS notes (
@@ -33,6 +67,35 @@ export class NoteStore {
33
67
  gross_msat INTEGER NOT NULL,
34
68
  net_msat INTEGER NOT NULL,
35
69
  settled INTEGER NOT NULL DEFAULT 0,
70
+ created_at INTEGER NOT NULL,
71
+ output_id TEXT
72
+ );
73
+ CREATE TABLE IF NOT EXISTS zap_invoices (
74
+ payment_hash TEXT PRIMARY KEY,
75
+ name TEXT NOT NULL,
76
+ recipient TEXT NOT NULL,
77
+ pr TEXT NOT NULL,
78
+ gross_msat INTEGER NOT NULL,
79
+ net_msat INTEGER NOT NULL,
80
+ zap_request TEXT,
81
+ settled INTEGER NOT NULL DEFAULT 0,
82
+ note_id TEXT,
83
+ wrap_json TEXT,
84
+ receipt_json TEXT,
85
+ created_at INTEGER NOT NULL,
86
+ settled_at INTEGER,
87
+ published_at INTEGER
88
+ );
89
+ CREATE TABLE IF NOT EXISTS zap_names (
90
+ name TEXT PRIMARY KEY,
91
+ pubkey TEXT NOT NULL,
92
+ created_at INTEGER NOT NULL,
93
+ paid_msat INTEGER NOT NULL DEFAULT 0,
94
+ source TEXT NOT NULL CHECK (source IN ('env','self'))
95
+ );
96
+ CREATE TABLE IF NOT EXISTS swaps (
97
+ fingerprint TEXT PRIMARY KEY,
98
+ outputs TEXT NOT NULL,
36
99
  created_at INTEGER NOT NULL
37
100
  );
38
101
  CREATE TABLE IF NOT EXISTS melts (
@@ -45,6 +108,15 @@ export class NoteStore {
45
108
  resolved_at INTEGER
46
109
  );
47
110
  `);
111
+ // `CREATE TABLE IF NOT EXISTS` leaves a database an earlier version
112
+ // made exactly as it found it, so a column added later is added here.
113
+ const invoiceColumns = this.db.prepare('PRAGMA table_info(mint_invoices)').all().map(column => column.name);
114
+ if (!invoiceColumns.includes('output_id')) {
115
+ this.db.exec('ALTER TABLE mint_invoices ADD COLUMN output_id TEXT');
116
+ }
117
+ // Two invoices may not name the same note. SQLite counts NULLs as
118
+ // distinct, so every unbound invoice still fits.
119
+ this.db.exec('CREATE UNIQUE INDEX IF NOT EXISTS mint_invoices_output_id ON mint_invoices (output_id)');
48
120
  }
49
121
  tx(fn) {
50
122
  this.db.exec('BEGIN IMMEDIATE');
@@ -73,16 +145,27 @@ export class NoteStore {
73
145
  setNoteState(id, state) {
74
146
  this.db.prepare('UPDATE notes SET state = ?, updated_at = ? WHERE id = ?').run(state, Date.now(), id);
75
147
  }
76
- // An output id may not collide with any existing note OR any mint
77
- // invoice's payment hash, settled or not. The invoice case is the subtle
78
- // one: /verify hands out a settled mint invoice's preimage, and that
79
- // preimage is the k1 of whatever note carries the invoice's payment hash
80
- // as its id - so letting a mutation claim such an id would point a future
81
- // payer's money at a stranger's note.
82
- assertOutputIdFree(id) {
148
+ // An output id may not collide with any existing note, any mint
149
+ // invoice's payment hash, settled or not, or any note an unsettled
150
+ // invoice has already been told to credit. The invoice cases are the
151
+ // subtle ones: /verify hands out a settled mint invoice's preimage, and
152
+ // that preimage is the k1 of whatever note carries the invoice's payment
153
+ // hash as its id - so letting a mutation claim such an id would point a
154
+ // future payer's money at a stranger's note. A bound invoice's `h` is
155
+ // the same hazard one step earlier: the note does not exist yet, but it
156
+ // is already spoken for.
157
+ //
158
+ // Public because the pay callback has to answer this before it asks the
159
+ // funding source for an invoice: a wallet must never pay for a quote the
160
+ // mint was always going to refuse.
161
+ outputIdInUse(id) {
83
162
  const asNote = this.db.prepare('SELECT 1 FROM notes WHERE id = ?').get(id);
84
163
  const asInvoice = this.db.prepare('SELECT 1 FROM mint_invoices WHERE payment_hash = ?').get(id);
85
- if (asNote || asInvoice)
164
+ const asBoundOutput = this.db.prepare('SELECT 1 FROM mint_invoices WHERE output_id = ?').get(id);
165
+ return Boolean(asNote || asInvoice || asBoundOutput);
166
+ }
167
+ assertOutputIdFree(id) {
168
+ if (this.outputIdInUse(id))
86
169
  throw new OutputCollisionError(`output id ${id} is already in use`);
87
170
  }
88
171
  assertOutstanding(id) {
@@ -97,7 +180,16 @@ export class NoteStore {
97
180
  }
98
181
  // The atomic mutation behind rotate, split and merge: burn every input,
99
182
  // mint every output, or do nothing at all.
100
- swap(inputIds, outputs) {
183
+ //
184
+ // `fingerprint`, when given, records which request minted these outputs
185
+ // from those inputs. A GET is retried by transports that have no idea a
186
+ // rotate spends anything - Go's net/http retries one on a reused idle
187
+ // connection, the JDK's HttpClient retries idempotent methods with no
188
+ // switch to stop it - and the retry arrives byte-identical after the
189
+ // inputs are already burned. Without provenance the mint can only say
190
+ // "already spent", and a wallet that believes it drops the only copy of
191
+ // a secret the mint really did mint a note against.
192
+ swap(inputIds, outputs, fingerprint) {
101
193
  this.tx(() => {
102
194
  for (const id of inputIds)
103
195
  this.assertOutstanding(id);
@@ -107,8 +199,24 @@ export class NoteStore {
107
199
  this.setNoteState(id, 'burned');
108
200
  for (const output of outputs)
109
201
  this.insertNote(output.id, output.amountMsat);
202
+ if (fingerprint !== undefined) {
203
+ this.db
204
+ .prepare('INSERT OR REPLACE INTO swaps (fingerprint, outputs, created_at) VALUES (?, ?, ?)')
205
+ .run(fingerprint, JSON.stringify(outputs.map(output => [output.id, output.amountMsat])), Date.now());
206
+ }
110
207
  });
111
208
  }
209
+ // What a previous request with this exact fingerprint minted, if any.
210
+ // Provenance is recorded rather than inferred on purpose: matching on
211
+ // "a note exists at h" alone would let anyone holding a burned k1 and
212
+ // any outstanding note id draw a success out of the mint.
213
+ swapByFingerprint(fingerprint) {
214
+ const row = this.db.prepare('SELECT outputs FROM swaps WHERE fingerprint = ?').get(fingerprint);
215
+ if (!row)
216
+ return null;
217
+ const parsed = JSON.parse(row.outputs);
218
+ return parsed.map(([id, amountMsat]) => ({ id, amountMsat }));
219
+ }
112
220
  // Reserves a note for a melt and records the melt, atomically. The melts
113
221
  // row is keyed by the invoice's payment hash; a duplicate hash means an
114
222
  // earlier melt already used this invoice and the INSERT itself refuses.
@@ -171,17 +279,22 @@ export class NoteStore {
171
279
  outcome: row.outcome
172
280
  }));
173
281
  }
174
- recordMintInvoice(paymentHash, pr, grossMsat, netMsat) {
282
+ // `outputId` is the note id the payer's wallet asked for with `h`. Both
283
+ // ids are checked in the same transaction that inserts the row, so two
284
+ // requests racing for one id cannot both be told yes.
285
+ recordMintInvoice(paymentHash, pr, grossMsat, netMsat, outputId = null) {
175
286
  this.tx(() => {
176
287
  this.assertOutputIdFree(paymentHash);
288
+ if (outputId !== null)
289
+ this.assertOutputIdFree(outputId);
177
290
  this.db
178
- .prepare('INSERT INTO mint_invoices (payment_hash, pr, gross_msat, net_msat, settled, created_at) VALUES (?, ?, ?, ?, 0, ?)')
179
- .run(paymentHash, pr, grossMsat, netMsat, Date.now());
291
+ .prepare('INSERT INTO mint_invoices (payment_hash, pr, gross_msat, net_msat, settled, created_at, output_id) VALUES (?, ?, ?, ?, 0, ?, ?)')
292
+ .run(paymentHash, pr, grossMsat, netMsat, Date.now(), outputId);
180
293
  });
181
294
  }
182
295
  mintInvoiceByHash(paymentHash) {
183
296
  const row = this.db
184
- .prepare('SELECT payment_hash, pr, gross_msat, net_msat, settled FROM mint_invoices WHERE payment_hash = ?')
297
+ .prepare('SELECT payment_hash, pr, gross_msat, net_msat, settled, output_id FROM mint_invoices WHERE payment_hash = ?')
185
298
  .get(paymentHash);
186
299
  if (!row)
187
300
  return null;
@@ -190,22 +303,32 @@ export class NoteStore {
190
303
  pr: row.pr,
191
304
  grossMsat: row.gross_msat,
192
305
  netMsat: row.net_msat,
193
- settled: row.settled === 1
306
+ settled: row.settled === 1,
307
+ outputId: row.output_id
194
308
  };
195
309
  }
310
+ // The invoice a payer bound to this note id, if any. The lookup a claim
311
+ // makes when the wallet named the note it was buying: the note does not
312
+ // exist until the invoice settles, and the wallet knows nothing but the
313
+ // secret it chose.
314
+ mintInvoiceByOutputId(outputId) {
315
+ const row = this.db.prepare('SELECT payment_hash FROM mint_invoices WHERE output_id = ?').get(outputId);
316
+ return row ? this.mintInvoiceByHash(row.payment_hash) : null;
317
+ }
196
318
  // Every unsettled mint invoice, for the expiry sweep. An unsettled row
197
319
  // past its bolt11 expiry can never settle - the funding source refuses
198
320
  // expired invoices - so it is dead weight, and every /p/cb call adds one.
199
321
  unsettledMintInvoices() {
200
322
  const rows = this.db
201
- .prepare('SELECT payment_hash, pr, gross_msat, net_msat, settled FROM mint_invoices WHERE settled = 0')
323
+ .prepare('SELECT payment_hash, pr, gross_msat, net_msat, settled, output_id FROM mint_invoices WHERE settled = 0')
202
324
  .all();
203
325
  return rows.map(row => ({
204
326
  paymentHash: row.payment_hash,
205
327
  pr: row.pr,
206
328
  grossMsat: row.gross_msat,
207
329
  netMsat: row.net_msat,
208
- settled: false
330
+ settled: false,
331
+ outputId: row.output_id
209
332
  }));
210
333
  }
211
334
  // Conditional on STILL unsettled: a settle landing between the sweep's
@@ -215,16 +338,148 @@ export class NoteStore {
215
338
  }
216
339
  // Paying a mint invoice is what brings its note into existence. Safe to
217
340
  // call twice: the second settle finds the note already minted.
341
+ //
342
+ // The note lands at the id the payer's wallet named, when it named one.
343
+ // Otherwise it lands at the invoice's payment hash, which is the older
344
+ // arrangement where the payment preimage is the spend secret.
218
345
  settleMintInvoice(paymentHash) {
219
346
  this.tx(() => {
220
347
  const invoice = this.mintInvoiceByHash(paymentHash);
221
348
  if (!invoice)
222
349
  return;
223
350
  this.db.prepare('UPDATE mint_invoices SET settled = 1 WHERE payment_hash = ?').run(paymentHash);
224
- if (!this.noteById(paymentHash))
225
- this.insertNote(paymentHash, invoice.netMsat);
351
+ const noteId = invoice.outputId ?? paymentHash;
352
+ if (!this.noteById(noteId))
353
+ this.insertNote(noteId, invoice.netMsat);
354
+ });
355
+ }
356
+ // ---- zap-to-note ----
357
+ recordZapInvoice(row) {
358
+ this.db
359
+ .prepare('INSERT INTO zap_invoices (payment_hash, name, recipient, pr, gross_msat, net_msat, zap_request, settled, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)')
360
+ .run(row.paymentHash, row.name, row.recipient, row.pr, row.grossMsat, row.netMsat, row.zapRequest, Date.now());
361
+ }
362
+ zapRow(row) {
363
+ return {
364
+ paymentHash: row.payment_hash,
365
+ name: row.name,
366
+ recipient: row.recipient,
367
+ pr: row.pr,
368
+ grossMsat: row.gross_msat,
369
+ netMsat: row.net_msat,
370
+ zapRequest: row.zap_request ?? null,
371
+ settled: row.settled === 1,
372
+ noteId: row.note_id ?? null,
373
+ wrapJson: row.wrap_json ?? null,
374
+ receiptJson: row.receipt_json ?? null,
375
+ settledAt: row.settled_at ?? null
376
+ };
377
+ }
378
+ static ZAP_COLUMNS = 'payment_hash, name, recipient, pr, gross_msat, net_msat, zap_request, settled, note_id, wrap_json, receipt_json, settled_at';
379
+ zapInvoiceByHash(paymentHash) {
380
+ const row = this.db
381
+ .prepare(`SELECT ${NoteStore.ZAP_COLUMNS} FROM zap_invoices WHERE payment_hash = ?`)
382
+ .get(paymentHash);
383
+ return row ? this.zapRow(row) : null;
384
+ }
385
+ unsettledZapInvoices() {
386
+ const rows = this.db
387
+ .prepare(`SELECT ${NoteStore.ZAP_COLUMNS} FROM zap_invoices WHERE settled = 0`)
388
+ .all();
389
+ return rows.map(row => this.zapRow(row));
390
+ }
391
+ deleteUnsettledZapInvoice(paymentHash) {
392
+ this.db.prepare('DELETE FROM zap_invoices WHERE payment_hash = ? AND settled = 0').run(paymentHash);
393
+ }
394
+ // The note comes into being and the events that announce it are parked
395
+ // for publishing, in one transaction: a crash between the two would
396
+ // otherwise leave a liability nobody was ever told about. Returns false
397
+ // if the row was already settled (a racing poll), in which case nothing
398
+ // was minted.
399
+ settleZapInvoice(paymentHash, noteId, wrapJson, receiptJson) {
400
+ return this.tx(() => {
401
+ const row = this.zapInvoiceByHash(paymentHash);
402
+ if (!row || row.settled)
403
+ return false;
404
+ this.assertOutputIdFree(noteId);
405
+ this.insertNote(noteId, row.netMsat);
406
+ this.db
407
+ .prepare('UPDATE zap_invoices SET settled = 1, note_id = ?, wrap_json = ?, receipt_json = ?, settled_at = ? WHERE payment_hash = ?')
408
+ .run(noteId, wrapJson, receiptJson, Date.now(), paymentHash);
409
+ return true;
410
+ });
411
+ }
412
+ // Settled zaps whose wrap has not yet reached a relay.
413
+ unpublishedZaps() {
414
+ const rows = this.db
415
+ .prepare(`SELECT ${NoteStore.ZAP_COLUMNS} FROM zap_invoices WHERE settled = 1 AND published_at IS NULL`)
416
+ .all();
417
+ return rows.map(row => this.zapRow(row));
418
+ }
419
+ // The wrap is on a relay: drop our copy. The receipt is dropped with it
420
+ // whether or not every relay took it - it is public and best-effort.
421
+ markZapPublished(paymentHash) {
422
+ this.db
423
+ .prepare('UPDATE zap_invoices SET wrap_json = NULL, receipt_json = NULL, published_at = ? WHERE payment_hash = ?')
424
+ .run(Date.now(), paymentHash);
425
+ }
426
+ // ---- lightning addresses ----
427
+ //
428
+ // One table for both kinds: names the operator set in the environment
429
+ // and names people registered themselves. The lookup path that serves a
430
+ // zap does not care which is which, so there is one of it.
431
+ zapName(name) {
432
+ const row = this.db
433
+ .prepare('SELECT name, pubkey, created_at, paid_msat, source FROM zap_names WHERE name = ?')
434
+ .get(name.toLowerCase());
435
+ if (!row)
436
+ return null;
437
+ return { name: row.name, pubkey: row.pubkey, createdAt: row.created_at, paidMsat: row.paid_msat, source: row.source };
438
+ }
439
+ zapNames() {
440
+ const rows = this.db
441
+ .prepare('SELECT name, pubkey, created_at, paid_msat, source FROM zap_names ORDER BY name')
442
+ .all();
443
+ return rows.map(row => ({
444
+ name: row.name,
445
+ pubkey: row.pubkey,
446
+ createdAt: row.created_at,
447
+ paidMsat: row.paid_msat,
448
+ source: row.source
449
+ }));
450
+ }
451
+ // The operator's own names, re-applied at every startup. Idempotent, and
452
+ // the environment wins: it is the operator's mint, and a name they put
453
+ // in the environment is one they mean to have.
454
+ putOperatorZapName(name, pubkey) {
455
+ this.db
456
+ .prepare(`INSERT INTO zap_names (name, pubkey, created_at, paid_msat, source) VALUES (?, ?, ?, 0, 'env')
457
+ ON CONFLICT(name) DO UPDATE SET pubkey = excluded.pubkey, source = 'env'`)
458
+ .run(name.toLowerCase(), pubkey, Date.now());
459
+ }
460
+ // A self-service registration: burn the note that paid for the name and
461
+ // record the name, or do neither. One transaction, because the two
462
+ // failure modes either side of it are both bad - a name nobody paid
463
+ // for, or a note burned for a name somebody else got first. The INSERT
464
+ // is also the race check: two requests for one name cannot both win.
465
+ buyZapName(args) {
466
+ this.tx(() => {
467
+ if (args.noteId !== undefined) {
468
+ this.assertOutstanding(args.noteId);
469
+ this.setNoteState(args.noteId, 'burned');
470
+ }
471
+ this.db
472
+ .prepare("INSERT INTO zap_names (name, pubkey, created_at, paid_msat, source) VALUES (?, ?, ?, ?, 'self')")
473
+ .run(args.name.toLowerCase(), args.pubkey, Date.now(), args.paidMsat);
226
474
  });
227
475
  }
476
+ removeZapName(name) {
477
+ return this.db.prepare('DELETE FROM zap_names WHERE name = ?').run(name.toLowerCase()).changes > 0;
478
+ }
479
+ zapNameCountFor(pubkey) {
480
+ const row = this.db.prepare('SELECT COUNT(*) AS n FROM zap_names WHERE pubkey = ?').get(pubkey);
481
+ return row.n;
482
+ }
228
483
  // Operator/dev funding path: mint a note directly, bypassing Lightning.
229
484
  // The fake backend's world only - the CLI never exposes it on a real one.
230
485
  creditNote(id, amountMsat) {
@@ -233,12 +488,93 @@ export class NoteStore {
233
488
  this.insertNote(id, amountMsat);
234
489
  });
235
490
  }
491
+ // Everything the mint owes and everything it is in the middle of
492
+ // paying, in one read. Public numbers only: this is what /stats
493
+ // publishes, and no per-note detail belongs anywhere near it.
494
+ liabilities(nowMs = Date.now()) {
495
+ const notes = this.db
496
+ .prepare("SELECT COUNT(*) AS count, COALESCE(SUM(amount_msat), 0) AS total FROM notes WHERE state IN ('outstanding','pending')")
497
+ .get();
498
+ const melts = this.db
499
+ .prepare('SELECT COUNT(*) AS count, COALESCE(SUM(amount_msat), 0) AS total, MIN(created_at) AS oldest FROM melts WHERE outcome IS NULL')
500
+ .get();
501
+ return {
502
+ outstandingMsat: notes.total,
503
+ outstandingNotes: notes.count,
504
+ pendingMsat: melts.total,
505
+ pendingMelts: melts.count,
506
+ // Whole seconds, and never negative however the clock has moved.
507
+ oldestPendingMeltAgeSecs: melts.oldest === null ? 0 : Math.max(0, Math.floor((nowMs - melts.oldest) / 1000))
508
+ };
509
+ }
510
+ // Note rows for the operator CLI. Newest first, capped by the caller:
511
+ // this is the operator's own database, not anything a request reaches.
512
+ notes(filter = {}) {
513
+ const limit = Math.max(1, Math.min(filter.limit ?? 20, 1000));
514
+ const rows = (filter.state === undefined
515
+ ? this.db
516
+ .prepare('SELECT id, amount_msat, state, created_at, updated_at FROM notes ORDER BY created_at DESC LIMIT ?')
517
+ .all(limit)
518
+ : this.db
519
+ .prepare('SELECT id, amount_msat, state, created_at, updated_at FROM notes WHERE state = ? ORDER BY created_at DESC LIMIT ?')
520
+ .all(filter.state, limit));
521
+ return rows.map(row => ({
522
+ id: row.id,
523
+ amountMsat: row.amount_msat,
524
+ state: row.state,
525
+ createdAt: row.created_at,
526
+ updatedAt: row.updated_at
527
+ }));
528
+ }
529
+ melts(filter = {}) {
530
+ const limit = Math.max(1, Math.min(filter.limit ?? 20, 1000));
531
+ const where = filter.pendingOnly === true ? 'WHERE outcome IS NULL' : '';
532
+ const rows = this.db
533
+ .prepare(`SELECT payment_hash, note_id, pr, amount_msat, outcome, created_at, resolved_at FROM melts ${where} ORDER BY created_at DESC LIMIT ?`)
534
+ .all(limit);
535
+ return rows.map(row => ({
536
+ paymentHash: row.payment_hash,
537
+ noteId: row.note_id,
538
+ pr: row.pr,
539
+ amountMsat: row.amount_msat,
540
+ outcome: row.outcome,
541
+ createdAt: row.created_at,
542
+ resolvedAt: row.resolved_at
543
+ }));
544
+ }
545
+ // Lifetime totals, read from the tables rather than counted in memory,
546
+ // so a restart does not reset them.
547
+ totals() {
548
+ const mints = this.db.prepare('SELECT COUNT(*) AS n FROM mint_invoices WHERE settled = 1').get();
549
+ const unsettled = this.db.prepare('SELECT COUNT(*) AS n FROM mint_invoices WHERE settled = 0').get();
550
+ const zaps = this.db.prepare('SELECT COUNT(*) AS n FROM zap_invoices WHERE settled = 1').get();
551
+ const melts = this.db
552
+ .prepare("SELECT COALESCE(outcome, 'pending') AS outcome, COUNT(*) AS n FROM melts GROUP BY 1")
553
+ .all();
554
+ const byOutcome = { paid: 0, restored: 0, pending: 0 };
555
+ for (const row of melts)
556
+ byOutcome[row.outcome] = row.n;
557
+ return { mints: mints.n, unsettledMintInvoices: unsettled.n, zaps: zaps.n, melts: byOutcome };
558
+ }
559
+ // VACUUM INTO: a consistent copy of the whole database, taken while the
560
+ // mint is running, without needing a sqlite3 binary on the box. SQLite
561
+ // refuses an existing target itself; the caller checks first so the
562
+ // operator gets a sentence rather than a driver error.
563
+ snapshot(path) {
564
+ this.db.exec(`VACUUM INTO '${path.replace(/'/g, "''")}'`);
565
+ }
236
566
  outstandingLiabilityMsat() {
237
567
  const row = this.db
238
568
  .prepare("SELECT COALESCE(SUM(amount_msat), 0) AS total FROM notes WHERE state IN ('outstanding','pending')")
239
569
  .get();
240
570
  return row.total;
241
571
  }
572
+ // What this connection will wait for a lock, so the guarantee is
573
+ // testable rather than assumed.
574
+ busyTimeoutMs() {
575
+ const row = this.db.prepare('PRAGMA busy_timeout').get();
576
+ return row?.timeout ?? 0;
577
+ }
242
578
  close() {
243
579
  this.db.close();
244
580
  }
@@ -0,0 +1 @@
1
+ export declare const packageVersion: string | undefined;
@@ -0,0 +1,17 @@
1
+ import { readFileSync } from 'node:fs';
2
+ // The running version, for the discovery endpoint and the operator CLI.
3
+ // Read from package.json rather than baked in by a build step: a mint that
4
+ // says it is on a version it is not is worse than one that says nothing.
5
+ // dist/ sits one level under the package root, and so does src/ when
6
+ // running the sources directly, so the relative path is the same either
7
+ // way. Unreadable means the field is simply absent.
8
+ export const packageVersion = (() => {
9
+ try {
10
+ const raw = readFileSync(new URL('../package.json', import.meta.url), 'utf8');
11
+ const version = JSON.parse(raw).version;
12
+ return typeof version === 'string' ? version : undefined;
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ })();
package/dist/zap.d.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { type Event } from 'nostr-tools/pure';
2
+ import type { Filter } from 'nostr-tools/filter';
3
+ import type { ZapConfig } from './config.ts';
4
+ import type { NoteStore } from './store.ts';
5
+ import type { LightningBackend } from './backends/types.ts';
6
+ export declare const NOTE_KIND = 2525;
7
+ export declare const INBOX_RELAYS_KIND = 10050;
8
+ export declare const ZAP_REQUEST_KIND = 9734;
9
+ export declare const INDEXER_RELAYS: string[];
10
+ export type NostrTransport = {
11
+ publish(relays: string[], event: Event): Promise<{
12
+ ok: string[];
13
+ failed: string[];
14
+ }>;
15
+ query(relays: string[], filter: Filter): Promise<Event[]>;
16
+ close(): void;
17
+ };
18
+ export declare const INBOX_RETRY_MS: number;
19
+ export declare const poolTransport: () => NostrTransport;
20
+ export declare const inboxRelays: (transport: NostrTransport, pubkey: string, lookOn: string[]) => Promise<string[]>;
21
+ export type ZapBridgeDeps = {
22
+ config: ZapConfig;
23
+ store: NoteStore;
24
+ backend: LightningBackend;
25
+ transport: NostrTransport;
26
+ netAfterMintFee: (grossMsat: number) => number;
27
+ minSendableMsat: number;
28
+ maxSendableMsat: number;
29
+ minMintMsat: number;
30
+ mintFeeLine: string | null;
31
+ feeInWords: string | null;
32
+ verify: boolean;
33
+ origin: string;
34
+ log?: (message: string) => void;
35
+ now?: () => number;
36
+ };
37
+ export type ZapCallbackResult = {
38
+ pr: string;
39
+ verify?: string;
40
+ } | {
41
+ reason: string;
42
+ };
43
+ export type ZapBridge = {
44
+ pubkey: string;
45
+ isZapName(name: string): boolean;
46
+ payRequest(name: string): Record<string, unknown> | null;
47
+ callback(name: string, amountMsat: number, nostrParam: string | null): Promise<ZapCallbackResult>;
48
+ settle(): Promise<number>;
49
+ publish(): Promise<number>;
50
+ sweep(nowMs?: number): number;
51
+ };
52
+ export declare const createZapBridge: (deps: ZapBridgeDeps) => ZapBridge;