diff --git a/packages/wasm-utxo/.mocharc.integrationLocalRpc.json b/packages/wasm-utxo/.mocharc.integrationLocalRpc.json new file mode 100644 index 00000000000..6c6a5c99e5b --- /dev/null +++ b/packages/wasm-utxo/.mocharc.integrationLocalRpc.json @@ -0,0 +1,5 @@ +{ + "extensions": ["ts", "tsx", "js", "jsx"], + "node-option": ["import=tsx/esm", "experimental-wasm-modules"], + "timeout": 120000 +} diff --git a/packages/wasm-utxo/.mocharc.json b/packages/wasm-utxo/.mocharc.json index e20ab5878b8..5ef1b0df60e 100644 --- a/packages/wasm-utxo/.mocharc.json +++ b/packages/wasm-utxo/.mocharc.json @@ -1,5 +1,5 @@ { "extensions": ["ts", "tsx", "js", "jsx"], - "ignore": ["test/benchmark/**"], + "ignore": ["test/benchmark/**", "test/integrationLocalRpc/**"], "node-option": ["import=tsx/esm", "experimental-wasm-modules"] } diff --git a/packages/wasm-utxo/cli/src/network.rs b/packages/wasm-utxo/cli/src/network.rs index 93ee43da3dc..097743b78bb 100644 --- a/packages/wasm-utxo/cli/src/network.rs +++ b/packages/wasm-utxo/cli/src/network.rs @@ -27,6 +27,7 @@ pub enum NetworkArg { Tzec, Pearl, Tpearl, + Tpearlreg, } impl From for Network { @@ -53,6 +54,7 @@ impl From for Network { NetworkArg::Tzec => Network::ZcashTestnet, NetworkArg::Pearl => Network::Pearl, NetworkArg::Tpearl => Network::PearlTestnet, + NetworkArg::Tpearlreg => Network::PearlRegtest, } } } diff --git a/packages/wasm-utxo/js/coinName.ts b/packages/wasm-utxo/js/coinName.ts index 148268b00ef..c0f93cd0e00 100644 --- a/packages/wasm-utxo/js/coinName.ts +++ b/packages/wasm-utxo/js/coinName.ts @@ -24,6 +24,7 @@ export const coinNames = [ "tzec", "pearl", "tpearl", + "tpearlreg", ] as const; export type CoinName = (typeof coinNames)[number]; @@ -53,6 +54,7 @@ export function getMainnet(name: CoinName): CoinName { case "tzec": return "zec"; case "tpearl": + case "tpearlreg": return "pearl"; default: return name; diff --git a/packages/wasm-utxo/js/testutils/AcidTest.ts b/packages/wasm-utxo/js/testutils/AcidTest.ts index ce78487fe41..a4233f96794 100644 --- a/packages/wasm-utxo/js/testutils/AcidTest.ts +++ b/packages/wasm-utxo/js/testutils/AcidTest.ts @@ -249,9 +249,15 @@ export class AcidTest { } /** - * Create the actual PSBT with all inputs and outputs + * Create the actual PSBT with all inputs and outputs. + * + * @param options.outpoints - Optional real outpoints (txid/vout/prevTx) to replace + * the synthetic `txid=0…0` placeholders. Used by integrationLocalRpc against a + * live regtest node; length must match `inputs`. */ - createPsbt(): BitGoPsbt { + createPsbt(options?: { + outpoints?: Array<{ txid: string; vout: number; prevTx?: Uint8Array }>; + }): BitGoPsbt { // Use ZcashBitGoPsbt for Zcash networks const isZcash = this.coin === "zec" || this.coin === "tzec"; const psbt = isZcash @@ -281,10 +287,21 @@ export class AcidTest { return tx.toBytes(); }; - // Add inputs with deterministic outpoints + if (options?.outpoints && options.outpoints.length !== this.inputs.length) { + throw new Error( + `outpoints length ${options.outpoints.length} !== inputs length ${this.inputs.length}`, + ); + } + + // Add inputs with deterministic outpoints (or real outpoints when provided) this.inputs.forEach((input, index) => { const walletKeys = input.walletKeys ?? this.rootWalletKeys; - const outpoint = { txid: "0".repeat(64), vout: index, value: input.value }; + const real = options?.outpoints?.[index]; + const outpoint = { + txid: real?.txid ?? "0".repeat(64), + vout: real?.vout ?? index, + value: input.value, + }; // scriptId variant: caller provides explicit chain + index if (input.scriptId) { @@ -295,7 +312,10 @@ export class AcidTest { this.coin, ); psbt.addWalletInput( - { ...outpoint, prevTx: buildPrevTx(index, script, input.value) }, + { + ...outpoint, + prevTx: real?.prevTx ?? buildPrevTx(index, script, input.value), + }, walletKeys, { scriptId: input.scriptId, signPath: { signer: "user", cosigner: "bitgo" } }, ); @@ -308,7 +328,10 @@ export class AcidTest { const ecpair = ECPair.fromPublicKey(this.getReplayProtectionKey().publicKey); const script = p2shP2pkOutputScript(ecpair.publicKey); psbt.addReplayProtectionInput( - { ...outpoint, prevTx: buildPrevTx(index, script, input.value) }, + { + ...outpoint, + prevTx: real?.prevTx ?? buildPrevTx(index, script, input.value), + }, ecpair, ); return; @@ -325,7 +348,10 @@ export class AcidTest { const script = outputScript(walletKeys, scriptId.chain, scriptId.index, this.coin); psbt.addWalletInput( - { ...outpoint, prevTx: buildPrevTx(index, script, input.value) }, + { + ...outpoint, + prevTx: real?.prevTx ?? buildPrevTx(index, script, input.value), + }, walletKeys, { scriptId, signPath }, ); diff --git a/packages/wasm-utxo/package.json b/packages/wasm-utxo/package.json index 9edf7116298..e6915e31ab8 100644 --- a/packages/wasm-utxo/package.json +++ b/packages/wasm-utxo/package.json @@ -62,6 +62,8 @@ "scripts": { "test": "npm run test:mocha && npm run test:wasm-pack && npm run test:imports", "test:mocha": "mocha --recursive 'test/**/*.ts'", + "test:integrationLocalRpc": "mocha --config .mocharc.integrationLocalRpc.json --recursive 'test/integrationLocalRpc/**/*.test.ts'", + "test:integrationLocalRpc:generate": "tsx --experimental-wasm-modules test/integrationLocalRpc/generate/main.ts", "test:benchmark": "mocha test/benchmark/signing.ts --timeout 600000", "test:wasm-pack": "npm run test:wasm-pack-node && npm run test:wasm-pack-chrome", "test:wasm-pack-node": "./scripts/wasm-pack-test.sh --node", diff --git a/packages/wasm-utxo/src/address/mod.rs b/packages/wasm-utxo/src/address/mod.rs index 893ef8a9ae9..3ea3cf05d3f 100644 --- a/packages/wasm-utxo/src/address/mod.rs +++ b/packages/wasm-utxo/src/address/mod.rs @@ -181,8 +181,7 @@ pub const ZCASH_TEST: Base58CheckCodec = Base58CheckCodec::new(0x1d25, 0x1cba); // Bech32Codec handles v0 (bech32) and v1+ (bech32m / BIP-350) automatically. pub const PEARL_BECH32: Bech32Codec = Bech32Codec::new("prl"); pub const PEARL_TEST_BECH32: Bech32Codec = Bech32Codec::new("tprl"); -// Regtest HRP used only in test fixtures; not needed in production code. -#[cfg(test)] +/// Regtest HRP (`rprl`) — used by pearld `--regtest` and CoinName `tpearlreg`. pub const PEARL_REGTEST_BECH32: Bech32Codec = Bech32Codec::new("rprl"); /// Convert output script to address string (convenience wrapper) diff --git a/packages/wasm-utxo/src/address/networks.rs b/packages/wasm-utxo/src/address/networks.rs index fadb46b7aeb..5b1a9401ecb 100644 --- a/packages/wasm-utxo/src/address/networks.rs +++ b/packages/wasm-utxo/src/address/networks.rs @@ -10,8 +10,8 @@ use super::{ BITCOIN_CASH_TESTNET_CASHADDR, BITCOIN_GOLD, BITCOIN_GOLD_BECH32, BITCOIN_GOLD_TESTNET, BITCOIN_GOLD_TESTNET_BECH32, BITCOIN_SV, BITCOIN_SV_TESTNET, DASH, DASH_TEST, DOGECOIN, DOGECOIN_TEST, ECASH, ECASH_CASHADDR, ECASH_TEST, ECASH_TEST_CASHADDR, LITECOIN, - LITECOIN_BECH32, LITECOIN_TEST, LITECOIN_TEST_BECH32, PEARL_BECH32, PEARL_TEST_BECH32, REGTEST, - REGTEST_BECH32, TESTNET, TESTNET_BECH32, ZCASH, ZCASH_TEST, + LITECOIN_BECH32, LITECOIN_TEST, LITECOIN_TEST_BECH32, PEARL_BECH32, PEARL_REGTEST_BECH32, + PEARL_TEST_BECH32, REGTEST, REGTEST_BECH32, TESTNET, TESTNET_BECH32, ZCASH, ZCASH_TEST, }; use crate::bitcoin::Script; use crate::fixed_script_wallet::wallet_scripts::OutputScriptType; @@ -49,6 +49,7 @@ fn get_decode_codecs(network: Network) -> Vec<&'static dyn AddressCodec> { // Pearl is Taproot-only; bech32m addresses only, no legacy codecs. Network::Pearl => vec![&PEARL_BECH32], Network::PearlTestnet => vec![&PEARL_TEST_BECH32], + Network::PearlRegtest => vec![&PEARL_REGTEST_BECH32], } } @@ -314,6 +315,7 @@ fn get_encode_codec( // Pearl is Taproot-only; assert_support() already rejects non-P2TR scripts above. Network::Pearl => Ok(&PEARL_BECH32), Network::PearlTestnet => Ok(&PEARL_TEST_BECH32), + Network::PearlRegtest => Ok(&PEARL_REGTEST_BECH32), } } @@ -876,7 +878,7 @@ mod tests { fn test_pearl_script_type_support() { use crate::fixed_script_wallet::wallet_scripts::OutputScriptType::*; - for network in [Network::Pearl, Network::PearlTestnet] { + for network in [Network::Pearl, Network::PearlTestnet, Network::PearlRegtest] { let support = network.output_script_support(); assert!(!support.legacy, "{network:?}: legacy should be false"); assert!(!support.segwit, "{network:?}: segwit should be false"); @@ -916,6 +918,11 @@ mod tests { testnet_addr.starts_with("tprl1p"), "Expected tprl1p… got {testnet_addr}" ); + let regtest_addr = from_output_script_with_network(&p2tr, Network::PearlRegtest).unwrap(); + assert!( + regtest_addr.starts_with("rprl1p"), + "Expected rprl1p… got {regtest_addr}" + ); // Round-trip: address → script let decoded = to_output_script_with_network(&mainnet_addr, Network::Pearl).unwrap(); @@ -923,6 +930,9 @@ mod tests { let decoded_test = to_output_script_with_network(&testnet_addr, Network::PearlTestnet).unwrap(); assert_eq!(decoded_test, p2tr); + let decoded_reg = + to_output_script_with_network(®test_addr, Network::PearlRegtest).unwrap(); + assert_eq!(decoded_reg, p2tr); // P2WPKH (v0) → "does not support segwit" let wpkh_hash = crate::bitcoin::WPubkeyHash::from_byte_array( diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs index 73fbd6322bd..74673e54a5d 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs @@ -414,7 +414,8 @@ impl BitGoPsbt { | Network::Litecoin | Network::LitecoinTestnet | Network::Pearl - | Network::PearlTestnet => Ok(BitGoPsbt::BitcoinLike( + | Network::PearlTestnet + | Network::PearlRegtest => Ok(BitGoPsbt::BitcoinLike( Psbt::deserialize(psbt_bytes)?, network, )), diff --git a/packages/wasm-utxo/src/networks.rs b/packages/wasm-utxo/src/networks.rs index 3f985f3c114..cd8ac5b5a72 100644 --- a/packages/wasm-utxo/src/networks.rs +++ b/packages/wasm-utxo/src/networks.rs @@ -60,6 +60,7 @@ pub enum Network { // No legacy outputs (no P2PKH/P2SH/P2WPKH/P2WSH). Bech32m only. Pearl, PearlTestnet, + PearlRegtest, } impl Network { @@ -89,6 +90,7 @@ impl Network { Network::ZcashTestnet, Network::Pearl, Network::PearlTestnet, + Network::PearlRegtest, ]; /// Returns the canonical string name of this network @@ -118,6 +120,7 @@ impl Network { Network::ZcashTestnet => "ZcashTestnet", Network::Pearl => "Pearl", Network::PearlTestnet => "PearlTestnet", + Network::PearlRegtest => "PearlRegtest", } } @@ -156,6 +159,7 @@ impl Network { "Pearl" => Some(Network::Pearl), "PearlTestnet" => Some(Network::PearlTestnet), + "PearlRegtest" => Some(Network::PearlRegtest), _ => None, } @@ -188,6 +192,10 @@ impl Network { "litecoinTest" => Some(Network::LitecoinTestnet), "zcash" => Some(Network::Zcash), "zcashTest" => Some(Network::ZcashTestnet), + // Pearl is not yet in @bitgo/utxo-lib; names reserved for future wiring. + "pearl" => Some(Network::Pearl), + "pearlTest" => Some(Network::PearlTestnet), + "pearlRegtest" => Some(Network::PearlRegtest), _ => None, } } @@ -219,6 +227,7 @@ impl Network { // Pearl is not yet in @bitgo/utxo-lib; names reserved for future wiring. Network::Pearl => "pearl", Network::PearlTestnet => "pearlTest", + Network::PearlRegtest => "pearlRegtest", } } @@ -249,6 +258,7 @@ impl Network { "tzec" => Some(Network::ZcashTestnet), "pearl" => Some(Network::Pearl), "tpearl" => Some(Network::PearlTestnet), + "tpearlreg" => Some(Network::PearlRegtest), _ => None, } } @@ -280,6 +290,7 @@ impl Network { Network::ZcashTestnet => "tzec", Network::Pearl => "pearl", Network::PearlTestnet => "tpearl", + Network::PearlRegtest => "tpearlreg", } } @@ -317,7 +328,7 @@ impl Network { Network::ZcashTestnet => Network::Zcash, Network::Pearl => Network::Pearl, - Network::PearlTestnet => Network::Pearl, + Network::PearlTestnet | Network::PearlRegtest => Network::Pearl, } } @@ -453,7 +464,7 @@ mod tests { #[test] fn test_all_networks() { // Verify ALL contains all networks - assert_eq!(Network::ALL.len(), 24); + assert_eq!(Network::ALL.len(), 25); // Verify no duplicates for (i, network1) in Network::ALL.iter().enumerate() { diff --git a/packages/wasm-utxo/src/test_utils.rs b/packages/wasm-utxo/src/test_utils.rs index 555db03e635..a862f006c3c 100644 --- a/packages/wasm-utxo/src/test_utils.rs +++ b/packages/wasm-utxo/src/test_utils.rs @@ -32,6 +32,7 @@ macro_rules! test_all_networks { #[case::zcash_testnet($crate::Network::ZcashTestnet)] #[case::pearl($crate::Network::Pearl)] #[case::pearl_testnet($crate::Network::PearlTestnet)] + #[case::pearl_regtest($crate::Network::PearlRegtest)] fn $test_name(#[case] $network: $crate::Network) $body }; } diff --git a/packages/wasm-utxo/src/wasm/psbt.rs b/packages/wasm-utxo/src/wasm/psbt.rs index 8e2d3e85270..29e73a38daa 100644 --- a/packages/wasm-utxo/src/wasm/psbt.rs +++ b/packages/wasm-utxo/src/wasm/psbt.rs @@ -1209,7 +1209,7 @@ mod tests { #[test] fn test_all_networks_macro_is_complete() { const _: () = assert!( - Network::ALL.len() == 24, + Network::ALL.len() == 25, "test_all_networks! macro is out of sync with Network::ALL" ); } diff --git a/packages/wasm-utxo/test/address/compatibility.ts b/packages/wasm-utxo/test/address/compatibility.ts index 07105a1c3d5..30983f0051d 100644 --- a/packages/wasm-utxo/test/address/compatibility.ts +++ b/packages/wasm-utxo/test/address/compatibility.ts @@ -69,6 +69,7 @@ const taprootGroups: CoinName[][] = [ ["tbtc", "tbtc4", "tbtcbgsig", "tbtcsig"], ["tbtcreg"], ["tpearl"], + ["tpearlreg"], ]; describe("address compatibility", function () { diff --git a/packages/wasm-utxo/test/integrationLocalRpc/README.md b/packages/wasm-utxo/test/integrationLocalRpc/README.md new file mode 100644 index 00000000000..65eacf0e95f --- /dev/null +++ b/packages/wasm-utxo/test/integrationLocalRpc/README.md @@ -0,0 +1,58 @@ +# integrationLocalRpc + +Local regtest-node tests for `@bitgo/wasm-utxo`, modeled on +[`utxo-lib/test/integration_local_rpc`](https://github.com/BitGo/BitGoJS/tree/master/modules/utxo-lib/test/integration_local_rpc) +and the docker harness in +[`indexer-utxo/containers`](https://github.com/BitGo/indexer-utxo/tree/master/containers). + +## Design + +- Same AcidTest kitchen-sink withdrawal loop for every wasm-utxo coin (as many + input/output script types as the coin permits), validated against a live + regtest node via RPC. +- **Not run on CI.** Default `npm test` / `test:mocha` ignores this directory. +- When run locally, the generator spins up Docker (or a reachable node), runs + the suite, and writes fixtures under `fixtures//` for commit. + +## Pearl notes + +`pearld` is a walletless btcd fork: + +- Mine with `generate` only (no `generatetoaddress`) — coinbases go to `--miningaddr` +- No wallet RPC; fund destinations by signing spends off-node with the known + mining key (`pearlConstants.ts`, privkey `0x01`) +- Taproot-only; regtest uses CoinName `tpearlreg` (HRP `rprl`) +- Default image: `319156457634.dkr.ecr.us-west-2.amazonaws.com/pearl-fullnode:1.0.2` + (override with `WASM_UTXO_PEARL_DOCKER_IMAGE`, e.g. local `prl-pearld:latest`) + +### Apple Silicon / amd64 Docker + +ZK-PoW mining in `pearld` **segfaults under amd64→arm64 Docker emulation** +(see `coins-sandbox/prl/prl_wasm_indexer_report.md`). On macOS arm64, use the +native binary from Pearl's GitHub release: + +```bash +gh release download pearl-wallet-v1.0.0 \ + --repo pearl-research-labs/pearl \ + --pattern "go-binaries-darwin-arm64*" \ + --dir /tmp/pearl +tar -xzf /tmp/pearl/go-binaries-darwin-arm64-v1.0.2.tar.gz -C /tmp/pearl +``` + +## Commands + +```bash +# Offline fixture checks (no Docker) — still local-only, not in default mocha +npm run test:integrationLocalRpc + +# Native pearld (recommended on Apple Silicon) +WASM_UTXO_PEARLD_BIN=/path/to/pearld \ + WASM_UTXO_TESTS_LOG_DOCKER=1 \ + npm run test:integrationLocalRpc:generate + +# Docker (linux/amd64 native hosts) +npm run test:integrationLocalRpc:generate + +# Local docker image override +WASM_UTXO_PEARL_DOCKER_IMAGE=prl-pearld:latest npm run test:integrationLocalRpc:generate +``` diff --git a/packages/wasm-utxo/test/integrationLocalRpc/acidTestRegtest.ts b/packages/wasm-utxo/test/integrationLocalRpc/acidTestRegtest.ts new file mode 100644 index 00000000000..e2668c885b7 --- /dev/null +++ b/packages/wasm-utxo/test/integrationLocalRpc/acidTestRegtest.ts @@ -0,0 +1,220 @@ +/** + * Run an AcidTest kitchen-sink withdrawal against a live regtest node. + * + * Shared loop for every wasm-utxo coin: fund each AcidTest input script type, + * build/sign via AcidTest.createPsbt with real outpoints, broadcast, return fixture. + * + * Pearl-specific: walletless pearld — coinbases go to --miningaddr; we fund + * destinations by signing key-path spends off-node with the known mining key. + */ +import assert from "node:assert/strict"; + +import { AcidTest } from "../../js/testutils/AcidTest.js"; +import { + ChainCode, + outputScript, + p2shP2pkOutputScript, + type InputScriptType, + type OutputScriptType, +} from "../../js/fixedScriptWallet/index.js"; +import { Descriptor, Transaction } from "../../js/index.js"; +import { createPsbt } from "../../js/descriptorWallet/psbt/createPsbt.js"; +import { signWithKey } from "../../js/descriptorWallet/psbt/sign.js"; +import type { CoinName } from "../../js/coinName.js"; + +import type { RpcClient, RpcTxVerbose } from "./generate/RpcClient.js"; +import { toRegtestAddress } from "./regtestAddress.js"; +import { + PEARL_FEE_SATOSHIS, + PEARL_MINING_DESCRIPTOR, + PEARL_MINING_PRIVKEY_HEX, + PEARL_MINING_TAPROOT_SCRIPT, + PEARL_MIN_HEIGHT, + PEARL_SIGN_COIN, +} from "./pearlConstants.js"; +import type { AcidTestRegtestFixture } from "./fixtures.js"; + +function inputScriptTypeToOutputScriptType(scriptType: InputScriptType): OutputScriptType { + switch (scriptType) { + case "p2sh": + case "p2shP2wsh": + case "p2wsh": + case "p2trLegacy": + return scriptType; + case "p2shP2pk": + return "p2sh"; + case "p2trMusig2ScriptPath": + case "p2trMusig2KeyPath": + return "p2trMusig2"; + } +} + +function hexToBytes(hex: string): Uint8Array { + return Uint8Array.from(Buffer.from(hex, "hex")); +} + +function bytesToHex(bytes: Uint8Array): string { + return Buffer.from(bytes).toString("hex"); +} + +/** Pull a mature coinbase UTXO from the mining address stream. */ +async function nextMiningCoinbase( + rpc: RpcClient, + height: number, +): Promise<{ txid: string; vout: number; value: bigint; prevTxHex: string }> { + const hash = await rpc.getBlockHash(height); + const block = await rpc.getBlockVerbose(hash, 2); + const txs = block.rawtx ?? (block.tx as RpcTxVerbose[]); + assert.ok(txs?.length, `no txs at height ${height}`); + const coinbase = typeof txs[0] === "string" ? await rpc.getRawTransactionVerbose(txs[0]) : txs[0]; + const out = coinbase.vout[0]; + const value = BigInt(Math.round(out.value * 1e8)); + return { + txid: coinbase.txid, + vout: out.n, + value, + prevTxHex: coinbase.hex, + }; +} + +/** + * Spend a mining coinbase to `destScript` (Taproot key-path), return funding outpoint. + */ +async function fundScriptFromMining( + rpc: RpcClient, + coinbaseHeight: number, + destScript: Uint8Array, + sendValue: bigint, +): Promise<{ txid: string; vout: number; value: bigint; prevTxHex: string }> { + const coinbase = await nextMiningCoinbase(rpc, coinbaseHeight); + assert.ok( + coinbase.value > sendValue + PEARL_FEE_SATOSHIS, + `coinbase ${coinbase.value} too small for send ${sendValue}`, + ); + + const descriptor = Descriptor.fromString(PEARL_MINING_DESCRIPTOR, "definite"); + const prevTx = Transaction.fromBytes(hexToBytes(coinbase.prevTxHex)); + + const psbt = createPsbt( + { version: 2, locktime: 0 }, + [ + { + hash: coinbase.txid, + index: coinbase.vout, + witnessUtxo: { + script: hexToBytes(PEARL_MINING_TAPROOT_SCRIPT), + value: coinbase.value, + }, + nonWitnessUtxo: prevTx.toBytes(), + descriptor, + }, + ], + [ + { script: destScript, value: sendValue }, + { + script: hexToBytes(PEARL_MINING_TAPROOT_SCRIPT), + value: coinbase.value - sendValue - PEARL_FEE_SATOSHIS, + }, + ], + ); + + signWithKey(psbt, hexToBytes(PEARL_MINING_PRIVKEY_HEX)); + // Descriptor Psbt uses finalize() (BitGoPsbt uses finalizeAllInputs) + psbt.finalize(); + const spendHex = bytesToHex(psbt.extractTransaction().toBytes()); + const txid = await rpc.sendRawTransaction(spendHex); + await rpc.generateBlocks(1); + + const verbose = await rpc.getRawTransactionVerbose(txid); + return { + txid, + vout: 0, + value: sendValue, + prevTxHex: verbose.hex, + }; +} + +function walletScriptForInput( + acid: AcidTest, + scriptType: InputScriptType, + index: number, +): Uint8Array { + if (scriptType === "p2shP2pk") { + return p2shP2pkOutputScript(acid.getReplayProtectionKey().publicKey); + } + const outType = inputScriptTypeToOutputScriptType(scriptType); + const chain = ChainCode.value(outType, "external"); + return outputScript(acid.rootWalletKeys, chain, index, acid.coin); +} + +/** + * Fund AcidTest inputs, build a real-outpoint fullsigned PSBT, broadcast, return fixture. + */ +export async function runAcidTestAgainstRegtest( + rpc: RpcClient, + coin: CoinName, +): Promise { + const signCoin: CoinName = coin === "pearl" || coin === "tpearl" ? PEARL_SIGN_COIN : coin; + // Default AcidTest config: exclude p2trMusig2ScriptPath (mixed script+key path + // MuSig2 nonce gen is broken in AcidTest.signPsbt — same as unit suite default). + const acid = AcidTest.withConfig(signCoin, "fullsigned", "psbt"); + + const networkInfo = await rpc.getNetworkInfo(); + let height = await rpc.getBlockCount(); + if (height < PEARL_MIN_HEIGHT) { + console.log( + `[${coin}] mining ${PEARL_MIN_HEIGHT - height} blocks to reach ${PEARL_MIN_HEIGHT}`, + ); + await rpc.generateBlocks(PEARL_MIN_HEIGHT - height); + height = await rpc.getBlockCount(); + } + + // Coinbases at heights 1..N; start spending from height 1 after maturity + let nextCoinbaseHeight = 1; + const outpoints: Array<{ txid: string; vout: number; prevTx: Uint8Array }> = []; + + for (let i = 0; i < acid.inputs.length; i++) { + const input = acid.inputs[i]; + assert.ok(input.scriptType, "AcidTest inputs must use scriptType"); + const script = walletScriptForInput(acid, input.scriptType, i); + const addr = toRegtestAddress(script, signCoin); + console.log(`[${coin}] funding ${input.scriptType} -> ${addr} value=${input.value}`); + + const fundedOut = await fundScriptFromMining(rpc, nextCoinbaseHeight++, script, input.value); + outpoints.push({ + txid: fundedOut.txid, + vout: fundedOut.vout, + prevTx: hexToBytes(fundedOut.prevTxHex), + }); + } + + const psbt = acid.createPsbt({ outpoints }); + psbt.finalizeAllInputs(); + const spendTx = psbt.extractTransaction(); + const spendTxHex = bytesToHex(spendTx.toBytes()); + const spendTxId = await rpc.sendRawTransaction(spendTxHex); + await rpc.generateBlocks(1); + + const spendTxVerbose = await rpc.getRawTransactionVerbose(spendTxId); + assert.strictEqual(spendTxVerbose.txid, spendTxId); + + return { + coin: signCoin, + acidTestName: acid.name, + networkInfo, + height: await rpc.getBlockCount(), + inputScriptTypes: acid.inputs.map((i) => { + assert.ok(i.scriptType); + return i.scriptType; + }), + outputScriptTypes: acid.outputs + .filter( + (o): o is { scriptType: OutputScriptType; value: bigint } => + "scriptType" in o && !!o.scriptType, + ) + .map((o) => o.scriptType), + spendTxHex, + spendTxId, + spendTxVerbose, + }; +} diff --git a/packages/wasm-utxo/test/integrationLocalRpc/fixtures.ts b/packages/wasm-utxo/test/integrationLocalRpc/fixtures.ts new file mode 100644 index 00000000000..a33201360ab --- /dev/null +++ b/packages/wasm-utxo/test/integrationLocalRpc/fixtures.ts @@ -0,0 +1,42 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { CoinName } from "../../js/coinName.js"; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +export function fixturesDir(coin: CoinName): string { + // Pearl fixtures live under "pearl" for pearl / tpearl / tpearlreg + const dir = coin === "tpearl" || coin === "tpearlreg" ? "pearl" : coin; + return path.join(here, "fixtures", dir); +} + +export async function writeFixture(coin: CoinName, name: string, data: unknown): Promise { + const dir = fixturesDir(coin); + await mkdir(dir, { recursive: true }); + const file = path.join(dir, name); + await writeFile(file, JSON.stringify(data, null, 2) + "\n", "utf8"); + return file; +} + +export async function readFixture(coin: CoinName, name: string): Promise { + const file = path.join(fixturesDir(coin), name); + return JSON.parse(await readFile(file, "utf8")) as T; +} + +export type AcidTestRegtestFixture = { + coin: CoinName; + /** AcidTest name, e.g. "tpearl fullsigned psbt" */ + acidTestName: string; + networkInfo: { subversion: string; version?: number }; + /** Height after mining + funding */ + height: number; + inputScriptTypes: string[]; + outputScriptTypes: string[]; + /** Fully signed extracted transaction hex accepted by the node */ + spendTxHex: string; + spendTxId: string; + /** Verbose getrawtransaction of the spend */ + spendTxVerbose: unknown; +}; diff --git a/packages/wasm-utxo/test/integrationLocalRpc/fixtures/pearl/acidTest.fullsigned.json b/packages/wasm-utxo/test/integrationLocalRpc/fixtures/pearl/acidTest.fullsigned.json new file mode 100644 index 00000000000..171daa4cb6f --- /dev/null +++ b/packages/wasm-utxo/test/integrationLocalRpc/fixtures/pearl/acidTest.fullsigned.json @@ -0,0 +1,133 @@ +{ + "coin": "tpearlreg", + "acidTestName": "tpearlreg fullsigned psbt", + "networkInfo": { + "version": 1000200, + "subversion": "/pearlwire:0.5.0/pearld:1.0.2/", + "protocolversion": 1, + "localservices": "0000000000000c4d", + "localservicesnames": [ + "NETWORK", + "BLOOM", + "WITNESS", + "COMPACT_FILTERS", + "NETWORK_LIMITED", + "P2P_V2" + ], + "localrelay": true, + "timeoffset": 0, + "connections": 0, + "connections_in": 0, + "connections_out": 0, + "networkactive": true, + "networks": [ + { + "name": "ipv4", + "limited": false, + "reachable": true, + "proxy": "", + "proxy_randomize_credentials": false + }, + { + "name": "ipv6", + "limited": false, + "reachable": true, + "proxy": "", + "proxy_randomize_credentials": false + }, + { + "name": "onion", + "limited": false, + "reachable": true, + "proxy": "", + "proxy_randomize_credentials": false + } + ], + "relayfee": 0.00001, + "incrementalfee": 0.00001, + "localaddresses": [], + "warnings": [] + }, + "height": 104, + "inputScriptTypes": ["p2trLegacy", "p2trMusig2KeyPath"], + "outputScriptTypes": ["p2trLegacy", "p2trMusig2"], + "spendTxHex": "02000000000102a700d07d57c3cb283be04745bd43762332640f4c77b5047893b8b46a834abff20000000000feffffff4efd6ef7f72fe7b0a9042c2398c91f6fc29e5f2eb6e120e51f8d9b123962331d0000000000feffffff03840300000000000022512090192f4b37a3c72468fe08217cf6427748be5e2b913cb222b057e0a93f02a29de8030000000000002251208ff285241a6daad1e56cd87ca3e76b105d2c931631941098c07323a83fa39e9c0000000000000000116a0f736574656320617374726f6e6f6d790440e90af67266ac1a5ffff6e3589f9203ef1ca3aa12e4ff47836734c14fde6da71b926c687825a80ea7c46e9a7dfaccfc28b51f18aa50773a535d2145d2fbdfc2f2406a108920a969704fec48b8867f6dab903454dda4521357bc2c63df653c73077770755667c9f56d1052ef69fddc9c107753b6b9208d6aca9674ec1ff055a0a3e44420feebbdfd5ca605ef275996313c9aee57ff8b918e054ae418ef6d2a7ed35c9b73ad20c7f52e8600fe864d92e0670a941c28b728d54209ad962a8ad819b728dd5a2d11ac41c13fdf506d0802c4536c3b61922fd4e4e1e8d4a47bc39a8cd71d81d540d499c5f540697d6a2c4c12d2cfd7c8a1e517b9b7e1d363e1884c916e5c7e574784addb4d014047f338f8f09e2c1729685e4c9ea6a30909a8c26452ffd573c52d0c00f980ae309c693bca54964531983a8bbe56f4489cbf414c495b567a8563952622f6012f2800000000", + "spendTxId": "9b19bf361d99153750b78c03756297daeeae99ae2ed28e7979ebc074780e99dd", + "spendTxVerbose": { + "hex": "02000000000102a700d07d57c3cb283be04745bd43762332640f4c77b5047893b8b46a834abff20000000000feffffff4efd6ef7f72fe7b0a9042c2398c91f6fc29e5f2eb6e120e51f8d9b123962331d0000000000feffffff03840300000000000022512090192f4b37a3c72468fe08217cf6427748be5e2b913cb222b057e0a93f02a29de8030000000000002251208ff285241a6daad1e56cd87ca3e76b105d2c931631941098c07323a83fa39e9c0000000000000000116a0f736574656320617374726f6e6f6d790440e90af67266ac1a5ffff6e3589f9203ef1ca3aa12e4ff47836734c14fde6da71b926c687825a80ea7c46e9a7dfaccfc28b51f18aa50773a535d2145d2fbdfc2f2406a108920a969704fec48b8867f6dab903454dda4521357bc2c63df653c73077770755667c9f56d1052ef69fddc9c107753b6b9208d6aca9674ec1ff055a0a3e44420feebbdfd5ca605ef275996313c9aee57ff8b918e054ae418ef6d2a7ed35c9b73ad20c7f52e8600fe864d92e0670a941c28b728d54209ad962a8ad819b728dd5a2d11ac41c13fdf506d0802c4536c3b61922fd4e4e1e8d4a47bc39a8cd71d81d540d499c5f540697d6a2c4c12d2cfd7c8a1e517b9b7e1d363e1884c916e5c7e574784addb4d014047f338f8f09e2c1729685e4c9ea6a30909a8c26452ffd573c52d0c00f980ae309c693bca54964531983a8bbe56f4489cbf414c495b567a8563952622f6012f2800000000", + "txid": "9b19bf361d99153750b78c03756297daeeae99ae2ed28e7979ebc074780e99dd", + "hash": "59f408778dd24d2b134feb8b6e5d173fa6f6d69b574cad64c831bb6ac44c4603", + "vsize": 288, + "size": 538, + "version": 2, + "locktime": 0, + "vin": [ + { + "txid": "f2bf4a836ab4b8937804b5774c0f6432237643bd4547e03b28cbc3577dd000a7", + "vout": 0, + "scriptSig": { + "asm": "", + "hex": "" + }, + "txinwitness": [ + "e90af67266ac1a5ffff6e3589f9203ef1ca3aa12e4ff47836734c14fde6da71b926c687825a80ea7c46e9a7dfaccfc28b51f18aa50773a535d2145d2fbdfc2f2", + "6a108920a969704fec48b8867f6dab903454dda4521357bc2c63df653c73077770755667c9f56d1052ef69fddc9c107753b6b9208d6aca9674ec1ff055a0a3e4", + "20feebbdfd5ca605ef275996313c9aee57ff8b918e054ae418ef6d2a7ed35c9b73ad20c7f52e8600fe864d92e0670a941c28b728d54209ad962a8ad819b728dd5a2d11ac", + "c13fdf506d0802c4536c3b61922fd4e4e1e8d4a47bc39a8cd71d81d540d499c5f540697d6a2c4c12d2cfd7c8a1e517b9b7e1d363e1884c916e5c7e574784addb4d" + ], + "sequence": 4294967294 + }, + { + "txid": "1d336239129b8d1fe520e1b62e5f9ec26f1fc998232c04a9b0e72ff7f76efd4e", + "vout": 0, + "scriptSig": { + "asm": "", + "hex": "" + }, + "txinwitness": [ + "47f338f8f09e2c1729685e4c9ea6a30909a8c26452ffd573c52d0c00f980ae309c693bca54964531983a8bbe56f4489cbf414c495b567a8563952622f6012f28" + ], + "sequence": 4294967294 + } + ], + "vout": [ + { + "value": 0.000009, + "n": 0, + "scriptPubKey": { + "asm": "1 90192f4b37a3c72468fe08217cf6427748be5e2b913cb222b057e0a93f02a29d", + "hex": "512090192f4b37a3c72468fe08217cf6427748be5e2b913cb222b057e0a93f02a29d", + "reqSigs": 1, + "type": "witness_v1_taproot", + "address": "rprl1pjqvj7jeh50rjg687pqsheajzwaytuh3tjy7tyg4s2ls2j0cz52ws40p3g2", + "addresses": ["rprl1pjqvj7jeh50rjg687pqsheajzwaytuh3tjy7tyg4s2ls2j0cz52ws40p3g2"] + } + }, + { + "value": 0.00001, + "n": 1, + "scriptPubKey": { + "asm": "1 8ff285241a6daad1e56cd87ca3e76b105d2c931631941098c07323a83fa39e9c", + "hex": "51208ff285241a6daad1e56cd87ca3e76b105d2c931631941098c07323a83fa39e9c", + "reqSigs": 1, + "type": "witness_v1_taproot", + "address": "rprl1p3leg2fq6dk4dretvmp728emtzpwjeyckxx2ppxxqwv36s0arn6wqa4f4ly", + "addresses": ["rprl1p3leg2fq6dk4dretvmp728emtzpwjeyckxx2ppxxqwv36s0arn6wqa4f4ly"] + } + }, + { + "value": 0, + "n": 2, + "scriptPubKey": { + "asm": "OP_RETURN 736574656320617374726f6e6f6d79", + "hex": "6a0f736574656320617374726f6e6f6d79", + "type": "nulldata" + } + } + ], + "blockhash": "c46fc0c9e3203cdd11a489a47349b9dd1779bfbcabaabf9f167009f876b90e40", + "confirmations": 1, + "time": 1785321264, + "blocktime": 1785321264 + } +} diff --git a/packages/wasm-utxo/test/integrationLocalRpc/generate/RpcClient.ts b/packages/wasm-utxo/test/integrationLocalRpc/generate/RpcClient.ts new file mode 100644 index 00000000000..8476e9e2561 --- /dev/null +++ b/packages/wasm-utxo/test/integrationLocalRpc/generate/RpcClient.ts @@ -0,0 +1,162 @@ +/** + * Minimal JSON-RPC client for regtest nodes. + * + * Covers Bitcoin Core and btcd-style (pearld) quirks needed by the AcidTest + * regtest loop. Not a full wallet RPC — pearld is walletless. + * + * Uses Authorization header (not user:pass@url) because Node's fetch rejects + * URLs that include credentials. + */ +import type { CoinName } from "../../../js/coinName.js"; + +export class RpcError extends Error { + constructor(public rpcError: { code: number; message: string }) { + super(`RPC error: ${rpcError.message} (code=${rpcError.code})`); + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export type RpcTxVerbose = { + txid: string; + hex: string; + vout: Array<{ + n: number; + value: number; + scriptPubKey: { hex: string; address?: string; addresses?: string[] }; + }>; +}; + +export type RpcBlockVerbose = { + hash: string; + height: number; + /** bitcoind */ tx?: Array; + /** pearld verbosity=2 */ rawtx?: RpcTxVerbose[]; +}; + +function parseRpcUrl(url: string): { endpoint: string; authorization?: string } { + const parsed = new URL(url); + const user = decodeURIComponent(parsed.username); + const pass = decodeURIComponent(parsed.password); + parsed.username = ""; + parsed.password = ""; + const endpoint = parsed.toString().replace(/\/$/, ""); + if (!user && !pass) { + return { endpoint }; + } + const token = Buffer.from(`${user}:${pass}`, "utf8").toString("base64"); + return { endpoint, authorization: `Basic ${token}` }; +} + +export class RpcClient { + private id = 0; + private readonly endpoint: string; + private readonly authorization?: string; + + constructor( + public readonly coin: CoinName, + url: string, + ) { + const parsed = parseRpcUrl(url); + this.endpoint = parsed.endpoint; + this.authorization = parsed.authorization; + } + + async exec(method: string, ...params: unknown[]): Promise { + const headers: Record = { "content-type": "application/json" }; + if (this.authorization) { + headers.authorization = this.authorization; + } + + const response = await fetch(this.endpoint, { + method: "POST", + headers, + body: JSON.stringify({ + jsonrpc: "1.0", + id: `${this.id++}`, + method, + params, + }), + }); + + const text = await response.text(); + let body: { result?: T; error?: { code: number; message: string } }; + try { + body = JSON.parse(text) as typeof body; + } catch { + throw new Error( + `non-JSON RPC response for ${method} (HTTP ${response.status}): ${text.slice(0, 120)}`, + ); + } + + if (body.error) { + throw new RpcError(body.error); + } + if (!response.ok) { + throw new Error(`HTTP ${response.status} for ${method}`); + } + return body.result; + } + + async getNetworkInfo(): Promise<{ subversion: string; version?: number }> { + return this.exec("getnetworkinfo"); + } + + async getBlockCount(): Promise { + return this.exec("getblockcount"); + } + + async getBlockHash(height: number): Promise { + return this.exec("getblockhash", height); + } + + async getBlockVerbose(hash: string, verbosity = 2): Promise { + return this.exec("getblock", hash, verbosity); + } + + async getRawTransaction(txid: string): Promise { + return this.exec("getrawtransaction", txid); + } + + async getRawTransactionVerbose(txid: string): Promise { + // pearld (btcd) requires verbose as int; bitcoind accepts bool + return this.exec("getrawtransaction", txid, 1); + } + + async sendRawTransaction(hex: string): Promise { + return this.exec("sendrawtransaction", hex); + } + + /** + * Mine `n` blocks. + * - bitcoind: generatetoaddress + * - pearld: generate (mines to --miningaddr; address arg ignored) + */ + async generateBlocks(n: number, address?: string): Promise { + if (this.coin === "pearl" || this.coin === "tpearl" || this.coin === "tpearlreg") { + await this.exec("generate", n); + return; + } + if (!address) { + throw new Error(`generatetoaddress requires an address for coin=${this.coin}`); + } + await this.exec("generatetoaddress", n, address); + } + + /** Wait until RPC responds (docker startup). */ + static async forUrlWait(coin: CoinName, url: string, attempts = 120): Promise { + const client = new RpcClient(coin, url); + for (let i = 0; i < attempts; i++) { + try { + await client.getBlockCount(); + return client; + } catch (e) { + console.error(`[${coin}] ${e}, waiting 1s...`); + await sleep(1000); + } + } + throw new Error(`could not connect to ${coin} RPC at ${url}`); + } +} diff --git a/packages/wasm-utxo/test/integrationLocalRpc/generate/main.ts b/packages/wasm-utxo/test/integrationLocalRpc/generate/main.ts new file mode 100644 index 00000000000..0baac21e7a5 --- /dev/null +++ b/packages/wasm-utxo/test/integrationLocalRpc/generate/main.ts @@ -0,0 +1,53 @@ +/** + * Generate integrationLocalRpc fixtures against a live regtest node. + * + * Usage (from packages/wasm-utxo): + * npm run test:integrationLocalRpc:generate + * + * Env: + * WASM_UTXO_PEARLD_BIN=/path/to/pearld — native pearld (preferred on Apple Silicon) + * WASM_UTXO_PEARL_DOCKER_IMAGE=… — docker image override + * WASM_UTXO_TESTS_LOG_DOCKER=1 — stream node stdout/stderr + * WASM_UTXO_REGTEST_COINS=pearl — comma-separated CoinNames (default: pearl) + */ +import { getRegtestNode, getRegtestNodeUrl } from "./regtestNode.js"; +import { RpcClient } from "./RpcClient.js"; +import { runAcidTestAgainstRegtest } from "../acidTestRegtest.js"; +import { writeFixture } from "../fixtures.js"; +import type { CoinName } from "../../../js/coinName.js"; + +function coinsFromEnv(): CoinName[] { + const raw = process.env.WASM_UTXO_REGTEST_COINS ?? "pearl"; + return raw.split(",").map((s) => s.trim()) as CoinName[]; +} + +async function generateForCoin(coin: CoinName): Promise { + const node = getRegtestNode(coin); + try { + const url = getRegtestNodeUrl(coin); + const rpc = await RpcClient.forUrlWait(coin, url); + console.log(`[${coin}] RPC ready, network=`, await rpc.getNetworkInfo()); + + const fixture = await runAcidTestAgainstRegtest(rpc, coin); + const path = await writeFixture(coin, "acidTest.fullsigned.json", fixture); + console.log(`[${coin}] wrote ${path}`); + console.log( + `[${coin}] spendTxId=${fixture.spendTxId} inputs=${fixture.inputScriptTypes.join(",")}`, + ); + } finally { + console.log(`[${coin}] stopping node…`); + await node.stop(); + } +} + +async function main(): Promise { + const coins = coinsFromEnv(); + for (const coin of coins) { + await generateForCoin(coin); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/wasm-utxo/test/integrationLocalRpc/generate/regtestNode.ts b/packages/wasm-utxo/test/integrationLocalRpc/generate/regtestNode.ts new file mode 100644 index 00000000000..438fc586e5a --- /dev/null +++ b/packages/wasm-utxo/test/integrationLocalRpc/generate/regtestNode.ts @@ -0,0 +1,203 @@ +/** + * Regtest node helper for wasm-utxo integrationLocalRpc tests. + * + * Adapted from BitGoJS utxo-lib + * `test/integration_local_rpc/generate/regtestNode.ts`, keyed by CoinName + * instead of utxo-lib Network. + * + * Pearl on Apple Silicon: prefer native pearld via WASM_UTXO_PEARLD_BIN + * (Docker amd64 ZK-PoW mining segfaults under emulation). + */ +import { spawn, type ChildProcess } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { execFile } from "node:child_process"; + +import type { CoinName } from "../../../js/coinName.js"; +import { PEARL_MINING_REGTEST_ADDRESS } from "../pearlConstants.js"; + +const execFileAsync = promisify(execFile); + +export type DockerImageParams = { + image: string; + /** Daemon binary; omit when the image entrypoint is already the daemon. */ + binary?: string; + rpcPort: number; + extraArgsDocker?: string[]; + extraArgsNode?: string[]; + /** btcd-style auth uses -rpcuser/-rpcpass; bitcoind uses -rpcpassword. */ + rpcAuthStyle: "bitcoind" | "btcd"; +}; + +/** Shared credentials for the lifetime of a Node process. */ +export const rpcUser = "wasmutxo"; +export const rpcPassword = randomBytes(16).toString("hex"); + +/** + * Coins that have a dockerized regtest harness. + * This PR wires Pearl only; other coins are stubs for the shared loop. + */ +export function getDockerParams(coin: CoinName): DockerImageParams { + switch (coin) { + case "pearl": + case "tpearl": + case "tpearlreg": + // See indexer-utxo/containers/docker-compose.develop-pearl.yml + // Mining address = keypath Taproot for privkey 0x01 (HRP rprl). + // Override with WASM_UTXO_PEARL_DOCKER_IMAGE (e.g. local prl-pearld:latest). + return { + image: + process.env.WASM_UTXO_PEARL_DOCKER_IMAGE ?? + "319156457634.dkr.ecr.us-west-2.amazonaws.com/pearl-fullnode:1.0.2", + // Force pearld even when the image entrypoint is bash (ECR compose style). + extraArgsDocker: ["--entrypoint=pearld"], + rpcPort: 18334, + rpcAuthStyle: "btcd", + extraArgsNode: [ + "--regtest", + "--txindex", + "--addrindex", + "--notls", + `--miningaddr=${PEARL_MINING_REGTEST_ADDRESS}`, + "--debuglevel=info", + ], + }; + default: + throw new Error( + `no regtest docker image configured for coin=${coin} yet (Pearl only in this PR)`, + ); + } +} + +export interface Node { + coin: CoinName; + rpcPort: number; + stop(): Promise; +} + +function pearlNodeArgs(rpcPort: number, datadir?: string): string[] { + return [ + "--regtest", + `--rpcuser=${rpcUser}`, + `--rpcpass=${rpcPassword}`, + `--rpclisten=127.0.0.1:${rpcPort}`, + "--txindex", + "--addrindex", + "--notls", + `--miningaddr=${PEARL_MINING_REGTEST_ADDRESS}`, + "--debuglevel=info", + ...(datadir ? [`--datadir=${datadir}`] : []), + ]; +} + +function stopProcess(proc: ChildProcess): Promise { + if (proc.killed || proc.exitCode !== null) { + return Promise.resolve(); + } + proc.kill(); + return new Promise((resolve) => { + proc.on("exit", () => resolve()); + setTimeout(resolve, 5_000); + }); +} + +/** Native pearld (WASM_UTXO_PEARLD_BIN) — required for mining on Apple Silicon. */ +function getNativePearlNode(coin: CoinName): Node { + const binary = process.env.WASM_UTXO_PEARLD_BIN; + if (!binary) { + throw new Error("WASM_UTXO_PEARLD_BIN is not set"); + } + const { rpcPort } = getDockerParams(coin); + const datadir = mkdtempSync(path.join(tmpdir(), "wasm-utxo-pearld-")); + const stdio: "ignore" | "inherit" = + process.env.WASM_UTXO_TESTS_LOG_DOCKER === "1" ? "inherit" : "ignore"; + + console.log(`[${coin}] starting native pearld (${binary}) datadir=${datadir}`); + const proc = spawn(binary, pearlNodeArgs(rpcPort, datadir), { stdio }); + + return { + coin, + rpcPort, + async stop(): Promise { + await stopProcess(proc); + try { + rmSync(datadir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } + }, + }; +} + +export function getRegtestNode(coin: CoinName): Node { + if ( + (coin === "pearl" || coin === "tpearl" || coin === "tpearlreg") && + process.env.WASM_UTXO_PEARLD_BIN + ) { + return getNativePearlNode(coin); + } + + const dockerParams = getDockerParams(coin); + const { rpcPort } = dockerParams; + + const authArgs = + dockerParams.rpcAuthStyle === "btcd" + ? [`--rpcuser=${rpcUser}`, `--rpcpass=${rpcPassword}`, `--rpclisten=0.0.0.0:${rpcPort}`] + : [ + `-rpcuser=${rpcUser}`, + `-rpcpassword=${rpcPassword}`, + `-rpcbind=0.0.0.0:${rpcPort}`, + `-rpcallowip=0.0.0.0/0`, + ]; + + const args = [ + "run", + "--rm", + `--publish=${rpcPort}:${rpcPort}`, + ...(dockerParams.extraArgsDocker ?? []), + dockerParams.image, + ...(dockerParams.binary ? [dockerParams.binary] : []), + ...authArgs, + ...(dockerParams.extraArgsNode ?? []), + ]; + + const stdio: "ignore" | "inherit" = + process.env.WASM_UTXO_TESTS_LOG_DOCKER === "1" ? "inherit" : "ignore"; + + console.log(`[${coin}] starting regtest docker…`); + const proc: ChildProcess = spawn("docker", args, { stdio }); + + return { + coin, + rpcPort, + stop(): Promise { + return stopProcess(proc); + }, + }; +} + +export function getRegtestNodeUrl(coin: CoinName): string { + const { rpcPort } = getDockerParams(coin); + return `http://${rpcUser}:${rpcPassword}@localhost:${rpcPort}`; +} + +export async function getRegtestNodeHelp( + coin: CoinName, +): Promise<{ stdout: string; stderr: string }> { + if (process.env.WASM_UTXO_PEARLD_BIN) { + return execFileAsync(process.env.WASM_UTXO_PEARLD_BIN, ["--help"]); + } + const dockerParams = getDockerParams(coin); + const args = [ + "run", + "--rm", + ...(dockerParams.extraArgsDocker ?? []), + dockerParams.image, + ...(dockerParams.binary ? [dockerParams.binary] : []), + "--help", + ]; + return execFileAsync("docker", args); +} diff --git a/packages/wasm-utxo/test/integrationLocalRpc/parse.test.ts b/packages/wasm-utxo/test/integrationLocalRpc/parse.test.ts new file mode 100644 index 00000000000..8f904fce080 --- /dev/null +++ b/packages/wasm-utxo/test/integrationLocalRpc/parse.test.ts @@ -0,0 +1,51 @@ +/** + * Offline checks against committed integrationLocalRpc fixtures. + * Not run on CI (directory ignored by default mocha); local only: + * npm run test:integrationLocalRpc + */ +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import path from "node:path"; + +import { Transaction } from "../../js/index.js"; +import { AcidTest } from "../../js/testutils/AcidTest.js"; +import type { CoinName } from "../../js/coinName.js"; +import { fixturesDir, readFixture, type AcidTestRegtestFixture } from "./fixtures.js"; +import { PEARL_SIGN_COIN } from "./pearlConstants.js"; + +const COINS: CoinName[] = ["pearl"]; + +describe("integrationLocalRpc fixtures (offline)", function () { + for (const coin of COINS) { + describe(coin, function () { + const fixturePath = path.join(fixturesDir(coin), "acidTest.fullsigned.json"); + + it("has committed acidTest.fullsigned.json", function () { + if (!existsSync(fixturePath)) { + this.skip(); + } + }); + + it("spendTxHex parses and matches AcidTest input script types", async function () { + if (!existsSync(fixturePath)) { + this.skip(); + } + const fixture = await readFixture(coin, "acidTest.fullsigned.json"); + assert.ok(fixture.spendTxHex.length > 0); + assert.ok(fixture.spendTxId.length === 64); + + const tx = Transaction.fromBytes(Buffer.from(fixture.spendTxHex, "hex")); + assert.strictEqual(tx.getId(), fixture.spendTxId); + + const signCoin = coin === "pearl" || coin === "tpearl" ? PEARL_SIGN_COIN : coin; + const acid = AcidTest.withConfig(signCoin, "fullsigned", "psbt"); + assert.deepStrictEqual( + fixture.inputScriptTypes, + acid.inputs.map((i) => i.scriptType), + ); + assert.ok(fixture.inputScriptTypes.includes("p2trLegacy")); + assert.ok(fixture.inputScriptTypes.includes("p2trMusig2KeyPath")); + }); + }); + } +}); diff --git a/packages/wasm-utxo/test/integrationLocalRpc/pearlConstants.ts b/packages/wasm-utxo/test/integrationLocalRpc/pearlConstants.ts new file mode 100644 index 00000000000..2a6c2a503a6 --- /dev/null +++ b/packages/wasm-utxo/test/integrationLocalRpc/pearlConstants.ts @@ -0,0 +1,39 @@ +/** + * Fixed mining keypair used with pearld `--miningaddr` (regtest). + * + * Matches indexer-utxo PearlTransactionSigner / docker-compose.develop-pearl.yml + * so coinbase UTXOs are spendable with a known Taproot key-path key. + */ + +/** Raw 32-byte Schnorr private key (NOT WIF). */ +export const PEARL_MINING_PRIVKEY_HEX = + "0000000000000000000000000000000000000000000000000000000000000001"; + +/** X-only pubkey for secp256k1 generator G (privkey 0x01). */ +export const PEARL_MINING_XONLY_PUBKEY = + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + +/** BIP-341 tweaked output key for tr(PEARL_MINING_XONLY_PUBKEY) with empty tree. */ +export const PEARL_MINING_TWEAKED_PUBKEY = + "da4710964f7852695de2da025290e24af6d8c281de5a0b902b7135fd9fd74d21"; + +/** OP_1 */ +export const PEARL_MINING_TAPROOT_SCRIPT = "5120" + PEARL_MINING_TWEAKED_PUBKEY; + +/** + * bech32m(P2TR, HRP=rprl). Must match --miningaddr in regtestNode.ts / + * indexer-utxo docker-compose.develop-pearl.yml. + */ +export const PEARL_MINING_REGTEST_ADDRESS = + "rprl1pmfr3p9j00pfxjh0zmgp99y8zftmd3s5pmedqhyptwy6lm87hf5ssgn706v"; + +/** Descriptor used for off-node key-path spends of mining UTXOs. */ +export const PEARL_MINING_DESCRIPTOR = `tr(${PEARL_MINING_XONLY_PUBKEY})`; + +/** Coin name used for sighash / addresses when talking to wasm-utxo (HRP `rprl`). */ +export const PEARL_SIGN_COIN = "tpearlreg" as const; + +/** Coinbase maturity + buffer before spending mining outputs. */ +export const PEARL_MIN_HEIGHT = 101; + +export const PEARL_FEE_SATOSHIS = 10_000n; diff --git a/packages/wasm-utxo/test/integrationLocalRpc/regtestAddress.ts b/packages/wasm-utxo/test/integrationLocalRpc/regtestAddress.ts new file mode 100644 index 00000000000..ac49c649a90 --- /dev/null +++ b/packages/wasm-utxo/test/integrationLocalRpc/regtestAddress.ts @@ -0,0 +1,15 @@ +/** + * Regtest address helpers. Pearl regtest uses CoinName `tpearlreg` (HRP `rprl`). + */ +import { fromOutputScriptWithCoin, toOutputScriptWithCoin } from "../../js/address.js"; +import type { CoinName } from "../../js/coinName.js"; + +/** Address for a script under a CoinName (use `tpearlreg` for Pearl regtest). */ +export function toRegtestAddress(script: Uint8Array, coin: CoinName): string { + return fromOutputScriptWithCoin(script, coin); +} + +/** Decode a Pearl regtest (`rprl…`) address to scriptPubKey. */ +export function fromPearlRegtestAddress(address: string): Uint8Array { + return toOutputScriptWithCoin(address, "tpearlreg"); +}