fractal-pqc 0.3.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/LICENSE +21 -0
- package/README.md +163 -0
- package/bin/cli.mjs +220 -0
- package/package.json +51 -0
- package/src/address.mjs +102 -0
- package/src/bech32.mjs +123 -0
- package/src/bitcoin.mjs +134 -0
- package/src/broadcast.mjs +117 -0
- package/src/fees.mjs +84 -0
- package/src/index.mjs +59 -0
- package/src/migration-envelope.mjs +206 -0
- package/src/psbt.mjs +149 -0
- package/src/tx.mjs +152 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// pqc-migration-kit — Quantum migration envelope for a Bitcoin-style key.
|
|
4
|
+
//
|
|
5
|
+
// PROBLEM (Galaxy "Bitcoin Quantum Readiness"): a large quantum computer running
|
|
6
|
+
// Shor's algorithm can recover a secp256k1 private key from an exposed public key,
|
|
7
|
+
// so any BTC output whose pubkey is on-chain becomes spendable by an attacker.
|
|
8
|
+
// The migration challenge is to let a holder bind their EXISTING classical key to a
|
|
9
|
+
// new post-quantum key and, from then on, require a post-quantum signature to spend —
|
|
10
|
+
// WITHOUT invalidating the history already signed under the classical key.
|
|
11
|
+
//
|
|
12
|
+
// This module is a self-contained, runnable REFERENCE of that mechanism. It is the
|
|
13
|
+
// same "verify-both during migration, no-downgrade" discipline FractalAI already runs
|
|
14
|
+
// in production on its own L1 (crypto-agility layer + on-chain rotation registry),
|
|
15
|
+
// distilled to the secp256k1 -> ML-DSA-65 case relevant to Bitcoin.
|
|
16
|
+
//
|
|
17
|
+
// HONEST SCOPE — read before citing:
|
|
18
|
+
// • This is a cryptographic reference/demonstrator, NOT deployed on Bitcoin, NOT a
|
|
19
|
+
// BIP, NOT consensus code. It does not touch the Bitcoin network.
|
|
20
|
+
// • It uses REAL primitives: @noble/curves secp256k1 (Bitcoin's curve) and
|
|
21
|
+
// @noble/post-quantum ml_dsa65 (NIST FIPS-204, Category 3). No mocks.
|
|
22
|
+
// • "Migration window" semantics here = a dual-key commitment + PQC-required
|
|
23
|
+
// authorization. Turning this into real Bitcoin custody tooling (UTXO/Taproot
|
|
24
|
+
// integration, P2QRH/BIP-360-style outputs, soft-fork analysis) is exactly the
|
|
25
|
+
// grant-funded work — see README.
|
|
26
|
+
|
|
27
|
+
import { secp256k1 } from "@noble/curves/secp256k1.js";
|
|
28
|
+
import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
|
|
29
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
30
|
+
import { sha3_256 } from "@noble/hashes/sha3.js";
|
|
31
|
+
|
|
32
|
+
// ── Canonical, domain-separated encoding ──────────────────────────────────────
|
|
33
|
+
// Mirrors FractalAI's on-chain signing discipline (VAID-1 / reserve_proof_message):
|
|
34
|
+
// fixed domain tag, length-prefixed fields, big-endian lengths, raw bytes — so the
|
|
35
|
+
// bytes signed are unambiguous and a verifier reconstructs them exactly.
|
|
36
|
+
|
|
37
|
+
const COMMIT_DOMAIN = new TextEncoder().encode("FRACTAL-PQC-MIGRATION-COMMIT-v1");
|
|
38
|
+
const SPEND_DOMAIN = new TextEncoder().encode("FRACTAL-PQC-MIGRATION-SPEND-v1");
|
|
39
|
+
|
|
40
|
+
function u32be(n) {
|
|
41
|
+
const b = new Uint8Array(4);
|
|
42
|
+
new DataView(b.buffer).setUint32(0, n >>> 0, false);
|
|
43
|
+
return b;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Length-prefixed concatenation of a domain tag and a list of byte fields. */
|
|
47
|
+
function canonical(domain, fields) {
|
|
48
|
+
const parts = [u32be(domain.length), domain];
|
|
49
|
+
for (const f of fields) {
|
|
50
|
+
parts.push(u32be(f.length), f);
|
|
51
|
+
}
|
|
52
|
+
let total = 0;
|
|
53
|
+
for (const p of parts) total += p.length;
|
|
54
|
+
const out = new Uint8Array(total);
|
|
55
|
+
let o = 0;
|
|
56
|
+
for (const p of parts) { out.set(p, o); o += p.length; }
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const toHex = (b) => Buffer.from(b).toString("hex");
|
|
61
|
+
const fromHex = (h) => Uint8Array.from(Buffer.from(h, "hex"));
|
|
62
|
+
|
|
63
|
+
// ── Identity ──────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A migrating holder's key material: their EXISTING classical secp256k1 key (as a
|
|
67
|
+
* Bitcoin holder already has) plus a freshly generated ML-DSA-65 post-quantum key.
|
|
68
|
+
* In practice the classical key is imported, not generated; we generate here so the
|
|
69
|
+
* reference is runnable end-to-end.
|
|
70
|
+
*/
|
|
71
|
+
export function generateMigrationIdentity() {
|
|
72
|
+
const classical = secp256k1.keygen(); // { secretKey, publicKey } — 33-byte compressed pub
|
|
73
|
+
const pq = ml_dsa65.keygen();
|
|
74
|
+
return {
|
|
75
|
+
classicalPriv: classical.secretKey,
|
|
76
|
+
classicalPub: classical.publicKey,
|
|
77
|
+
pqSecret: pq.secretKey,
|
|
78
|
+
pqPublic: pq.publicKey,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Migration commitment (the "quantum-resistant binding") ─────────────────────
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Bind the classical pubkey to the PQC pubkey with a DUAL signature: the classical
|
|
86
|
+
* key signs the binding (proving current control) AND the PQC key signs it (proving
|
|
87
|
+
* possession of the successor key). Anchoring this commitment on-chain (out of scope
|
|
88
|
+
* here) is what makes a holder's migration publicly, permanently verifiable.
|
|
89
|
+
*/
|
|
90
|
+
export function createMigrationCommitment(identity) {
|
|
91
|
+
const msg = canonical(COMMIT_DOMAIN, [identity.classicalPub, identity.pqPublic]);
|
|
92
|
+
// secp256k1 signs the 32-byte hash of the message (Bitcoin/ECDSA convention).
|
|
93
|
+
// @noble/curves v2 returns a 64-byte compact signature directly.
|
|
94
|
+
const classicalSig = secp256k1.sign(sha256(msg), identity.classicalPriv);
|
|
95
|
+
// ML-DSA-65 signs the full message. @noble API: sign(message, secretKey).
|
|
96
|
+
const pqSig = ml_dsa65.sign(msg, identity.pqSecret);
|
|
97
|
+
return {
|
|
98
|
+
version: "pqc-migration-commit-v1",
|
|
99
|
+
classicalPub: toHex(identity.classicalPub),
|
|
100
|
+
pqPublic: toHex(identity.pqPublic),
|
|
101
|
+
classicalSig: toHex(classicalSig),
|
|
102
|
+
pqSig: toHex(pqSig),
|
|
103
|
+
// The anchor LEAF: publish/anchor this first-seen so verifiers can pin it. As a
|
|
104
|
+
// verify-time field it is only a self-consistency check (derivable from the cert) —
|
|
105
|
+
// the authentication comes from matching it against the external anchor, not from
|
|
106
|
+
// its mere presence.
|
|
107
|
+
factHash: toHex(sha3_256(msg)),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Verify a migration commitment. BOTH signatures must verify over the exact same
|
|
113
|
+
* canonical binding (the crypto-agility "verify-both" invariant).
|
|
114
|
+
*
|
|
115
|
+
* ⚠️ WELL-FORMEDNESS IS NOT THE SECURITY PROPERTY. This function alone proves only
|
|
116
|
+
* that whoever built the cert held BOTH secret keys at build time — it does NOT prove
|
|
117
|
+
* the cert is the holder's canonical binding. An attacker who recovered the classical
|
|
118
|
+
* key (post-Shor) can build a well-formed cert binding the victim's classical pubkey
|
|
119
|
+
* to the ATTACKER's own PQC key. To get the quantum no-downgrade property you MUST
|
|
120
|
+
* pass `opts.anchoredFactHash` — the factHash of the holder's FIRST-SEEN, immutably
|
|
121
|
+
* anchored commitment (e.g. an on-chain Merkle leaf published before Q-day). The
|
|
122
|
+
* verifier pins that value; a rebound cert has a different factHash and is rejected.
|
|
123
|
+
*
|
|
124
|
+
* @param {object} cert
|
|
125
|
+
* @param {{anchoredFactHash?: string}} [opts]
|
|
126
|
+
* @returns {{valid:boolean, wellFormed:boolean, classicalOk:boolean, pqOk:boolean, factOk:boolean, anchorOk:(boolean|null)}}
|
|
127
|
+
*/
|
|
128
|
+
export function verifyMigrationCommitment(cert, opts = {}) {
|
|
129
|
+
try {
|
|
130
|
+
const classicalPub = fromHex(cert.classicalPub);
|
|
131
|
+
const pqPublic = fromHex(cert.pqPublic);
|
|
132
|
+
const msg = canonical(COMMIT_DOMAIN, [classicalPub, pqPublic]);
|
|
133
|
+
const classicalOk = secp256k1.verify(fromHex(cert.classicalSig), sha256(msg), classicalPub);
|
|
134
|
+
// @noble API: verify(signature, message, publicKey).
|
|
135
|
+
const pqOk = ml_dsa65.verify(fromHex(cert.pqSig), msg, pqPublic);
|
|
136
|
+
const computedFact = toHex(sha3_256(msg));
|
|
137
|
+
const factOk = cert.factHash === computedFact;
|
|
138
|
+
const wellFormed = classicalOk && pqOk && factOk;
|
|
139
|
+
// Anchor pin: null = not checked (well-formedness only); true/false = matches the
|
|
140
|
+
// holder's anchored binding or not. `valid` is the SECURE result: it requires the
|
|
141
|
+
// anchor to match when one is supplied, and never over-reports.
|
|
142
|
+
const anchorOk = opts.anchoredFactHash == null ? null : (computedFact === opts.anchoredFactHash);
|
|
143
|
+
return { valid: wellFormed && anchorOk !== false, wellFormed, classicalOk, pqOk, factOk, anchorOk };
|
|
144
|
+
} catch (err) {
|
|
145
|
+
return { valid: false, wellFormed: false, classicalOk: false, pqOk: false, factOk: false, anchorOk: false, error: String(err) };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Post-migration authorization ("spend") ─────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* After migration, authorize an action (e.g. a spend committing to a tx hash) with the
|
|
153
|
+
* PQC key. The quantum property holds ONLY when the verifier pins the holder's ANCHORED
|
|
154
|
+
* commitment (see verifySpend): the PQC signature must be under the pubkey the holder
|
|
155
|
+
* anchored first-seen, so a classical-key attacker who re-binds to their own PQC key is
|
|
156
|
+
* rejected at the anchor check, not here.
|
|
157
|
+
*/
|
|
158
|
+
export function authorizeSpend(identity, txHash /* Uint8Array (32 bytes) */) {
|
|
159
|
+
const msg = canonical(SPEND_DOMAIN, [identity.pqPublic, txHash]);
|
|
160
|
+
return toHex(ml_dsa65.sign(msg, identity.pqSecret));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Verify a post-migration spend against the holder's ANCHORED commitment.
|
|
165
|
+
*
|
|
166
|
+
* `opts.anchoredFactHash` is REQUIRED — the factHash of the holder's first-seen,
|
|
167
|
+
* immutably anchored commitment (from the on-chain anchor / first-seen registry, NOT
|
|
168
|
+
* from the cert being checked). Steps: (1) the presented cert must match that anchor
|
|
169
|
+
* and be well-formed, then (2) the PQC signature must authorize txHash under the
|
|
170
|
+
* anchored PQC pubkey.
|
|
171
|
+
*
|
|
172
|
+
* This is what delivers the quantum property: an attacker who recovered the classical
|
|
173
|
+
* key can forge a well-formed cert binding the victim's classical key to the attacker's
|
|
174
|
+
* OWN PQC key, but that cert's factHash ≠ the anchored one, so it is rejected here
|
|
175
|
+
* (`anchor-mismatch`). Calling without an anchor fails closed (`anchor-required`).
|
|
176
|
+
*
|
|
177
|
+
* @param {object} cert
|
|
178
|
+
* @param {Uint8Array} txHash 32-byte tx hash
|
|
179
|
+
* @param {string} pqSigHex hex ML-DSA-65 signature over the SPEND message
|
|
180
|
+
* @param {{anchoredFactHash: string}} opts REQUIRED anchor pin
|
|
181
|
+
*/
|
|
182
|
+
export function verifySpend(cert, txHash, pqSigHex, opts = {}) {
|
|
183
|
+
if (!opts.anchoredFactHash) {
|
|
184
|
+
return {
|
|
185
|
+
valid: false,
|
|
186
|
+
reason: "anchor-required",
|
|
187
|
+
note:
|
|
188
|
+
"verifySpend requires opts.anchoredFactHash (the holder's first-seen, anchored " +
|
|
189
|
+
"commitment). Without it the quantum no-downgrade property does not hold.",
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const commit = verifyMigrationCommitment(cert, { anchoredFactHash: opts.anchoredFactHash });
|
|
193
|
+
if (!commit.valid) {
|
|
194
|
+
return { valid: false, reason: commit.anchorOk === false ? "anchor-mismatch" : "invalid-commitment", commit };
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
const pqPublic = fromHex(cert.pqPublic);
|
|
198
|
+
const msg = canonical(SPEND_DOMAIN, [pqPublic, txHash]);
|
|
199
|
+
const ok = ml_dsa65.verify(fromHex(pqSigHex), msg, pqPublic);
|
|
200
|
+
return { valid: ok, reason: ok ? "ok" : "bad-pq-signature", commit };
|
|
201
|
+
} catch (err) {
|
|
202
|
+
return { valid: false, reason: "error", error: String(err), commit };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export const _internal = { canonical, COMMIT_DOMAIN, SPEND_DOMAIN, toHex, fromHex };
|
package/src/psbt.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Minimal BIP-174 PSBT (v0) for P2TR key-path spends: create → sign → finalize.
|
|
4
|
+
// Supports exactly the fields a Taproot key-path custodian flow needs:
|
|
5
|
+
// global : PSBT_GLOBAL_UNSIGNED_TX (0x00)
|
|
6
|
+
// input : PSBT_IN_WITNESS_UTXO (0x01), PSBT_IN_SIGHASH_TYPE (0x03),
|
|
7
|
+
// PSBT_IN_TAP_KEY_SIG (0x13), PSBT_IN_TAP_INTERNAL_KEY (0x17)
|
|
8
|
+
// Validated in test/vectors.mjs by (a) serialize→parse round-trip and (b) that a signed
|
|
9
|
+
// PSBT finalizes to the SAME network tx as the vetted direct signer in tx.mjs.
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
serializeLegacy, serializeSegwit, taprootKeyPathSighash, taprootTweakPrivateKey, txid, _internal,
|
|
13
|
+
} from "./tx.mjs";
|
|
14
|
+
import { schnorr } from "@noble/curves/secp256k1.js";
|
|
15
|
+
|
|
16
|
+
const { toHex, fromHex, varint, u64le } = _internal;
|
|
17
|
+
const MAGIC = Uint8Array.of(0x70, 0x73, 0x62, 0x74, 0xff); // "psbt\xff"
|
|
18
|
+
const cat = (...a) => { let n = 0; for (const x of a) n += x.length; const o = new Uint8Array(n); let i = 0; for (const x of a) { o.set(x, i); i += x.length; } return o; };
|
|
19
|
+
const SEP = Uint8Array.of(0x00);
|
|
20
|
+
|
|
21
|
+
// key-value: <varint keylen><keytype(1)+keydata><varint vallen><val>. Our keys have no
|
|
22
|
+
// keydata, so keylen = 1.
|
|
23
|
+
function kv(keytype, value) { return cat(varint(1), Uint8Array.of(keytype), varint(value.length), value); }
|
|
24
|
+
function witnessUtxoBytes(o) { return cat(u64le(o.valueSats), varint(o.scriptPubKey.length), o.scriptPubKey); }
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build an unsigned PSBT.
|
|
28
|
+
* @param {{version?:number, locktime?:number, inputs:Array, outputs:Array}} tx
|
|
29
|
+
* @param {Array<{witnessUtxo:{valueSats:number|bigint, scriptPubKey:Uint8Array}, tapInternalKey:Uint8Array}>} inputsMeta
|
|
30
|
+
*/
|
|
31
|
+
export function createPsbt(tx, inputsMeta) {
|
|
32
|
+
if (inputsMeta.length !== tx.inputs.length) throw new Error("inputsMeta must match tx.inputs length");
|
|
33
|
+
return {
|
|
34
|
+
tx: { version: tx.version ?? 2, locktime: tx.locktime ?? 0, inputs: tx.inputs, outputs: tx.outputs },
|
|
35
|
+
inputs: inputsMeta.map((m) => ({ witnessUtxo: m.witnessUtxo, tapInternalKey: m.tapInternalKey, tapKeySig: null, sighashType: 0 })),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Serialize a PSBT to bytes (BIP-174). */
|
|
40
|
+
export function serializePsbt(psbt) {
|
|
41
|
+
const parts = [MAGIC];
|
|
42
|
+
// global: unsigned tx (empty scriptSigs, no witness — serializeLegacy provides that).
|
|
43
|
+
parts.push(kv(0x00, serializeLegacy(psbt.tx)), SEP);
|
|
44
|
+
for (const inp of psbt.inputs) {
|
|
45
|
+
parts.push(kv(0x01, witnessUtxoBytes(inp.witnessUtxo)));
|
|
46
|
+
if (inp.sighashType !== undefined && inp.sighashType !== null) {
|
|
47
|
+
const st = new Uint8Array(4); new DataView(st.buffer).setUint32(0, inp.sighashType >>> 0, true);
|
|
48
|
+
parts.push(kv(0x03, st));
|
|
49
|
+
}
|
|
50
|
+
if (inp.tapKeySig) parts.push(kv(0x13, inp.tapKeySig));
|
|
51
|
+
if (inp.tapInternalKey) parts.push(kv(0x17, inp.tapInternalKey));
|
|
52
|
+
parts.push(SEP);
|
|
53
|
+
}
|
|
54
|
+
for (const _ of psbt.tx.outputs) parts.push(SEP); // one (empty) map per output
|
|
55
|
+
return cat(...parts);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const serializePsbtHex = (psbt) => toHex(serializePsbt(psbt));
|
|
59
|
+
export const serializePsbtBase64 = (psbt) => Buffer.from(serializePsbt(psbt)).toString("base64");
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Parse a PSBT (bytes) for structural validation. NOTE: this is intentionally lossy — it
|
|
63
|
+
* validates the BIP-174 framing (magic, key-value encoding, no duplicate keys, no trailing
|
|
64
|
+
* bytes) and recovers input/output COUNTS, but does not decode field values. The create→
|
|
65
|
+
* sign→finalize flow keeps the rich object in memory; don't rely on `parsePsbt(...).tx` for
|
|
66
|
+
* amounts/scripts.
|
|
67
|
+
*/
|
|
68
|
+
export function parsePsbt(bytes) {
|
|
69
|
+
let p = 0;
|
|
70
|
+
const b = bytes instanceof Uint8Array ? bytes : fromHex(bytes);
|
|
71
|
+
for (let i = 0; i < MAGIC.length; i++) if (b[p++] !== MAGIC[i]) throw new Error("bad PSBT magic");
|
|
72
|
+
const readVarint = () => { const f = b[p++]; if (f < 0xfd) return f; if (f === 0xfd) { const v = b[p] | (b[p + 1] << 8); p += 2; return v; } if (f === 0xfe) { const v = new DataView(b.buffer, b.byteOffset + p, 4).getUint32(0, true); p += 4; return v; } const v = Number(new DataView(b.buffer, b.byteOffset + p, 8).getBigUint64(0, true)); p += 8; return v; };
|
|
73
|
+
const readMap = () => {
|
|
74
|
+
const rec = {}; const seen = new Set();
|
|
75
|
+
for (;;) {
|
|
76
|
+
const keylen = readVarint();
|
|
77
|
+
if (keylen === 0) break; // separator
|
|
78
|
+
const keytype = b[p]; const key = b.slice(p, p + keylen); p += keylen;
|
|
79
|
+
// BIP-174: duplicate keys (full key bytes) MUST make the PSBT invalid.
|
|
80
|
+
const kk = toHex(key);
|
|
81
|
+
if (seen.has(kk)) throw new Error("duplicate PSBT key");
|
|
82
|
+
seen.add(kk);
|
|
83
|
+
const vallen = readVarint(); const val = b.slice(p, p + vallen); p += vallen;
|
|
84
|
+
rec[keytype] = { key, val };
|
|
85
|
+
}
|
|
86
|
+
return rec;
|
|
87
|
+
};
|
|
88
|
+
const global = readMap();
|
|
89
|
+
if (!global[0x00]) throw new Error("PSBT missing unsigned tx");
|
|
90
|
+
// Parse the embedded unsigned tx to learn input/output counts.
|
|
91
|
+
const tx = parseLegacyTx(global[0x00].val);
|
|
92
|
+
const inputs = tx.inputs.map(() => readMap());
|
|
93
|
+
const outputs = tx.outputs.map(() => readMap());
|
|
94
|
+
if (p !== b.length) throw new Error("trailing bytes after PSBT");
|
|
95
|
+
return { magicOk: true, tx, inputMaps: inputs, outputMaps: outputs };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function parseLegacyTx(b) {
|
|
99
|
+
let p = 0;
|
|
100
|
+
const u32 = () => { const v = new DataView(b.buffer, b.byteOffset + p, 4).getUint32(0, true); p += 4; return v; };
|
|
101
|
+
const rd = (n) => { const s = b.slice(p, p + n); p += n; return s; };
|
|
102
|
+
const vi = () => { const f = b[p++]; if (f < 0xfd) return f; if (f === 0xfd) { const v = b[p] | (b[p + 1] << 8); p += 2; return v; } if (f === 0xfe) { const v = u32(); return v; } const v = Number(new DataView(b.buffer, b.byteOffset + p, 8).getBigUint64(0, true)); p += 8; return v; };
|
|
103
|
+
const version = u32();
|
|
104
|
+
// BIP-174: the global unsigned tx MUST NOT contain witness data. A 0x00 marker where the
|
|
105
|
+
// input-count varint belongs signals a segwit-serialized tx → reject rather than misparse.
|
|
106
|
+
if (b[p] === 0x00) throw new Error("PSBT unsigned tx must not be segwit-serialized");
|
|
107
|
+
const nin = vi(); const inputs = [];
|
|
108
|
+
for (let i = 0; i < nin; i++) { rd(32); u32(); const sl = vi(); rd(sl); u32(); inputs.push({}); }
|
|
109
|
+
const nout = vi(); const outputs = [];
|
|
110
|
+
for (let i = 0; i < nout; i++) { p += 8; const sl = vi(); rd(sl); outputs.push({}); }
|
|
111
|
+
u32();
|
|
112
|
+
return { version, inputs, outputs };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Sign every input of a PSBT as a Taproot key-path spend (SIGHASH_DEFAULT), using the
|
|
117
|
+
* matching internal private key. The sighash commits to ALL inputs' amounts+scriptPubKeys,
|
|
118
|
+
* taken from each input's witnessUtxo.
|
|
119
|
+
* @param {object} psbt
|
|
120
|
+
* @param {Uint8Array[]} internalPrivs one internal private key per input
|
|
121
|
+
*/
|
|
122
|
+
export function signPsbtTaprootKeyPath(psbt, internalPrivs) {
|
|
123
|
+
if (internalPrivs.length !== psbt.inputs.length) throw new Error("internalPrivs must match inputs length");
|
|
124
|
+
const values = psbt.inputs.map((i) => i.witnessUtxo.valueSats);
|
|
125
|
+
const spks = psbt.inputs.map((i) => i.witnessUtxo.scriptPubKey);
|
|
126
|
+
for (let idx = 0; idx < psbt.inputs.length; idx++) {
|
|
127
|
+
const { privKey, outputXOnly } = taprootTweakPrivateKey(internalPrivs[idx]);
|
|
128
|
+
// Refuse to sign an input this key does NOT control — otherwise we'd silently emit an
|
|
129
|
+
// unspendable tx (the sig wouldn't satisfy the P2TR output being spent). P2TR spk =
|
|
130
|
+
// 0x51 0x20 <32B output key>.
|
|
131
|
+
const spk = spks[idx];
|
|
132
|
+
if (spk.length !== 34 || spk[0] !== 0x51 || spk[1] !== 0x20 || !outputXOnly.every((v, i) => v === spk[2 + i])) {
|
|
133
|
+
throw new Error(`input ${idx}: signing key does not control this scriptPubKey`);
|
|
134
|
+
}
|
|
135
|
+
const sighash = taprootKeyPathSighash(psbt.tx, idx, values, spks);
|
|
136
|
+
psbt.inputs[idx].tapKeySig = schnorr.sign(sighash, privKey);
|
|
137
|
+
}
|
|
138
|
+
return psbt;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Finalize a fully-signed PSBT into a broadcastable network transaction.
|
|
143
|
+
* @returns {{hex:string, txid:string}}
|
|
144
|
+
*/
|
|
145
|
+
export function finalizePsbt(psbt) {
|
|
146
|
+
if (psbt.inputs.some((i) => !i.tapKeySig)) throw new Error("PSBT not fully signed");
|
|
147
|
+
const tx = { ...psbt.tx, inputs: psbt.tx.inputs.map((inp, i) => ({ ...inp, witness: [psbt.inputs[i].tapKeySig] })) };
|
|
148
|
+
return { hex: toHex(serializeSegwit(tx)), txid: txid(tx) };
|
|
149
|
+
}
|
package/src/tx.mjs
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Minimal REAL Bitcoin transaction layer: serialization + txid, plus BIP-341 Taproot
|
|
4
|
+
// key-path signing (SIGHASH_DEFAULT). Enough to CONSTRUCT and SIGN a P2TR spend.
|
|
5
|
+
//
|
|
6
|
+
// Validated in test/vectors.mjs against:
|
|
7
|
+
// • the Bitcoin genesis coinbase txid (serialization + double-SHA256 txid correct),
|
|
8
|
+
// • the BIP-341 taproot tweak (private↔public key consistency: the tweaked private
|
|
9
|
+
// key's Schnorr pubkey equals the tweaked output key from address.mjs),
|
|
10
|
+
// • sign→verify: the produced Schnorr signature verifies against the Taproot output
|
|
11
|
+
// key, i.e. it satisfies a key-path spend of that output.
|
|
12
|
+
//
|
|
13
|
+
// HONEST SCOPE: the BIP-341 sighash is implemented to spec and the signature verifies
|
|
14
|
+
// against the output key. The FINAL consensus proof — broadcasting a funded spend on
|
|
15
|
+
// Bitcoin testnet and/or cross-checking against the official BIP-341 sighash test
|
|
16
|
+
// vector file — is the closing validation step (needs a funded testnet UTXO). This
|
|
17
|
+
// module gives a custodian real address derivation + a ready-to-validate spend path.
|
|
18
|
+
|
|
19
|
+
import { secp256k1, schnorr } from "@noble/curves/secp256k1.js";
|
|
20
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
21
|
+
import { taprootTweakOutputKey } from "./address.mjs";
|
|
22
|
+
|
|
23
|
+
const N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
|
|
24
|
+
|
|
25
|
+
// ── byte helpers ───────────────────────────────────────────────────────────────
|
|
26
|
+
const cat = (...arrs) => { let n = 0; for (const a of arrs) n += a.length; const o = new Uint8Array(n); let i = 0; for (const a of arrs) { o.set(a, i); i += a.length; } return o; };
|
|
27
|
+
const u8 = (n) => Uint8Array.of(n & 0xff);
|
|
28
|
+
function u32le(n) { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, n >>> 0, true); return b; }
|
|
29
|
+
function u64le(n) {
|
|
30
|
+
const bn = BigInt(n);
|
|
31
|
+
// Reject out-of-range amounts instead of silently truncating (>u64) or wrapping
|
|
32
|
+
// negatives. Real BTC values are ≤ MAX_MONEY (2.1e15 sats) ≪ u64.
|
|
33
|
+
if (bn < 0n || bn > 0xffffffffffffffffn) throw new RangeError("valueSats out of u64 range");
|
|
34
|
+
const b = new Uint8Array(8); const dv = new DataView(b.buffer);
|
|
35
|
+
dv.setUint32(0, Number(bn & 0xffffffffn), true); dv.setUint32(4, Number((bn >> 32n) & 0xffffffffn), true); return b;
|
|
36
|
+
}
|
|
37
|
+
function varint(n) {
|
|
38
|
+
n = Number(n);
|
|
39
|
+
if (n < 0xfd) return u8(n);
|
|
40
|
+
if (n <= 0xffff) { const b = new Uint8Array(3); b[0] = 0xfd; new DataView(b.buffer).setUint16(1, n, true); return b; }
|
|
41
|
+
if (n <= 0xffffffff) { const b = new Uint8Array(5); b[0] = 0xfe; new DataView(b.buffer).setUint32(1, n, true); return b; }
|
|
42
|
+
const b = new Uint8Array(9); b[0] = 0xff; new DataView(b.buffer).setBigUint64(1, BigInt(n), true); return b;
|
|
43
|
+
}
|
|
44
|
+
const varbytes = (b) => cat(varint(b.length), b);
|
|
45
|
+
const rev = (b) => Uint8Array.from(b).reverse();
|
|
46
|
+
const dsha = (b) => sha256(sha256(b));
|
|
47
|
+
const toHex = (b) => Buffer.from(b).toString("hex");
|
|
48
|
+
const fromHex = (h) => Uint8Array.from(Buffer.from(h, "hex"));
|
|
49
|
+
function bytesToBigInt(b) { let x = 0n; for (const y of b) x = (x << 8n) | BigInt(y); return x; }
|
|
50
|
+
function bigIntTo32(x) { const o = new Uint8Array(32); for (let i = 31; i >= 0; i--) { o[i] = Number(x & 0xffn); x >>= 8n; } return o; }
|
|
51
|
+
|
|
52
|
+
// A P2TR scriptPubKey is OP_1 (0x51) PUSH32 <output key>.
|
|
53
|
+
export const p2trScriptPubKey = (outputXOnly) => cat(Uint8Array.of(0x51, 0x20), outputXOnly);
|
|
54
|
+
|
|
55
|
+
// ── transaction model ────────────────────────────────────────────────────────
|
|
56
|
+
// input: { txid: hex (big-endian, as shown in explorers), vout, sequence?, witness?: Uint8Array[] }
|
|
57
|
+
// output: { valueSats: number|bigint, scriptPubKey: Uint8Array }
|
|
58
|
+
|
|
59
|
+
function serializeInputs(inputs) {
|
|
60
|
+
const parts = [varint(inputs.length)];
|
|
61
|
+
for (const i of inputs) {
|
|
62
|
+
if (!/^[0-9a-fA-F]{64}$/.test(i.txid)) throw new Error(`invalid txid (need 64 hex chars): ${i.txid}`);
|
|
63
|
+
parts.push(rev(fromHex(i.txid))); // prevout hash, internal LE order
|
|
64
|
+
parts.push(u32le(i.vout));
|
|
65
|
+
parts.push(varint(0)); // empty scriptSig (segwit)
|
|
66
|
+
parts.push(u32le(i.sequence ?? 0xffffffff));
|
|
67
|
+
}
|
|
68
|
+
return cat(...parts);
|
|
69
|
+
}
|
|
70
|
+
function serializeOutputs(outputs) {
|
|
71
|
+
const parts = [varint(outputs.length)];
|
|
72
|
+
for (const o of outputs) { parts.push(u64le(o.valueSats)); parts.push(varbytes(o.scriptPubKey)); }
|
|
73
|
+
return cat(...parts);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Legacy (non-witness) serialization — used for txid of legacy txs and as the base. */
|
|
77
|
+
export function serializeLegacy(tx) {
|
|
78
|
+
return cat(u32le(tx.version ?? 1), serializeInputs(tx.inputs), serializeOutputs(tx.outputs), u32le(tx.locktime ?? 0));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** SegWit serialization (marker+flag+witness). */
|
|
82
|
+
export function serializeSegwit(tx) {
|
|
83
|
+
const parts = [u32le(tx.version ?? 1), Uint8Array.of(0x00, 0x01), serializeInputs(tx.inputs), serializeOutputs(tx.outputs)];
|
|
84
|
+
for (const i of tx.inputs) {
|
|
85
|
+
const w = i.witness ?? [];
|
|
86
|
+
parts.push(varint(w.length));
|
|
87
|
+
for (const item of w) parts.push(varbytes(item));
|
|
88
|
+
}
|
|
89
|
+
parts.push(u32le(tx.locktime ?? 0));
|
|
90
|
+
return cat(...parts);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** txid = reverse(double-SHA256(legacy serialization)), hex as shown in explorers. */
|
|
94
|
+
export function txid(tx) { return toHex(rev(dsha(serializeLegacy(tx)))); }
|
|
95
|
+
|
|
96
|
+
// ── BIP-341 key-path sighash (SIGHASH_DEFAULT = 0x00) ───────────────────────────
|
|
97
|
+
// prevoutScriptPubKeys / prevoutValues describe EVERY input being spent (required by
|
|
98
|
+
// the taproot sighash), in input order.
|
|
99
|
+
export function taprootKeyPathSighash(tx, inputIndex, prevoutValues, prevoutScriptPubKeys) {
|
|
100
|
+
// The taproot sighash commits to ALL spent inputs' amounts + scriptPubKeys, so these
|
|
101
|
+
// arrays must line up with the inputs or the sighash is silently wrong (unspendable).
|
|
102
|
+
if (prevoutValues.length !== tx.inputs.length || prevoutScriptPubKeys.length !== tx.inputs.length) {
|
|
103
|
+
throw new Error("prevoutValues / prevoutScriptPubKeys must match tx.inputs length");
|
|
104
|
+
}
|
|
105
|
+
if (inputIndex < 0 || inputIndex >= tx.inputs.length) throw new Error("inputIndex out of range");
|
|
106
|
+
const version = u32le(tx.version ?? 1);
|
|
107
|
+
const locktime = u32le(tx.locktime ?? 0);
|
|
108
|
+
const shaPrevouts = sha256(cat(...tx.inputs.map((i) => cat(rev(fromHex(i.txid)), u32le(i.vout)))));
|
|
109
|
+
const shaAmounts = sha256(cat(...prevoutValues.map((v) => u64le(v))));
|
|
110
|
+
const shaScriptpubkeys = sha256(cat(...prevoutScriptPubKeys.map((s) => varbytes(s))));
|
|
111
|
+
const shaSequences = sha256(cat(...tx.inputs.map((i) => u32le(i.sequence ?? 0xffffffff))));
|
|
112
|
+
const shaOutputs = sha256(cat(...tx.outputs.map((o) => cat(u64le(o.valueSats), varbytes(o.scriptPubKey)))));
|
|
113
|
+
const spendType = u8(0); // no annex, key path
|
|
114
|
+
const msg = cat(
|
|
115
|
+
u8(0x00), // hash_type = SIGHASH_DEFAULT
|
|
116
|
+
version, locktime,
|
|
117
|
+
shaPrevouts, shaAmounts, shaScriptpubkeys, shaSequences,
|
|
118
|
+
shaOutputs,
|
|
119
|
+
spendType, u32le(inputIndex),
|
|
120
|
+
);
|
|
121
|
+
// Epoch byte 0x00 is prepended to the tagged-hash input.
|
|
122
|
+
return schnorr.utils.taggedHash("TapSighash", cat(u8(0x00), msg));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── BIP-341 key-path signing ────────────────────────────────────────────────────
|
|
126
|
+
/**
|
|
127
|
+
* Tweak an internal private key for key-path spending: normalize to even-Y (BIP-340),
|
|
128
|
+
* add the taproot tweak. Returns { privKey (32B), outputXOnly (32B) }.
|
|
129
|
+
*/
|
|
130
|
+
export function taprootTweakPrivateKey(internalPriv) {
|
|
131
|
+
let d = bytesToBigInt(internalPriv) % N;
|
|
132
|
+
const P = secp256k1.Point.BASE.multiply(d);
|
|
133
|
+
if (P.toAffine().y % 2n === 1n) d = N - d; // BIP-340 even-Y normalization
|
|
134
|
+
const internalXOnly = bigIntTo32(secp256k1.Point.BASE.multiply(d).toAffine().x);
|
|
135
|
+
const t = bytesToBigInt(schnorr.utils.taggedHash("TapTweak", internalXOnly)) % N;
|
|
136
|
+
const dPrime = (d + t) % N;
|
|
137
|
+
return { privKey: bigIntTo32(dPrime), outputXOnly: taprootTweakOutputKey(internalXOnly) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Sign one key-path P2TR input. Returns the 64-byte Schnorr witness signature (hex).
|
|
142
|
+
* Also fills tx.inputs[inputIndex].witness = [sig].
|
|
143
|
+
*/
|
|
144
|
+
export function signTaprootKeyPath(tx, inputIndex, internalPriv, prevoutValues, prevoutScriptPubKeys) {
|
|
145
|
+
const { privKey } = taprootTweakPrivateKey(internalPriv);
|
|
146
|
+
const sighash = taprootKeyPathSighash(tx, inputIndex, prevoutValues, prevoutScriptPubKeys);
|
|
147
|
+
const sig = schnorr.sign(sighash, privKey);
|
|
148
|
+
tx.inputs[inputIndex].witness = [sig];
|
|
149
|
+
return toHex(sig);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export const _internal = { toHex, fromHex, dsha, rev, varint, u64le, N };
|