UNPKG

caravan-x

Version:

A terminal-based utility for managing Caravan multisig wallets in regtest mode. This tool simplifies development and testing with Caravan by providing an easy-to-use interface

540 lines (448 loc) 15.6 kB
/** * @name Multisig CPFP (Child-Pays-For-Parent) Test * @description Tests CPFP functionality with multisig wallets. Creates a parent transaction * with low fee, then a child transaction with high fee to bump the parent's priority. * @version 1.0.0 * @author Mrigesh Thakur */ const config = { // Wallet configuration walletName: "cpfp_multisig_test", requiredSigners: 2, totalSigners: 3, addressType: "P2WSH", // Parent transaction (low fee, will be "stuck") parentAmount: 1.0, parentFeeRate: 1, // sat/vB - very low // Child transaction (high fee, pays for parent) childAmount: 0.5, // Spends from parent output childFeeRate: 50, // sat/vB - high enough to pull parent // Mining initialBlocks: 110, confirmationBlocks: 1, }; // Wallet storage let signerWallets = []; let watcherWallet = null; let fundingWallet = null; let multisigAddresses = []; let extendedPublicKeys = []; // Transaction storage let parentTx = null; let childTx = null; /** * Create signer wallet and extract xpub */ async function createSignerWallet(index) { const walletName = `${config.walletName}_signer_${index}`; console.log(`Creating signer wallet ${index}: ${walletName}`); await bitcoinService.createWallet(walletName, { disablePrivateKeys: false, blank: false, descriptorWallet: true, }); await new Promise((resolve) => setTimeout(resolve, 1000)); const descriptors = await rpcClient.callRpc( "listdescriptors", [true], walletName, ); const externalDesc = descriptors.descriptors.find((d) => !d.internal); const match = externalDesc.desc.match( /\[([a-f0-9]+)\/([^\]]+)\]([a-zA-Z0-9]+)/, ); if (!match) { throw new Error(`Failed to parse descriptor for ${walletName}`); } const fingerprint = match[1]; const xpub = match[3]; signerWallets.push({ name: walletName, fingerprint, xpub }); extendedPublicKeys.push({ name: `Signer ${index}`, xpub: xpub, bip32Path: `m/48'/1'/0'/2'`, xfp: fingerprint, method: "text", }); console.log(` ✓ Fingerprint: ${fingerprint}`); return walletName; } /** * Create watch-only multisig wallet */ async function createWatcherWallet() { const walletName = `${config.walletName}_watcher`; console.log(`\nCreating watcher wallet: ${walletName}`); await bitcoinService.createWallet(walletName, { disablePrivateKeys: true, blank: true, descriptorWallet: true, }); const receiveXpubs = extendedPublicKeys.map( (k) => `[${k.xfp}/48'/1'/0'/2']${k.xpub}/0/*`, ); const changeXpubs = extendedPublicKeys.map( (k) => `[${k.xfp}/48'/1'/0'/2']${k.xpub}/1/*`, ); const receiveDesc = `wsh(sortedmulti(${config.requiredSigners},${receiveXpubs.join(",")}))`; const changeDesc = `wsh(sortedmulti(${config.requiredSigners},${changeXpubs.join(",")}))`; const receiveInfo = await rpcClient.callRpc("getdescriptorinfo", [ receiveDesc, ]); const changeInfo = await rpcClient.callRpc("getdescriptorinfo", [changeDesc]); await bitcoinService.rpc.importDescriptors(walletName, [ { desc: receiveInfo.descriptor, internal: false, range: [0, 100], timestamp: "now", watchonly: true, active: true, }, { desc: changeInfo.descriptor, internal: true, range: [0, 100], timestamp: "now", watchonly: true, active: true, }, ]); await new Promise((resolve) => setTimeout(resolve, 2000)); multisigAddresses = await rpcClient.callRpc("deriveaddresses", [ receiveInfo.descriptor, [0, 9], ]); watcherWallet = walletName; console.log(` ✓ Watcher created with ${multisigAddresses.length} addresses`); return walletName; } /** * Create helper wallets */ async function createHelperWallets() { console.log("\nCreating helper wallets..."); fundingWallet = `${config.walletName}_funder`; await bitcoinService.createWallet(fundingWallet, { disablePrivateKeys: false, descriptorWallet: true, }); console.log(` ✓ Funding wallet: ${fundingWallet}`); } /** * Fund wallets */ async function fundWallets() { console.log("\nFunding wallets..."); const fundingAddress = await bitcoinService.getNewAddress(fundingWallet); await rpcClient.callRpc("generatetoaddress", [ config.initialBlocks, fundingAddress, ]); console.log(` ✓ Mined ${config.initialBlocks} blocks`); // Fund multisig wallet with substantial amount const multisigAddress = multisigAddresses[0]; await bitcoinService.sendToAddress(fundingWallet, multisigAddress, 10.0); await rpcClient.callRpc("generatetoaddress", [1, fundingAddress]); console.log(` ✓ Multisig funded with 10 BTC`); // Fund signer wallets for (const signer of signerWallets) { const signerAddress = await bitcoinService.getNewAddress(signer.name); await bitcoinService.sendToAddress(fundingWallet, signerAddress, 1.0); } await rpcClient.callRpc("generatetoaddress", [1, fundingAddress]); console.log(` ✓ Signer wallets funded`); } /** * Create parent transaction with LOW fee */ async function createParentTransaction() { console.log("\n" + "=".repeat(50)); console.log("CREATING PARENT TRANSACTION (LOW FEE)"); console.log("=".repeat(50)); // Parent sends to the SECOND multisig address (child will spend from there) const parentDestination = multisigAddresses[1]; console.log(` Destination: ${parentDestination}`); console.log(` Amount: ${config.parentAmount} BTC`); console.log( ` Fee rate: ${config.parentFeeRate} sat/vB (LOW - will be stuck)`, ); const outputs = [{ [parentDestination]: config.parentAmount }]; const psbtResult = await transactionService.createPSBT( watcherWallet, outputs, { feeRate: config.parentFeeRate, }, ); // Sign with required signers let signedPsbt = psbtResult; for (let i = 0; i < config.requiredSigners; i++) { const signer = signerWallets[i]; console.log(` Signing with ${signer.name}...`); signedPsbt = await transactionService.processPSBT(signer.name, signedPsbt); } const finalized = await transactionService.finalizePSBT(signedPsbt); if (!finalized.complete) { throw new Error("Failed to finalize parent transaction"); } const txid = await transactionService.broadcastTransaction(finalized.hex); parentTx = { txid, hex: finalized.hex, feeRate: config.parentFeeRate, destination: parentDestination, amount: config.parentAmount, }; console.log(`\n ✓ Parent TX broadcast: ${txid}`); console.log( ` This transaction has a very low fee and would normally be stuck!`, ); return parentTx; } /** * Wait for parent to be in mempool */ async function waitForParentInMempool() { console.log("\nWaiting for parent transaction in mempool..."); await new Promise((resolve) => setTimeout(resolve, 2000)); try { const mempoolEntry = await rpcClient.callRpc("getmempoolentry", [ parentTx.txid, ]); console.log(` ✓ Parent in mempool`); console.log(` Size: ${mempoolEntry.vsize} vB`); console.log( ` Fee: ${(mempoolEntry.fees.base * 100000000).toFixed(0)} sats`, ); console.log(` Descendant count: ${mempoolEntry.descendantcount}`); return mempoolEntry; } catch (error) { console.log(` ✗ Parent not in mempool: ${error.message}`); throw error; } } /** * Create child transaction with HIGH fee (CPFP) */ async function createChildTransaction() { console.log("\n" + "=".repeat(50)); console.log("CREATING CHILD TRANSACTION (HIGH FEE - CPFP)"); console.log("=".repeat(50)); // Wait for watcher to see the parent console.log(" Waiting for watcher to detect parent output..."); await new Promise((resolve) => setTimeout(resolve, 3000)); // Rescan the wallet to pick up the parent transaction try { await rpcClient.callRpc("rescanblockchain", [0], watcherWallet); } catch (e) { // Rescan might not be needed in regtest } // Child sends to third multisig address or back to funding const childDestination = multisigAddresses[2]; console.log(` Spending from parent output: ${parentTx.destination}`); console.log(` Destination: ${childDestination}`); console.log(` Amount: ${config.childAmount} BTC`); console.log( ` Fee rate: ${config.childFeeRate} sat/vB (HIGH - pulls parent)`, ); const outputs = [{ [childDestination]: config.childAmount }]; // Create PSBT - it should use the parent's output as input const psbtResult = await transactionService.createPSBT( watcherWallet, outputs, { feeRate: config.childFeeRate, }, ); // Sign let signedPsbt = psbtResult; for (let i = 0; i < config.requiredSigners; i++) { const signer = signerWallets[i]; console.log(` Signing with ${signer.name}...`); signedPsbt = await transactionService.processPSBT(signer.name, signedPsbt); } const finalized = await transactionService.finalizePSBT(signedPsbt); if (!finalized.complete) { throw new Error("Failed to finalize child transaction"); } const txid = await transactionService.broadcastTransaction(finalized.hex); childTx = { txid, hex: finalized.hex, feeRate: config.childFeeRate, destination: childDestination, amount: config.childAmount, parentTxid: parentTx.txid, }; console.log(`\n ✓ Child TX broadcast: ${txid}`); console.log(` High fee child "pulls" the low fee parent into blocks!`); return childTx; } /** * Verify CPFP relationship in mempool */ async function verifyCPFPInMempool() { console.log("\nVerifying CPFP relationship in mempool..."); await new Promise((resolve) => setTimeout(resolve, 2000)); try { // Check parent const parentEntry = await rpcClient.callRpc("getmempoolentry", [ parentTx.txid, ]); console.log(`\n Parent TX:`); console.log(` Descendant count: ${parentEntry.descendantcount}`); console.log(` Descendant size: ${parentEntry.descendantsize} vB`); console.log( ` Descendant fees: ${(parentEntry.fees.descendant * 100000000).toFixed(0)} sats`, ); // Check child const childEntry = await rpcClient.callRpc("getmempoolentry", [ childTx.txid, ]); console.log(`\n Child TX:`); console.log(` Ancestor count: ${childEntry.ancestorcount}`); console.log(` Ancestor size: ${childEntry.ancestorsize} vB`); console.log( ` Ancestor fees: ${(childEntry.fees.ancestor * 100000000).toFixed(0)} sats`, ); // Calculate effective fee rate (CPFP rate) const totalFees = parentEntry.fees.descendant * 100000000; const totalSize = parentEntry.descendantsize; const effectiveFeeRate = totalFees / totalSize; console.log( `\n CPFP Effective Fee Rate: ${effectiveFeeRate.toFixed(2)} sat/vB`, ); console.log(` (Combined parent + child fees / combined size)`); if (parentEntry.descendantcount > 1) { console.log(`\n ✓ CPFP relationship established!`); return true; } return false; } catch (error) { console.log(` Error checking mempool: ${error.message}`); return false; } } /** * Mine and confirm both transactions */ async function confirmTransactions() { console.log("\nMining block to confirm both transactions..."); const minerAddress = await bitcoinService.getNewAddress(fundingWallet); await rpcClient.callRpc("generatetoaddress", [ config.confirmationBlocks, minerAddress, ]); // Check both are confirmed let parentConfirmed = false; let childConfirmed = false; try { const parentRaw = await rpcClient.callRpc("getrawtransaction", [ parentTx.txid, true, ]); parentConfirmed = parentRaw.confirmations > 0; console.log(` Parent confirmations: ${parentRaw.confirmations}`); } catch (e) { console.log(` Could not check parent: ${e.message}`); } try { const childRaw = await rpcClient.callRpc("getrawtransaction", [ childTx.txid, true, ]); childConfirmed = childRaw.confirmations > 0; console.log(` Child confirmations: ${childRaw.confirmations}`); } catch (e) { console.log(` Could not check child: ${e.message}`); } if (parentConfirmed && childConfirmed) { console.log(`\n ✓ Both transactions confirmed in the same block!`); console.log( ` The high-fee child pulled the low-fee parent into the block.`, ); return true; } return false; } /** * Generate report */ function generateReport() { console.log("\n" + "=".repeat(60)); console.log("MULTISIG CPFP TEST REPORT"); console.log("=".repeat(60)); console.log("\nWallet Configuration:"); console.log( ` Multisig: ${config.requiredSigners}-of-${config.totalSigners}`, ); console.log(` Address Type: ${config.addressType}`); console.log(` Watcher: ${watcherWallet}`); console.log("\nTransaction Chain:"); console.log(` Parent TX: ${parentTx?.txid || "N/A"}`); console.log(` Fee Rate: ${config.parentFeeRate} sat/vB (LOW)`); console.log(` Amount: ${config.parentAmount} BTC`); console.log(` Child TX: ${childTx?.txid || "N/A"}`); console.log(` Fee Rate: ${config.childFeeRate} sat/vB (HIGH)`); console.log(` Amount: ${config.childAmount} BTC`); console.log("\nCPFP Explanation:"); console.log( ` The parent transaction had a very low fee (${config.parentFeeRate} sat/vB)`, ); console.log(` and would normally wait a long time for confirmation.`); console.log( ` The child transaction spent the parent's output with a high fee`, ); console.log( ` (${config.childFeeRate} sat/vB), making the combined package attractive to miners.`, ); console.log(` Both transactions were confirmed together!`); } /** * Main execution */ async function runMultisigCPFPTest() { console.log("╔══════════════════════════════════════════════════════════╗"); console.log("║ MULTISIG CPFP (CHILD-PAYS-FOR-PARENT) TEST ║"); console.log("║ Tests fee bumping by spending unconfirmed outputs ║"); console.log("╚══════════════════════════════════════════════════════════╝\n"); try { // Setup wallets for (let i = 1; i <= config.totalSigners; i++) { await createSignerWallet(i); } await createWatcherWallet(); await createHelperWallets(); await fundWallets(); // CPFP Test await createParentTransaction(); await waitForParentInMempool(); console.log("\nWaiting before creating child transaction..."); await new Promise((resolve) => setTimeout(resolve, 3000)); await createChildTransaction(); await verifyCPFPInMempool(); const confirmed = await confirmTransactions(); // Report generateReport(); return { success: confirmed, parentTxid: parentTx.txid, childTxid: childTx.txid, parentFeeRate: config.parentFeeRate, childFeeRate: config.childFeeRate, }; } catch (error) { console.error(`\n✗ Error in CPFP test: ${error.message}`); throw error; } } // Execute runMultisigCPFPTest() .then((result) => { console.log("\n✓ Multisig CPFP Test completed!"); console.log(JSON.stringify(result, null, 2)); }) .catch((error) => { console.error("\n✗ Test failed:", error.message); });