fractal-pqc 0.3.0 → 0.3.2

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/README.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # fractal-pqc
2
2
 
3
+ > ⚠️ **Reference toolkit — NOT for production custody of real funds (yet).** It is
4
+ > **unaudited**, **key-path-only**, proven on **signet (not mainnet)**, and its quantum
5
+ > guarantee depends on a first-seen anchor registry that is still on the roadmap. Use it
6
+ > for **evaluation, testnet, education, and building on** — do **not** secure real Bitcoin
7
+ > with it until it is audited and 1.0. We say precisely what it is.
8
+
3
9
  A small, **runnable, test-vector-verified** reference for quantum-safe migration of a
4
10
  Bitcoin-style key. It binds a classical **secp256k1 / Taproot** key to a post-quantum
5
11
  **ML-DSA-65 (NIST FIPS-204)** key and requires a post-quantum signature to authorize —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fractal-pqc",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Runnable reference for quantum-safe migration of a Bitcoin-style key: bind secp256k1/Taproot to ML-DSA-65 (FIPS-204), derive P2TR addresses, build+sign BIP-341 key-path spends (official-vector-verified), and broadcast on testnet. Real primitives, honest scope.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,6 +26,7 @@
26
26
  "files": [
27
27
  "src",
28
28
  "bin",
29
+ "test",
29
30
  "README.md",
30
31
  "LICENSE"
31
32
  ],
package/src/index.mjs CHANGED
@@ -56,4 +56,4 @@ export {
56
56
  parsePsbt, signPsbtTaprootKeyPath, finalizePsbt,
57
57
  } from "./psbt.mjs";
58
58
 
59
- export const VERSION = "0.3.0";
59
+ export const VERSION = "0.3.2";
@@ -0,0 +1,44 @@
1
+ {
2
+ "_source": "Official BIP-341 wallet-test-vectors.json — keyPathSpending[0], input idx 4 (hashType=SIGHASH_DEFAULT). Proves taprootKeyPathSighash is Bitcoin-consensus-correct.",
3
+ "rawUnsignedTx": "02000000097de20cbff686da83a54981d2b9bab3586f4ca7e48f57f5b55963115f3b334e9c010000000000000000d7b7cab57b1393ace2d064f4d4a2cb8af6def61273e127517d44759b6dafdd990000000000fffffffff8e1f583384333689228c5d28eac13366be082dc57441760d957275419a418420000000000fffffffff0689180aa63b30cb162a73c6d2a38b7eeda2a83ece74310fda0843ad604853b0100000000feffffffaa5202bdf6d8ccd2ee0f0202afbbb7461d9264a25e5bfd3c5a52ee1239e0ba6c0000000000feffffff956149bdc66faa968eb2be2d2faa29718acbfe3941215893a2a3446d32acd050000000000000000000e664b9773b88c09c32cb70a2a3e4da0ced63b7ba3b22f848531bbb1d5d5f4c94010000000000000000e9aa6b8e6c9de67619e6a3924ae25696bb7b694bb677a632a74ef7eadfd4eabf0000000000ffffffffa778eb6a263dc090464cd125c466b5a99667720b1c110468831d058aa1b82af10100000000ffffffff0200ca9a3b000000001976a91406afd46bcdfd22ef94ac122aa11f241244a37ecc88ac807840cb0000000020ac9a87f5594be208f8532db38cff670c450ed2fea8fcdefcc9a663f78bab962b0065cd1d",
4
+ "utxosSpent": [
5
+ {
6
+ "scriptPubKey": "512053a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343",
7
+ "amountSats": 420000000
8
+ },
9
+ {
10
+ "scriptPubKey": "5120147c9c57132f6e7ecddba9800bb0c4449251c92a1e60371ee77557b6620f3ea3",
11
+ "amountSats": 462000000
12
+ },
13
+ {
14
+ "scriptPubKey": "76a914751e76e8199196d454941c45d1b3a323f1433bd688ac",
15
+ "amountSats": 294000000
16
+ },
17
+ {
18
+ "scriptPubKey": "5120e4d810fd50586274face62b8a807eb9719cef49c04177cc6b76a9a4251d5450e",
19
+ "amountSats": 504000000
20
+ },
21
+ {
22
+ "scriptPubKey": "512091b64d5324723a985170e4dc5a0f84c041804f2cd12660fa5dec09fc21783605",
23
+ "amountSats": 630000000
24
+ },
25
+ {
26
+ "scriptPubKey": "00147dd65592d0ab2fe0d0257d571abf032cd9db93dc",
27
+ "amountSats": 378000000
28
+ },
29
+ {
30
+ "scriptPubKey": "512075169f4001aa68f15bbed28b218df1d0a62cbbcf1188c6665110c293c907b831",
31
+ "amountSats": 672000000
32
+ },
33
+ {
34
+ "scriptPubKey": "5120712447206d7a5238acc7ff53fbe94a3b64539ad291c7cdbc490b7577e4b17df5",
35
+ "amountSats": 546000000
36
+ },
37
+ {
38
+ "scriptPubKey": "512077e30a5522dd9f894c3f8b8bd4c4b2cf82ca7da8a3ea6a239655c39c050ab220",
39
+ "amountSats": 588000000
40
+ }
41
+ ],
42
+ "inputIndex": 4,
43
+ "expectedSigHash": "4f900a0bae3f1446fd48490c2958b5a023228f01661cda3496a11da502a7f7ef"
44
+ }
@@ -0,0 +1,234 @@
1
+ // Real, runnable test vectors for the quantum migration envelope.
2
+ // `npm test` (or `node test/vectors.mjs`) exercises the full flow with REAL
3
+ // secp256k1 + ML-DSA-65 keys and prints a machine-checkable summary. No mocks.
4
+
5
+ import { sha256 } from "@noble/hashes/sha2.js";
6
+ import {
7
+ generateMigrationIdentity,
8
+ createMigrationCommitment,
9
+ verifyMigrationCommitment,
10
+ authorizeSpend,
11
+ verifySpend,
12
+ _internal,
13
+ } from "../src/migration-envelope.mjs";
14
+ import {
15
+ generateTaprootKey,
16
+ taprootSign,
17
+ taprootVerify,
18
+ verifiesBip340OfficialVector,
19
+ createTaprootPqcCommitment,
20
+ verifyTaprootPqcCommitment,
21
+ } from "../src/bitcoin.mjs";
22
+
23
+ let pass = 0, fail = 0;
24
+ function check(name, cond) {
25
+ if (cond) { pass++; console.log(` ✓ ${name}`); }
26
+ else { fail++; console.log(` ✗ ${name}`); }
27
+ }
28
+
29
+ console.log("pqc-migration-kit — real secp256k1 + ML-DSA-65 migration envelope\n");
30
+
31
+ // 1. Identity uses real key sizes.
32
+ const id = generateMigrationIdentity();
33
+ console.log("Key material (real):");
34
+ console.log(` secp256k1 pubkey : ${id.classicalPub.length} bytes (compressed)`);
35
+ console.log(` ML-DSA-65 pubkey : ${id.pqPublic.length} bytes (FIPS-204 Cat-3)`);
36
+ console.log(` ML-DSA-65 secret : ${id.pqSecret.length} bytes\n`);
37
+ check("secp256k1 compressed pubkey is 33 bytes", id.classicalPub.length === 33);
38
+ check("ML-DSA-65 pubkey is 1952 bytes (FIPS-204)", id.pqPublic.length === 1952);
39
+
40
+ // 2. A well-formed migration commitment verifies (both signatures).
41
+ const cert = createMigrationCommitment(id);
42
+ const v = verifyMigrationCommitment(cert);
43
+ console.log("\nMigration commitment:");
44
+ console.log(` factHash (anchorable leaf): ${cert.factHash}`);
45
+ check("valid commitment verifies (classical + PQC both)", v.valid);
46
+ check(" ↳ classical secp256k1 signature ok", v.classicalOk);
47
+ check(" ↳ post-quantum ML-DSA-65 signature ok", v.pqOk);
48
+
49
+ // 3. Tamper the bound PQC pubkey → commitment must fail (binding is tamper-evident).
50
+ const tampered = { ...cert, pqPublic: cert.pqPublic.slice(0, -2) + (cert.pqPublic.endsWith("00") ? "11" : "00") };
51
+ check("tampered PQC pubkey is rejected", verifyMigrationCommitment(tampered).valid === false);
52
+
53
+ // 4. Swapping in a different classical signature → fails.
54
+ const id2 = generateMigrationIdentity();
55
+ const cert2 = createMigrationCommitment(id2);
56
+ const forged = { ...cert, classicalSig: cert2.classicalSig };
57
+ check("foreign classical signature is rejected", verifyMigrationCommitment(forged).valid === false);
58
+
59
+ // 5. Post-migration spend: PQC signature authorizes a tx hash, verified against the
60
+ // holder's ANCHORED commitment (anchor = the factHash published first-seen on-chain).
61
+ const anchor = cert.factHash; // as if read from the immutable on-chain anchor
62
+ const txHash = sha256(new TextEncoder().encode("spend:send 0.5 BTC to bc1q..."));
63
+ const spendSig = authorizeSpend(id, txHash);
64
+ check("valid PQC spend verifies against the anchored commitment", verifySpend(cert, txHash, spendSig, { anchoredFactHash: anchor }).valid === true);
65
+
66
+ // 6. Spend for a DIFFERENT tx hash must fail (signature binds the exact tx).
67
+ const otherTx = sha256(new TextEncoder().encode("spend:send 5 BTC to attacker"));
68
+ check("PQC spend does not replay onto another tx", verifySpend(cert, otherTx, spendSig, { anchoredFactHash: anchor }).valid === false);
69
+
70
+ // 7. verifySpend fails CLOSED without an anchor (the quantum property needs the pin).
71
+ check("verifySpend without an anchor is rejected (anchor-required)", verifySpend(cert, txHash, spendSig).reason === "anchor-required");
72
+
73
+ // 8. THE quantum property, correctly modeled: an attacker who recovered the classical
74
+ // key (post-Shor) forges a well-formed cert re-binding the victim's classical key to
75
+ // the ATTACKER's OWN PQC key, then signs a spend with that PQC key. It is well-formed
76
+ // but its factHash ≠ the holder's anchored factHash → REJECTED at the anchor.
77
+ const attackerId = { classicalPriv: id.classicalPriv, classicalPub: id.classicalPub, pqSecret: id2.pqSecret, pqPublic: id2.pqPublic };
78
+ const attackerCert = createMigrationCommitment(attackerId); // victim classical pub, attacker PQC key
79
+ const attackerSpendSig = authorizeSpend(attackerId, txHash);
80
+ check("attacker's rebound cert is self-consistent (well-formed)", verifyMigrationCommitment(attackerCert).wellFormed === true);
81
+ const attackerSpend = verifySpend(attackerCert, txHash, attackerSpendSig, { anchoredFactHash: anchor });
82
+ check("classical-key attacker CANNOT spend against the anchored key (anchor-mismatch)", attackerSpend.valid === false && attackerSpend.reason === "anchor-mismatch");
83
+
84
+ // 8. Determinism of canonical encoding (verifier reconstructs identical bytes).
85
+ const a = _internal.canonical(_internal.COMMIT_DOMAIN, [id.classicalPub, id.pqPublic]);
86
+ const b = _internal.canonical(_internal.COMMIT_DOMAIN, [id.classicalPub, id.pqPublic]);
87
+ check("canonical encoding is deterministic", _internal.toHex(a) === _internal.toHex(b));
88
+
89
+ // ── Bitcoin Taproot (BIP-340 Schnorr) — real, consensus-correct ────────────────
90
+ console.log("\nBitcoin Taproot (BIP-340 Schnorr):");
91
+
92
+ // 9. Our Schnorr verifier accepts the OFFICIAL BIP-340 test vector → Bitcoin-correct.
93
+ check("official BIP-340 test vector verifies (Bitcoin consensus-correct)", verifiesBip340OfficialVector() === true);
94
+
95
+ // 10. Real Taproot sign/verify roundtrip with x-only 32-byte key.
96
+ const tap = generateTaprootKey();
97
+ check("Taproot x-only pubkey is 32 bytes", tap.xOnlyPub.length === 32);
98
+ const tapMsg = sha256(new TextEncoder().encode("taproot sighash placeholder"));
99
+ const tapSig = taprootSign(tap.secretKey, tapMsg);
100
+ check("Taproot Schnorr sign/verify roundtrip", taprootVerify(tapSig, tapMsg, tap.xOnlyPub) === true);
101
+ check("Taproot Schnorr rejects a tampered message", taprootVerify(tapSig, sha256(new TextEncoder().encode("other")), tap.xOnlyPub) === false);
102
+
103
+ // 11. Taproot → PQC quantum-recovery commitment (P2QRH-style), dual-signed.
104
+ const pqId = generateMigrationIdentity();
105
+ const btcCert = createTaprootPqcCommitment(tap, pqId);
106
+ const bv = verifyTaprootPqcCommitment(btcCert);
107
+ console.log(` recovery commitment factHash: ${btcCert.factHash}`);
108
+ check("Taproot→PQC recovery commitment verifies (Schnorr + ML-DSA-65)", bv.valid === true);
109
+ check(" ↳ Taproot Schnorr side ok", bv.taprootOk === true);
110
+ check(" ↳ ML-DSA-65 side ok", bv.pqOk === true);
111
+ const btcTampered = { ...btcCert, pqPublic: btcCert.pqPublic.slice(0, -2) + (btcCert.pqPublic.endsWith("00") ? "11" : "00") };
112
+ check("tampered PQC key in recovery commitment is rejected", verifyTaprootPqcCommitment(btcTampered).valid === false);
113
+
114
+ // ── Bitcoin addresses + transactions (real, official-vector-checked) ───────────
115
+ console.log("\nBitcoin addresses + transactions:");
116
+ const { segwitDecode, segwitEncode } = await import("../src/bech32.mjs");
117
+ const { taprootTweakOutputKey, p2trAddress, p2wpkhAddress } = await import("../src/address.mjs");
118
+ const bx = await import("../src/tx.mjs");
119
+ const { schnorr } = await import("@noble/curves/secp256k1.js");
120
+ const hx = (h) => Uint8Array.from(Buffer.from(h, "hex"));
121
+ const hex = (b) => Buffer.from(b).toString("hex");
122
+
123
+ // bech32m — official BIP-350 P2TR vector round-trips.
124
+ const trAddr = "bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0";
125
+ const trDec = segwitDecode("bc", trAddr);
126
+ check("BIP-350 P2TR address decodes to witver 1 / 32-byte program", trDec?.version === 1 && trDec.program.length === 32);
127
+ check("BIP-350 P2TR address re-encodes identically (bech32m)", segwitEncode("bc", 1, trDec.program) === trAddr);
128
+ // bech32 — official BIP-173 P2WPKH v0 program.
129
+ const wpDec = segwitDecode("bc", "BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4");
130
+ check("BIP-173 P2WPKH v0 program = 751e76e8…33bd6", wpDec && hex(wpDec.program) === "751e76e8199196d454941c45d1b3a323f1433bd6");
131
+
132
+ // BIP-341 official test vector: internal key → tweaked output key → address.
133
+ const internalP = hx("d6889cb081036e0faefa3a35157ad71086b123b2b144b649798b494c300a961d");
134
+ check("BIP-341 taproot tweak → official output key", hex(taprootTweakOutputKey(internalP)) === "53a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343");
135
+ check("BIP-341 P2TR address = official bc1p2wsldez…59h4z5", p2trAddress(internalP, "bc") === "bc1p2wsldez5mud2yam29q22wgfh9439spgduvct83k3pm50fcxa5dps59h4z5");
136
+
137
+ // Serialization + txid vs the Bitcoin GENESIS coinbase (consensus ground truth).
138
+ const genesisRaw = hx("01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff4d04ffff001d0104455468652054696d65732030332f4a616e2f32303039204368616e63656c6c6f72206f6e206272696e6b206f66207365636f6e64206261696c6f757420666f722062616e6b73ffffffff0100f2052a01000000434104678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5fac00000000");
139
+ check("genesis coinbase txid (serialization + txid correct)",
140
+ hex(bx._internal.rev(bx._internal.dsha(genesisRaw))) === "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b");
141
+
142
+ // Real key-path P2TR spend: tweaked private key pubkey == output key, and the Schnorr
143
+ // signature over the BIP-341 sighash verifies against that output key.
144
+ const spend = generateTaprootKey();
145
+ const { privKey, outputXOnly } = bx.taprootTweakPrivateKey(spend.secretKey);
146
+ check("tweaked private key's pubkey equals the tweaked output key", hex(schnorr.getPublicKey(privKey)) === hex(outputXOnly));
147
+ const spk = bx.p2trScriptPubKey(outputXOnly);
148
+ const tx = { version: 2, locktime: 0, inputs: [{ txid: "aa".repeat(32), vout: 0 }], outputs: [{ valueSats: 9000, scriptPubKey: spk }] };
149
+ const sigHex = bx.signTaprootKeyPath(tx, 0, spend.secretKey, [10000], [spk]);
150
+ const sighash = bx.taprootKeyPathSighash(tx, 0, [10000], [spk]);
151
+ check("key-path spend signature verifies against the Taproot output key", taprootVerify(hx(sigHex), sighash, outputXOnly) === true);
152
+ check("signed P2TR tx serializes (segwit) and yields a txid", typeof bx.txid(tx) === "string" && bx.serializeSegwit(tx).length > 0);
153
+ console.log(` demo P2TR address: ${p2trAddress(spend.xOnlyPub, "tb")}`);
154
+
155
+ // THE consensus proof: the BIP-341 key-path sighash matches the OFFICIAL vector byte-for-byte.
156
+ const { readFileSync } = await import("node:fs");
157
+ const { fileURLToPath } = await import("node:url");
158
+ const { dirname, join } = await import("node:path");
159
+ const here = dirname(fileURLToPath(import.meta.url));
160
+ const V = JSON.parse(readFileSync(join(here, "bip341-keypath-vector.json"), "utf8"));
161
+ function parseRawTx(raw) {
162
+ const b = hx(raw); let p = 0;
163
+ const u32 = () => { const v = new DataView(b.buffer, b.byteOffset + p, 4).getUint32(0, true); p += 4; return v; };
164
+ const rd = (n) => { const s = b.slice(p, p + n); p += n; return s; };
165
+ const vi = () => { const f = b[p++]; if (f < 0xfd) return f; if (f === 0xfd) { const v = new DataView(b.buffer, b.byteOffset + p, 2).getUint16(0, true); p += 2; return v; } if (f === 0xfe) { const v = u32(); return v; } const v = new DataView(b.buffer, b.byteOffset + p, 8).getBigUint64(0, true); p += 8; return Number(v); };
166
+ const version = u32(); const nin = vi(); const inputs = [];
167
+ for (let i = 0; i < nin; i++) { const prev = rd(32); const vout = u32(); const sl = vi(); rd(sl); const sequence = u32(); inputs.push({ txid: hex(Uint8Array.from(prev).reverse()), vout, sequence }); }
168
+ const nout = vi(); const outputs = [];
169
+ for (let i = 0; i < nout; i++) { const lo = new DataView(b.buffer, b.byteOffset + p, 4).getUint32(0, true); const hiv = new DataView(b.buffer, b.byteOffset + p + 4, 4).getUint32(0, true); p += 8; const valueSats = BigInt(hiv) * 0x100000000n + BigInt(lo); const sl = vi(); outputs.push({ valueSats, scriptPubKey: rd(sl) }); }
170
+ const locktime = u32(); return { version, locktime, inputs, outputs };
171
+ }
172
+ const otx = parseRawTx(V.rawUnsignedTx);
173
+ check("BIP-341 vector tx round-trips our serializer", hex(bx.serializeLegacy(otx)) === V.rawUnsignedTx);
174
+ const oSig = bx.taprootKeyPathSighash(otx, V.inputIndex, V.utxosSpent.map((u) => BigInt(u.amountSats)), V.utxosSpent.map((u) => hx(u.scriptPubKey)));
175
+ check("BIP-341 key-path sighash MATCHES the OFFICIAL vector (consensus-correct)", hex(oSig) === V.expectedSigHash);
176
+
177
+ // ── Fees + PSBT (BIP-174) — funded P2TR spend end-to-end ───────────────────────
178
+ console.log("\nFees + PSBT (funded P2TR spend):");
179
+ const fees = await import("../src/fees.mjs");
180
+ const psbtMod = await import("../src/psbt.mjs");
181
+ check("P2TR vsize 1-in/1-out ≈ 111 vB", fees.estimateP2trVsize(1, 1) === 111);
182
+ check("P2TR vsize 1-in/2-out ≈ 154 vB", fees.estimateP2trVsize(1, 2) === 154);
183
+ const uTxos = [{ txid: "ab".repeat(32), vout: 0, valueSats: 100000 }];
184
+ const sel = fees.selectCoins(uTxos, 60000, 8);
185
+ check("coin selection conserves value (in = target + fee + change)", 100000 === 60000 + sel.fee + sel.changeSats);
186
+ const spk2 = bx.p2trScriptPubKey(outputXOnly);
187
+ const outs = [{ valueSats: 60000, scriptPubKey: spk2 }];
188
+ if (sel.hasChange) outs.push({ valueSats: sel.changeSats, scriptPubKey: spk2 });
189
+ const ftx = { version: 2, locktime: 0, inputs: sel.inputs.map((u) => ({ txid: u.txid, vout: u.vout, sequence: 0xffffffff })), outputs: outs };
190
+ const meta = sel.inputs.map(() => ({ witnessUtxo: { valueSats: 100000, scriptPubKey: spk2 }, tapInternalKey: spend.xOnlyPub }));
191
+ const psbt = psbtMod.createPsbt(ftx, meta);
192
+ const parsed = psbtMod.parsePsbt(psbtMod.serializePsbt(psbt));
193
+ check("PSBT serialize→parse round-trips (magic + input/output counts)", parsed.magicOk && parsed.tx.inputs.length === ftx.inputs.length && parsed.tx.outputs.length === outs.length);
194
+ check("PSBT base64 carries the BIP-174 magic", psbtMod.serializePsbtBase64(psbt).startsWith("cHNidP8"));
195
+ psbtMod.signPsbtTaprootKeyPath(psbt, [spend.secretKey]);
196
+ const fin = psbtMod.finalizePsbt(psbt);
197
+ const fsighash = bx.taprootKeyPathSighash(ftx, 0, [100000], [spk2]);
198
+ check("finalized PSBT witness sig verifies against the output key", taprootVerify(psbt.inputs[0].tapKeySig, fsighash, outputXOnly) === true);
199
+ check("finalized PSBT yields a broadcastable tx (hex + txid)", typeof fin.hex === "string" && fin.hex.length > 0 && typeof fin.txid === "string");
200
+
201
+ // Regression: red-team fixes (fees false-insufficient, PSBT key-control, trailing bytes).
202
+ check("fees: fundable changeless spend is NOT falsely rejected", (() => {
203
+ const r = fees.selectCoins([{ txid: "aa".repeat(32), vout: 0, valueSats: 10454 }], 10000, 1);
204
+ return r.hasChange === false && r.changeSats === 0 && r.fee >= 111;
205
+ })());
206
+ check("PSBT signer refuses a key that doesn't control the input", (() => {
207
+ const B = generateTaprootKey();
208
+ const p2 = psbtMod.createPsbt(ftx, meta);
209
+ try { psbtMod.signPsbtTaprootKeyPath(p2, [B.secretKey]); return false; } catch { return true; }
210
+ })());
211
+ check("PSBT parser rejects trailing bytes", (() => {
212
+ const ser = psbtMod.serializePsbt(psbtMod.createPsbt(ftx, meta));
213
+ try { psbtMod.parsePsbt(new Uint8Array([...ser, 1, 2, 3])); return false; } catch { return true; }
214
+ })());
215
+
216
+ // ── Testnet broadcaster (offline dry-run — no network) ─────────────────────────
217
+ console.log("\nTestnet broadcaster (dry-run):");
218
+ const addrMod = await import("../src/address.mjs");
219
+ const bcast = await import("../src/broadcast.mjs");
220
+ // addressToScriptPubKey inverts p2trAddress: derive addr → decode back to the same spk.
221
+ const rtSpk = addrMod.addressToScriptPubKey(addrMod.p2trAddress(spend.xOnlyPub, "tb"), "tb");
222
+ check("addressToScriptPubKey(p2trAddress(k)) == p2trScriptPubKey(tweak(k))", hex(rtSpk) === hex(bx.p2trScriptPubKey(outputXOnly)));
223
+ // Full build+sign path with provided UTXOs + fee rate → dry-run, no network call.
224
+ const destAddr = addrMod.p2trAddress(generateTaprootKey().xOnlyPub, "tb");
225
+ const dry = await bcast.sendP2trKeyPath({
226
+ internalPriv: spend.secretKey, to: destAddr, amountSats: 50000, network: "tb",
227
+ feeRate: 2, utxos: [{ txid: "ab".repeat(32), vout: 0, valueSats: 100000 }], broadcast: false,
228
+ });
229
+ check("send-testnet dry-run builds a signed tx (not broadcast)", dry.broadcasted === false && typeof dry.hex === "string" && dry.hex.length > 0);
230
+ check("send-testnet dry-run conserves value (in = amount + fee + change)", 100000 === 50000 + dry.fee + dry.changeSats);
231
+ check("send-testnet dry-run yields an explorer link for the txid", dry.explorer.includes(dry.txid));
232
+
233
+ console.log(`\n${pass} passed, ${fail} failed`);
234
+ process.exit(fail === 0 ? 0 : 1);