@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.
@@ -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;
@@ -4,20 +4,24 @@ import { hexToBytes } from '@noble/hashes/utils.js';
4
4
  import { bolt11PaymentHash } from 'farrier-kit/bolt11';
5
5
  import { fakeBolt11 } from "./fake-bolt11.js";
6
6
  import { PaymentAlreadyKnownError, PaymentFailedError, PaymentPendingError } from "./types.js";
7
- export const createFakeBackend = () => {
7
+ // One bitcoin, so a development mint covers anything it is likely to mint.
8
+ export const FAKE_LOCAL_BALANCE_MSAT = 100_000_000_000;
9
+ export const createFakeBackend = (options = {}) => {
10
+ const autoSettle = options.autoSettle === true;
8
11
  const invoices = new Map();
9
12
  const payments = new Map();
10
13
  const knownPreimages = new Map();
14
+ let localBalanceMsat = FAKE_LOCAL_BALANCE_MSAT;
11
15
  let payMode = 'succeed';
12
16
  return {
13
17
  name: 'fake',
14
18
  async createInvoice({ amountMsat, preimageHex, memo }) {
15
19
  const paymentHashHex = bytesToHex(sha256(hexToBytes(preimageHex)));
16
20
  const pr = fakeBolt11({ amountMsat, paymentHashHex, memo });
17
- invoices.set(paymentHashHex, { preimageHex, amountMsat, settled: false });
21
+ invoices.set(paymentHashHex, { preimageHex, amountMsat, settled: autoSettle });
18
22
  return { pr };
19
23
  },
20
- async payInvoice({ pr }) {
24
+ async payInvoice({ pr, amountMsat }) {
21
25
  const paymentHashHex = bolt11PaymentHash(pr);
22
26
  if (!paymentHashHex)
23
27
  throw new PaymentFailedError('That is not a decodable invoice.');
@@ -27,24 +31,25 @@ export const createFakeBackend = () => {
27
31
  throw new PaymentAlreadyKnownError('this node already has a payment for that hash');
28
32
  }
29
33
  const preimageHex = knownPreimages.get(paymentHashHex) ?? null;
34
+ const sentMsat = amountMsat ?? null;
30
35
  switch (payMode) {
31
36
  case 'succeed':
32
- payments.set(paymentHashHex, { status: 'complete', preimageHex });
37
+ payments.set(paymentHashHex, { status: 'complete', preimageHex, amountMsat: sentMsat });
33
38
  return { preimageHex, feeMsat: 0 };
34
39
  case 'fail-clean':
35
- payments.set(paymentHashHex, { status: 'failed', preimageHex: null });
40
+ payments.set(paymentHashHex, { status: 'failed', preimageHex: null, amountMsat: sentMsat });
36
41
  throw new PaymentFailedError('Could not find a route to pay this invoice.');
37
42
  case 'fail-then-paid':
38
- payments.set(paymentHashHex, { status: 'complete', preimageHex });
43
+ payments.set(paymentHashHex, { status: 'complete', preimageHex, amountMsat: sentMsat });
39
44
  throw new PaymentFailedError('Timed out trying to find a route to pay this invoice.');
40
45
  case 'ambiguous-paid':
41
- payments.set(paymentHashHex, { status: 'complete', preimageHex });
46
+ payments.set(paymentHashHex, { status: 'complete', preimageHex, amountMsat: sentMsat });
42
47
  throw new Error('connection reset mid-payment');
43
48
  case 'ambiguous-unpaid':
44
- payments.set(paymentHashHex, { status: 'failed', preimageHex: null });
49
+ payments.set(paymentHashHex, { status: 'failed', preimageHex: null, amountMsat: sentMsat });
45
50
  throw new Error('connection reset mid-payment');
46
51
  case 'ambiguous-pending':
47
- payments.set(paymentHashHex, { status: 'pending', preimageHex });
52
+ payments.set(paymentHashHex, { status: 'pending', preimageHex, amountMsat: sentMsat });
48
53
  throw new Error('connection reset mid-payment');
49
54
  }
50
55
  },
@@ -69,7 +74,11 @@ export const createFakeBackend = () => {
69
74
  return payment?.status === 'complete' ? payment.preimageHex : null;
70
75
  },
71
76
  async nodeInfo() {
72
- return { alias: 'moneyer (fake funding source)', color: '#c9ced8' };
77
+ return {
78
+ alias: 'moneyer (fake funding source)',
79
+ color: '#c9ced8',
80
+ ...(localBalanceMsat !== undefined ? { localBalanceMsat } : {})
81
+ };
73
82
  },
74
83
  control: {
75
84
  settleInvoice(paymentHashHex) {
@@ -94,7 +103,14 @@ export const createFakeBackend = () => {
94
103
  payment.preimageHex = preimageHex;
95
104
  },
96
105
  seedForeignPayment(paymentHashHex, status = 'complete') {
97
- payments.set(paymentHashHex, { status, preimageHex: null });
106
+ payments.set(paymentHashHex, { status, preimageHex: null, amountMsat: null });
107
+ },
108
+ sentAmountMsat(paymentHashHex) {
109
+ const payment = payments.get(paymentHashHex);
110
+ return payment ? payment.amountMsat : undefined;
111
+ },
112
+ setLocalBalanceMsat(msat) {
113
+ localBalanceMsat = msat;
98
114
  },
99
115
  invoiceByHash(paymentHashHex) {
100
116
  return invoices.get(paymentHashHex);
@@ -1,4 +1,5 @@
1
- import { hexToBytes } from '@noble/hashes/utils.js';
1
+ import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
2
+ import { sha256 } from '@noble/hashes/sha2.js';
2
3
  import { verifyPreimage } from 'farrier-kit/preimage';
3
4
  import { PaymentAlreadyKnownError, PaymentFailedError, PaymentPendingError } from "./types.js";
4
5
  // lnd over its REST proxy. AddInvoice never returns a preimage but accepts
@@ -81,20 +82,33 @@ export const createLndBackend = (config) => {
81
82
  };
82
83
  return {
83
84
  name: 'lnd',
84
- async createInvoice({ amountMsat, preimageHex, memo }) {
85
+ async createInvoice({ amountMsat, preimageHex, memo, descriptionForHash }) {
85
86
  const res = await json('/v1/invoices', {
86
87
  method: 'POST',
87
- body: { value_msat: String(amountMsat), memo, r_preimage: hexToBase64(preimageHex) }
88
+ body: {
89
+ value_msat: String(amountMsat),
90
+ r_preimage: hexToBase64(preimageHex),
91
+ ...(descriptionForHash === undefined
92
+ ? { memo }
93
+ : { description_hash: hexToBase64(bytesToHex(sha256(utf8ToBytes(descriptionForHash)))) })
94
+ }
88
95
  });
89
96
  if (!res.ok || typeof res.json?.payment_request !== 'string') {
90
97
  throw new Error(`lnd did not return a payment_request (${res.status}).`);
91
98
  }
92
99
  return { pr: res.json.payment_request };
93
100
  },
94
- async payInvoice({ pr, feeLimitMsat }) {
101
+ async payInvoice({ pr, feeLimitMsat, amountMsat }) {
95
102
  const { result, error } = await streamUntil('/v2/router/send', {
96
103
  method: 'POST',
97
- body: { payment_request: pr, timeout_seconds: 60, fee_limit_msat: String(feeLimitMsat) },
104
+ body: {
105
+ payment_request: pr,
106
+ timeout_seconds: 60,
107
+ fee_limit_msat: String(feeLimitMsat),
108
+ // Only for an invoice that states no amount: lnd refuses
109
+ // amt_msat alongside one that does.
110
+ ...(amountMsat !== undefined ? { amt_msat: String(amountMsat) } : {})
111
+ },
98
112
  timeoutMs: 90_000
99
113
  }, payment => (payment?.status === 'SUCCEEDED' || payment?.status === 'FAILED' ? payment : undefined));
100
114
  if (!result) {
@@ -176,6 +190,15 @@ export const createLndBackend = (config) => {
176
190
  if (channels.ok && Array.isArray(channels.json?.channels)) {
177
191
  capacityMsat = channels.json.channels.reduce((sum, channel) => sum + Number(channel.capacity ?? 0) * 1000, 0);
178
192
  }
193
+ // Outbound liquidity, best-effort for the same reason as capacity:
194
+ // the macaroon may not carry offchain:read.
195
+ let localBalanceMsat;
196
+ const balance = await json('/v1/balance/channels');
197
+ if (balance.ok) {
198
+ const msat = Number(balance.json?.local_balance?.msat);
199
+ if (Number.isSafeInteger(msat))
200
+ localBalanceMsat = msat;
201
+ }
179
202
  return {
180
203
  ...(res.json?.alias ? { alias: res.json.alias } : {}),
181
204
  ...(uris[0] || res.json?.identity_pubkey ? { uri: uris[0] ?? res.json.identity_pubkey } : {}),
@@ -184,7 +207,8 @@ export const createLndBackend = (config) => {
184
207
  // half keeps a NaN from an unparseable channel capacity out.
185
208
  ...(capacityMsat !== undefined && Number.isFinite(capacityMsat) ? { capacityMsat } : {}),
186
209
  ...(Number.isSafeInteger(numChannels) ? { numChannels } : {}),
187
- ...(Number.isSafeInteger(numPeers) ? { numPeers } : {})
210
+ ...(Number.isSafeInteger(numPeers) ? { numPeers } : {}),
211
+ ...(localBalanceMsat !== undefined ? { localBalanceMsat } : {})
188
212
  };
189
213
  }
190
214
  };
@@ -5,6 +5,7 @@ export type NodeInfo = {
5
5
  capacityMsat?: number;
6
6
  numChannels?: number;
7
7
  numPeers?: number;
8
+ localBalanceMsat?: number;
8
9
  };
9
10
  export type PaymentOutcome = {
10
11
  preimageHex: string | null;
@@ -22,12 +23,14 @@ export interface LightningBackend {
22
23
  amountMsat: number;
23
24
  preimageHex: string;
24
25
  memo: string;
26
+ descriptionForHash?: string;
25
27
  }): Promise<{
26
28
  pr: string;
27
29
  }>;
28
30
  payInvoice(args: {
29
31
  pr: string;
30
32
  feeLimitMsat: number;
33
+ amountMsat?: number;
31
34
  }): Promise<PaymentOutcome>;
32
35
  isPaymentComplete(paymentHashHex: string): Promise<boolean>;
33
36
  isInvoiceSettled(paymentHashHex: string): Promise<boolean>;
package/dist/cli.js CHANGED
@@ -10,21 +10,35 @@ import { createMoneyer } from "./server.js";
10
10
  // moneyer --dev in-memory fake funding source, an ephemeral signing
11
11
  // key, and one funded 21-sat note printed for a wallet
12
12
  // to play with. Nothing here is payable.
13
- const { values } = parseArgs({
13
+ // moneyer admin ... operate a running mint - see admin.ts
14
+ const { values, positionals } = parseArgs({
14
15
  options: {
15
16
  dev: { type: 'boolean', default: false },
16
17
  help: { type: 'boolean', default: false }
17
- }
18
+ },
19
+ allowPositionals: true,
20
+ // The admin subcommand carries flags of its own (--state, --limit,
21
+ // --pending), parsed there rather than declared here.
22
+ strict: false
18
23
  });
24
+ if (positionals[0] === 'admin') {
25
+ const args = process.argv.slice(2);
26
+ const { runAdmin } = await import("./admin.js");
27
+ process.exit(await runAdmin(args.slice(args.indexOf('admin') + 1)));
28
+ }
19
29
  if (values.help) {
20
30
  console.log([
21
31
  'moneyer - an LNURLcash (LUD-25) mint',
22
32
  '',
23
33
  'Usage: moneyer [--dev]',
34
+ ' moneyer admin <command>',
24
35
  '',
25
36
  ' --dev fake funding source, in-memory store, ephemeral signing key,',
26
37
  ' and a funded 21 sat note printed at startup. Unpayable, for',
27
38
  ' wallets to develop against.',
39
+ ' admin operate a running mint: status, notes, melts, reconcile,',
40
+ ' sweep, snapshot, names, keys, verify-note.',
41
+ ' `moneyer admin help` lists them.',
28
42
  '',
29
43
  'Configuration is MONEYER_* environment variables - see README.md.'
30
44
  ].join('\n'));
@@ -41,6 +55,14 @@ if (config.backend.kind === 'fake' && !values.dev) {
41
55
  console.error('The fake funding source mints unpayable invoices - refusing to run it outside --dev.');
42
56
  process.exit(1);
43
57
  }
58
+ // --dev means a mint a wallet can actually use. Without settling invoices
59
+ // the fake source hands out quotes nobody can pay, so a wallet pointed at
60
+ // it mints nothing and has no note to split, merge, melt or send - the
61
+ // mint looks alive and every flow that needs money dead-ends. Only ever
62
+ // reachable here, on the one backend that moves nothing.
63
+ if (config.backend.kind === 'fake' && values.dev) {
64
+ config.backend = { ...config.backend, autoSettle: true };
65
+ }
44
66
  const log = (message) => console.error(`[moneyer] ${message}`);
45
67
  const moneyer = await createMoneyer(config, { log });
46
68
  console.log(`moneyer listening on ${moneyer.url}`);
@@ -52,7 +74,7 @@ if (values.dev) {
52
74
  const k1 = bytesToHex(randomBytes(32));
53
75
  moneyer.store.creditNote(hashK1(k1), 21_000);
54
76
  console.log(` a 21 sat note: ${buildNoteUrl(`${moneyer.url}/w`, k1, 21_000)}`);
55
- console.log('\nnothing here is payable - the fake funding source invents its invoices');
77
+ console.log('\nDEV MINT - the fake funding source invents its invoices and treats every one\nof them as paid the moment it is issued, so minting here costs nothing and\nthe notes it hands out are worth nothing. Melts always succeed and send no\nsats anywhere. Never point a wallet holding real money at this.');
56
78
  }
57
79
  const shutdown = () => {
58
80
  moneyer.close().then(() => process.exit(0), () => process.exit(1));
package/dist/config.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { MintFee } from 'lnurlcash-kit';
2
2
  export type BackendConfig = {
3
3
  kind: 'fake';
4
+ autoSettle?: boolean;
4
5
  } | {
5
6
  kind: 'cln';
6
7
  url: string;
@@ -10,24 +11,46 @@ export type BackendConfig = {
10
11
  url: string;
11
12
  macaroon: string;
12
13
  };
14
+ export type MintContact = {
15
+ nostr?: string;
16
+ email?: string;
17
+ url?: string;
18
+ };
13
19
  export type MoneyerConfig = {
14
20
  host: string;
15
21
  port: number;
16
22
  publicOrigin?: string;
17
23
  username: string;
18
24
  description: string;
25
+ name?: string;
26
+ contact?: MintContact;
27
+ tosUrl?: string;
28
+ motd?: string;
19
29
  minSendableMsat: number;
20
30
  maxSendableMsat: number;
21
31
  minMintMsat: number;
22
32
  mintFee: MintFee | null;
23
33
  roundFeeToSat?: boolean;
24
34
  signingKey?: string;
35
+ previousSigningPubkeys?: string[];
25
36
  dbPath: string;
26
37
  backend: BackendConfig;
27
38
  verify: boolean;
28
39
  walletUrl?: string;
29
40
  maxK1s: number;
41
+ stats?: boolean;
42
+ statsRatioOnly?: boolean;
43
+ statsPublish?: boolean;
44
+ announce?: boolean;
45
+ metrics?: boolean;
30
46
  sunset: boolean;
47
+ zap?: ZapConfig;
48
+ namePriceMsat?: number;
49
+ };
50
+ export type ZapConfig = {
51
+ nostrKey: string;
52
+ relays: string[];
53
+ names: Record<string, string>;
31
54
  };
32
55
  export declare const DEFAULTS: {
33
56
  readonly host: "127.0.0.1";
@@ -42,5 +65,11 @@ export declare const DEFAULTS: {
42
65
  readonly maxK1s: 21;
43
66
  readonly sunset: false;
44
67
  readonly roundFeeToSat: false;
68
+ readonly stats: true;
69
+ readonly statsRatioOnly: false;
70
+ readonly statsPublish: false;
71
+ readonly announce: false;
72
+ readonly metrics: false;
45
73
  };
46
74
  export declare const configFromEnv: (env?: NodeJS.ProcessEnv) => MoneyerConfig;
75
+ export declare const pubkeyHex: (value: string) => string;
package/dist/config.js CHANGED
@@ -1,3 +1,7 @@
1
+ import { getPublicKey } from 'nostr-tools/pure';
2
+ import { secp256k1 } from '@noble/curves/secp256k1.js';
3
+ import { decode as decodeNip19, npubEncode } from 'nostr-tools/nip19';
4
+ import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
1
5
  export const DEFAULTS = {
2
6
  host: '127.0.0.1',
3
7
  port: 3737,
@@ -10,7 +14,12 @@ export const DEFAULTS = {
10
14
  verify: true,
11
15
  maxK1s: 21,
12
16
  sunset: false,
13
- roundFeeToSat: false
17
+ roundFeeToSat: false,
18
+ stats: true,
19
+ statsRatioOnly: false,
20
+ statsPublish: false,
21
+ announce: false,
22
+ metrics: false
14
23
  };
15
24
  const int = (value, fallback) => {
16
25
  if (value === undefined || value === '')
@@ -27,6 +36,50 @@ const flag = (value, fallback) => {
27
36
  return value !== '0' && value.toLowerCase() !== 'false';
28
37
  };
29
38
  const HEX32 = /^[0-9a-f]{64}$/i;
39
+ // Trimmed, or absent. An empty variable is the operator not setting it,
40
+ // which is not the same as setting it to nothing.
41
+ const text = (value) => {
42
+ const trimmed = value?.trim();
43
+ return trimmed ? trimmed : undefined;
44
+ };
45
+ const webUrl = (name, value) => {
46
+ const trimmed = text(value);
47
+ if (trimmed === undefined)
48
+ return undefined;
49
+ let protocol;
50
+ try {
51
+ protocol = new URL(trimmed).protocol;
52
+ }
53
+ catch {
54
+ throw new Error(`${name} is not a URL: ${JSON.stringify(value)}.`);
55
+ }
56
+ if (protocol !== 'https:' && protocol !== 'http:') {
57
+ throw new Error(`${name} must be http or https, got ${JSON.stringify(value)}.`);
58
+ }
59
+ return trimmed;
60
+ };
61
+ // The MOTD is a line on a mint card, not a blog. A runaway one would push
62
+ // every other field off a wallet's screen, so it is refused at startup
63
+ // rather than truncated behind the operator's back.
64
+ const MOTD_MAX = 280;
65
+ const contactFromEnv = (env) => {
66
+ const rawNostr = text(env.MONEYER_CONTACT_NOSTR);
67
+ // Normalised to npub on the wire whichever form the operator set: a
68
+ // wallet showing a contact wants the form a person can paste back.
69
+ const nostr = rawNostr === undefined ? undefined : npubEncode(pubkeyHex(rawNostr));
70
+ const email = text(env.MONEYER_CONTACT_EMAIL);
71
+ if (email !== undefined && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
72
+ throw new Error(`MONEYER_CONTACT_EMAIL is not an email address: ${JSON.stringify(email)}.`);
73
+ }
74
+ const url = webUrl('MONEYER_CONTACT_URL', env.MONEYER_CONTACT_URL);
75
+ if (nostr === undefined && email === undefined && url === undefined)
76
+ return undefined;
77
+ return {
78
+ ...(nostr ? { nostr } : {}),
79
+ ...(email ? { email } : {}),
80
+ ...(url ? { url } : {})
81
+ };
82
+ };
30
83
  // Reads MONEYER_* from the environment. Throws rather than guessing: a mint
31
84
  // that starts with a half-understood configuration is holding other
32
85
  // people's money on a misunderstanding.
@@ -40,10 +93,16 @@ export const configFromEnv = (env = process.env) => {
40
93
  if (signingKey !== undefined && !HEX32.test(signingKey)) {
41
94
  throw new Error('MONEYER_SIGNING_KEY must be 32 bytes of hex.');
42
95
  }
96
+ const previousSigningPubkeys = previousPubkeysFromEnv(env, signingKey);
43
97
  const kind = env.MONEYER_BACKEND ?? 'fake';
44
98
  let backend;
45
99
  if (kind === 'fake') {
46
- backend = { kind: 'fake' };
100
+ // A development-only shortcut, and only reachable on the backend that
101
+ // moves no money: every invoice this mint issues is treated as paid
102
+ // the instant it is issued. It is what makes a local mint usable to a
103
+ // wallet, and it would be a licence to print sats on any other
104
+ // backend, which is why it is read here and nowhere else.
105
+ backend = { kind: 'fake', autoSettle: env.MONEYER_FAKE_AUTOSETTLE === 'true' };
47
106
  }
48
107
  else if (kind === 'cln') {
49
108
  if (!env.MONEYER_BACKEND_URL || !env.MONEYER_BACKEND_RUNE) {
@@ -80,23 +139,155 @@ export const configFromEnv = (env = process.env) => {
80
139
  throw new Error(`MONEYER_PUBLIC_ORIGIN must be http or https, got ${JSON.stringify(publicOrigin)}.`);
81
140
  }
82
141
  }
142
+ const motd = text(env.MONEYER_MOTD);
143
+ if (motd !== undefined && motd.length > MOTD_MAX) {
144
+ throw new Error(`MONEYER_MOTD must be at most ${MOTD_MAX} characters - it is a banner, not a page.`);
145
+ }
146
+ const contact = contactFromEnv(env);
147
+ const tosUrl = webUrl('MONEYER_TOS_URL', env.MONEYER_TOS_URL);
148
+ const name = text(env.MONEYER_NAME);
149
+ const zap = zapFromEnv(env);
150
+ // Unset is not the same as zero here: unset means registration is
151
+ // closed, zero means free.
152
+ const rawPrice = env.MONEYER_NAME_PRICE_MSAT?.trim();
153
+ const namePriceMsat = rawPrice === undefined || rawPrice === '' ? undefined : int(rawPrice, 0);
154
+ if (namePriceMsat !== undefined && !zap) {
155
+ // A registered name that cannot be wrapped to its owner is a name
156
+ // that takes payments nobody can collect.
157
+ throw new Error('MONEYER_NAME_PRICE_MSAT needs zap-to-note configured - a name pays out as a gift-wrapped note.');
158
+ }
159
+ if (zap && zap.names[env.MONEYER_USERNAME ?? DEFAULTS.username]) {
160
+ throw new Error('MONEYER_ZAP_NAMES must not reuse the mint username.');
161
+ }
162
+ if (flag(env.MONEYER_ANNOUNCE, DEFAULTS.announce) && !zap) {
163
+ // An announcement is a Nostr event, and the mint's Nostr identity is
164
+ // the zap one. Turning this on without it would be a mint that thinks
165
+ // it is listed and is not.
166
+ throw new Error('MONEYER_ANNOUNCE needs the mint Nostr identity - set MONEYER_NOSTR_KEY and MONEYER_NOSTR_RELAYS.');
167
+ }
168
+ if (zap && !publicOrigin) {
169
+ // A settled zap is minted by a timer, with no request to read a Host
170
+ // header from, and the note URL it wraps must be right first time.
171
+ throw new Error('Zap-to-note needs MONEYER_PUBLIC_ORIGIN.');
172
+ }
83
173
  return {
84
174
  host: env.MONEYER_HOST ?? DEFAULTS.host,
85
175
  port: int(env.MONEYER_PORT, DEFAULTS.port),
86
176
  ...(publicOrigin ? { publicOrigin } : {}),
87
177
  username: env.MONEYER_USERNAME ?? DEFAULTS.username,
88
178
  description: env.MONEYER_DESCRIPTION ?? DEFAULTS.description,
179
+ ...(name ? { name } : {}),
180
+ ...(contact ? { contact } : {}),
181
+ ...(tosUrl ? { tosUrl } : {}),
182
+ ...(motd ? { motd } : {}),
89
183
  minSendableMsat,
90
184
  maxSendableMsat,
91
185
  minMintMsat: int(env.MONEYER_MIN_MINT_MSAT, DEFAULTS.minMintMsat),
92
186
  mintFee: baseFeeMsat === 0 && feePpm === 0 ? null : { baseFeeMsat, feePpm },
93
187
  roundFeeToSat: flag(env.MONEYER_ROUND_FEE_TO_SAT, DEFAULTS.roundFeeToSat),
94
188
  ...(signingKey ? { signingKey: signingKey.toLowerCase() } : {}),
189
+ ...(previousSigningPubkeys.length ? { previousSigningPubkeys } : {}),
95
190
  dbPath: env.MONEYER_DB ?? DEFAULTS.dbPath,
96
191
  backend,
97
192
  verify: flag(env.MONEYER_VERIFY, DEFAULTS.verify),
193
+ stats: flag(env.MONEYER_STATS, DEFAULTS.stats),
194
+ statsRatioOnly: flag(env.MONEYER_STATS_RATIO_ONLY, DEFAULTS.statsRatioOnly),
195
+ statsPublish: flag(env.MONEYER_STATS_PUBLISH, DEFAULTS.statsPublish),
196
+ announce: flag(env.MONEYER_ANNOUNCE, DEFAULTS.announce),
197
+ metrics: flag(env.MONEYER_METRICS, DEFAULTS.metrics),
98
198
  ...(env.MONEYER_WALLET_URL ? { walletUrl: env.MONEYER_WALLET_URL.replace(/\/+$/, '') } : {}),
99
199
  maxK1s: int(env.MONEYER_MAX_K1S, DEFAULTS.maxK1s),
100
- sunset: flag(env.MONEYER_SUNSET, DEFAULTS.sunset)
200
+ sunset: flag(env.MONEYER_SUNSET, DEFAULTS.sunset),
201
+ ...(zap ? { zap } : {}),
202
+ ...(namePriceMsat !== undefined ? { namePriceMsat } : {})
101
203
  };
102
204
  };
205
+ // MONEYER_PREVIOUS_SIGNING_PUBKEYS="02ab...,03cd..." - the keys this mint
206
+ // signed under before the current one. Every entry must be a point the
207
+ // curve accepts, because a typo here would quietly tell wallets to accept
208
+ // a key that verifies nothing, and it must not be the current key, which
209
+ // would say the mint had rotated to itself.
210
+ const previousPubkeysFromEnv = (env, signingKey) => {
211
+ const raw = env.MONEYER_PREVIOUS_SIGNING_PUBKEYS?.trim();
212
+ if (!raw)
213
+ return [];
214
+ const current = signingKey ? bytesToHex(secp256k1.getPublicKey(hexToBytes(signingKey.toLowerCase()), true)) : null;
215
+ const pubkeys = [];
216
+ for (const entry of raw.split(',')) {
217
+ const pubkey = entry.trim().toLowerCase();
218
+ if (!pubkey)
219
+ continue;
220
+ if (!/^0[23][0-9a-f]{64}$/.test(pubkey)) {
221
+ throw new Error(`MONEYER_PREVIOUS_SIGNING_PUBKEYS entry is not a compressed secp256k1 pubkey: ${JSON.stringify(entry)}.`);
222
+ }
223
+ try {
224
+ secp256k1.Point.fromHex(pubkey);
225
+ }
226
+ catch {
227
+ throw new Error(`MONEYER_PREVIOUS_SIGNING_PUBKEYS entry is not a point on the curve: ${JSON.stringify(entry)}.`);
228
+ }
229
+ if (pubkey === current) {
230
+ throw new Error('MONEYER_PREVIOUS_SIGNING_PUBKEYS must not repeat the current signing key - a mint has not rotated to itself.');
231
+ }
232
+ if (!pubkeys.includes(pubkey))
233
+ pubkeys.push(pubkey);
234
+ }
235
+ return pubkeys;
236
+ };
237
+ const NAME = /^[a-z0-9._-]{1,64}$/;
238
+ // An npub or 32-byte hex, to hex. Throws on anything else.
239
+ export const pubkeyHex = (value) => {
240
+ const trimmed = value.trim();
241
+ if (HEX32.test(trimmed))
242
+ return trimmed.toLowerCase();
243
+ if (/^npub1/i.test(trimmed)) {
244
+ try {
245
+ const decoded = decodeNip19(trimmed.toLowerCase());
246
+ if (decoded.type === 'npub')
247
+ return decoded.data;
248
+ }
249
+ catch {
250
+ // fall through to the one error below
251
+ }
252
+ }
253
+ throw new Error(`Not a Nostr pubkey: ${JSON.stringify(value)}.`);
254
+ };
255
+ // MONEYER_ZAP_NAMES="alice=npub1...,bob=<hex>" with MONEYER_NOSTR_KEY and
256
+ // MONEYER_NOSTR_RELAYS. The key and the relays go together: a name
257
+ // without a key cannot wrap. The names themselves are now optional, since
258
+ // a mint can open registration and start with none of its own.
259
+ const zapFromEnv = (env) => {
260
+ const rawNames = env.MONEYER_ZAP_NAMES?.trim();
261
+ const nostrKey = env.MONEYER_NOSTR_KEY?.trim();
262
+ const rawRelays = env.MONEYER_NOSTR_RELAYS?.trim();
263
+ if (!rawNames && !nostrKey && !rawRelays)
264
+ return undefined;
265
+ if (!nostrKey || !rawRelays) {
266
+ throw new Error('Zap-to-note needs MONEYER_NOSTR_KEY and MONEYER_NOSTR_RELAYS.');
267
+ }
268
+ if (!HEX32.test(nostrKey))
269
+ throw new Error('MONEYER_NOSTR_KEY must be 32 bytes of hex.');
270
+ // Refuse a key the curve refuses, at startup rather than on the first zap.
271
+ getPublicKey(hexToBytes(nostrKey));
272
+ const relays = rawRelays
273
+ .split(',')
274
+ .map(r => r.trim())
275
+ .filter(Boolean);
276
+ if (!relays.length || relays.some(r => !/^wss?:\/\//.test(r))) {
277
+ throw new Error('MONEYER_NOSTR_RELAYS must be a comma-separated list of ws:// or wss:// URLs.');
278
+ }
279
+ const names = {};
280
+ for (const entry of (rawNames ?? '').split(',').filter(Boolean)) {
281
+ const [name, pubkey, ...rest] = entry.split('=').map(s => s.trim());
282
+ if (!name || !pubkey || rest.length) {
283
+ throw new Error(`MONEYER_ZAP_NAMES entry is not name=pubkey: ${JSON.stringify(entry)}.`);
284
+ }
285
+ const lowered = name.toLowerCase();
286
+ if (!NAME.test(lowered))
287
+ throw new Error(`MONEYER_ZAP_NAMES name is not a lightning-address local part: ${JSON.stringify(name)}.`);
288
+ if (lowered === '_')
289
+ throw new Error('MONEYER_ZAP_NAMES must not claim the bare-domain name "_".');
290
+ names[lowered] = pubkeyHex(pubkey);
291
+ }
292
+ return { nostrKey: nostrKey.toLowerCase(), relays, names };
293
+ };
package/dist/index.d.ts CHANGED
@@ -1,10 +1,13 @@
1
1
  export { configFromEnv, DEFAULTS, type MoneyerConfig, type BackendConfig } from './config.ts';
2
2
  export { createMoneyer, type Moneyer, type MoneyerDeps } from './server.ts';
3
- export { NoteStore, NotePendingError, NoteUnavailableError, OutputCollisionError, type NoteRow, type NoteState, type MeltRow, type MintInvoiceRow } from './store.ts';
4
- export { createNoteSigner, noteIdSignatureDigest, type NoteSigner } from './signing.ts';
3
+ export { NoteStore, NotePendingError, NoteUnavailableError, OutputCollisionError, type NoteRow, type NoteState, type MeltRow, type MintInvoiceRow, type Liabilities } from './store.ts';
4
+ export { createNoteSigner, noteIdSignatureDigest, recoversToPubkey, signDigestRecoverable, type NoteSigner } from './signing.ts';
5
+ export { STATS_D_TAG, STATS_KIND, buildStats, canonicalJson, signStats, statsDigest, statsSnapshotContent, verifyStatsSignature, verifyStatsSnapshot, type MintStats } from './stats.ts';
6
+ export { ANNOUNCE_D_TAG, ANNOUNCE_KIND, ANNOUNCE_MESSAGE_PREFIX, announcementContent, announcementDigest, canonicalise, signAnnouncement, verifyAnnouncement, type MintAddressDocument } from './announce.ts';
5
7
  export { PaymentAlreadyKnownError, PaymentFailedError, PaymentPendingError, type LightningBackend, type NodeInfo, type PaymentOutcome } from './backends/types.ts';
6
8
  export { createFakeBackend, type FakeBackend, type FakePayMode } from './backends/fake.ts';
7
9
  export { createClnBackend } from './backends/cln.ts';
8
10
  export { createLndBackend } from './backends/lnd.ts';
9
11
  export { fakeBolt11 } from './backends/fake-bolt11.ts';
10
12
  export { runMelt, reconcilePendingMelts, type MeltJob, type MeltDeps } from './melt.ts';
13
+ export { runAdmin, adminHelp, type AdminDeps } from './admin.ts';
package/dist/index.js CHANGED
@@ -14,10 +14,13 @@
14
14
  export { configFromEnv, DEFAULTS } from "./config.js";
15
15
  export { createMoneyer } from "./server.js";
16
16
  export { NoteStore, NotePendingError, NoteUnavailableError, OutputCollisionError } from "./store.js";
17
- export { createNoteSigner, noteIdSignatureDigest } from "./signing.js";
17
+ export { createNoteSigner, noteIdSignatureDigest, recoversToPubkey, signDigestRecoverable } from "./signing.js";
18
+ export { STATS_D_TAG, STATS_KIND, buildStats, canonicalJson, signStats, statsDigest, statsSnapshotContent, verifyStatsSignature, verifyStatsSnapshot } from "./stats.js";
19
+ export { ANNOUNCE_D_TAG, ANNOUNCE_KIND, ANNOUNCE_MESSAGE_PREFIX, announcementContent, announcementDigest, canonicalise, signAnnouncement, verifyAnnouncement } from "./announce.js";
18
20
  export { PaymentAlreadyKnownError, PaymentFailedError, PaymentPendingError } from "./backends/types.js";
19
21
  export { createFakeBackend } from "./backends/fake.js";
20
22
  export { createClnBackend } from "./backends/cln.js";
21
23
  export { createLndBackend } from "./backends/lnd.js";
22
24
  export { fakeBolt11 } from "./backends/fake-bolt11.js";
23
25
  export { runMelt, reconcilePendingMelts } from "./melt.js";
26
+ export { runAdmin, adminHelp } from "./admin.js";
package/dist/landing.d.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import type { MoneyerConfig } from './config.ts';
2
2
  import type { NodeInfo } from './backends/types.ts';
3
+ import type { MintStats } from './stats.ts';
3
4
  export declare const landingPage: (args: {
4
5
  config: MoneyerConfig;
5
6
  host: string;
6
7
  mintPubkey: string | null;
7
8
  nodeInfo: NodeInfo;
9
+ stats?: MintStats | null;
8
10
  }) => string;