@forgesworn/moneyer 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +170 -0
- package/README.md +447 -11
- package/THREAT-MODEL.md +15 -6
- package/dist/admin.d.ts +12 -0
- package/dist/admin.js +410 -0
- package/dist/announce.d.ts +12 -0
- package/dist/announce.js +82 -0
- package/dist/backends/cln.js +30 -8
- package/dist/backends/fake-bolt11.d.ts +1 -1
- package/dist/backends/fake-bolt11.js +3 -1
- package/dist/backends/fake.d.ts +7 -1
- package/dist/backends/fake.js +27 -11
- package/dist/backends/lnd.js +30 -6
- package/dist/backends/types.d.ts +3 -0
- package/dist/cli.js +25 -3
- package/dist/config.d.ts +29 -0
- package/dist/config.js +194 -3
- package/dist/index.d.ts +5 -2
- package/dist/index.js +4 -1
- package/dist/landing.d.ts +2 -0
- package/dist/landing.js +42 -2
- package/dist/melt.d.ts +1 -0
- package/dist/melt.js +2 -1
- package/dist/names.d.ts +39 -0
- package/dist/names.js +172 -0
- package/dist/privacy.d.ts +2 -0
- package/dist/privacy.js +14 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +561 -41
- package/dist/signing.d.ts +2 -0
- package/dist/signing.js +34 -0
- package/dist/stats.d.ts +31 -0
- package/dist/stats.js +115 -0
- package/dist/store.d.ts +105 -3
- package/dist/store.js +355 -19
- package/dist/version.d.ts +1 -0
- package/dist/version.js +17 -0
- package/dist/zap.d.ts +52 -0
- package/dist/zap.js +294 -0
- package/llms.txt +25 -8
- package/package.json +4 -3
package/dist/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
|
-
|
|
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
|
|
77
|
-
// invoice's payment hash, settled or not
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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;
|
package/dist/version.js
ADDED
|
@@ -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;
|