@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.
@@ -0,0 +1,85 @@
1
+ export declare const TAPLEAF_VERSION = 192;
2
+ export declare const KEY_PATH_LOCKTIME = 0;
3
+ export declare const KEY_PATH_SEQUENCE = 4294967295;
4
+ export declare const NUMS_H: Uint8Array<ArrayBufferLike> & Uint8Array<ArrayBuffer>;
5
+ export declare const isXOnlyPoint: (x: Uint8Array) => boolean;
6
+ export declare const tapLeafHash: (script: Uint8Array, version?: number) => Uint8Array;
7
+ export declare const taprootTweak: (internalKey: Uint8Array, merkleRoot: Uint8Array) => {
8
+ outputKey: Uint8Array;
9
+ parity: 0 | 1;
10
+ } | null;
11
+ export declare const outputKeyOf: (script: Uint8Array, controlBlock: Uint8Array) => Uint8Array | null;
12
+ export declare const bearerLeaf: (h: Uint8Array) => Uint8Array;
13
+ export declare const bearerNote: (h: Uint8Array) => {
14
+ outputKey: Uint8Array;
15
+ controlBlock: Uint8Array;
16
+ leaf: Uint8Array;
17
+ };
18
+ export declare const bearerNoteId: (hHex: string) => string;
19
+ export declare const bearerNoteIdOfPreimage: (k1Hex: string) => string;
20
+ export declare const NOTE_PURPOSE_WALLET = 0;
21
+ export declare const NOTE_PURPOSE_CHANGE = 1;
22
+ export declare const NOTE_PURPOSE_LIGHTNING_ADDRESS = 2;
23
+ export declare const deriveNotePubkey: (branchPubkeyXOnly: Uint8Array, chainCode: Uint8Array, purpose: number, index: number) => Uint8Array;
24
+ export declare const spendDomainOf: (origin: string) => string;
25
+ export declare const spendPrevout: (domain: string) => Uint8Array;
26
+ export declare const spendSigMsg: (args: {
27
+ outputKey: Uint8Array;
28
+ domain: string;
29
+ locktime: number;
30
+ sequence: number;
31
+ leafScript?: Uint8Array;
32
+ }) => Uint8Array;
33
+ export declare const keyPathSighash: (outputKey: Uint8Array, domain: string) => Uint8Array;
34
+ export declare const scriptPathSighash: (outputKey: Uint8Array, domain: string, leafScript: Uint8Array, locktime: number, sequence: number) => Uint8Array;
35
+ export declare const encodeCp1: (outputKey: Uint8Array) => string;
36
+ export declare const encodeCk1: (outputKey: Uint8Array, signature: Uint8Array) => string;
37
+ export declare const decodeCp1: (value: string) => Uint8Array | null;
38
+ export declare const decodeNote: (value: string) => string | null;
39
+ export type ScriptSpend = {
40
+ kind: 'script';
41
+ outputKey: Uint8Array;
42
+ locktime: number;
43
+ sequence: number;
44
+ script: Uint8Array;
45
+ controlBlock: Uint8Array;
46
+ witness: Uint8Array[];
47
+ };
48
+ export type Spend = {
49
+ kind: 'key';
50
+ outputKey: Uint8Array;
51
+ signature: Uint8Array;
52
+ } | ScriptSpend | {
53
+ kind: 'recovered';
54
+ outputKey: Uint8Array;
55
+ };
56
+ export declare const encodeCw1: (spend: Omit<ScriptSpend, "kind" | "outputKey">) => string;
57
+ export declare const decodeSpend: (k1: string) => Spend | null;
58
+ export declare const checkLeaf: (script: Uint8Array, controlBlock: Uint8Array) => string | null;
59
+ export declare const checkTimeClaim: (claim: {
60
+ locktime: number;
61
+ sequence: number;
62
+ now: number;
63
+ lockedAt: number;
64
+ }) => string | null;
65
+ export type ScriptVerifier = (args: {
66
+ outputKey: Uint8Array;
67
+ domain: string;
68
+ spend: ScriptSpend;
69
+ now: number;
70
+ lockedAt: number;
71
+ }) => Promise<string | null>;
72
+ export type SpendVerdict = {
73
+ ok: true;
74
+ } | {
75
+ ok: false;
76
+ reason: string;
77
+ specific: boolean;
78
+ };
79
+ export declare const verifySpend: (spend: Spend, context: {
80
+ outputKey: Uint8Array;
81
+ domains: string[];
82
+ now: number;
83
+ lockedAt: number;
84
+ scriptVerifier?: ScriptVerifier;
85
+ }) => Promise<SpendVerdict>;
package/dist/spend.js ADDED
@@ -0,0 +1,454 @@
1
+ import { sha256 } from '@noble/hashes/sha2.js';
2
+ import { schnorr, secp256k1 } from '@noble/curves/secp256k1.js';
3
+ import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
4
+ import { bech32m } from '@scure/base';
5
+ // LUD-25 notes and their spends.
6
+ //
7
+ // Every note is a BIP-341 taproot output key Q, and this mint stores it
8
+ // under hex(Q). A `k1` a redeemer presents is a spend of one:
9
+ //
10
+ // ck1<Q || sig> key path: a BIP-340 signature by Q
11
+ // cw1<...> script path: a leaf of Q's tree, its control block and
12
+ // the witness that satisfies it
13
+ // 64 hex a bearer note's preimage, the short form of its cw1
14
+ //
15
+ // Every signature signs the BIP-341 sighash of input 0 of one fixed,
16
+ // never-broadcast transaction whose prevout is bound to this mint's domain,
17
+ // so a spend one mint has seen cannot be replayed at another. The rules
18
+ // here follow lnurlcash/kernel, the reference mint's verifier, field for
19
+ // field; where the two could disagree the kernel is right.
20
+ //
21
+ // Nothing here is secret. Every key, leaf and control block is public, so
22
+ // none of this needs to be constant-time.
23
+ export const TAPLEAF_VERSION = 0xc0;
24
+ export const KEY_PATH_LOCKTIME = 0;
25
+ export const KEY_PATH_SEQUENCE = 0xffffffff;
26
+ // BIP-341's nothing-up-my-sleeve point. Nobody knows its discrete log, so
27
+ // a note built on it has no key path: only its leaf can spend it.
28
+ export const NUMS_H = hexToBytes('50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0');
29
+ const HEX32 = /^[0-9a-f]{64}$/i;
30
+ const MAX_MERKLE_DEPTH = 128;
31
+ // BIP-342 keeps the 520-byte cap on every initial stack element.
32
+ const MAX_STACK_ELEMENT = 520;
33
+ // No URL gets near this; it only stops a hostile string costing work.
34
+ const MAX_BECH32_CHARS = 8192;
35
+ const CURVE_ORDER = secp256k1.Point.CURVE().n;
36
+ const taggedHash = (tag, ...parts) => schnorr.utils.taggedHash(tag, ...parts);
37
+ const compactSize = (n) => {
38
+ if (n < 0xfd)
39
+ return new Uint8Array([n]);
40
+ if (n <= 0xffff)
41
+ return new Uint8Array([0xfd, n & 0xff, n >> 8]);
42
+ const out = new Uint8Array(5);
43
+ out[0] = 0xfe;
44
+ new DataView(out.buffer).setUint32(1, n, true);
45
+ return out;
46
+ };
47
+ const u32le = (n) => {
48
+ const out = new Uint8Array(4);
49
+ new DataView(out.buffer).setUint32(0, n, true);
50
+ return out;
51
+ };
52
+ const equalBytes = (a, b) => a.length === b.length && a.every((byte, i) => byte === b[i]);
53
+ const liftX = (x) => {
54
+ if (x.length !== 32)
55
+ return null;
56
+ try {
57
+ return schnorr.utils.lift_x(BigInt(`0x${bytesToHex(x)}`));
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ };
63
+ // Is `x` the x coordinate of a curve point? LUD-25 has a mint refuse a cp1
64
+ // that is not: no spend could ever open it.
65
+ export const isXOnlyPoint = (x) => liftX(x) !== null;
66
+ export const tapLeafHash = (script, version = TAPLEAF_VERSION) => taggedHash('TapLeaf', new Uint8Array([version]), compactSize(script.length), script);
67
+ // Q = lift_x(P) + tagged_hash("TapTweak", P || root)·G, with Q's parity.
68
+ // Null if P is not a point or the tweak is out of range.
69
+ export const taprootTweak = (internalKey, merkleRoot) => {
70
+ const p = liftX(internalKey);
71
+ if (!p)
72
+ return null;
73
+ const t = BigInt(`0x${bytesToHex(taggedHash('TapTweak', internalKey, merkleRoot))}`);
74
+ if (t >= CURVE_ORDER)
75
+ return null;
76
+ const q = p.add(secp256k1.Point.BASE.multiply(t));
77
+ if (q.equals(secp256k1.Point.ZERO))
78
+ return null;
79
+ return { outputKey: schnorr.utils.pointToBytes(q), parity: q.y % 2n === 0n ? 0 : 1 };
80
+ };
81
+ // The Q a leaf and its control block commit to, or null if the control
82
+ // block is malformed. The parity bit is checked too, so a Q returned here
83
+ // is exactly the one the spend is valid against.
84
+ export const outputKeyOf = (script, controlBlock) => {
85
+ if (controlBlock.length < 33 || (controlBlock.length - 33) % 32 !== 0)
86
+ return null;
87
+ if ((controlBlock.length - 33) / 32 > MAX_MERKLE_DEPTH)
88
+ return null;
89
+ let node = tapLeafHash(script, controlBlock[0] & 0xfe);
90
+ for (let i = 33; i < controlBlock.length; i += 32) {
91
+ const sibling = controlBlock.subarray(i, i + 32);
92
+ node = bytesToHex(node) < bytesToHex(sibling)
93
+ ? taggedHash('TapBranch', node, sibling)
94
+ : taggedHash('TapBranch', sibling, node);
95
+ }
96
+ const tweaked = taprootTweak(controlBlock.subarray(1, 33), node);
97
+ if (!tweaked || tweaked.parity !== (controlBlock[0] & 1))
98
+ return null;
99
+ return tweaked.outputKey;
100
+ };
101
+ // ---- the bearer note ----
102
+ //
103
+ // NUMS internal key, one `OP_SHA256 <h> OP_EQUAL` leaf: spent by revealing
104
+ // the preimage, with no signature and so bound to no mint. Everything but
105
+ // the preimage follows from h, which is why its short forms work.
106
+ export const bearerLeaf = (h) => {
107
+ if (h.length !== 32)
108
+ throw new Error('h must be 32 bytes.');
109
+ return concatBytes(new Uint8Array([0xa8, 0x20]), h, new Uint8Array([0x87]));
110
+ };
111
+ export const bearerNote = (h) => {
112
+ const leaf = bearerLeaf(h);
113
+ const tweaked = taprootTweak(NUMS_H, tapLeafHash(leaf));
114
+ // NUMS_H is a point and a hash is never out of range in practice.
115
+ if (!tweaked)
116
+ throw new Error('Bearer note tweak failed.');
117
+ return {
118
+ outputKey: tweaked.outputKey,
119
+ controlBlock: concatBytes(new Uint8Array([TAPLEAF_VERSION | tweaked.parity]), NUMS_H),
120
+ leaf
121
+ };
122
+ };
123
+ // hex(Q) of the bearer note whose hash is `hHex`.
124
+ export const bearerNoteId = (hHex) => bytesToHex(bearerNote(hexToBytes(hHex.toLowerCase())).outputKey);
125
+ // hex(Q) of the bearer note a hex preimage opens.
126
+ export const bearerNoteIdOfPreimage = (k1Hex) => bytesToHex(bearerNote(sha256(hexToBytes(k1Hex.toLowerCase()))).outputKey);
127
+ // ---- a cx1 branch's note keys ----
128
+ //
129
+ // t = tagged_hash("LNURLcash/derive", P || chaincode || ser32(purpose) || ser32(i)) mod n
130
+ // pk_i = x(lift_x(P) + t·G)
131
+ //
132
+ // `purpose` splits one branch into independent counters so a wallet's own
133
+ // indices and this mint's auto-minted ones never meet. The mint derives on
134
+ // two: the wallet's index 0 proves a name's cx1, and Lightning Address
135
+ // payments land on their own counter.
136
+ export const NOTE_PURPOSE_WALLET = 0;
137
+ export const NOTE_PURPOSE_CHANGE = 1;
138
+ export const NOTE_PURPOSE_LIGHTNING_ADDRESS = 2;
139
+ const ser32 = (n) => {
140
+ const bytes = new Uint8Array(4);
141
+ new DataView(bytes.buffer).setUint32(0, n, false);
142
+ return bytes;
143
+ };
144
+ // Throws for the vanishingly rare tweak that lands on the point at infinity.
145
+ export const deriveNotePubkey = (branchPubkeyXOnly, chainCode, purpose, index) => {
146
+ const p = liftX(branchPubkeyXOnly);
147
+ if (!p)
148
+ throw new Error('branch key is not on the curve');
149
+ const t = BigInt(`0x${bytesToHex(taggedHash('LNURLcash/derive', branchPubkeyXOnly, chainCode, ser32(purpose), ser32(index)))}`) %
150
+ CURVE_ORDER;
151
+ return schnorr.utils.pointToBytes(p.add(secp256k1.Point.BASE.multiply(t)));
152
+ };
153
+ // The `h` inside a leaf, if the leaf is exactly a bearer note's.
154
+ const bearerHashOfLeaf = (script) => script.length === 35 && script[0] === 0xa8 && script[1] === 0x20 && script[34] === 0x87 ? script.subarray(2, 34) : null;
155
+ // ---- what a signature signs ----
156
+ // The bare lowercase hostname a spend at `origin` is bound to: never the
157
+ // scheme or the port.
158
+ export const spendDomainOf = (origin) => new URL(origin).hostname.toLowerCase();
159
+ export const spendPrevout = (domain) => {
160
+ if (!domain)
161
+ throw new Error('A spend domain is required.');
162
+ return taggedHash('LNURLcash/mint', utf8ToBytes(domain.toLowerCase()));
163
+ };
164
+ const I64_ZERO = new Uint8Array(8);
165
+ // BIP-341's SigMsg for input 0 of the canonical spend transaction under
166
+ // SIGHASH_DEFAULT, with BIP-342's extension when a leaf is given.
167
+ export const spendSigMsg = (args) => {
168
+ if (args.outputKey.length !== 32)
169
+ throw new Error('Q must be 32 bytes.');
170
+ const scriptPubKey = concatBytes(new Uint8Array([0x51, 0x20]), args.outputKey);
171
+ const parts = [
172
+ new Uint8Array([0x00]), // hash_type
173
+ u32le(2), // nVersion
174
+ u32le(args.locktime),
175
+ sha256(concatBytes(spendPrevout(args.domain), u32le(0))), // sha_prevouts
176
+ sha256(I64_ZERO), // sha_amounts
177
+ sha256(concatBytes(new Uint8Array([scriptPubKey.length]), scriptPubKey)), // sha_scriptpubkeys
178
+ sha256(u32le(args.sequence)), // sha_sequences
179
+ sha256(concatBytes(I64_ZERO, new Uint8Array([0x00]))), // sha_outputs
180
+ new Uint8Array([args.leafScript ? 0x02 : 0x00]), // spend_type, no annex
181
+ u32le(0) // input_index
182
+ ];
183
+ if (args.leafScript) {
184
+ parts.push(tapLeafHash(args.leafScript), new Uint8Array([0x00]), new Uint8Array([0xff, 0xff, 0xff, 0xff]));
185
+ }
186
+ return concatBytes(...parts);
187
+ };
188
+ const tapSighash = (sigMsg) => taggedHash('TapSighash', new Uint8Array([0x00]), sigMsg);
189
+ // What a ck1's signature signs.
190
+ export const keyPathSighash = (outputKey, domain) => tapSighash(spendSigMsg({ outputKey, domain, locktime: KEY_PATH_LOCKTIME, sequence: KEY_PATH_SEQUENCE }));
191
+ // What a SIGHASH_DEFAULT signature inside a cw1's leaf signs.
192
+ export const scriptPathSighash = (outputKey, domain, leafScript, locktime, sequence) => tapSighash(spendSigMsg({ outputKey, domain, locktime, sequence, leafScript }));
193
+ // ---- the wire values ----
194
+ const decodeBech32m = (value, hrp) => {
195
+ try {
196
+ const decoded = bech32m.decode(value, MAX_BECH32_CHARS);
197
+ return decoded.prefix === hrp ? bech32m.fromWords(decoded.words) : null;
198
+ }
199
+ catch {
200
+ return null;
201
+ }
202
+ };
203
+ export const encodeCp1 = (outputKey) => bech32m.encode('cp', bech32m.toWords(outputKey), MAX_BECH32_CHARS);
204
+ export const encodeCk1 = (outputKey, signature) => bech32m.encode('ck', bech32m.toWords(concatBytes(outputKey, signature)), MAX_BECH32_CHARS);
205
+ // A `cp1`, only if its Q is a point.
206
+ export const decodeCp1 = (value) => {
207
+ const q = decodeBech32m(value.trim(), 'cp');
208
+ return q && q.length === 32 && isXOnlyPoint(q) ? q : null;
209
+ };
210
+ // hex(Q) of whatever was put where a `cp1` goes - a mint comment, p1/p2,
211
+ // ?p= - a `cp1`, or a bearer note's 64-hex `h`. Null if it is neither.
212
+ export const decodeNote = (value) => {
213
+ const trimmed = value.trim();
214
+ if (HEX32.test(trimmed))
215
+ return bearerNoteId(trimmed);
216
+ const q = decodeCp1(trimmed);
217
+ return q ? bytesToHex(q) : null;
218
+ };
219
+ export const encodeCw1 = (spend) => {
220
+ const head = new Uint8Array(8);
221
+ new DataView(head.buffer).setUint32(0, spend.locktime, false);
222
+ new DataView(head.buffer).setUint32(4, spend.sequence, false);
223
+ const items = [spend.script, spend.controlBlock, ...spend.witness].map(item => {
224
+ const len = new Uint8Array(2);
225
+ new DataView(len.buffer).setUint16(0, item.length, false);
226
+ return concatBytes(len, item);
227
+ });
228
+ return bech32m.encode('cw', bech32m.toWords(concatBytes(head, ...items)), MAX_BECH32_CHARS);
229
+ };
230
+ const decodeCw1 = (value) => {
231
+ const data = decodeBech32m(value, 'cw');
232
+ if (!data || data.length < 8)
233
+ return null;
234
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
235
+ const parts = [];
236
+ let i = 8;
237
+ while (i < data.length) {
238
+ if (i + 2 > data.length)
239
+ return null;
240
+ const n = view.getUint16(i, false);
241
+ i += 2;
242
+ if (i + n > data.length)
243
+ return null;
244
+ parts.push(data.slice(i, i + n));
245
+ i += n;
246
+ }
247
+ if (parts.length < 2)
248
+ return null;
249
+ const outputKey = outputKeyOf(parts[0], parts[1]);
250
+ if (!outputKey)
251
+ return null;
252
+ return {
253
+ kind: 'script',
254
+ outputKey,
255
+ locktime: view.getUint32(0, false),
256
+ sequence: view.getUint32(4, false),
257
+ script: parts[0],
258
+ controlBlock: parts[1],
259
+ witness: parts.slice(2)
260
+ };
261
+ };
262
+ // The Lightning-signed-message digest the pre-LUD-25 recoverable ck1 signed.
263
+ const LEGACY_ECDSA_DIGEST = sha256(sha256(utf8ToBytes('Lightning Signed Message:LNURLcash')));
264
+ const recoverLegacyCk1 = (signature) => {
265
+ try {
266
+ const recidLeading = concatBytes(new Uint8Array([signature[64]]), signature.subarray(0, 64));
267
+ return secp256k1.recoverPublicKey(recidLeading, LEGACY_ECDSA_DIGEST, { prehash: false }).subarray(1);
268
+ }
269
+ catch {
270
+ return null;
271
+ }
272
+ };
273
+ // What a redeemer put in `k1`, decoded, or null if it is no spend at all.
274
+ // Nothing here checks a signature against a note: that needs the note's
275
+ // record and this mint's domain (see verifySpend).
276
+ export const decodeSpend = (k1) => {
277
+ const value = k1.trim();
278
+ if (HEX32.test(value)) {
279
+ const preimage = hexToBytes(value.toLowerCase());
280
+ const note = bearerNote(sha256(preimage));
281
+ return {
282
+ kind: 'script',
283
+ outputKey: note.outputKey,
284
+ locktime: KEY_PATH_LOCKTIME,
285
+ sequence: KEY_PATH_SEQUENCE,
286
+ script: note.leaf,
287
+ controlBlock: note.controlBlock,
288
+ witness: [preimage]
289
+ };
290
+ }
291
+ const ck1 = decodeBech32m(value, 'ck');
292
+ if (ck1) {
293
+ if (ck1.length === 96)
294
+ return { kind: 'key', outputKey: ck1.slice(0, 32), signature: ck1.slice(32) };
295
+ if (ck1.length === 65) {
296
+ const outputKey = recoverLegacyCk1(ck1);
297
+ return outputKey ? { kind: 'recovered', outputKey } : null;
298
+ }
299
+ return null;
300
+ }
301
+ return decodeCw1(value);
302
+ };
303
+ // ---- verifying a spend ----
304
+ // BIP-342's OP_SUCCESSx: 80, 98, 126-129, 131-134, 137-138, 141-142,
305
+ // 149-153, 187-254.
306
+ const OP_SUCCESS = new Set([80, 98, 137, 138, 141, 142]);
307
+ for (const [from, to] of [
308
+ [126, 129],
309
+ [131, 134],
310
+ [149, 153],
311
+ [187, 254]
312
+ ]) {
313
+ for (let op = from; op <= to; op++)
314
+ OP_SUCCESS.add(op);
315
+ }
316
+ const opcodes = function* (script) {
317
+ let i = 0;
318
+ while (i < script.length) {
319
+ const op = script[i];
320
+ i += 1;
321
+ if (op >= 1 && op <= 75)
322
+ i += op;
323
+ else if (op === 0x4c) {
324
+ if (i + 1 > script.length)
325
+ return;
326
+ i += 1 + script[i];
327
+ }
328
+ else if (op === 0x4d) {
329
+ if (i + 2 > script.length)
330
+ return;
331
+ i += 2 + (script[i] | (script[i + 1] << 8));
332
+ }
333
+ else if (op === 0x4e) {
334
+ if (i + 4 > script.length)
335
+ return;
336
+ i += 4 + new DataView(script.buffer, script.byteOffset + i, 4).getUint32(0, true);
337
+ }
338
+ else
339
+ yield op;
340
+ }
341
+ };
342
+ // Tapscript's upgrade hooks succeed unconditionally, so a leaf using one
343
+ // would be spendable by anyone who saw it. Refused before anything runs.
344
+ export const checkLeaf = (script, controlBlock) => {
345
+ if (controlBlock.length === 0 || (controlBlock[0] & 0xfe) !== TAPLEAF_VERSION)
346
+ return 'unknown tapleaf version';
347
+ for (const op of opcodes(script)) {
348
+ if (OP_SUCCESS.has(op))
349
+ return 'leaf uses a reserved OP_SUCCESS opcode';
350
+ }
351
+ return null;
352
+ };
353
+ const LOCKTIME_THRESHOLD = 500_000_000;
354
+ const SEQUENCE_DISABLE_FLAG = 0x80000000;
355
+ const CSV_TYPE_FLAG = 1 << 22;
356
+ // The redeemer's signed time claim against this mint's own clock, in Unix
357
+ // seconds. A timelock "verified" here means this mint asserted its clock:
358
+ // a custodial policy, not a consensus proof, and never to be described as
359
+ // trustless.
360
+ export const checkTimeClaim = (claim) => {
361
+ if (claim.locktime !== 0) {
362
+ if (claim.locktime < LOCKTIME_THRESHOLD)
363
+ return 'block-height locktimes have no meaning without a chain';
364
+ if (claim.locktime > claim.now)
365
+ return `locktime ${claim.locktime} is in the future (now ${claim.now})`;
366
+ }
367
+ if ((claim.sequence & SEQUENCE_DISABLE_FLAG) !== 0)
368
+ return null;
369
+ if ((claim.sequence & CSV_TYPE_FLAG) === 0)
370
+ return 'block-count relative locks have no meaning without a chain';
371
+ const elapsed = claim.now - claim.lockedAt;
372
+ const required = (claim.sequence & 0xffff) * 512;
373
+ if (elapsed < required)
374
+ return `relative lock of ${required}s not yet satisfied (${Math.max(elapsed, 0)}s elapsed)`;
375
+ return null;
376
+ };
377
+ const LEGACY_OWNERSHIP_MESSAGE = utf8ToBytes('LNURLcash');
378
+ const LEGACY_OWNERSHIP_DIGEST = sha256(LEGACY_OWNERSHIP_MESSAGE);
379
+ const schnorrVerifies = (signature, message, key) => {
380
+ try {
381
+ return schnorr.verify(signature, message, key);
382
+ }
383
+ catch {
384
+ return false;
385
+ }
386
+ };
387
+ // Does `spend` open the note `outputKey`? `domains` are every host this
388
+ // mint answers on: a signature bound to any of them is this mint's.
389
+ // `lockedAt` is when this mint credited the note, where a relative lock
390
+ // starts counting.
391
+ //
392
+ // A key-path failure is never explained: it would only help someone guess.
393
+ // A script path's reason is safe to hand back (`specific`), since the cw1
394
+ // already discloses everything it has.
395
+ export const verifySpend = async (spend, context) => {
396
+ const invalid = { ok: false, reason: 'invalid', specific: false };
397
+ if (!equalBytes(spend.outputKey, context.outputKey))
398
+ return invalid;
399
+ if (spend.kind === 'recovered')
400
+ return { ok: true };
401
+ if (spend.kind === 'key') {
402
+ for (const domain of context.domains) {
403
+ if (schnorrVerifies(spend.signature, keyPathSighash(spend.outputKey, domain), spend.outputKey))
404
+ return { ok: true };
405
+ }
406
+ // Deprecated: the fixed messages a ck1 signed before every spend moved
407
+ // onto the canonical transaction. Kept, as the reference mint keeps
408
+ // them, so notes already handed out stay redeemable.
409
+ if (schnorrVerifies(spend.signature, LEGACY_OWNERSHIP_DIGEST, spend.outputKey) ||
410
+ schnorrVerifies(spend.signature, LEGACY_OWNERSHIP_MESSAGE, spend.outputKey)) {
411
+ return { ok: true };
412
+ }
413
+ return invalid;
414
+ }
415
+ const specific = (reason) => ({ ok: false, reason, specific: true });
416
+ const leafProblem = checkLeaf(spend.script, spend.controlBlock);
417
+ if (leafProblem)
418
+ return specific(leafProblem);
419
+ const timeProblem = checkTimeClaim({
420
+ locktime: spend.locktime,
421
+ sequence: spend.sequence,
422
+ now: context.now,
423
+ lockedAt: context.lockedAt
424
+ });
425
+ if (timeProblem)
426
+ return specific(timeProblem);
427
+ // A bearer leaf, under any internal key and at any depth, is evaluated
428
+ // here: OP_SHA256 <h> OP_EQUAL leaves exactly one element, true only for
429
+ // a lone witness item under the stack-element cap that hashes to h. That
430
+ // is the whole of what Bitcoin Core would decide about it.
431
+ const h = bearerHashOfLeaf(spend.script);
432
+ if (h) {
433
+ const [preimage, ...rest] = spend.witness;
434
+ if (!preimage || rest.length > 0 || preimage.length > MAX_STACK_ELEMENT)
435
+ return specific('the witness does not satisfy the leaf');
436
+ return equalBytes(sha256(preimage), h) ? { ok: true } : specific('the witness does not satisfy the leaf');
437
+ }
438
+ if (!context.scriptVerifier)
439
+ return specific('this mint cannot verify that script yet');
440
+ let last = 'bitcoin core rejected the spend';
441
+ for (const domain of context.domains) {
442
+ const reason = await context.scriptVerifier({
443
+ outputKey: context.outputKey,
444
+ domain,
445
+ spend,
446
+ now: context.now,
447
+ lockedAt: context.lockedAt
448
+ });
449
+ if (reason === null)
450
+ return { ok: true };
451
+ last = reason;
452
+ }
453
+ return specific(last);
454
+ };
package/dist/store.d.ts CHANGED
@@ -3,6 +3,7 @@ export type NoteRow = {
3
3
  id: string;
4
4
  amountMsat: number;
5
5
  state: NoteState;
6
+ createdAt: number;
6
7
  };
7
8
  export type MintInvoiceRow = {
8
9
  paymentHash: string;
@@ -11,6 +12,7 @@ export type MintInvoiceRow = {
11
12
  netMsat: number;
12
13
  settled: boolean;
13
14
  outputId: string | null;
15
+ outputNamed: string | null;
14
16
  createdAt: number;
15
17
  };
16
18
  export type MeltRow = {
@@ -86,6 +88,9 @@ export declare class NoteStore {
86
88
  constructor(path: string, options?: {
87
89
  readOnly?: boolean;
88
90
  });
91
+ private indexLegacyIds;
92
+ legacyIdOf(q: string): string | null;
93
+ noteByQ(q: string): NoteRow | null;
89
94
  unnamedVerifyCutover(): number;
90
95
  private tx;
91
96
  noteById(id: string): NoteRow | null;
@@ -107,7 +112,7 @@ export declare class NoteStore {
107
112
  restoreMelt(paymentHash: string): void;
108
113
  meltByHash(paymentHash: string): MeltRow | null;
109
114
  pendingMelts(): MeltRow[];
110
- recordMintInvoice(paymentHash: string, pr: string, grossMsat: number, netMsat: number, outputId?: string | null): void;
115
+ recordMintInvoice(paymentHash: string, pr: string, grossMsat: number, netMsat: number, outputId?: string | null, outputNamed?: string | null): void;
111
116
  mintInvoiceByHash(paymentHash: string): MintInvoiceRow | null;
112
117
  mintInvoiceByOutputId(outputId: string): MintInvoiceRow | null;
113
118
  unsettledMintInvoices(): MintInvoiceRow[];