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/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
+ }
@@ -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";