@forgesworn/moneyer 0.1.1 → 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/admin.js ADDED
@@ -0,0 +1,410 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils.js';
3
+ import { secp256k1 } from '@noble/curves/secp256k1.js';
4
+ import { hashK1, noteDeclaredAmount, noteK1, noteSignature, verifyNoteSignature } from 'lnurlcash-kit';
5
+ import { configFromEnv, pubkeyHex } from "./config.js";
6
+ import { NoteStore } from "./store.js";
7
+ import { buildStats } from "./stats.js";
8
+ import { reconcilePendingMelts } from "./melt.js";
9
+ import { sweepExpiredMintInvoices } from "./server.js";
10
+ import { createFakeBackend } from "./backends/fake.js";
11
+ import { createClnBackend } from "./backends/cln.js";
12
+ import { createLndBackend } from "./backends/lnd.js";
13
+ import { packageVersion } from "./version.js";
14
+ const COMMANDS = [
15
+ ['status', 'liabilities, melts in flight, node balance, coverage, keys'],
16
+ ['notes [--state s] [--limit n]', 'list notes; state is outstanding, pending or burned'],
17
+ ['note <id|k1>', 'one note, by id or by the secret itself'],
18
+ ['melts [--pending]', 'list melts, newest first'],
19
+ ['reconcile', 'resolve melts left in flight, and say what changed'],
20
+ ['sweep', 'delete mint invoices whose expiry is provably past'],
21
+ ['snapshot <path>', 'a consistent copy of the database, taken live'],
22
+ ['names list|add <name> <npub>|rm <name>', 'the lightning addresses this mint pays out as notes'],
23
+ ['keys rotate', 'generate a signing key and print the two env lines'],
24
+ ['verify-note <url>', 'check a note offline, then say what the mint holds']
25
+ ];
26
+ export const adminHelp = () => [
27
+ 'moneyer admin - operate a running mint',
28
+ '',
29
+ 'Usage: moneyer admin <command> [options]',
30
+ '',
31
+ ...COMMANDS.map(([name, what]) => ` ${name.padEnd(30)} ${what}`),
32
+ '',
33
+ 'Reads the same MONEYER_* environment as the mint itself, so run it',
34
+ 'with that environment loaded. Read-only unless the command says',
35
+ 'otherwise (reconcile, sweep and snapshot are the only ones that are not).'
36
+ ].join('\n');
37
+ const sats = (msat) => `${(msat / 1000).toLocaleString('en-GB')} sat`;
38
+ const age = (fromMs, nowMs) => {
39
+ const seconds = Math.max(0, Math.floor((nowMs - fromMs) / 1000));
40
+ if (seconds < 60)
41
+ return `${seconds}s`;
42
+ if (seconds < 3600)
43
+ return `${Math.floor(seconds / 60)}m`;
44
+ if (seconds < 86_400)
45
+ return `${Math.floor(seconds / 3600)}h`;
46
+ return `${Math.floor(seconds / 86_400)}d`;
47
+ };
48
+ const backendFor = (config) => {
49
+ switch (config.backend.kind) {
50
+ case 'fake':
51
+ return createFakeBackend();
52
+ case 'cln':
53
+ return createClnBackend(config.backend);
54
+ case 'lnd':
55
+ return createLndBackend(config.backend);
56
+ }
57
+ };
58
+ const signingPubkey = (config) => config.signingKey ? bytesToHex(secp256k1.getPublicKey(hexToBytes(config.signingKey), true)) : null;
59
+ // Options are parsed here rather than in the CLI so a test can drive a
60
+ // command with the same argv an operator would type.
61
+ const parse = (argv) => {
62
+ const positionals = [];
63
+ const flags = new Map();
64
+ for (let i = 0; i < argv.length; i++) {
65
+ const arg = argv[i];
66
+ if (!arg.startsWith('--')) {
67
+ positionals.push(arg);
68
+ continue;
69
+ }
70
+ const [name, inline] = arg.slice(2).split('=', 2);
71
+ if (inline !== undefined) {
72
+ flags.set(name, inline);
73
+ }
74
+ else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')) {
75
+ flags.set(name, argv[++i]);
76
+ }
77
+ else {
78
+ flags.set(name, true);
79
+ }
80
+ }
81
+ return { positionals, flags };
82
+ };
83
+ export const runAdmin = async (argv, deps = {}) => {
84
+ const env = deps.env ?? process.env;
85
+ const out = deps.out ?? ((line) => console.log(line));
86
+ const err = deps.err ?? ((line) => console.error(line));
87
+ const now = deps.now ?? (() => Date.now());
88
+ const { positionals, flags } = parse(argv);
89
+ const command = positionals[0];
90
+ if (command === undefined || command === 'help' || flags.has('help')) {
91
+ out(adminHelp());
92
+ return command === undefined ? 2 : 0;
93
+ }
94
+ const known = new Set(['status', 'notes', 'note', 'melts', 'reconcile', 'sweep', 'snapshot', 'names', 'keys', 'verify-note']);
95
+ if (!known.has(command)) {
96
+ err(`Unknown command ${JSON.stringify(command)}. Try: moneyer admin help`);
97
+ return 2;
98
+ }
99
+ let config;
100
+ try {
101
+ config = configFromEnv(env);
102
+ }
103
+ catch (error) {
104
+ err(`Configuration: ${error.message}`);
105
+ return 1;
106
+ }
107
+ // `keys rotate` writes nothing and reads nothing: it generates a key
108
+ // and prints two lines for the operator to paste. No database needed,
109
+ // which also means it works before a mint has ever run.
110
+ if (command === 'keys') {
111
+ if (positionals[1] !== 'rotate') {
112
+ err('Usage: moneyer admin keys rotate');
113
+ return 2;
114
+ }
115
+ const fresh = bytesToHex(randomBytes(32));
116
+ const previous = signingPubkey(config);
117
+ const history = [...(previous ? [previous] : []), ...(config.previousSigningPubkeys ?? [])];
118
+ out('# A new signing key. Nothing has been written - paste these into the');
119
+ out('# mint environment and restart. Keep the OLD PUBLIC key in the list');
120
+ out('# below or every note already issued stops verifying.');
121
+ out(`MONEYER_SIGNING_KEY=${fresh}`);
122
+ if (history.length)
123
+ out(`MONEYER_PREVIOUS_SIGNING_PUBKEYS=${history.join(',')}`);
124
+ out('');
125
+ out(`# the new mint pubkey will be ${bytesToHex(secp256k1.getPublicKey(hexToBytes(fresh), true))}`);
126
+ if (!previous)
127
+ out('# this mint has no signing key configured yet, so there is no history to keep');
128
+ return 0;
129
+ }
130
+ const mutates = command === 'reconcile' ||
131
+ command === 'sweep' ||
132
+ command === 'snapshot' ||
133
+ (command === 'names' && positionals[1] !== undefined && positionals[1] !== 'list');
134
+ let store;
135
+ const ownsStore = deps.store === undefined;
136
+ if (deps.store) {
137
+ store = deps.store;
138
+ }
139
+ else {
140
+ try {
141
+ store = new NoteStore(config.dbPath, { readOnly: !mutates });
142
+ }
143
+ catch (error) {
144
+ err(`Cannot open ${config.dbPath}: ${error.message}`);
145
+ return 1;
146
+ }
147
+ }
148
+ // Opened lazily: most commands never touch the funding source, and a
149
+ // node that is down should not stop `notes` from listing.
150
+ const held = {};
151
+ const fundingSource = () => {
152
+ held.backend ??= deps.backend ?? backendFor(config);
153
+ return held.backend;
154
+ };
155
+ try {
156
+ switch (command) {
157
+ case 'status': {
158
+ const liabilities = store.liabilities(now());
159
+ const totals = store.totals();
160
+ let localBalanceMsat;
161
+ try {
162
+ localBalanceMsat = (await fundingSource().nodeInfo?.())?.localBalanceMsat;
163
+ }
164
+ catch (error) {
165
+ err(`funding source unreachable: ${error.message}`);
166
+ }
167
+ const stats = buildStats({ liabilities, localBalanceMsat, at: now() });
168
+ const previous = config.previousSigningPubkeys ?? [];
169
+ out(`moneyer ${packageVersion ?? 'unknown version'} at ${config.dbPath}`);
170
+ out(` outstanding ${sats(liabilities.outstandingMsat)} over ${liabilities.outstandingNotes} note${liabilities.outstandingNotes === 1 ? '' : 's'}`);
171
+ out(` melts in flight ${liabilities.pendingMelts}${liabilities.pendingMelts ? ` (${sats(liabilities.pendingMsat)}, oldest ${liabilities.oldestPendingMeltAgeSecs}s)` : ''}`);
172
+ out(` unsettled invoices ${totals.unsettledMintInvoices}`);
173
+ out(` node balance ${localBalanceMsat === undefined ? 'not reported' : sats(localBalanceMsat)}`);
174
+ out(` coverage ${stats.coverage === undefined ? (liabilities.outstandingMsat === 0 ? 'nothing outstanding' : 'unknown') : stats.coverage}`);
175
+ out(` lifetime ${totals.mints} mints, ${totals.melts.paid} melts paid, ${totals.melts.restored} restored, ${totals.zaps} zaps`);
176
+ out(` signing key ${signingPubkey(config) ?? 'none - notes go out unsigned'}`);
177
+ out(` previous keys ${previous.length ? previous.join(', ') : 'none'}`);
178
+ out(` funding source ${config.backend.kind}`);
179
+ return 0;
180
+ }
181
+ case 'notes': {
182
+ const state = flags.get('state');
183
+ if (state !== undefined && (state === true || !['outstanding', 'pending', 'burned'].includes(state))) {
184
+ err('--state must be outstanding, pending or burned.');
185
+ return 2;
186
+ }
187
+ const limitRaw = flags.get('limit');
188
+ const limit = limitRaw === undefined || limitRaw === true ? 20 : Number(limitRaw);
189
+ if (!Number.isSafeInteger(limit) || limit < 1) {
190
+ err('--limit must be a positive whole number.');
191
+ return 2;
192
+ }
193
+ const rows = store.notes({ ...(state ? { state: state } : {}), limit });
194
+ if (!rows.length) {
195
+ out('no notes');
196
+ return 0;
197
+ }
198
+ for (const row of rows) {
199
+ out(`${row.id} ${sats(row.amountMsat).padStart(14)} ${row.state.padEnd(11)} ${age(row.createdAt, now())} old`);
200
+ }
201
+ return 0;
202
+ }
203
+ case 'note': {
204
+ const wanted = positionals[1];
205
+ if (!wanted) {
206
+ err('Usage: moneyer admin note <id|k1>');
207
+ return 2;
208
+ }
209
+ const asked = wanted.toLowerCase();
210
+ let note = store.noteById(asked);
211
+ let id = asked;
212
+ // 64 hex that names no note is very likely the secret itself,
213
+ // which an operator has in front of them far more often than an
214
+ // id. Hash it and look again rather than say "unknown".
215
+ if (!note && /^[0-9a-f]{64}$/.test(asked)) {
216
+ id = hashK1(asked);
217
+ note = store.noteById(id);
218
+ if (note)
219
+ out(`(that is a secret; its note id is ${id})`);
220
+ }
221
+ if (!note) {
222
+ const invoice = store.mintInvoiceByHash(asked);
223
+ if (invoice) {
224
+ out(`${asked} is a mint invoice for ${sats(invoice.grossMsat)}, netting ${sats(invoice.netMsat)}`);
225
+ out(` ${invoice.settled ? 'settled - the note exists at this id' : 'unsettled - no note yet'}`);
226
+ return 0;
227
+ }
228
+ out(`no note, and no mint invoice, at ${asked}`);
229
+ return 1;
230
+ }
231
+ out(`${id}`);
232
+ out(` value ${sats(note.amountMsat)}`);
233
+ out(` state ${note.state}`);
234
+ const melt = store.melts({ limit: 1000 }).find(row => row.noteId === id);
235
+ if (melt) {
236
+ out(` melt ${melt.outcome ?? 'in flight'} for ${sats(melt.amountMsat)}, ${age(melt.createdAt, now())} ago`);
237
+ out(` hash ${melt.paymentHash}`);
238
+ }
239
+ return 0;
240
+ }
241
+ case 'melts': {
242
+ const rows = store.melts({ pendingOnly: flags.has('pending'), limit: 50 });
243
+ if (!rows.length) {
244
+ out(flags.has('pending') ? 'no melts in flight' : 'no melts');
245
+ return 0;
246
+ }
247
+ for (const row of rows) {
248
+ out(`${row.paymentHash} ${sats(row.amountMsat).padStart(14)} ${(row.outcome ?? 'in flight').padEnd(9)} ${age(row.createdAt, now())} ago note ${row.noteId.slice(0, 12)}`);
249
+ }
250
+ return 0;
251
+ }
252
+ case 'reconcile': {
253
+ const before = new Map(store.melts({ pendingOnly: true, limit: 1000 }).map(row => [row.paymentHash, row]));
254
+ if (!before.size) {
255
+ out('nothing in flight');
256
+ return 0;
257
+ }
258
+ await reconcilePendingMelts(store, fundingSource(), new Set());
259
+ let changed = 0;
260
+ for (const paymentHash of before.keys()) {
261
+ const after = store.meltByHash(paymentHash);
262
+ if (after?.outcome) {
263
+ changed++;
264
+ out(`${paymentHash} ${after.outcome === 'paid' ? 'confirmed paid - note burned' : 'confirmed unpaid - note restored'}`);
265
+ }
266
+ }
267
+ out(`${changed} of ${before.size} resolved${changed === before.size ? '' : ', the rest still have no terminal answer'}`);
268
+ return 0;
269
+ }
270
+ case 'sweep': {
271
+ const swept = sweepExpiredMintInvoices(store, now());
272
+ out(`swept ${swept} expired mint invoice${swept === 1 ? '' : 's'}`);
273
+ return 0;
274
+ }
275
+ case 'snapshot': {
276
+ const path = positionals[1];
277
+ if (!path) {
278
+ err('Usage: moneyer admin snapshot <path>');
279
+ return 2;
280
+ }
281
+ if (existsSync(path)) {
282
+ err(`${path} already exists - refusing to overwrite a snapshot.`);
283
+ return 1;
284
+ }
285
+ store.snapshot(path);
286
+ out(`snapshot written to ${path}`);
287
+ return 0;
288
+ }
289
+ case 'names': {
290
+ const sub = positionals[1] ?? 'list';
291
+ if (sub === 'list') {
292
+ const names = store.zapNames();
293
+ // A name in the environment that the mint has not started with
294
+ // yet is not in the table. Showing it as missing would be a lie
295
+ // about the configuration, so it is shown as waiting.
296
+ const known = new Set(names.map(row => row.name));
297
+ const waiting = Object.entries(config.zap?.names ?? {}).filter(([name]) => !known.has(name));
298
+ if (!names.length && !waiting.length) {
299
+ out('no lightning addresses');
300
+ return 0;
301
+ }
302
+ for (const row of names) {
303
+ out(`${row.name.padEnd(20)} ${row.pubkey} ${row.source}${row.paidMsat ? ` (paid ${sats(row.paidMsat)})` : ''}`);
304
+ }
305
+ for (const [name, pubkey] of waiting)
306
+ out(`${name.padEnd(20)} ${pubkey} env (waiting for a restart)`);
307
+ return 0;
308
+ }
309
+ if (sub === 'add') {
310
+ const [, , name, key] = positionals;
311
+ if (!name || !key) {
312
+ err('Usage: moneyer admin names add <name> <npub|hex>');
313
+ return 2;
314
+ }
315
+ let hex;
316
+ try {
317
+ hex = pubkeyHex(key);
318
+ }
319
+ catch (error) {
320
+ err(error.message);
321
+ return 2;
322
+ }
323
+ // An operator-granted name is recorded the same way an
324
+ // environment one is: the operator IS the environment here, and
325
+ // a name they grant should not look like one somebody bought.
326
+ store.putOperatorZapName(name.toLowerCase(), hex);
327
+ out(`${name.toLowerCase()} now pays out to ${hex}`);
328
+ return 0;
329
+ }
330
+ if (sub === 'rm') {
331
+ const name = positionals[2];
332
+ if (!name) {
333
+ err('Usage: moneyer admin names rm <name>');
334
+ return 2;
335
+ }
336
+ const removed = store.removeZapName(name);
337
+ out(removed ? `${name.toLowerCase()} removed` : `no such name: ${name}`);
338
+ // A name in MONEYER_ZAP_NAMES comes back at the next restart,
339
+ // and an operator deleting one should know that now.
340
+ if (removed && config.zap?.names[name.toLowerCase()]) {
341
+ out('(it is still in MONEYER_ZAP_NAMES and will return on the next restart)');
342
+ }
343
+ return removed ? 0 : 1;
344
+ }
345
+ err('Usage: moneyer admin names list|add <name> <npub>|rm <name>');
346
+ return 2;
347
+ }
348
+ case 'verify-note': {
349
+ const url = positionals[1];
350
+ if (!url) {
351
+ err('Usage: moneyer admin verify-note <url>');
352
+ return 2;
353
+ }
354
+ let k1;
355
+ try {
356
+ k1 = noteK1(url) ?? '';
357
+ }
358
+ catch {
359
+ k1 = '';
360
+ }
361
+ if (!k1) {
362
+ err('That is not a note URL - no k1 in it.');
363
+ return 2;
364
+ }
365
+ const id = hashK1(k1);
366
+ const declared = noteDeclaredAmount(url);
367
+ const signature = noteSignature(url);
368
+ const current = signingPubkey(config);
369
+ const keys = [...(current ? [current] : []), ...(config.previousSigningPubkeys ?? [])];
370
+ out(`note id ${id}`);
371
+ if (declared !== null)
372
+ out(`declared ${sats(declared)}`);
373
+ if (!signature) {
374
+ out('signature none on the URL');
375
+ }
376
+ else if (declared === null) {
377
+ out('signature present, but the URL declares no amount to check it against');
378
+ }
379
+ else {
380
+ const signedBy = keys.find(pubkey => verifyNoteSignature(k1, declared, signature, pubkey));
381
+ out(signedBy === undefined
382
+ ? 'signature DOES NOT verify against this mint'
383
+ : `signature verifies against ${signedBy === current ? 'the current key' : `a previous key (${signedBy})`}`);
384
+ }
385
+ const note = store.noteById(id);
386
+ out(`mint ${note ? `holds ${sats(note.amountMsat)}, state ${note.state}` : 'has no note at this id'}`);
387
+ // A note the mint does not know, or one already burned, is the
388
+ // answer the operator came for; say it in the exit code too.
389
+ return note && note.state !== 'burned' ? 0 : 1;
390
+ }
391
+ default:
392
+ return 2;
393
+ }
394
+ }
395
+ catch (error) {
396
+ const message = error.message;
397
+ // A database with no tables is a path that has never held a mint -
398
+ // far more likely a typo than a corrupt file, and worth saying so.
399
+ err(/no such table/.test(message)
400
+ ? `${config.dbPath} does not look like a moneyer database - has the mint ever run against it?`
401
+ : message);
402
+ return 1;
403
+ }
404
+ finally {
405
+ if (ownsStore)
406
+ store.close();
407
+ if (deps.backend === undefined && held.backend)
408
+ await held.backend.close?.();
409
+ }
410
+ };
@@ -0,0 +1,12 @@
1
+ export declare const ANNOUNCE_KIND = 30078;
2
+ export declare const ANNOUNCE_D_TAG = "lnurlcash-mint";
3
+ export declare const ANNOUNCE_MESSAGE_PREFIX = "LNURLcash-mint:";
4
+ export type MintAddressDocument = Record<string, unknown>;
5
+ export declare const canonicalise: (value: unknown) => string;
6
+ export declare const announcementDigest: (document: MintAddressDocument) => Uint8Array;
7
+ export declare const signAnnouncement: (document: MintAddressDocument, privateKeyHex: string) => string;
8
+ export declare const announcementContent: (document: MintAddressDocument, privateKeyHex?: string | undefined) => string;
9
+ export declare const verifyAnnouncement: (content: string, mintPubkeyHex: string) => {
10
+ valid: boolean;
11
+ document: MintAddressDocument | null;
12
+ };
@@ -0,0 +1,82 @@
1
+ import { sha256 } from '@noble/hashes/sha2.js';
2
+ import { utf8ToBytes } from '@noble/hashes/utils.js';
3
+ import { recoversToPubkey, signDigestRecoverable } from "./signing.js";
4
+ // The mint announcing itself.
5
+ //
6
+ // A wallet only ever learns of a mint by being told its address; there is
7
+ // no way to find one. Cashu has NIP-87 for this, and its kinds are not
8
+ // ours to take. A new kind of our own is a protocol decision that belongs
9
+ // with the NIPs, so this uses what already exists: the same NIP-78
10
+ // replaceable kind the hourly liabilities snapshot goes out under, with a
11
+ // `d` tag of its own.
12
+ //
13
+ // The content is the mint's own discovery document, unchanged, so there is
14
+ // one description of a mint and not two. It carries a signature by the
15
+ // NOTE signing key as well, which is the key a holder already checks their
16
+ // notes against: an announcement that verifies against `mintPubkey` is the
17
+ // mint speaking, whoever the Nostr identity publishing it belongs to.
18
+ export const ANNOUNCE_KIND = 30078;
19
+ export const ANNOUNCE_D_TAG = 'lnurlcash-mint';
20
+ export const ANNOUNCE_MESSAGE_PREFIX = 'LNURLcash-mint:';
21
+ // RFC 8785 canonical JSON, over the shapes a mint address document holds:
22
+ // strings, finite numbers, booleans, arrays, and nested objects. Keys sort
23
+ // by UTF-16 code unit, which is what JavaScript's own string comparison
24
+ // does, and numbers serialise the ECMAScript way, which is what the RFC
25
+ // specifies. `undefined` members are dropped exactly as JSON.stringify
26
+ // drops them, so what is signed matches what goes on the wire.
27
+ export const canonicalise = (value) => {
28
+ if (value === null)
29
+ return 'null';
30
+ if (typeof value === 'string')
31
+ return JSON.stringify(value);
32
+ if (typeof value === 'boolean')
33
+ return value ? 'true' : 'false';
34
+ if (typeof value === 'number') {
35
+ if (!Number.isFinite(value))
36
+ throw new Error('Only a finite number can be signed.');
37
+ return String(value);
38
+ }
39
+ if (Array.isArray(value))
40
+ return `[${value.map(canonicalise).join(',')}]`;
41
+ if (typeof value === 'object') {
42
+ const members = Object.entries(value)
43
+ .filter(([, item]) => item !== undefined)
44
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
45
+ .map(([key, item]) => `${JSON.stringify(key)}:${canonicalise(item)}`);
46
+ return `{${members.join(',')}}`;
47
+ }
48
+ throw new Error(`A ${typeof value} cannot be signed.`);
49
+ };
50
+ // The same "Lightning Signed Message" wrapping the notes use, over a
51
+ // prefix of its own, so an announcement signature can never be replayed as
52
+ // a note signature or as a liabilities signature.
53
+ export const announcementDigest = (document) => sha256(sha256(utf8ToBytes(`Lightning Signed Message:${ANNOUNCE_MESSAGE_PREFIX}${canonicalise(document)}`)));
54
+ export const signAnnouncement = (document, privateKeyHex) => signDigestRecoverable(announcementDigest(document), privateKeyHex);
55
+ // What goes in the event: the document as the endpoint serves it, plus
56
+ // `sig`. A mint with no note signing key has no `mintPubkey` to verify
57
+ // against either, so it announces the document alone.
58
+ export const announcementContent = (document, privateKeyHex) => JSON.stringify(privateKeyHex ? { ...document, sig: signAnnouncement(document, privateKeyHex) } : document);
59
+ // The reverse: read an announcement and check it against the note-signing
60
+ // key it claims. `mintPubkey` inside the document is what a wallet holding
61
+ // a note of this mint already knows, so an announcement that recovers to
62
+ // it needs nothing else trusted.
63
+ export const verifyAnnouncement = (content, mintPubkeyHex) => {
64
+ let parsed;
65
+ try {
66
+ parsed = JSON.parse(content);
67
+ }
68
+ catch {
69
+ return { valid: false, document: null };
70
+ }
71
+ const { sig, ...document } = parsed;
72
+ if (typeof sig !== 'string')
73
+ return { valid: false, document: null };
74
+ let digest;
75
+ try {
76
+ digest = announcementDigest(document);
77
+ }
78
+ catch {
79
+ return { valid: false, document: null };
80
+ }
81
+ return { valid: recoversToPubkey(digest, sig, mintPubkeyHex), document };
82
+ };
@@ -36,24 +36,33 @@ export const createClnBackend = (config) => {
36
36
  };
37
37
  return {
38
38
  name: 'cln',
39
- async createInvoice({ amountMsat, preimageHex, memo }) {
39
+ async createInvoice({ amountMsat, preimageHex, memo, descriptionForHash }) {
40
40
  const result = await mustCall('/v1/invoice', {
41
41
  amount_msat: amountMsat,
42
42
  label: bytesToHex(randomBytes(16)),
43
- description: memo,
43
+ description: descriptionForHash ?? memo,
44
+ // Commit to the hash of the description rather than carry it: the
45
+ // zap request is far over bolt11's description limit.
46
+ ...(descriptionForHash === undefined ? {} : { deschashonly: true }),
44
47
  preimage: preimageHex
45
48
  });
46
49
  if (typeof result?.bolt11 !== 'string')
47
50
  throw new Error('cln did not return a bolt11 invoice.');
48
51
  return { pr: result.bolt11 };
49
52
  },
50
- async payInvoice({ pr, feeLimitMsat }) {
53
+ async payInvoice({ pr, feeLimitMsat, amountMsat }) {
51
54
  // xpay resolves once it stops retrying, which is NOT proof that no
52
55
  // HTLC it already sent remains outstanding - the caller confirms via
53
56
  // isPaymentComplete before restoring anything either way. The
54
57
  // timeout sits above xpay's own default 60s retry_for so a clean
55
58
  // failure response is not turned into an ambiguous one at the wire.
56
- const res = await call('/v1/xpay', { invstring: pr, maxfee: feeLimitMsat }, 90_000);
59
+ const res = await call('/v1/xpay', {
60
+ invstring: pr,
61
+ maxfee: feeLimitMsat,
62
+ // xpay wants amount_msat only for an invoice that states no
63
+ // amount, and refuses it for one that does.
64
+ ...(amountMsat !== undefined ? { amount_msat: amountMsat } : {})
65
+ }, 90_000);
57
66
  if (!res.ok) {
58
67
  const code = res.json?.code;
59
68
  // 219: this node already paid that hash. On a shared node that is
@@ -70,10 +79,10 @@ export const createClnBackend = (config) => {
70
79
  const preimageHex = res.json?.payment_preimage;
71
80
  if (typeof preimageHex !== 'string')
72
81
  throw new Error('cln did not return a payment_preimage.');
73
- const amountMsat = res.json?.amount_msat;
82
+ const deliveredMsat = res.json?.amount_msat;
74
83
  const amountSentMsat = res.json?.amount_sent_msat;
75
- const feeMsat = typeof amountMsat === 'number' && typeof amountSentMsat === 'number'
76
- ? amountSentMsat - amountMsat
84
+ const feeMsat = typeof deliveredMsat === 'number' && typeof amountSentMsat === 'number'
85
+ ? amountSentMsat - deliveredMsat
77
86
  : null;
78
87
  return { preimageHex, feeMsat };
79
88
  },
@@ -118,12 +127,25 @@ export const createClnBackend = (config) => {
118
127
  const color = typeof info?.color === 'string' ? `#${info.color.replace(/^#/, '')}` : undefined;
119
128
  const numChannels = Number(info?.num_active_channels);
120
129
  const numPeers = Number(info?.num_peers);
130
+ // Outbound liquidity over channels that can actually route today.
131
+ // A channel still opening or already closing holds funds this mint
132
+ // cannot pay out with, so it is not counted.
133
+ let localBalanceMsat;
134
+ const funds = await call('/v1/listfunds', {});
135
+ if (funds.ok && Array.isArray(funds.json?.channels)) {
136
+ const total = funds.json.channels
137
+ .filter((channel) => channel?.state === 'CHANNELD_NORMAL')
138
+ .reduce((sum, channel) => sum + Number(channel.our_amount_msat ?? 0), 0);
139
+ if (Number.isSafeInteger(total))
140
+ localBalanceMsat = total;
141
+ }
121
142
  return {
122
143
  ...(info?.alias ? { alias: info.alias } : {}),
123
144
  ...(uri ? { uri } : {}),
124
145
  ...(color && /^#[0-9a-fA-F]{6}$/.test(color) ? { color } : {}),
125
146
  ...(Number.isSafeInteger(numChannels) ? { numChannels } : {}),
126
- ...(Number.isSafeInteger(numPeers) ? { numPeers } : {})
147
+ ...(Number.isSafeInteger(numPeers) ? { numPeers } : {}),
148
+ ...(localBalanceMsat !== undefined ? { localBalanceMsat } : {})
127
149
  };
128
150
  }
129
151
  };
@@ -1,5 +1,5 @@
1
1
  export declare const fakeBolt11: (args: {
2
- amountMsat: number;
2
+ amountMsat?: number;
3
3
  paymentHashHex: string;
4
4
  memo?: string;
5
5
  timestamp?: number;
@@ -37,8 +37,10 @@ const tagged = (type, data) => [
37
37
  ...data
38
38
  ];
39
39
  const amountHrp = (msat) => (msat % 100 === 0 ? `${msat / 100}n` : `${msat * 10}p`);
40
+ // `amountMsat` omitted builds an invoice that states no amount, which is
41
+ // the shape a payee uses when the payer decides what to send.
40
42
  export const fakeBolt11 = (args) => {
41
- const hrp = `lnbc${amountHrp(args.amountMsat)}`;
43
+ const hrp = `lnbc${args.amountMsat === undefined ? '' : amountHrp(args.amountMsat)}`;
42
44
  const words = [];
43
45
  const timestamp = args.timestamp ?? Math.floor(Date.now() / 1000);
44
46
  for (let i = 6; i >= 0; i--)
@@ -1,5 +1,8 @@
1
1
  import { type LightningBackend } from './types.ts';
2
2
  export type FakePayMode = 'succeed' | 'fail-clean' | 'fail-then-paid' | 'ambiguous-paid' | 'ambiguous-unpaid' | 'ambiguous-pending';
3
+ export type FakeBackendOptions = {
4
+ autoSettle?: boolean;
5
+ };
3
6
  export type FakeBackend = LightningBackend & {
4
7
  control: {
5
8
  settleInvoice(paymentHashHex: string): void;
@@ -12,6 +15,9 @@ export type FakeBackend = LightningBackend & {
12
15
  amountMsat: number;
13
16
  settled: boolean;
14
17
  } | undefined;
18
+ sentAmountMsat(paymentHashHex: string): number | null | undefined;
19
+ setLocalBalanceMsat(msat: number | undefined): void;
15
20
  };
16
21
  };
17
- export declare const createFakeBackend: () => FakeBackend;
22
+ export declare const FAKE_LOCAL_BALANCE_MSAT = 100000000000;
23
+ export declare const createFakeBackend: (options?: FakeBackendOptions) => FakeBackend;