@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/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
- }) => NameGranted | NameRefusal;
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 { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
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, hashK1, noteK1 } from '@lnurlcash/kit';
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 (/^[0-9a-f]{64}$/i.test(offered)) {
149
- k1 = offered.toLowerCase();
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 = store.noteById(hashK1(k1));
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) => number;
36
+ export declare const sweepExpiredMintInvoices: (store: NoteStore, backend: Pick<LightningBackend, "isInvoiceSettled">, nowMs?: number) => Promise<{
37
+ swept: number;
38
+ settled: number;
39
+ }>;