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
package/src/bech32.mjs
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// bech32 / bech32m (BIP-173 / BIP-350) + SegWit address encode/decode.
|
|
4
|
+
// Reference implementation (derived from the BIP-173 public-domain reference),
|
|
5
|
+
// used to derive REAL Bitcoin addresses (P2WPKH `bc1q…`, P2TR `bc1p…`).
|
|
6
|
+
// Validated against the official BIP-350 test vectors in test/vectors.mjs.
|
|
7
|
+
|
|
8
|
+
const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
|
|
9
|
+
const GENERATOR = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
|
|
10
|
+
|
|
11
|
+
export const ENCODING = { BECH32: "bech32", BECH32M: "bech32m" };
|
|
12
|
+
const CONST = { bech32: 1, bech32m: 0x2bc830a3 };
|
|
13
|
+
|
|
14
|
+
function polymod(values) {
|
|
15
|
+
let chk = 1;
|
|
16
|
+
for (const v of values) {
|
|
17
|
+
const top = chk >> 25;
|
|
18
|
+
chk = ((chk & 0x1ffffff) << 5) ^ v;
|
|
19
|
+
for (let i = 0; i < 5; i++) if ((top >> i) & 1) chk ^= GENERATOR[i];
|
|
20
|
+
}
|
|
21
|
+
return chk;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function hrpExpand(hrp) {
|
|
25
|
+
const out = [];
|
|
26
|
+
for (let i = 0; i < hrp.length; i++) out.push(hrp.charCodeAt(i) >> 5);
|
|
27
|
+
out.push(0);
|
|
28
|
+
for (let i = 0; i < hrp.length; i++) out.push(hrp.charCodeAt(i) & 31);
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function verifyChecksum(hrp, data, spec) {
|
|
33
|
+
return polymod(hrpExpand(hrp).concat(data)) === CONST[spec];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function createChecksum(hrp, data, spec) {
|
|
37
|
+
const values = hrpExpand(hrp).concat(data).concat([0, 0, 0, 0, 0, 0]);
|
|
38
|
+
const mod = polymod(values) ^ CONST[spec];
|
|
39
|
+
const out = [];
|
|
40
|
+
for (let i = 0; i < 6; i++) out.push((mod >> (5 * (5 - i))) & 31);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function bech32Encode(hrp, data, spec) {
|
|
45
|
+
const combined = data.concat(createChecksum(hrp, data, spec));
|
|
46
|
+
let out = hrp + "1";
|
|
47
|
+
for (const d of combined) out += CHARSET.charAt(d);
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function bech32Decode(bechStr) {
|
|
52
|
+
if (bechStr.length > 90) return null;
|
|
53
|
+
// BIP-173: every character must be printable ASCII [33,126]. The CHARSET lookup only
|
|
54
|
+
// guards the data section; the HRP must be range-checked explicitly or a control/high
|
|
55
|
+
// byte in the HRP with a valid checksum would be wrongly accepted.
|
|
56
|
+
for (let i = 0; i < bechStr.length; i++) {
|
|
57
|
+
const c = bechStr.charCodeAt(i);
|
|
58
|
+
if (c < 33 || c > 126) return null;
|
|
59
|
+
}
|
|
60
|
+
const lower = bechStr.toLowerCase();
|
|
61
|
+
const upper = bechStr.toUpperCase();
|
|
62
|
+
if (bechStr !== lower && bechStr !== upper) return null; // mixed case
|
|
63
|
+
const str = lower;
|
|
64
|
+
const pos = str.lastIndexOf("1");
|
|
65
|
+
if (pos < 1 || pos + 7 > str.length) return null;
|
|
66
|
+
const hrp = str.slice(0, pos);
|
|
67
|
+
const data = [];
|
|
68
|
+
for (let i = pos + 1; i < str.length; i++) {
|
|
69
|
+
const d = CHARSET.indexOf(str.charAt(i));
|
|
70
|
+
if (d === -1) return null;
|
|
71
|
+
data.push(d);
|
|
72
|
+
}
|
|
73
|
+
for (const spec of [ENCODING.BECH32, ENCODING.BECH32M]) {
|
|
74
|
+
if (verifyChecksum(hrp, data, spec)) return { hrp, data: data.slice(0, -6), spec };
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Base conversion between bit groups (e.g. 8-bit bytes -> 5-bit groups).
|
|
80
|
+
export function convertBits(data, fromBits, toBits, pad) {
|
|
81
|
+
let acc = 0, bits = 0;
|
|
82
|
+
const out = [];
|
|
83
|
+
const maxv = (1 << toBits) - 1;
|
|
84
|
+
const maxAcc = (1 << (fromBits + toBits - 1)) - 1;
|
|
85
|
+
for (const value of data) {
|
|
86
|
+
if (value < 0 || value >> fromBits) return null;
|
|
87
|
+
acc = ((acc << fromBits) | value) & maxAcc;
|
|
88
|
+
bits += fromBits;
|
|
89
|
+
while (bits >= toBits) { bits -= toBits; out.push((acc >> bits) & maxv); }
|
|
90
|
+
}
|
|
91
|
+
if (pad) { if (bits) out.push((acc << (toBits - bits)) & maxv); }
|
|
92
|
+
else if (bits >= fromBits || ((acc << (toBits - bits)) & maxv)) return null;
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Encode a SegWit address. witver 0 uses bech32; witver 1+ uses bech32m (BIP-350).
|
|
98
|
+
* @param {string} hrp "bc" (mainnet), "tb" (testnet), "bcrt" (regtest)
|
|
99
|
+
* @param {number} witver witness version (0..16)
|
|
100
|
+
* @param {Uint8Array} program witness program bytes (20 for v0 P2WPKH, 32 for v1 P2TR)
|
|
101
|
+
*/
|
|
102
|
+
export function segwitEncode(hrp, witver, program) {
|
|
103
|
+
const spec = witver === 0 ? ENCODING.BECH32 : ENCODING.BECH32M;
|
|
104
|
+
const five = convertBits(Array.from(program), 8, 5, true);
|
|
105
|
+
if (five === null) return null;
|
|
106
|
+
const addr = bech32Encode(hrp, [witver].concat(five), spec);
|
|
107
|
+
return segwitDecode(hrp, addr) ? addr : null; // round-trip sanity
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Decode+validate a SegWit address; returns {version, program} or null. */
|
|
111
|
+
export function segwitDecode(hrp, addr) {
|
|
112
|
+
const dec = bech32Decode(addr);
|
|
113
|
+
if (!dec || dec.hrp !== hrp || dec.data.length < 1) return null;
|
|
114
|
+
const witver = dec.data[0];
|
|
115
|
+
const program = convertBits(dec.data.slice(1), 5, 8, false);
|
|
116
|
+
if (program === null || program.length < 2 || program.length > 40) return null;
|
|
117
|
+
if (witver > 16) return null;
|
|
118
|
+
if (witver === 0 && program.length !== 20 && program.length !== 32) return null;
|
|
119
|
+
// BIP-350: v0 must be bech32, v1+ must be bech32m.
|
|
120
|
+
if (witver === 0 && dec.spec !== ENCODING.BECH32) return null;
|
|
121
|
+
if (witver !== 0 && dec.spec !== ENCODING.BECH32M) return null;
|
|
122
|
+
return { version: witver, program: Uint8Array.from(program) };
|
|
123
|
+
}
|
package/src/bitcoin.mjs
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// pqc-migration-kit/bitcoin — REAL Bitcoin Taproot (BIP-340 Schnorr) primitives plus
|
|
4
|
+
// a post-quantum recovery commitment for a Taproot output (P2QRH-style).
|
|
5
|
+
//
|
|
6
|
+
// This is the module that turns "we have zero secp256k1/Taproot" into a concrete,
|
|
7
|
+
// test-vector-verified capability:
|
|
8
|
+
// • Real BIP-340 Schnorr over secp256k1 (Bitcoin's Taproot signature scheme),
|
|
9
|
+
// validated against the OFFICIAL BIP-340 test vector — i.e. byte-for-byte
|
|
10
|
+
// consensus-correct, not an approximation.
|
|
11
|
+
// • A quantum-recovery commitment binding a Taproot x-only output key to an
|
|
12
|
+
// ML-DSA-65 (FIPS-204) key, dual-signed (Schnorr + PQC). A Taproot holder can
|
|
13
|
+
// publish this today so that, at/after Q-day, control is provable via the PQC key.
|
|
14
|
+
//
|
|
15
|
+
// HONEST SCOPE: this handles Bitcoin KEYS and SIGNATURES (real) and a migration
|
|
16
|
+
// commitment (real). It does NOT yet build/broadcast Bitcoin transactions, parse
|
|
17
|
+
// UTXOs, or define a soft-fork output type — that is the grant-funded roadmap in
|
|
18
|
+
// README.md. We claim exactly what the code does: Bitcoin-correct Schnorr + a
|
|
19
|
+
// dual-signed PQC recovery commitment.
|
|
20
|
+
|
|
21
|
+
import { schnorr } from "@noble/curves/secp256k1.js";
|
|
22
|
+
import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
|
|
23
|
+
import { sha3_256 } from "@noble/hashes/sha3.js";
|
|
24
|
+
|
|
25
|
+
const enc = new TextEncoder();
|
|
26
|
+
const COMMIT_DOMAIN = enc.encode("FRACTAL-BTC-TAPROOT-PQC-RECOVERY-v1");
|
|
27
|
+
|
|
28
|
+
function u32be(n) {
|
|
29
|
+
const b = new Uint8Array(4);
|
|
30
|
+
new DataView(b.buffer).setUint32(0, n >>> 0, false);
|
|
31
|
+
return b;
|
|
32
|
+
}
|
|
33
|
+
function canonical(domain, fields) {
|
|
34
|
+
const parts = [u32be(domain.length), domain];
|
|
35
|
+
for (const f of fields) parts.push(u32be(f.length), f);
|
|
36
|
+
let total = 0; for (const p of parts) total += p.length;
|
|
37
|
+
const out = new Uint8Array(total); let o = 0;
|
|
38
|
+
for (const p of parts) { out.set(p, o); o += p.length; }
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
const toHex = (b) => Buffer.from(b).toString("hex");
|
|
42
|
+
const fromHex = (h) => Uint8Array.from(Buffer.from(h, "hex"));
|
|
43
|
+
|
|
44
|
+
// ── Real Taproot key + BIP-340 Schnorr ─────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
/** A Taproot key pair: 32-byte x-only public key (Bitcoin Taproot output key form). */
|
|
47
|
+
export function generateTaprootKey() {
|
|
48
|
+
const kp = schnorr.keygen();
|
|
49
|
+
const publicKey = kp.publicKey ?? schnorr.getPublicKey(kp.secretKey);
|
|
50
|
+
return { secretKey: kp.secretKey, xOnlyPub: publicKey };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** BIP-340 Schnorr sign over a 32-byte message (e.g. a Taproot sighash). */
|
|
54
|
+
export function taprootSign(secretKey, msg32) {
|
|
55
|
+
return schnorr.sign(msg32, secretKey);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** BIP-340 Schnorr verify. Same signature Bitcoin consensus uses for Taproot. */
|
|
59
|
+
export function taprootVerify(sig, msg32, xOnlyPub) {
|
|
60
|
+
try { return schnorr.verify(sig, msg32, xOnlyPub); } catch { return false; }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Official BIP-340 test vector (index 1). If this fails, our Schnorr is NOT
|
|
64
|
+
// Bitcoin-compatible — so we assert it in the test suite as a consensus check.
|
|
65
|
+
export const BIP340_VECTOR_1 = {
|
|
66
|
+
pubkey: "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659",
|
|
67
|
+
message: "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89",
|
|
68
|
+
signature:
|
|
69
|
+
"6896BD60EEAE296DB48A229FF71DFE071BDE413E6D43F917DC8DCF8C78DE33418906D11AC976ABCCB20B091292BFF4EA897EFCB639EA871CFA95F6DE339E4B0A",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Returns true iff our Schnorr verifier accepts the official BIP-340 vector. */
|
|
73
|
+
export function verifiesBip340OfficialVector() {
|
|
74
|
+
return taprootVerify(
|
|
75
|
+
fromHex(BIP340_VECTOR_1.signature),
|
|
76
|
+
fromHex(BIP340_VECTOR_1.message),
|
|
77
|
+
fromHex(BIP340_VECTOR_1.pubkey),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Taproot → PQC quantum-recovery commitment (P2QRH-style) ────────────────────
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Bind a Taproot output key to a post-quantum ML-DSA-65 key with a DUAL signature.
|
|
85
|
+
* The Taproot key signs (proving current control of the UTXO's key) and the PQC key
|
|
86
|
+
* signs (proving possession of the recovery key). Publishing this commitment lets a
|
|
87
|
+
* holder prove, after secp256k1 is broken, that THEY pre-registered the successor key
|
|
88
|
+
* — the honest first building block of a Bitcoin quantum-migration path.
|
|
89
|
+
*/
|
|
90
|
+
export function createTaprootPqcCommitment(taproot, pqIdentity) {
|
|
91
|
+
const msg = canonical(COMMIT_DOMAIN, [taproot.xOnlyPub, pqIdentity.pqPublic]);
|
|
92
|
+
const taprootSig = schnorr.sign(sha3_256(msg), taproot.secretKey); // 32-byte digest
|
|
93
|
+
const pqSig = ml_dsa65.sign(msg, pqIdentity.pqSecret);
|
|
94
|
+
return {
|
|
95
|
+
version: "btc-taproot-pqc-recovery-v1",
|
|
96
|
+
taprootXOnlyPub: toHex(taproot.xOnlyPub),
|
|
97
|
+
pqPublic: toHex(pqIdentity.pqPublic),
|
|
98
|
+
taprootSig: toHex(taprootSig),
|
|
99
|
+
pqSig: toHex(pqSig),
|
|
100
|
+
factHash: toHex(sha3_256(msg)),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Verify a Taproot→PQC recovery commitment: BOTH signatures must verify over the same
|
|
106
|
+
* canonical binding.
|
|
107
|
+
*
|
|
108
|
+
* ⚠️ Like the migration envelope, well-formedness ≠ security. A classical-key attacker
|
|
109
|
+
* (post-Shor) can build a well-formed cert binding the victim's Taproot key to the
|
|
110
|
+
* attacker's OWN PQC key. For the recovery property you MUST pass `opts.anchoredFactHash`
|
|
111
|
+
* — the factHash of the holder's first-seen, immutably anchored commitment — which the
|
|
112
|
+
* verifier pins; a rebound cert has a different factHash and is rejected.
|
|
113
|
+
*
|
|
114
|
+
* @param {object} cert
|
|
115
|
+
* @param {{anchoredFactHash?: string}} [opts]
|
|
116
|
+
*/
|
|
117
|
+
export function verifyTaprootPqcCommitment(cert, opts = {}) {
|
|
118
|
+
try {
|
|
119
|
+
const xOnly = fromHex(cert.taprootXOnlyPub);
|
|
120
|
+
const pqPublic = fromHex(cert.pqPublic);
|
|
121
|
+
const msg = canonical(COMMIT_DOMAIN, [xOnly, pqPublic]);
|
|
122
|
+
const taprootOk = taprootVerify(fromHex(cert.taprootSig), sha3_256(msg), xOnly);
|
|
123
|
+
const pqOk = ml_dsa65.verify(fromHex(cert.pqSig), msg, pqPublic);
|
|
124
|
+
const computedFact = toHex(sha3_256(msg));
|
|
125
|
+
const factOk = cert.factHash === computedFact;
|
|
126
|
+
const wellFormed = taprootOk && pqOk && factOk;
|
|
127
|
+
const anchorOk = opts.anchoredFactHash == null ? null : (computedFact === opts.anchoredFactHash);
|
|
128
|
+
return { valid: wellFormed && anchorOk !== false, wellFormed, taprootOk, pqOk, factOk, anchorOk };
|
|
129
|
+
} catch (err) {
|
|
130
|
+
return { valid: false, wellFormed: false, taprootOk: false, pqOk: false, factOk: false, anchorOk: false, error: String(err) };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const _internal = { canonical, COMMIT_DOMAIN, toHex, fromHex };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Testnet broadcaster for P2TR key-path spends. Fetches UTXOs + fee rate for a funded
|
|
4
|
+
// address, builds + signs the spend with the kit, and (optionally) broadcasts via a
|
|
5
|
+
// public Esplora-style API (mempool.space / blockstream.info).
|
|
6
|
+
//
|
|
7
|
+
// Default is DRY-RUN: it returns the signed tx hex + txid WITHOUT sending. Pass
|
|
8
|
+
// { broadcast: true } to actually publish. Live broadcast requires a real funded UTXO
|
|
9
|
+
// on the chosen network — this module never invents funds.
|
|
10
|
+
|
|
11
|
+
import { generateTaprootKey } from "./bitcoin.mjs";
|
|
12
|
+
import { p2trAddress, addressToScriptPubKey, taprootTweakOutputKey } from "./address.mjs";
|
|
13
|
+
import { p2trScriptPubKey } from "./tx.mjs";
|
|
14
|
+
import { selectCoins } from "./fees.mjs";
|
|
15
|
+
import { createPsbt, signPsbtTaprootKeyPath, finalizePsbt } from "./psbt.mjs";
|
|
16
|
+
import { schnorr } from "@noble/curves/secp256k1.js";
|
|
17
|
+
|
|
18
|
+
const NETWORKS = {
|
|
19
|
+
tb: { hrp: "tb", api: "https://mempool.space/testnet/api", explorer: "https://mempool.space/testnet" },
|
|
20
|
+
bc: { hrp: "bc", api: "https://mempool.space/api", explorer: "https://mempool.space" },
|
|
21
|
+
signet: { hrp: "tb", api: "https://mempool.space/signet/api", explorer: "https://mempool.space/signet" },
|
|
22
|
+
// Mutinynet: a public custom signet (30s blocks) with a fast Esplora — same Bitcoin
|
|
23
|
+
// consensus + tb1 address format, ideal for a quick end-to-end demo.
|
|
24
|
+
mutinynet: { hrp: "tb", api: "https://mutinynet.com/api", explorer: "https://mutinynet.com" },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function net(network = "tb") {
|
|
28
|
+
const n = NETWORKS[network];
|
|
29
|
+
if (!n) throw new Error(`unknown network '${network}' (tb | bc | signet)`);
|
|
30
|
+
return n;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function getJson(url) {
|
|
34
|
+
const res = await fetch(url);
|
|
35
|
+
if (!res.ok) throw new Error(`GET ${url} → ${res.status} ${await res.text().catch(() => "")}`);
|
|
36
|
+
return res.json();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Fetch confirmed+unconfirmed UTXOs for an address (Esplora `/address/:a/utxo`). */
|
|
40
|
+
export async function fetchUtxos(address, { network = "tb", apiBase } = {}) {
|
|
41
|
+
const base = apiBase || net(network).api;
|
|
42
|
+
const list = await getJson(`${base}/address/${address}/utxo`);
|
|
43
|
+
return list.map((u) => ({ txid: u.txid, vout: u.vout, valueSats: u.value, status: u.status }));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Recommended fee rate (sat/vB). Falls back to 1 on parse issues. */
|
|
47
|
+
export async function fetchFeeRate({ network = "tb", apiBase, tier = "halfHourFee" } = {}) {
|
|
48
|
+
const base = apiBase || net(network).api;
|
|
49
|
+
const j = await getJson(`${base}/v1/fees/recommended`);
|
|
50
|
+
return Math.max(1, Math.ceil(j[tier] ?? j.hourFee ?? 1));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Broadcast a raw tx hex (Esplora `POST /tx`). Returns the txid. */
|
|
54
|
+
export async function broadcastTx(rawHex, { network = "tb", apiBase } = {}) {
|
|
55
|
+
const base = apiBase || net(network).api;
|
|
56
|
+
const res = await fetch(`${base}/tx`, { method: "POST", body: rawHex });
|
|
57
|
+
const text = await res.text();
|
|
58
|
+
if (!res.ok) throw new Error(`broadcast rejected (${res.status}): ${text}`);
|
|
59
|
+
return text.trim(); // Esplora returns the txid as plaintext
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const explorerTxUrl = (txid, network = "tb") => `${net(network).explorer}/tx/${txid}`;
|
|
63
|
+
export const explorerAddrUrl = (addr, network = "tb") => `${net(network).explorer}/address/${addr}`;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build (and optionally broadcast) a P2TR key-path spend from a funded Taproot address.
|
|
67
|
+
*
|
|
68
|
+
* @param {object} opts
|
|
69
|
+
* @param {Uint8Array} opts.internalPriv 32-byte Taproot internal private key (sender)
|
|
70
|
+
* @param {string} opts.to destination address (any SegWit address on the network)
|
|
71
|
+
* @param {number} opts.amountSats amount to send
|
|
72
|
+
* @param {"tb"|"bc"|"signet"} [opts.network="tb"]
|
|
73
|
+
* @param {number} [opts.feeRate] sat/vB; if omitted, fetched from the network
|
|
74
|
+
* @param {Array} [opts.utxos] override UTXOs (otherwise fetched for the sender address)
|
|
75
|
+
* @param {boolean} [opts.broadcast=false] actually publish (default: dry-run)
|
|
76
|
+
* @param {string} [opts.apiBase] override the Esplora API base
|
|
77
|
+
* @returns {Promise<{fromAddress,toScriptPubKey,inputs,fee,changeSats,hex,txid,vsize,broadcasted,explorer}>}
|
|
78
|
+
*/
|
|
79
|
+
export async function sendP2trKeyPath(opts) {
|
|
80
|
+
const { internalPriv, to, amountSats, network = "tb", broadcast = false, apiBase } = opts;
|
|
81
|
+
if (!(internalPriv instanceof Uint8Array) || internalPriv.length !== 32) throw new Error("internalPriv must be a 32-byte key");
|
|
82
|
+
const n = net(network);
|
|
83
|
+
const internalXOnly = schnorr.getPublicKey(internalPriv);
|
|
84
|
+
const fromAddress = p2trAddress(internalXOnly, n.hrp);
|
|
85
|
+
const senderSpk = p2trScriptPubKey(taprootTweakOutputKey(internalXOnly));
|
|
86
|
+
const toSpk = addressToScriptPubKey(to, n.hrp);
|
|
87
|
+
|
|
88
|
+
const feeRate = opts.feeRate ?? (await fetchFeeRate({ network, apiBase }));
|
|
89
|
+
const utxos = opts.utxos ?? (await fetchUtxos(fromAddress, { network, apiBase }));
|
|
90
|
+
if (!utxos.length) throw new Error(`no UTXOs for ${fromAddress} — fund it first (${explorerAddrUrl(fromAddress, network)})`);
|
|
91
|
+
|
|
92
|
+
const sel = selectCoins(utxos, amountSats, feeRate);
|
|
93
|
+
const outputs = [{ valueSats: amountSats, scriptPubKey: toSpk }];
|
|
94
|
+
if (sel.hasChange) outputs.push({ valueSats: sel.changeSats, scriptPubKey: senderSpk }); // change → self
|
|
95
|
+
const tx = {
|
|
96
|
+
version: 2, locktime: 0,
|
|
97
|
+
inputs: sel.inputs.map((u) => ({ txid: u.txid, vout: u.vout, sequence: 0xfffffffd })),
|
|
98
|
+
outputs,
|
|
99
|
+
};
|
|
100
|
+
const meta = sel.inputs.map(() => ({ witnessUtxo: { valueSats: 0, scriptPubKey: senderSpk }, tapInternalKey: internalXOnly }));
|
|
101
|
+
// fill real spent amounts (needed by the BIP-341 sighash)
|
|
102
|
+
sel.inputs.forEach((u, i) => { meta[i].witnessUtxo.valueSats = u.valueSats; });
|
|
103
|
+
|
|
104
|
+
const psbt = createPsbt(tx, meta);
|
|
105
|
+
signPsbtTaprootKeyPath(psbt, sel.inputs.map(() => internalPriv));
|
|
106
|
+
const fin = finalizePsbt(psbt);
|
|
107
|
+
|
|
108
|
+
let broadcasted = false;
|
|
109
|
+
if (broadcast) { await broadcastTx(fin.hex, { network, apiBase }); broadcasted = true; }
|
|
110
|
+
return {
|
|
111
|
+
fromAddress, toScriptPubKey: Buffer.from(toSpk).toString("hex"),
|
|
112
|
+
inputs: sel.inputs.length, fee: sel.fee, changeSats: sel.changeSats, vsize: sel.vsize,
|
|
113
|
+
hex: fin.hex, txid: fin.txid, broadcasted, explorer: explorerTxUrl(fin.txid, network),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export { generateTaprootKey };
|
package/src/fees.mjs
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Fee estimation + coin selection for P2TR (Taproot key-path) spends.
|
|
4
|
+
// Weights follow BIP-141 (segwit) / BIP-341: vsize = ceil(weight/4),
|
|
5
|
+
// weight = base_bytes*4 + witness_bytes.
|
|
6
|
+
//
|
|
7
|
+
// Per-element sizes (key-path P2TR, SIGHASH_DEFAULT 64-byte sig):
|
|
8
|
+
// tx overhead base 4(version)+1(nin)+1(nout)+4(locktime)=10 ; witness 2(marker+flag)
|
|
9
|
+
// P2TR input base 36(outpoint)+1(empty scriptSig)+4(sequence)=41 ; witness 1+1+64=66
|
|
10
|
+
// P2TR output base 8(value)+1(len)+34(OP_1 PUSH32)=43
|
|
11
|
+
// P2WPKH output base 8+1+22=31 (for change to a bech32 v0 addr, if used)
|
|
12
|
+
|
|
13
|
+
export const VSIZE = {
|
|
14
|
+
OVERHEAD_BASE: 10,
|
|
15
|
+
OVERHEAD_WITNESS: 2,
|
|
16
|
+
P2TR_IN_BASE: 41,
|
|
17
|
+
P2TR_IN_WITNESS: 66,
|
|
18
|
+
P2TR_OUT: 43,
|
|
19
|
+
P2WPKH_OUT: 31,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const DUST_P2TR = 330; // sats; a P2TR output below this is non-standard (dust)
|
|
23
|
+
|
|
24
|
+
/** Estimated virtual size (vB) for an all-P2TR-input tx with the given output count. */
|
|
25
|
+
export function estimateP2trVsize(numInputs, numP2trOutputs) {
|
|
26
|
+
const base = VSIZE.OVERHEAD_BASE + VSIZE.P2TR_IN_BASE * numInputs + VSIZE.P2TR_OUT * numP2trOutputs;
|
|
27
|
+
const witness = VSIZE.OVERHEAD_WITNESS + VSIZE.P2TR_IN_WITNESS * numInputs;
|
|
28
|
+
const weight = base * 4 + witness;
|
|
29
|
+
return Math.ceil(weight / 4);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Fee in sats for a vsize at a given fee rate (sat/vB), rounded up. */
|
|
33
|
+
export function feeForVsize(vsize, feeRateSatPerVb) {
|
|
34
|
+
return Math.ceil(vsize * feeRateSatPerVb);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Accumulative (largest-first) coin selection for a single P2TR recipient output plus an
|
|
39
|
+
* optional P2TR change output. All amounts in sats.
|
|
40
|
+
*
|
|
41
|
+
* @param {Array<{txid:string, vout:number, valueSats:number|bigint}>} utxos available UTXOs
|
|
42
|
+
* @param {number|bigint} targetSats amount to send to the recipient
|
|
43
|
+
* @param {number} feeRateSatPerVb fee rate
|
|
44
|
+
* @param {number} [dust=330] dust threshold for the change output
|
|
45
|
+
* @returns {{inputs:Array, fee:number, changeSats:number, hasChange:boolean, vsize:number}}
|
|
46
|
+
* @throws if funds are insufficient to cover target + fee
|
|
47
|
+
*/
|
|
48
|
+
export function selectCoins(utxos, targetSats, feeRateSatPerVb, dust = DUST_P2TR) {
|
|
49
|
+
const target = BigInt(targetSats);
|
|
50
|
+
if (target <= 0n) throw new Error("targetSats must be positive");
|
|
51
|
+
if (!(feeRateSatPerVb > 0)) throw new Error("feeRateSatPerVb must be positive");
|
|
52
|
+
// Guard against silent Number() precision loss on return (real BTC ≪ 2^53 sats).
|
|
53
|
+
const MAX = BigInt(Number.MAX_SAFE_INTEGER);
|
|
54
|
+
if (target > MAX) throw new Error("targetSats exceeds MAX_SAFE_INTEGER");
|
|
55
|
+
for (const u of utxos) if (BigInt(u.valueSats) > MAX) throw new Error("utxo valueSats exceeds MAX_SAFE_INTEGER");
|
|
56
|
+
const sorted = [...utxos].sort((a, b) => {
|
|
57
|
+
const x = BigInt(a.valueSats), y = BigInt(b.valueSats);
|
|
58
|
+
return y > x ? 1 : y < x ? -1 : 0; // largest-first, stable for equal values
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const chosen = [];
|
|
62
|
+
let sum = 0n;
|
|
63
|
+
for (const u of sorted) {
|
|
64
|
+
chosen.push(u);
|
|
65
|
+
sum += BigInt(u.valueSats);
|
|
66
|
+
// Try WITH change first (recipient + change = 2 outputs).
|
|
67
|
+
const vsizeWithChange = estimateP2trVsize(chosen.length, 2);
|
|
68
|
+
const feeWithChange = BigInt(feeForVsize(vsizeWithChange, feeRateSatPerVb));
|
|
69
|
+
const change = sum - target - feeWithChange;
|
|
70
|
+
if (change >= BigInt(dust)) {
|
|
71
|
+
return { inputs: chosen, fee: Number(feeWithChange), changeSats: Number(change), hasChange: true, vsize: vsizeWithChange };
|
|
72
|
+
}
|
|
73
|
+
// A dust-free change output isn't viable (change < dust). If we can still cover a
|
|
74
|
+
// changeless tx, take it and fold the sub-dust remainder into the fee — never create
|
|
75
|
+
// a dust output, and never falsely reject fundable UTXOs (the earlier extra guard
|
|
76
|
+
// `remainder < dust` rejected valid changeless spends when the remainder was ≥ dust).
|
|
77
|
+
const vsizeNoChange = estimateP2trVsize(chosen.length, 1);
|
|
78
|
+
const feeNoChange = BigInt(feeForVsize(vsizeNoChange, feeRateSatPerVb));
|
|
79
|
+
if (sum >= target + feeNoChange) {
|
|
80
|
+
return { inputs: chosen, fee: Number(sum - target), changeSats: 0, hasChange: false, vsize: vsizeNoChange };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
throw new Error("insufficient funds for target + fee");
|
|
84
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// fractal-pqc — SDK entry point.
|
|
4
|
+
//
|
|
5
|
+
// Programmatic surface for quantum-safe migration of a Bitcoin-style key:
|
|
6
|
+
// bind secp256k1 / Taproot to ML-DSA-65 (FIPS-204) and require a post-quantum
|
|
7
|
+
// signature to authorize. Real primitives, honestly scoped (see README).
|
|
8
|
+
//
|
|
9
|
+
// import {
|
|
10
|
+
// generateMigrationIdentity, createMigrationCommitment, verifyMigrationCommitment,
|
|
11
|
+
// authorizeSpend, verifySpend,
|
|
12
|
+
// generateTaprootKey, taprootSign, taprootVerify, verifiesBip340OfficialVector,
|
|
13
|
+
// createTaprootPqcCommitment, verifyTaprootPqcCommitment,
|
|
14
|
+
// } from "fractal-pqc";
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
generateMigrationIdentity,
|
|
18
|
+
createMigrationCommitment,
|
|
19
|
+
verifyMigrationCommitment,
|
|
20
|
+
authorizeSpend,
|
|
21
|
+
verifySpend,
|
|
22
|
+
} from "./migration-envelope.mjs";
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
generateTaprootKey,
|
|
26
|
+
taprootSign,
|
|
27
|
+
taprootVerify,
|
|
28
|
+
verifiesBip340OfficialVector,
|
|
29
|
+
createTaprootPqcCommitment,
|
|
30
|
+
verifyTaprootPqcCommitment,
|
|
31
|
+
BIP340_VECTOR_1,
|
|
32
|
+
} from "./bitcoin.mjs";
|
|
33
|
+
|
|
34
|
+
// Real Bitcoin address + transaction layer (BIP-173/350/341).
|
|
35
|
+
export {
|
|
36
|
+
bech32Encode, bech32Decode, segwitEncode, segwitDecode, convertBits, ENCODING,
|
|
37
|
+
} from "./bech32.mjs";
|
|
38
|
+
export {
|
|
39
|
+
taprootTweakOutputKey, p2trAddress, p2wpkhAddress, hash160, addressToScriptPubKey,
|
|
40
|
+
} from "./address.mjs";
|
|
41
|
+
// Testnet broadcaster (fetch UTXOs/fees, build+sign, optionally publish).
|
|
42
|
+
export {
|
|
43
|
+
fetchUtxos, fetchFeeRate, broadcastTx, sendP2trKeyPath, explorerTxUrl, explorerAddrUrl,
|
|
44
|
+
} from "./broadcast.mjs";
|
|
45
|
+
export {
|
|
46
|
+
serializeLegacy, serializeSegwit, txid, p2trScriptPubKey,
|
|
47
|
+
taprootKeyPathSighash, taprootTweakPrivateKey, signTaprootKeyPath,
|
|
48
|
+
} from "./tx.mjs";
|
|
49
|
+
|
|
50
|
+
// Fees + coin selection, and BIP-174 PSBT (P2TR key-path).
|
|
51
|
+
export {
|
|
52
|
+
estimateP2trVsize, feeForVsize, selectCoins, VSIZE,
|
|
53
|
+
} from "./fees.mjs";
|
|
54
|
+
export {
|
|
55
|
+
createPsbt, serializePsbt, serializePsbtHex, serializePsbtBase64,
|
|
56
|
+
parsePsbt, signPsbtTaprootKeyPath, finalizePsbt,
|
|
57
|
+
} from "./psbt.mjs";
|
|
58
|
+
|
|
59
|
+
export const VERSION = "0.3.0";
|