@forgesworn/moneyer 0.16.2 → 0.17.1
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 +91 -0
- package/README.md +73 -34
- package/TERMS-TEMPLATE.md +14 -0
- package/THREAT-MODEL.md +46 -14
- package/dist/admin.js +28 -16
- package/dist/backends/lnd.js +8 -1
- package/dist/cli.js +3 -2
- package/dist/config.d.ts +1 -0
- package/dist/config.js +3 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/kernel.d.ts +10 -0
- package/dist/kernel.js +105 -0
- package/dist/names.d.ts +12 -2
- package/dist/names.js +50 -7
- package/dist/server.d.ts +6 -1
- package/dist/server.js +244 -152
- package/dist/spend.d.ts +85 -0
- package/dist/spend.js +454 -0
- package/dist/store.d.ts +6 -1
- package/dist/store.js +91 -11
- package/dist/zap.js +11 -6
- package/llms.txt +22 -10
- package/package.json +3 -2
- package/scripts/kernel-verifier.py +59 -0
package/dist/kernel.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { bytesToHex } from '@noble/hashes/utils.js';
|
|
4
|
+
import { encodeCw1 } from "./spend.js";
|
|
5
|
+
// A ScriptVerifier backed by a long-lived child process speaking one JSON
|
|
6
|
+
// request and one JSON reply per line: scripts/kernel-verifier.py, which
|
|
7
|
+
// runs Bitcoin Core's own interpreter through lnurlcash-kernel.
|
|
8
|
+
//
|
|
9
|
+
// LUD-25 has a mint accept any consensus-valid tapscript, and moneyer is
|
|
10
|
+
// not a tapscript interpreter. Rather than write one, it borrows the one
|
|
11
|
+
// the reference mint uses, so the two mints accept and refuse exactly the
|
|
12
|
+
// same spends.
|
|
13
|
+
//
|
|
14
|
+
// Anything short of a verdict - the process missing, crashing, answering
|
|
15
|
+
// with an error, or too slowly - refuses the spend. The note is untouched
|
|
16
|
+
// and the wallet can try again; accepting on a broken verifier would be
|
|
17
|
+
// the one unrecoverable answer.
|
|
18
|
+
export const UNAVAILABLE = 'script verification is unavailable - try again later';
|
|
19
|
+
export const createKernelVerifier = (options) => {
|
|
20
|
+
const [program, ...args] = options.command;
|
|
21
|
+
if (!program)
|
|
22
|
+
throw new Error('The script verifier needs a command.');
|
|
23
|
+
const timeoutMs = options.timeoutMs ?? 5_000;
|
|
24
|
+
const log = options.log ?? (() => { });
|
|
25
|
+
let child = null;
|
|
26
|
+
let nextId = 1;
|
|
27
|
+
const pending = new Map();
|
|
28
|
+
const settle = (id, reason) => {
|
|
29
|
+
const waiting = pending.get(id);
|
|
30
|
+
if (!waiting)
|
|
31
|
+
return;
|
|
32
|
+
clearTimeout(waiting.timer);
|
|
33
|
+
pending.delete(id);
|
|
34
|
+
waiting.resolve(reason);
|
|
35
|
+
};
|
|
36
|
+
const refuseAll = () => {
|
|
37
|
+
for (const id of [...pending.keys()])
|
|
38
|
+
settle(id, UNAVAILABLE);
|
|
39
|
+
};
|
|
40
|
+
const running = () => {
|
|
41
|
+
if (child)
|
|
42
|
+
return child;
|
|
43
|
+
const started = spawn(program, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
44
|
+
started.on('error', err => {
|
|
45
|
+
log(`script verifier failed to start: ${err.message}`);
|
|
46
|
+
if (child === started)
|
|
47
|
+
child = null;
|
|
48
|
+
refuseAll();
|
|
49
|
+
});
|
|
50
|
+
started.on('exit', code => {
|
|
51
|
+
log(`script verifier exited (${code ?? 'signal'})`);
|
|
52
|
+
if (child === started)
|
|
53
|
+
child = null;
|
|
54
|
+
refuseAll();
|
|
55
|
+
});
|
|
56
|
+
// A write to a process that has just died fails asynchronously; the
|
|
57
|
+
// exit handler above has already refused whatever was waiting.
|
|
58
|
+
started.stdin.on('error', err => log(`script verifier stdin: ${err.message}`));
|
|
59
|
+
started.stderr.on('data', chunk => log(`script verifier: ${String(chunk).trim()}`));
|
|
60
|
+
createInterface({ input: started.stdout }).on('line', line => {
|
|
61
|
+
let reply;
|
|
62
|
+
try {
|
|
63
|
+
reply = JSON.parse(line);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (typeof reply.id !== 'number')
|
|
69
|
+
return;
|
|
70
|
+
if (reply.ok === true)
|
|
71
|
+
settle(reply.id, null);
|
|
72
|
+
else if (typeof reply.reason === 'string')
|
|
73
|
+
settle(reply.id, reply.reason);
|
|
74
|
+
else {
|
|
75
|
+
log(`script verifier error: ${String(reply.error)}`);
|
|
76
|
+
settle(reply.id, UNAVAILABLE);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
child = started;
|
|
80
|
+
return started;
|
|
81
|
+
};
|
|
82
|
+
const verify = ({ outputKey, domain, spend, now, lockedAt }) => new Promise(resolve => {
|
|
83
|
+
const id = nextId++;
|
|
84
|
+
const timer = setTimeout(() => {
|
|
85
|
+
log('script verifier timed out');
|
|
86
|
+
settle(id, UNAVAILABLE);
|
|
87
|
+
}, timeoutMs);
|
|
88
|
+
pending.set(id, { resolve, timer });
|
|
89
|
+
const request = { id, q: bytesToHex(outputKey), domain, cw1: encodeCw1(spend), now, locked_at: lockedAt };
|
|
90
|
+
try {
|
|
91
|
+
running().stdin.write(`${JSON.stringify(request)}\n`);
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
log(`script verifier unreachable: ${err.message}`);
|
|
95
|
+
settle(id, UNAVAILABLE);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
return Object.assign(verify, {
|
|
99
|
+
close: () => {
|
|
100
|
+
refuseAll();
|
|
101
|
+
child?.kill();
|
|
102
|
+
child = null;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
};
|
package/dist/names.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { NoteStore } from './store.ts';
|
|
1
|
+
import { NoteStore, type NoteRow } from './store.ts';
|
|
2
2
|
export declare const NIP98_KIND = 27235;
|
|
3
3
|
export declare const NIP98_WINDOW_SECS = 60;
|
|
4
4
|
export declare const NAME_RULE: RegExp;
|
|
@@ -16,6 +16,13 @@ export declare const validateNip98: (authorization: string | undefined, request:
|
|
|
16
16
|
body: string;
|
|
17
17
|
nowSecs?: number;
|
|
18
18
|
}) => Nip98Success | Nip98Failure;
|
|
19
|
+
export declare const addressProofVerifies: (args: {
|
|
20
|
+
cx1: string;
|
|
21
|
+
action: "register" | "unregister";
|
|
22
|
+
name: string;
|
|
23
|
+
domains: string[];
|
|
24
|
+
sig: unknown;
|
|
25
|
+
}) => boolean;
|
|
19
26
|
export type NameRefusal = {
|
|
20
27
|
reason: string;
|
|
21
28
|
status: number;
|
|
@@ -35,8 +42,11 @@ export declare const registerName: (args: {
|
|
|
35
42
|
name?: unknown;
|
|
36
43
|
note?: unknown;
|
|
37
44
|
cx1?: unknown;
|
|
45
|
+
sig?: unknown;
|
|
38
46
|
};
|
|
39
47
|
priceMsat: number | undefined;
|
|
40
48
|
reserved: string[];
|
|
41
49
|
host: string;
|
|
42
|
-
|
|
50
|
+
domains: string[];
|
|
51
|
+
openNote: (k1: string) => Promise<NoteRow | null>;
|
|
52
|
+
}) => Promise<NameGranted | NameRefusal>;
|
package/dist/names.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { sha256 } from '@noble/hashes/sha2.js';
|
|
2
|
-
import {
|
|
2
|
+
import { schnorr } from '@noble/curves/secp256k1.js';
|
|
3
|
+
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
|
|
3
4
|
import { verifyEvent } from 'nostr-tools/pure';
|
|
4
|
-
import { decodeCx1,
|
|
5
|
+
import { decodeCx1, noteK1 } from '@lnurlcash/kit';
|
|
5
6
|
import { NotePendingError, NoteStore, NoteUnavailableError } from "./store.js";
|
|
7
|
+
import { NOTE_PURPOSE_WALLET, decodeSpend, deriveNotePubkey } from "./spend.js";
|
|
6
8
|
// Self-service lightning addresses.
|
|
7
9
|
//
|
|
8
10
|
// A name here is a lightning address whose payouts are bearer notes
|
|
@@ -85,6 +87,35 @@ export const validateNip98 = (authorization, request) => {
|
|
|
85
87
|
}
|
|
86
88
|
return { pubkey: event.pubkey };
|
|
87
89
|
};
|
|
90
|
+
// LUD-25's address proof. A cx1 is public, so pointing a name at one, or
|
|
91
|
+
// away from one, has to be proven by the branch itself: a BIP-340
|
|
92
|
+
// signature by its index-0 key over sha256("LNURLcash:<action>:<domain>:
|
|
93
|
+
// <name>"). The domain is this mint's own, so a proof one mint has seen
|
|
94
|
+
// cannot be replayed at another; the action and name are in it so a
|
|
95
|
+
// register cannot be replayed as an unregister, or for another name.
|
|
96
|
+
export const addressProofVerifies = (args) => {
|
|
97
|
+
if (typeof args.sig !== 'string' || !/^[0-9a-f]{128}$/i.test(args.sig))
|
|
98
|
+
return false;
|
|
99
|
+
const branch = decodeCx1(args.cx1);
|
|
100
|
+
if (!branch)
|
|
101
|
+
return false;
|
|
102
|
+
let firstKey;
|
|
103
|
+
try {
|
|
104
|
+
firstKey = deriveNotePubkey(branch.pubkeyXOnly, branch.chainCode, NOTE_PURPOSE_WALLET, 0);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
const signature = hexToBytes(args.sig.toLowerCase());
|
|
110
|
+
return args.domains.some(domain => {
|
|
111
|
+
try {
|
|
112
|
+
return schnorr.verify(signature, sha256(utf8ToBytes(`LNURLcash:${args.action}:${domain}:${args.name}`)), firstKey);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
};
|
|
88
119
|
const refuse = (reason, status) => ({ reason, status });
|
|
89
120
|
export const isRefusal = (result) => 'reason' in result;
|
|
90
121
|
// The whole registration, minus the transport. Burning the note and
|
|
@@ -92,7 +123,7 @@ export const isRefusal = (result) => 'reason' in result;
|
|
|
92
123
|
// exists without payment is worse than a note burned without a name,
|
|
93
124
|
// and the INSERT is what settles a race between two people asking for
|
|
94
125
|
// the same name at once.
|
|
95
|
-
export const registerName = (args) => {
|
|
126
|
+
export const registerName = async (args) => {
|
|
96
127
|
const { store, pubkey, priceMsat } = args;
|
|
97
128
|
const raw = typeof args.body.name === 'string' ? args.body.name.trim().toLowerCase() : '';
|
|
98
129
|
if (!NAME_RULE.test(raw)) {
|
|
@@ -113,10 +144,19 @@ export const registerName = (args) => {
|
|
|
113
144
|
return refuse('That is not a cx1.', 400);
|
|
114
145
|
cx1 = given;
|
|
115
146
|
}
|
|
147
|
+
const proven = (branch, action) => addressProofVerifies({ cx1: branch, action, name: raw, domains: args.domains, sig: args.body.sig });
|
|
148
|
+
const unproven = refuse('Setting or clearing a cx1 needs "sig": the branch\'s index-0 key signing sha256("LNURLcash:register:<domain>:<name>"), or :unregister: to clear.', 403);
|
|
116
149
|
const existing = store.zapName(raw);
|
|
117
150
|
if (existing) {
|
|
118
|
-
// Its owner may set or clear the branch; nobody else may touch it.
|
|
151
|
+
// Its owner may set or clear the branch; nobody else may touch it. The
|
|
152
|
+
// NIP-98 key owns the name, and the branch on file must agree to any
|
|
153
|
+
// change of where it pays: LUD-25 has an overwrite proven by the
|
|
154
|
+
// branch currently registered, not the one being switched to.
|
|
119
155
|
if (existing.pubkey === pubkey && cx1 !== undefined) {
|
|
156
|
+
if (cx1 === null && existing.cx1 && !proven(existing.cx1, 'unregister'))
|
|
157
|
+
return unproven;
|
|
158
|
+
if (cx1 !== null && !proven(existing.cx1 ?? cx1, 'register'))
|
|
159
|
+
return unproven;
|
|
120
160
|
store.setZapNameCx1(raw, cx1);
|
|
121
161
|
return { name: raw, pubkey, paidMsat: 0, cx1, updated: true };
|
|
122
162
|
}
|
|
@@ -126,6 +166,9 @@ export const registerName = (args) => {
|
|
|
126
166
|
// names, not a holder choosing where their own name pays.
|
|
127
167
|
if (priceMsat === undefined)
|
|
128
168
|
return refuse('This mint is not registering names.', 404);
|
|
169
|
+
// A fresh name pointed at a branch proves control of that branch.
|
|
170
|
+
if (typeof cx1 === 'string' && !proven(cx1, 'register'))
|
|
171
|
+
return unproven;
|
|
129
172
|
let paidMsat = 0;
|
|
130
173
|
let noteId;
|
|
131
174
|
if (priceMsat === 0) {
|
|
@@ -145,8 +188,8 @@ export const registerName = (args) => {
|
|
|
145
188
|
// plain-http service - correct for a wallet reaching a stranger, and
|
|
146
189
|
// wrong for a mint reading a note it minted itself.
|
|
147
190
|
let k1 = null;
|
|
148
|
-
if (
|
|
149
|
-
k1 = offered
|
|
191
|
+
if (decodeSpend(offered)) {
|
|
192
|
+
k1 = offered;
|
|
150
193
|
}
|
|
151
194
|
else {
|
|
152
195
|
let url;
|
|
@@ -162,7 +205,7 @@ export const registerName = (args) => {
|
|
|
162
205
|
}
|
|
163
206
|
if (!k1)
|
|
164
207
|
return refuse('That is not a note.', 400);
|
|
165
|
-
const note =
|
|
208
|
+
const note = await args.openNote(k1);
|
|
166
209
|
if (!note || note.state === 'burned')
|
|
167
210
|
return refuse('That note is spent or was never minted here.', 400);
|
|
168
211
|
if (note.state === 'pending')
|
package/dist/server.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { MoneyerConfig } from './config.ts';
|
|
2
2
|
import { NoteStore } from './store.ts';
|
|
3
3
|
import { type NoteSigner } from './signing.ts';
|
|
4
|
+
import { type ScriptVerifier } from './spend.ts';
|
|
4
5
|
import { type LightningBackend } from './backends/types.ts';
|
|
5
6
|
import { type NostrTransport, type ZapBridge } from './zap.ts';
|
|
6
7
|
import { type MintStats } from './stats.ts';
|
|
@@ -28,7 +29,11 @@ export type MoneyerDeps = {
|
|
|
28
29
|
nostr?: NostrTransport;
|
|
29
30
|
zapPollMs?: number;
|
|
30
31
|
statsPublishMs?: number;
|
|
32
|
+
scriptVerifier?: ScriptVerifier;
|
|
31
33
|
};
|
|
32
34
|
export { describeFee } from './fee-words.ts';
|
|
33
35
|
export declare const createMoneyer: (config: MoneyerConfig, deps?: MoneyerDeps) => Promise<Moneyer>;
|
|
34
|
-
export declare const sweepExpiredMintInvoices: (store: NoteStore, nowMs?: number) =>
|
|
36
|
+
export declare const sweepExpiredMintInvoices: (store: NoteStore, backend: Pick<LightningBackend, "isInvoiceSettled">, nowMs?: number) => Promise<{
|
|
37
|
+
swept: number;
|
|
38
|
+
settled: number;
|
|
39
|
+
}>;
|