diff --git a/packages/wasm-utxo/js/fixedScriptWallet/chains.ts b/packages/wasm-utxo/js/fixedScriptWallet/chains.ts index b9655df..b2851ff 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/chains.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/chains.ts @@ -21,10 +21,13 @@ const chainCodeSet = new Set(chainCodes); const chainToMeta = new Map(); const scriptTypeToChain = new Map(); -// Initialize from WASM (called once at load time) -function assertChainCode(n: number): ChainCode { +/** + * Assert that a number is a valid chain code. + * @throws Error if the number is not a valid chain code + */ +export function assertChainCode(n: number): ChainCode { if (!chainCodeSet.has(n)) { - throw new Error(`Invalid chain code from WASM: ${n}`); + throw new Error(`Invalid chain code: ${n}`); } return n as ChainCode; } diff --git a/packages/wasm-utxo/js/fixedScriptWallet/index.ts b/packages/wasm-utxo/js/fixedScriptWallet/index.ts index 7afcb17..e1891cc 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/index.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/index.ts @@ -5,8 +5,14 @@ export { RootWalletKeys, type WalletKeysArg, type IWalletKeys } from "./RootWall export { ReplayProtection, type ReplayProtectionArg } from "./ReplayProtection.js"; export { outputScript, address } from "./address.js"; export { Dimensions } from "./Dimensions.js"; -export { type OutputScriptType, type InputScriptType, type ScriptType } from "./scriptType.js"; -export { ChainCode, chainCodes, type Scope } from "./chains.js"; +export { + outputScriptTypes, + inputScriptTypes, + type OutputScriptType, + type InputScriptType, + type ScriptType, +} from "./scriptType.js"; +export { ChainCode, chainCodes, assertChainCode, type Scope } from "./chains.js"; // Bitcoin-like PSBT (for all non-Zcash networks) export { @@ -61,3 +67,23 @@ import type { ScriptType } from "./scriptType.js"; export function supportsScriptType(coin: CoinName, scriptType: ScriptType): boolean { return FixedScriptWalletNamespace.supports_script_type(coin, scriptType); } + +/** + * Create an OP_RETURN output script with optional data + * + * @param data - Optional data bytes to include in the OP_RETURN script + * @returns The OP_RETURN script as a Uint8Array + * + * @example + * ```typescript + * // Empty OP_RETURN + * const script = createOpReturnScript(); + * + * // OP_RETURN with data + * const data = new Uint8Array([1, 2, 3, 4]); + * const script = createOpReturnScript(data); + * ``` + */ +export function createOpReturnScript(data?: Uint8Array): Uint8Array { + return FixedScriptWalletNamespace.create_op_return_script(data); +} diff --git a/packages/wasm-utxo/js/fixedScriptWallet/scriptType.ts b/packages/wasm-utxo/js/fixedScriptWallet/scriptType.ts index 52a1481..616e880 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/scriptType.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/scriptType.ts @@ -1,30 +1,43 @@ /** - * Fixed-script wallet output script types (2-of-3 multisig) + * All output script types for fixed-script wallets (2-of-3 multisig) * - * This type represents the abstract script type, independent of chain (external/internal). + * This represents the abstract script type, independent of chain (external/internal). * Use this for checking network support or when you need the script type without derivation info. */ -export type OutputScriptType = - | "p2sh" - | "p2shP2wsh" - | "p2wsh" - | "p2tr" // alias for p2trLegacy - | "p2trLegacy" - | "p2trMusig2"; +export const outputScriptTypes = [ + "p2sh", + "p2shP2wsh", + "p2wsh", + "p2trLegacy", + "p2trMusig2", +] as const; /** - * Input script types for fixed-script wallets + * Output script type for fixed-script wallets + * + * Note: "p2tr" is an alias for "p2trLegacy" for backward compatibility. + */ +export type OutputScriptType = (typeof outputScriptTypes)[number] | "p2tr"; + +/** + * All input script types for fixed-script wallets * * These are more specific than output types and include single-sig and taproot variants. */ -export type InputScriptType = - | "p2shP2pk" - | "p2sh" - | "p2shP2wsh" - | "p2wsh" - | "p2trLegacy" - | "p2trMusig2ScriptPath" - | "p2trMusig2KeyPath"; +export const inputScriptTypes = [ + "p2shP2pk", + "p2sh", + "p2shP2wsh", + "p2wsh", + "p2trLegacy", + "p2trMusig2ScriptPath", + "p2trMusig2KeyPath", +] as const; + +/** + * Input script type for fixed-script wallets + */ +export type InputScriptType = (typeof inputScriptTypes)[number]; /** * Union of all script types that can be checked for network support diff --git a/packages/wasm-utxo/js/testutils/AcidTest.ts b/packages/wasm-utxo/js/testutils/AcidTest.ts new file mode 100644 index 0000000..19fb06e --- /dev/null +++ b/packages/wasm-utxo/js/testutils/AcidTest.ts @@ -0,0 +1,459 @@ +import { BitGoPsbt, type SignerKey } from "../fixedScriptWallet/BitGoPsbt.js"; +import { ZcashBitGoPsbt } from "../fixedScriptWallet/ZcashBitGoPsbt.js"; +import { RootWalletKeys } from "../fixedScriptWallet/RootWalletKeys.js"; +import { BIP32 } from "../bip32.js"; +import { ECPair } from "../ecpair.js"; +import { + assertChainCode, + ChainCode, + createOpReturnScript, + inputScriptTypes, + outputScript, + outputScriptTypes, + supportsScriptType, + type InputScriptType, + type OutputScriptType, + type ScriptId, +} from "../fixedScriptWallet/index.js"; +import type { CoinName } from "../coinName.js"; +import { coinNames, isMainnet } from "../coinName.js"; +import { getDefaultWalletKeys, getWalletKeysForSeed, getKeyTriple } from "./keys.js"; +import type { Triple } from "../triple.js"; + +export const signStages = ["unsigned", "halfsigned", "fullsigned"] as const; +export type SignStage = (typeof signStages)[number]; + +export const txFormats = ["psbt", "psbt-lite"] as const; +export type TxFormat = (typeof txFormats)[number]; + +/** + * Utility type to make union variants mutually exclusive. + * For each variant T in the union, adds `?: never` for all keys from other variants. + */ +type Exclusive = T extends unknown + ? T & Partial, never>> + : never; + +/** Base input fields */ +type InputBase = { + value: bigint; + /** Wallet keys to use. Defaults to root wallet keys */ + walletKeys?: RootWalletKeys; +}; + +/** Input variant types */ +type InputVariant = { scriptType: InputScriptType; index?: number } | { scriptId: ScriptId }; + +/** + * Input configuration for AcidTest PSBT + * + * Either specify `scriptType` (chain derived from type, index defaults to position) + * or specify `scriptId` (explicit chain + index). + */ +export type Input = InputBase & Exclusive; + +/** Base output fields */ +type OutputBase = { + value: bigint; + /** Wallet keys to use. Defaults to root wallet keys. null = external output (no bip32 derivation) */ + walletKeys?: RootWalletKeys | null; +}; + +/** Output variant types */ +type OutputVariant = + | { scriptType: OutputScriptType; index?: number } + | { scriptId: ScriptId } + | { opReturn: string } + | { address: string } + | { script: Uint8Array }; + +/** + * Output configuration for AcidTest PSBT + * + * Specify one of: + * - `scriptType` (chain derived from type, index defaults to position) + * - `scriptId` (explicit chain + index) + * - `opReturn` for OP_RETURN data output + * - `address` for address-based output + * - `script` for raw script output + */ +export type Output = OutputBase & Exclusive; + +type SuiteConfig = { + /** + * By default, we exclude p2trMusig2ScriptPath from the inputs since + * it uses user + backup keys (not typical 2-of-3 with user + bitgo). + * Set to true to include this input type. + */ + includeP2trMusig2ScriptPath?: boolean; +}; + +// Re-export for convenience +export { inputScriptTypes, outputScriptTypes }; + +/** + * Creates a valid PSBT with as many features as possible (kitchen sink). + * + * - Inputs: + * - All wallet script types supported by the network + * - A p2shP2pk input (for replay protection) + * - Outputs: + * - All wallet script types supported by the network + * - A p2sh output with derivation info of a different wallet + * - A p2sh output with no derivation info (external output) + * - An OP_RETURN output + * + * Signature stages: + * - unsigned: No signatures + * - halfsigned: One signature per input (user key) + * - fullsigned: Two signatures per input (user + bitgo) + * + * Transaction formats: + * - psbt: Full PSBT with non_witness_utxo + * - psbt-lite: Only witness_utxo (no non_witness_utxo) + */ +export class AcidTest { + public readonly network: CoinName; + public readonly signStage: SignStage; + public readonly txFormat: TxFormat; + public readonly rootWalletKeys: RootWalletKeys; + public readonly otherWalletKeys: RootWalletKeys; + public readonly inputs: Input[]; + public readonly outputs: Output[]; + // Store private keys for signing + private readonly userXprv: BIP32; + private readonly backupXprv: BIP32; + private readonly bitgoXprv: BIP32; + + constructor( + network: CoinName, + signStage: SignStage, + txFormat: TxFormat, + rootWalletKeys: RootWalletKeys, + otherWalletKeys: RootWalletKeys, + inputs: Input[], + outputs: Output[], + xprvTriple: Triple, + ) { + this.network = network; + this.signStage = signStage; + this.txFormat = txFormat; + this.rootWalletKeys = rootWalletKeys; + this.otherWalletKeys = otherWalletKeys; + this.inputs = inputs; + this.outputs = outputs; + this.userXprv = xprvTriple[0]; + this.backupXprv = xprvTriple[1]; + this.bitgoXprv = xprvTriple[2]; + } + + /** + * Create an AcidTest with specific configuration + */ + static withConfig( + network: CoinName, + signStage: SignStage, + txFormat: TxFormat, + suiteConfig: SuiteConfig = {}, + ): AcidTest { + const rootWalletKeys = getDefaultWalletKeys(); + const otherWalletKeys = getWalletKeysForSeed("too many secrets"); + + // Filter inputs based on network support + const inputs: Input[] = inputScriptTypes + .filter((scriptType) => { + // p2shP2pk is always supported (single-sig replay protection) + if (scriptType === "p2shP2pk") return true; + + // Map input script types to output script types for support check + if (scriptType === "p2trMusig2KeyPath" || scriptType === "p2trMusig2ScriptPath") { + return supportsScriptType(network, "p2trMusig2"); + } + return supportsScriptType(network, scriptType); + }) + .filter( + (scriptType) => + (suiteConfig.includeP2trMusig2ScriptPath ?? false) || + scriptType !== "p2trMusig2ScriptPath", + ) + .map((scriptType, index) => ({ + scriptType, + value: BigInt(10000 + index * 10000), // Deterministic amounts + })); + + // Filter outputs based on network support + const outputs: Output[] = outputScriptTypes + .filter((scriptType) => supportsScriptType(network, scriptType)) + .map((scriptType, index) => ({ + scriptType, + value: BigInt(900 + index * 100), // Deterministic amounts + })); + + // Test other wallet output (with derivation info) + outputs.push({ scriptType: "p2sh", value: BigInt(800), walletKeys: otherWalletKeys }); + + // Test non-wallet output (no derivation info) + outputs.push({ scriptType: "p2sh", value: BigInt(700), walletKeys: null }); + + // Test OP_RETURN output + outputs.push({ opReturn: "setec astronomy", value: BigInt(0) }); + + // Get private keys for signing + const xprvTriple = getKeyTriple("default"); + + return new AcidTest( + network, + signStage, + txFormat, + rootWalletKeys, + otherWalletKeys, + inputs, + outputs, + xprvTriple, + ); + } + + /** + * Get a human-readable name for this test configuration + */ + get name(): string { + return `${this.network} ${this.signStage} ${this.txFormat}`; + } + + /** + * Get the BIP32 user key for replay protection (p2shP2pk) + */ + getReplayProtectionKey(): BIP32 { + return this.rootWalletKeys.userKey(); + } + + /** + * Create the actual PSBT with all inputs and outputs + */ + createPsbt(): BitGoPsbt { + // Use ZcashBitGoPsbt for Zcash networks + const isZcash = this.network === "zec" || this.network === "tzec"; + const psbt = isZcash + ? ZcashBitGoPsbt.createEmptyWithConsensusBranchId(this.network, this.rootWalletKeys, { + version: 2, + lockTime: 0, + consensusBranchId: 0xc2d6d0b4, // NU5 + }) + : BitGoPsbt.createEmpty(this.network, this.rootWalletKeys, { + version: 2, + lockTime: 0, + }); + + // Add inputs with deterministic outpoints + this.inputs.forEach((input, index) => { + // Resolve scriptId: either from explicit scriptId or from scriptType + index + const scriptId: ScriptId = input.scriptId ?? { + chain: ChainCode.value("p2sh", "external"), + index: input.index ?? index, + }; + const walletKeys = input.walletKeys ?? this.rootWalletKeys; + + // Get scriptType: either explicit or derive from scriptId chain + const scriptType = input.scriptType ?? ChainCode.scriptType(assertChainCode(scriptId.chain)); + + if (scriptType === "p2shP2pk") { + // Add replay protection input + const replayKey = this.getReplayProtectionKey(); + // Convert BIP32 to ECPair using public key + const ecpair = ECPair.fromPublicKey(replayKey.publicKey); + psbt.addReplayProtectionInput( + { + txid: "0".repeat(64), + vout: index, + value: input.value, + }, + ecpair, + ); + } else { + // Determine signing path based on input type + let signPath: { signer: SignerKey; cosigner: SignerKey }; + + if (scriptType === "p2trMusig2ScriptPath") { + // Script path uses user + backup + signPath = { signer: "user", cosigner: "backup" }; + } else { + // Default: user + bitgo + signPath = { signer: "user", cosigner: "bitgo" }; + } + + psbt.addWalletInput( + { + txid: "0".repeat(64), + vout: index, + value: input.value, + }, + walletKeys, + { + scriptId, + signPath, + }, + ); + } + }); + + // Add outputs + this.outputs.forEach((output, index) => { + if (output.opReturn !== undefined) { + // OP_RETURN output + const data = new TextEncoder().encode(output.opReturn); + const script = createOpReturnScript(data); + psbt.addOutput(script, output.value); + } else if (output.address !== undefined) { + // Address-based output + psbt.addOutput(output.address, output.value); + } else if (output.script !== undefined) { + // Raw script output + psbt.addOutput(output.script, output.value); + } else { + // Wallet output: resolve scriptId from scriptType or explicit scriptId + const scriptId: ScriptId = output.scriptId ?? { + chain: output.scriptType ? ChainCode.value(output.scriptType, "external") : 0, + index: output.index ?? index, + }; + + if (output.walletKeys === null) { + // External output (no wallet keys, no bip32 derivation) + // Use high index for external outputs if not specified + const externalScriptId: ScriptId = output.scriptId ?? { + chain: scriptId.chain, + index: output.index ?? 1000 + index, + }; + const script = outputScript( + this.rootWalletKeys, + externalScriptId.chain, + externalScriptId.index, + this.network, + ); + psbt.addOutput(script, output.value); + } else { + // Wallet output (with or without different wallet keys) + const walletKeys = output.walletKeys ?? this.rootWalletKeys; + psbt.addWalletOutput(walletKeys, { + chain: scriptId.chain, + index: scriptId.index, + value: output.value, + }); + } + } + }); + + // Apply signing based on stage + if (this.signStage !== "unsigned") { + this.signPsbt(psbt); + } + + return psbt; + } + + /** + * Sign the PSBT according to the sign stage + */ + private signPsbt(psbt: BitGoPsbt): void { + // Use private keys stored in constructor + const userKey = this.userXprv; + const backupKey = this.backupXprv; + const bitgoKey = this.bitgoXprv; + + // Generate MuSig2 nonces for user if needed + const hasMusig2Inputs = this.inputs.some( + (input) => + input.scriptType === "p2trMusig2KeyPath" || input.scriptType === "p2trMusig2ScriptPath", + ); + + if (hasMusig2Inputs) { + const isZcash = this.network === "zec" || this.network === "tzec"; + if (isZcash) { + throw new Error("Zcash does not support MuSig2/Taproot inputs"); + } + + // Generate nonces with user key + psbt.generateMusig2Nonces(userKey); + + if (this.signStage === "fullsigned") { + // Create a second PSBT with cosigner nonces for combination + // For p2trMusig2ScriptPath use backup, for p2trMusig2KeyPath use bitgo + // Since we might have both types, we need to generate nonces separately + const bytes = psbt.serialize(); + + const hasKeyPath = this.inputs.some((input) => input.scriptType === "p2trMusig2KeyPath"); + const hasScriptPath = this.inputs.some( + (input) => input.scriptType === "p2trMusig2ScriptPath", + ); + + if (hasKeyPath && !hasScriptPath) { + // Only key path inputs - generate bitgo nonces for all + const psbt2 = BitGoPsbt.fromBytes(bytes, this.network); + psbt2.generateMusig2Nonces(bitgoKey); + psbt.combineMusig2Nonces(psbt2); + } else if (hasScriptPath && !hasKeyPath) { + // Only script path inputs - generate backup nonces for all + const psbt2 = BitGoPsbt.fromBytes(bytes, this.network); + psbt2.generateMusig2Nonces(backupKey); + psbt.combineMusig2Nonces(psbt2); + } else { + const psbt2 = BitGoPsbt.fromBytes(bytes, this.network); + psbt2.generateMusig2Nonces(bitgoKey); + psbt.combineMusig2Nonces(psbt2); + } + } + } + + // Sign all wallet inputs with user key (bulk - more efficient) + psbt.sign(userKey); + + // Sign replay protection inputs with raw private key + const hasReplayProtection = this.inputs.some((input) => input.scriptType === "p2shP2pk"); + if (hasReplayProtection) { + if (!userKey.privateKey) { + throw new Error("User key must have private key for signing replay protection inputs"); + } + psbt.sign(userKey.privateKey); + } + + // For fullsigned, sign with cosigner + if (this.signStage === "fullsigned") { + const hasScriptPath = this.inputs.some( + (input) => input.scriptType === "p2trMusig2ScriptPath", + ); + + if (hasScriptPath) { + // Mixed case: script path uses backup, others use bitgo + // Need per-input signing (slow) to handle different cosigners + this.inputs.forEach((input, index) => { + if (input.scriptType === "p2shP2pk") { + // Replay protection is single-sig, already fully signed + return; + } + if (input.scriptType === "p2trMusig2ScriptPath") { + psbt.signInput(index, backupKey); + } else { + psbt.signInput(index, bitgoKey); + } + }); + } else { + // No script path - can use bulk signing with bitgo (fast) + psbt.sign(bitgoKey); + } + } + } + + /** + * Generate test suite for all networks, sign stages, and tx formats + */ + static forAllNetworksSignStagesTxFormats(suiteConfig: SuiteConfig = {}): AcidTest[] { + return (coinNames as readonly CoinName[]) + .filter((network) => isMainnet(network) && network !== "bsv") // Exclude bitcoinsv + .flatMap((network) => + signStages.flatMap((signStage) => + txFormats.map((txFormat) => + AcidTest.withConfig(network, signStage, txFormat, suiteConfig), + ), + ), + ); + } +} diff --git a/packages/wasm-utxo/js/testutils/index.ts b/packages/wasm-utxo/js/testutils/index.ts new file mode 100644 index 0000000..ca57a07 --- /dev/null +++ b/packages/wasm-utxo/js/testutils/index.ts @@ -0,0 +1,2 @@ +export * from "./keys.js"; +export * from "./AcidTest.js"; diff --git a/packages/wasm-utxo/js/testutils/keys.ts b/packages/wasm-utxo/js/testutils/keys.ts new file mode 100644 index 0000000..cb1881a --- /dev/null +++ b/packages/wasm-utxo/js/testutils/keys.ts @@ -0,0 +1,123 @@ +import * as crypto from "crypto"; +import { BIP32 } from "../bip32.js"; +import { RootWalletKeys } from "../fixedScriptWallet/RootWalletKeys.js"; +import type { Triple } from "../triple.js"; + +/** + * Generate a deterministic BIP32 key from a seed string. + * Uses SHA256 hash of the seed to create the key, matching utxo-lib's implementation. + * + * @param seed - Seed string for deterministic key generation + * @returns BIP32 key derived from the seed + * + * @example + * ```typescript + * const key = getKey("user"); + * const xpub = key.neutered().toBase58(); + * ``` + */ +export function getKey(seed: string): BIP32 { + return BIP32.fromSeed(crypto.createHash("sha256").update(seed).digest()); +} + +/** + * Generate a triple of BIP32 keys for a 2-of-3 multisig wallet. + * Keys are generated deterministically from the seed string with suffixes .0, .1, .2 + * for user, backup, and bitgo keys respectively. + * + * @param seed - Base seed string for key generation + * @returns Triple of BIP32 keys [user, backup, bitgo] + * + * @example + * ```typescript + * const keys = getKeyTriple("default"); + * const [user, backup, bitgo] = keys; + * ``` + */ +export function getKeyTriple(seed: string): Triple { + return [getKey(seed + ".0"), getKey(seed + ".1"), getKey(seed + ".2")]; +} + +/** + * Create RootWalletKeys from a seed string. + * Uses standard derivation prefixes ["m/0/0", "m/0/0", "m/0/0"]. + * + * @param seed - Seed string for key generation + * @returns RootWalletKeys instance + * + * @example + * ```typescript + * const walletKeys = getWalletKeysForSeed("default"); + * ``` + */ +export function getWalletKeysForSeed(seed: string): RootWalletKeys { + const triple = getKeyTriple(seed); + return RootWalletKeys.from({ + triple, + derivationPrefixes: ["m/0/0", "m/0/0", "m/0/0"], + }); +} + +/** + * Get the default wallet keys for testing. + * Equivalent to getWalletKeysForSeed("default"). + * + * @returns RootWalletKeys instance with default seed + * + * @example + * ```typescript + * const walletKeys = getDefaultWalletKeys(); + * ``` + */ +export function getDefaultWalletKeys(): RootWalletKeys { + return getWalletKeysForSeed("default"); +} + +/** + * Get the key name (user, backup, or bitgo) for a given key in a triple. + * + * @param triple - Triple of BIP32 keys + * @param key - Key to find in the triple + * @returns "user", "backup", "bitgo", or undefined if not found + */ +export function getKeyName( + triple: Triple, + key: BIP32, +): "user" | "backup" | "bitgo" | undefined { + const index = triple.findIndex((k) => { + const kb58 = k.toBase58(); + const keyb58 = key.toBase58(); + return kb58 === keyb58; + }); + + if (index === 0) return "user"; + if (index === 1) return "backup"; + if (index === 2) return "bitgo"; + return undefined; +} + +/** + * Get the default cosigner for a given signer key. + * - If signer is user, returns bitgo + * - If signer is backup, returns bitgo + * - If signer is bitgo, returns user + * + * @param keyset - Triple of keys [user, backup, bitgo] + * @param signer - The signing key + * @returns The default cosigner key + */ +export function getDefaultCosigner(keyset: Triple, signer: T): T { + const [user, backup, bitgo] = keyset; + + if (signer === user) { + return bitgo; + } + if (signer === backup) { + return bitgo; + } + if (signer === bitgo) { + return user; + } + + throw new Error("signer not in keyset"); +} diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs index 7bf196b..38531ad 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -159,6 +159,27 @@ impl FixedScriptWalletNamespace { Ok(network.output_script_support().supports_script_type(st)) } + /// Create an OP_RETURN output script with optional data + /// + /// # Arguments + /// * `data` - Optional data bytes to include in the OP_RETURN script + /// + /// # Returns + /// The OP_RETURN script as bytes + #[wasm_bindgen] + pub fn create_op_return_script(data: Option>) -> Result, WasmUtxoError> { + use miniscript::bitcoin::opcodes::all::OP_RETURN; + use miniscript::bitcoin::script::{Builder, PushBytesBuf}; + + let mut builder = Builder::new().push_opcode(OP_RETURN); + if let Some(data) = data { + let push_bytes = PushBytesBuf::try_from(data) + .map_err(|e| WasmUtxoError::new(&format!("Data too large for OP_RETURN: {}", e)))?; + builder = builder.push_slice(push_bytes); + } + Ok(builder.into_script().to_bytes()) + } + /// Get all chain code metadata for building TypeScript lookup tables /// /// Returns an array of [chainCode, scriptType, scope] tuples where: diff --git a/packages/wasm-utxo/test/acid-test/acidTest.test.ts b/packages/wasm-utxo/test/acid-test/acidTest.test.ts new file mode 100644 index 0000000..6be5983 --- /dev/null +++ b/packages/wasm-utxo/test/acid-test/acidTest.test.ts @@ -0,0 +1,358 @@ +import { describe, it } from "mocha"; +import * as assert from "assert"; +import { AcidTest, signStages, txFormats } from "../../js/testutils/AcidTest.js"; +import { BitGoPsbt } from "../../js/fixedScriptWallet/BitGoPsbt.js"; +import { coinNames, isMainnet } from "../../js/coinName.js"; + +describe("AcidTest", function () { + describe("Basic Creation", function () { + it("should create AcidTest with default config for btc", function () { + const test = AcidTest.withConfig("btc", "unsigned", "psbt"); + + assert.strictEqual(test.network, "btc"); + assert.strictEqual(test.signStage, "unsigned"); + assert.strictEqual(test.txFormat, "psbt"); + assert.ok(test.rootWalletKeys); + assert.ok(test.otherWalletKeys); + assert.ok(test.inputs.length > 0); + assert.ok(test.outputs.length > 0); + }); + + it("should have correct name format", function () { + const test = AcidTest.withConfig("btc", "halfsigned", "psbt-lite"); + assert.strictEqual(test.name, "btc halfsigned psbt-lite"); + }); + + it("should filter inputs by network support", function () { + // Bitcoin supports all script types (6 by default, excludes p2trMusig2ScriptPath) + const btcTest = AcidTest.withConfig("btc", "unsigned", "psbt"); + assert.ok( + btcTest.inputs.length >= 6, + "Bitcoin should have all input types (except p2trMusig2ScriptPath)", + ); + + // Dogecoin only supports p2sh (legacy) + const dogeTest = AcidTest.withConfig("doge", "unsigned", "psbt"); + const dogeInputTypes = dogeTest.inputs.map((i) => i.scriptType); + assert.ok(dogeInputTypes.includes("p2sh"), "Doge should have p2sh"); + assert.ok(dogeInputTypes.includes("p2shP2pk"), "Doge should have p2shP2pk"); + assert.ok(!dogeInputTypes.includes("p2wsh"), "Doge should not have p2wsh"); + assert.ok(!dogeInputTypes.includes("p2trLegacy"), "Doge should not have taproot"); + }); + + it("should filter outputs by network support", function () { + // Litecoin supports segwit but not taproot + const ltcTest = AcidTest.withConfig("ltc", "unsigned", "psbt"); + const ltcOutputTypes = ltcTest.outputs + .filter((o) => "scriptType" in o) + .map((o) => ("scriptType" in o ? o.scriptType : null)); + + assert.ok(ltcOutputTypes.includes("p2sh"), "Litecoin should have p2sh"); + assert.ok(ltcOutputTypes.includes("p2wsh"), "Litecoin should have p2wsh"); + assert.ok(!ltcOutputTypes.includes("p2trLegacy"), "Litecoin should not have taproot"); + }); + + it("should always include OP_RETURN, external, and other wallet outputs", function () { + const test = AcidTest.withConfig("btc", "unsigned", "psbt"); + + // Check for OP_RETURN + const hasOpReturn = test.outputs.some((o) => "opReturn" in o); + assert.ok(hasOpReturn, "Should have OP_RETURN output"); + + // Check for external output (walletKeys: null) + const hasExternal = test.outputs.some((o) => o.walletKeys === null); + assert.ok(hasExternal, "Should have external output"); + + // Check for other wallet output + const hasOtherWallet = test.outputs.some( + (o) => o.walletKeys && o.walletKeys !== test.rootWalletKeys, + ); + assert.ok(hasOtherWallet, "Should have other wallet output"); + }); + + it("should use deterministic input amounts", function () { + const test = AcidTest.withConfig("btc", "unsigned", "psbt"); + + // Check that amounts are deterministic and increasing + for (let i = 0; i < test.inputs.length; i++) { + const expectedAmount = BigInt(10000 + i * 10000); + assert.strictEqual( + test.inputs[i].value, + expectedAmount, + `Input ${i} should have value ${expectedAmount}`, + ); + } + }); + }); + + describe("PSBT Creation", function () { + it("should create unsigned PSBT", function () { + const test = AcidTest.withConfig("btc", "unsigned", "psbt"); + const psbt = test.createPsbt(); + + assert.ok(psbt); + + // Verify no signatures present + const rpKey = test.getReplayProtectionKey(); + const replayProtection = { publicKeys: [rpKey.publicKey] }; + const parsed = psbt.parseTransactionWithWalletKeys(test.rootWalletKeys, replayProtection); + const user = test.rootWalletKeys.userKey(); + const backup = test.rootWalletKeys.backupKey(); + const bitgo = test.rootWalletKeys.bitgoKey(); + + for (let i = 0; i < parsed.inputs.length; i++) { + assert.strictEqual( + psbt.verifySignature(i, user), + false, + `Input ${i} should not have user signature`, + ); + assert.strictEqual( + psbt.verifySignature(i, backup), + false, + `Input ${i} should not have backup signature`, + ); + assert.strictEqual( + psbt.verifySignature(i, bitgo), + false, + `Input ${i} should not have bitgo signature`, + ); + } + }); + + it("should create halfsigned PSBT", function () { + const test = AcidTest.withConfig("btc", "halfsigned", "psbt"); + const psbt = test.createPsbt(); + + assert.ok(psbt); + + // Verify one signature per input (user only) + const rpKey = test.getReplayProtectionKey(); + const replayProtection = { publicKeys: [rpKey.publicKey] }; + const parsed = psbt.parseTransactionWithWalletKeys(test.rootWalletKeys, replayProtection); + const user = test.rootWalletKeys.userKey(); + const backup = test.rootWalletKeys.backupKey(); + const bitgo = test.rootWalletKeys.bitgoKey(); + + for (let i = 0; i < parsed.inputs.length; i++) { + // Check if this is a replay protection input + const isReplayProtection = parsed.inputs[i].scriptType === "p2shP2pk"; + + if (isReplayProtection) { + // Replay protection inputs are signed with user private key + // but verification needs the public key (ECPair), not BIP32 with derivation + assert.strictEqual( + psbt.verifySignature(i, user.publicKey), + true, + `Input ${i} (replay protection) should have user signature`, + ); + } else { + // Regular inputs should have user signature only + assert.strictEqual( + psbt.verifySignature(i, user), + true, + `Input ${i} should have user signature`, + ); + assert.strictEqual( + psbt.verifySignature(i, backup), + false, + `Input ${i} should not have backup signature`, + ); + assert.strictEqual( + psbt.verifySignature(i, bitgo), + false, + `Input ${i} should not have bitgo signature`, + ); + } + } + }); + + it("should create fullsigned PSBT", function () { + const test = AcidTest.withConfig("btc", "fullsigned", "psbt"); + const psbt = test.createPsbt(); + + assert.ok(psbt); + + // Verify two signatures per input (user + bitgo or user + backup) + const rpKey = test.getReplayProtectionKey(); + const replayProtection = { publicKeys: [rpKey.publicKey] }; + const parsed = psbt.parseTransactionWithWalletKeys(test.rootWalletKeys, replayProtection); + const user = test.rootWalletKeys.userKey(); + const backup = test.rootWalletKeys.backupKey(); + const bitgo = test.rootWalletKeys.bitgoKey(); + + for (let i = 0; i < parsed.inputs.length; i++) { + // Use the original input spec to determine expected signing behavior + const inputSpec = test.inputs[i]; + const isReplayProtection = inputSpec.scriptType === "p2shP2pk"; + const isMusig2ScriptPath = inputSpec.scriptType === "p2trMusig2ScriptPath"; + + if (isReplayProtection) { + // Replay protection inputs are signed with user private key + // but verification needs the public key (ECPair), not BIP32 with derivation + assert.strictEqual( + psbt.verifySignature(i, user.publicKey), + true, + `Input ${i} (replay protection) should have user signature`, + ); + } else { + // Regular inputs should have user signature + assert.strictEqual( + psbt.verifySignature(i, user), + true, + `Input ${i} should have user signature`, + ); + + // p2trMusig2ScriptPath uses user + backup, others use user + bitgo + if (isMusig2ScriptPath) { + assert.strictEqual( + psbt.verifySignature(i, backup), + true, + `Input ${i} (p2trMusig2ScriptPath) should have backup signature`, + ); + assert.strictEqual( + psbt.verifySignature(i, bitgo), + false, + `Input ${i} (p2trMusig2ScriptPath) should not have bitgo signature`, + ); + } else { + assert.strictEqual( + psbt.verifySignature(i, bitgo), + true, + `Input ${i} should have bitgo signature`, + ); + assert.strictEqual( + psbt.verifySignature(i, backup), + false, + `Input ${i} should not have backup signature`, + ); + } + } + } + }); + + it("should serialize and deserialize PSBT", function () { + const test = AcidTest.withConfig("btc", "unsigned", "psbt"); + const psbt = test.createPsbt(); + + const bytes = psbt.serialize(); + assert.ok(bytes); + assert.ok(bytes.length > 0); + + // Deserialize and check it works + const psbt2 = BitGoPsbt.fromBytes(bytes, test.network); + assert.ok(psbt2); + }); + }); + + describe("Suite Generation", function () { + it("should generate suite for all networks/stages/formats", function () { + const suite = AcidTest.forAllNetworksSignStagesTxFormats({ + includeP2trMusig2ScriptPath: true, + }); + + assert.ok(suite.length > 0); + + // Should have entries for each mainnet (except bsv) × 3 sign stages × 2 formats + const mainnetCoins = coinNames.filter((coin) => isMainnet(coin) && coin !== "bsv"); + const expectedCount = mainnetCoins.length * signStages.length * txFormats.length; + + assert.strictEqual( + suite.length, + expectedCount, + `Should have ${expectedCount} test cases (${mainnetCoins.length} networks × ${signStages.length} stages × ${txFormats.length} formats)`, + ); + }); + + it("should not include bitcoinsv in suite", function () { + const suite = AcidTest.forAllNetworksSignStagesTxFormats({ + includeP2trMusig2ScriptPath: true, + }); + const hasBsv = suite.some((test) => test.network === "bsv"); + assert.ok(!hasBsv, "Suite should not include bitcoinsv"); + }); + + it("should include all sign stages", function () { + const suite = AcidTest.forAllNetworksSignStagesTxFormats({ + includeP2trMusig2ScriptPath: true, + }); + + signStages.forEach((stage) => { + const hasStage = suite.some((test) => test.signStage === stage); + assert.ok(hasStage, `Suite should include ${stage}`); + }); + }); + + it("should include all tx formats", function () { + const suite = AcidTest.forAllNetworksSignStagesTxFormats({ + includeP2trMusig2ScriptPath: true, + }); + + txFormats.forEach((format) => { + const hasFormat = suite.some((test) => test.txFormat === format); + assert.ok(hasFormat, `Suite should include ${format}`); + }); + }); + }); + + describe("Config Options", function () { + it("should exclude p2trMusig2ScriptPath by default", function () { + const test = AcidTest.withConfig("btc", "unsigned", "psbt"); + + const hasScriptPath = test.inputs.some((i) => i.scriptType === "p2trMusig2ScriptPath"); + assert.ok(!hasScriptPath, "Should not include p2trMusig2ScriptPath by default"); + }); + + it("should include p2trMusig2ScriptPath when configured", function () { + const test = AcidTest.withConfig("btc", "unsigned", "psbt", { + includeP2trMusig2ScriptPath: true, + }); + + const hasScriptPath = test.inputs.some((i) => i.scriptType === "p2trMusig2ScriptPath"); + assert.ok(hasScriptPath, "Should include p2trMusig2ScriptPath when configured"); + }); + }); + + describe("Replay Protection", function () { + it("should include p2shP2pk input", function () { + const test = AcidTest.withConfig("btc", "unsigned", "psbt"); + + const hasReplayProtection = test.inputs.some((i) => i.scriptType === "p2shP2pk"); + assert.ok(hasReplayProtection, "Should include p2shP2pk replay protection input"); + }); + + it("should provide replay protection key", function () { + const test = AcidTest.withConfig("btc", "unsigned", "psbt"); + const key = test.getReplayProtectionKey(); + + assert.ok(key); + assert.ok(key.publicKey); + assert.ok(key.publicKey.length === 33 || key.publicKey.length === 65); + }); + }); + + describe("Network-Specific Tests", function () { + it("should create valid PSBT for Bitcoin", function () { + const test = AcidTest.withConfig("btc", "unsigned", "psbt"); + const psbt = test.createPsbt(); + assert.ok(psbt); + }); + + it("should create valid PSBT for Litecoin", function () { + const test = AcidTest.withConfig("ltc", "unsigned", "psbt"); + const psbt = test.createPsbt(); + assert.ok(psbt); + }); + + it("should create valid PSBT for Dogecoin", function () { + const test = AcidTest.withConfig("doge", "unsigned", "psbt"); + const psbt = test.createPsbt(); + assert.ok(psbt); + }); + + it("should create valid PSBT for Zcash", function () { + const test = AcidTest.withConfig("zec", "unsigned", "psbt"); + const psbt = test.createPsbt(); + assert.ok(psbt); + }); + }); +});