@forgesworn/moneyer 0.16.1 → 0.17.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 +84 -0
- package/README.md +69 -31
- package/TERMS-TEMPLATE.md +14 -0
- package/THREAT-MODEL.md +43 -11
- package/dist/admin.js +24 -14
- 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 +2 -0
- package/dist/server.js +201 -139
- 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 +25 -11
- package/package.json +3 -2
- package/scripts/kernel-verifier.py +59 -0
package/dist/spend.d.ts
ADDED
|
@@ -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[];
|