@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/THREAT-MODEL.md CHANGED
@@ -26,11 +26,13 @@ parameters in one request are refused outright; they would otherwise count
26
26
  one note's value twice into the output.
27
27
 
28
28
  **A holder claiming an output id that already exists.** `h`/`h2` may not
29
- collide with any existing note OR any mint invoice's payment hash, settled
30
- or not. The invoice case is the subtle one: `/verify` hands a settled mint
31
- invoice's preimage to its payer, and that preimage is the k1 of whatever
32
- note carries the hash as its id - minting "over" such an id would point a
33
- future payer's money at a stranger's note.
29
+ collide with any existing note, any mint invoice's payment hash, settled
30
+ or not, or any note a payer has bought by naming it. The invoice cases are
31
+ the subtle ones: `/verify` hands a settled mint invoice's preimage to its
32
+ payer, and that preimage is the k1 of whatever note carries the hash as
33
+ its id - minting "over" such an id would point a future payer's money at a
34
+ stranger's note. A named note is the same hazard one step earlier, since
35
+ it is spoken for before it exists.
34
36
 
35
37
  **A holder melting into the mint's own invoice.** Refused synchronously:
36
38
  paying it would route the funding source's money at itself, which real
@@ -70,12 +72,19 @@ runs at startup and on demand, resolving pending melts by asking the
70
72
  funding source, never by assumption.
71
73
 
72
74
  **The verify endpoint as an oracle.** `/verify` serves bearer material
73
- (the preimage). It is only served once the funding source reports
75
+ (the preimage) whenever the note was minted the old way, at the invoice's
76
+ payment hash. It is only served once the funding source reports
74
77
  settlement, is fetched live rather than cached, and has a real off switch
75
78
  (`MONEYER_VERIFY=0` makes it a 404). Anyone who saw the unpaid invoice can
76
79
  poll it, which is why wallets must rotate immediately on claim - stated in
77
80
  the spec and enforced by the companion wallet.
78
81
 
82
+ The durable answer is for the wallet to name the note it is buying (`h` on
83
+ the pay callback, see the README). The note is then credited at a secret
84
+ only the buyer ever held, the preimage is bearer material for nothing, and
85
+ what `/verify` serves is an ordinary payment proof. Wallets should prefer
86
+ that path wherever a mint advertises `mintToHash`.
87
+
79
88
  ## Out of scope
80
89
 
81
90
  - Compromise of the host or the funding source credentials: at that point
@@ -0,0 +1,12 @@
1
+ import { NoteStore } from './store.ts';
2
+ import type { LightningBackend } from './backends/types.ts';
3
+ export type AdminDeps = {
4
+ env?: NodeJS.ProcessEnv;
5
+ out?: (line: string) => void;
6
+ err?: (line: string) => void;
7
+ store?: NoteStore;
8
+ backend?: LightningBackend;
9
+ now?: () => number;
10
+ };
11
+ export declare const adminHelp: () => string;
12
+ export declare const runAdmin: (argv: string[], deps?: AdminDeps) => Promise<number>;
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--)