@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/backends/fake.d.ts
CHANGED
|
@@ -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
|
|
22
|
+
export declare const FAKE_LOCAL_BALANCE_MSAT = 100000000000;
|
|
23
|
+
export declare const createFakeBackend: (options?: FakeBackendOptions) => FakeBackend;
|
package/dist/backends/fake.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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 {
|
|
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);
|
package/dist/backends/lnd.js
CHANGED
|
@@ -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: {
|
|
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: {
|
|
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
|
};
|
package/dist/backends/types.d.ts
CHANGED
|
@@ -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
|
-
|
|
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('\
|
|
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
|
-
|
|
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;
|