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

708 lines (595 loc) 20.3 kB
/** * @name Caravan Health Privacy Test * @description Creates multisig wallets with different privacy levels to test Caravan's health package. * Generates wallets with good (no UTXO mixing, no address reuse), moderate (some UTXO mixing), * and bad (UTXO mixing + address reuse) privacy patterns. * @version 1.0.0 * @author Mrigesh Thakur */ // Configuration for privacy test scenarios const config = { // Base name for wallets baseName: "health_test", // Number of transactions per wallet transactionsPerWallet: 15, // Multisig configuration requiredSigners: 2, totalSigners: 3, // Address type: P2WSH, P2SH-P2WSH, or P2SH addressType: "P2WSH", // Initial funding amount per signer wallet initialFunding: 50, // Amount ranges for transactions (in BTC) amounts: { small: 0.001, medium: 0.01, large: 0.1, }, // Blocks to mine between operations confirmationBlocks: 1, }; // Storage for created wallets and addresses const createdWallets = { good: { signers: [], watcher: null, addresses: [], config: null }, moderate: { signers: [], watcher: null, addresses: [], config: null }, bad: { signers: [], watcher: null, addresses: [], config: null }, }; // Track all transactions for reporting const transactionHistory = { good: [], moderate: [], bad: [], }; /** * Create a single signer wallet and extract its xpub */ async function createSignerWallet(name, index) { const walletName = `${name}_signer_${index}`; console.log(`Creating signer wallet: ${walletName}`); await bitcoinService.createWallet(walletName, { disablePrivateKeys: false, blank: false, descriptorWallet: true, }); // Wait for wallet to initialize await new Promise((resolve) => setTimeout(resolve, 1000)); // Get wallet descriptors to extract xpub and fingerprint const descriptors = await rpcClient.callRpc( "listdescriptors", [true], walletName, ); // Find external descriptor (not internal/change) const externalDesc = descriptors.descriptors.find((d) => !d.internal); if (!externalDesc) { throw new Error(`Could not find external descriptor for ${walletName}`); } // Extract fingerprint and xpub from descriptor // Format: wpkh([fingerprint/path]xpub.../0/*) const descStr = externalDesc.desc; const match = descStr.match(/\[([a-f0-9]+)\/([^\]]+)\]([a-zA-Z0-9]+)/); if (!match) { throw new Error(`Could not parse descriptor: ${descStr}`); } const fingerprint = match[1]; const derivationPath = match[2]; const xpub = match[3]; console.log(` Fingerprint: ${fingerprint}`); console.log(` XPub: ${xpub.substring(0, 20)}...`); return { walletName, fingerprint, derivationPath, xpub, // Full extended public key info for Caravan config extendedPublicKey: { name: `Signer ${index}`, xpub: xpub, bip32Path: `m/48'/1'/0'/2'`, // Standard BIP48 path for P2WSH multisig xfp: fingerprint, method: "text", }, }; } /** * Create watch-only wallet with imported descriptors */ async function createWatcherWallet(name, extendedPublicKeys) { const watcherName = `${name}_watcher`; console.log(`Creating watcher wallet: ${watcherName}`); await bitcoinService.createWallet(watcherName, { disablePrivateKeys: true, blank: true, descriptorWallet: true, }); // Build Caravan-compatible descriptors using sortedmulti const receiveXpubs = extendedPublicKeys.map( (key) => `[${key.xfp}/48'/1'/0'/2']${key.xpub}/0/*`, ); const changeXpubs = extendedPublicKeys.map( (key) => `[${key.xfp}/48'/1'/0'/2']${key.xpub}/1/*`, ); let receiveDescriptor, changeDescriptor; switch (config.addressType) { case "P2WSH": receiveDescriptor = `wsh(sortedmulti(${config.requiredSigners},${receiveXpubs.join(",")}))`; changeDescriptor = `wsh(sortedmulti(${config.requiredSigners},${changeXpubs.join(",")}))`; break; case "P2SH-P2WSH": receiveDescriptor = `sh(wsh(sortedmulti(${config.requiredSigners},${receiveXpubs.join(",")})))`; changeDescriptor = `sh(wsh(sortedmulti(${config.requiredSigners},${changeXpubs.join(",")})))`; break; case "P2SH": receiveDescriptor = `sh(sortedmulti(${config.requiredSigners},${receiveXpubs.join(",")}))`; changeDescriptor = `sh(sortedmulti(${config.requiredSigners},${changeXpubs.join(",")}))`; break; default: throw new Error(`Unsupported address type: ${config.addressType}`); } // Get checksums for descriptors const receiveInfo = await rpcClient.callRpc("getdescriptorinfo", [ receiveDescriptor, ]); const changeInfo = await rpcClient.callRpc("getdescriptorinfo", [ changeDescriptor, ]); const receiveWithChecksum = receiveInfo.descriptor; const changeWithChecksum = changeInfo.descriptor; console.log( ` Receive descriptor checksum: ${receiveWithChecksum.split("#")[1]}`, ); console.log( ` Change descriptor checksum: ${changeWithChecksum.split("#")[1]}`, ); // Import descriptors const descriptors = [ { desc: receiveWithChecksum, internal: false, range: [0, 1000], timestamp: "now", watchonly: true, active: true, }, { desc: changeWithChecksum, internal: true, range: [0, 1000], timestamp: "now", watchonly: true, active: true, }, ]; const importResult = await bitcoinService.rpc.importDescriptors( watcherName, descriptors, ); const success = Array.isArray(importResult) ? importResult.every((r) => r.success) : false; if (!success) { console.error("Warning: Some descriptors may not have imported correctly"); } // Wait for descriptors to become active await new Promise((resolve) => setTimeout(resolve, 2000)); return { watcherName, receiveDescriptor: receiveWithChecksum, changeDescriptor: changeWithChecksum, }; } /** * Derive addresses from the watcher wallet */ async function deriveAddresses(watcherName, receiveDescriptor, count) { console.log(`Deriving ${count} addresses from watcher wallet...`); const addresses = await rpcClient.callRpc("deriveaddresses", [ receiveDescriptor, [0, count - 1], ]); console.log(` Derived ${addresses.length} addresses`); console.log(` First address: ${addresses[0]}`); console.log(` Last address: ${addresses[addresses.length - 1]}`); return addresses; } /** * Fund signer wallets by mining */ async function fundSignerWallets(signerWallets) { console.log("Funding signer wallets..."); for (const signer of signerWallets) { const address = await bitcoinService.getNewAddress(signer.walletName); await rpcClient.callRpc("generatetoaddress", [10, address]); } // Mine additional blocks to mature coinbase const maturityAddress = await bitcoinService.getNewAddress( signerWallets[0].walletName, ); await rpcClient.callRpc("generatetoaddress", [101, maturityAddress]); console.log(" Signer wallets funded and coinbase matured"); } /** * Create Caravan-compatible wallet config */ function createCaravanConfig(name, extendedPublicKeys, watcherName) { return { name: name, addressType: config.addressType, network: "regtest", quorum: { requiredSigners: config.requiredSigners, totalSigners: config.totalSigners, }, extendedPublicKeys: extendedPublicKeys, startingAddressIndex: 0, uuid: `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`, client: { type: "private", url: "http://127.0.0.1:18443", username: "user", walletName: watcherName, }, }; } /** * Create a complete multisig wallet setup */ async function createMultisigWallet(privacyLevel) { const name = `${config.baseName}_${privacyLevel}`; console.log(`\n${"=".repeat(60)}`); console.log(`Creating ${privacyLevel.toUpperCase()} privacy wallet: ${name}`); console.log(`${"=".repeat(60)}`); // Create signer wallets const signers = []; for (let i = 1; i <= config.totalSigners; i++) { const signer = await createSignerWallet(name, i); signers.push(signer); } // Extract extended public keys const extendedPublicKeys = signers.map((s) => s.extendedPublicKey); // Create watcher wallet const watcher = await createWatcherWallet(name, extendedPublicKeys); // Derive addresses const addressCount = privacyLevel === "bad" ? 5 : config.transactionsPerWallet + 5; const addresses = await deriveAddresses( watcher.watcherName, watcher.receiveDescriptor, addressCount, ); // Fund signer wallets await fundSignerWallets(signers); // Create Caravan config const caravanConfig = createCaravanConfig( name, extendedPublicKeys, watcher.watcherName, ); // Store wallet info createdWallets[privacyLevel] = { signers, watcher, addresses, config: caravanConfig, }; console.log( `✓ ${privacyLevel.toUpperCase()} privacy wallet created successfully`, ); return createdWallets[privacyLevel]; } /** * Create transactions with GOOD privacy patterns * - No address reuse * - No UTXO mixing (simple spends with single inputs) * - Varied amounts */ async function createGoodPrivacyTransactions(walletData) { console.log("\nCreating GOOD privacy transactions..."); console.log(" Pattern: Single input, unique addresses, varied amounts"); const { signers, addresses } = walletData; let addressIndex = 0; for (let i = 0; i < config.transactionsPerWallet; i++) { // Use different signer for each transaction const signerIndex = i % signers.length; const signer = signers[signerIndex]; // Always use a fresh address (no reuse) const toAddress = addresses[addressIndex++]; // Vary the amounts to improve spread factor const amountVariants = [ config.amounts.small * (1 + Math.random()), config.amounts.medium * (1 + Math.random() * 0.5), config.amounts.large * (0.5 + Math.random() * 0.5), ]; const amount = amountVariants[i % 3]; try { const txid = await bitcoinService.sendToAddress( signer.walletName, toAddress, parseFloat(amount.toFixed(8)), ); transactionHistory.good.push({ index: i, txid, from: signer.walletName, to: toAddress, amount, type: "simple_spend", }); console.log( ` TX ${i + 1}: ${amount.toFixed(8)} BTC -> ${toAddress.substring(0, 20)}...`, ); // Mine a block occasionally for confirmations if (i > 0 && i % 5 === 0) { await rpcClient.callRpc("generatetoaddress", [ 1, await bitcoinService.getNewAddress(signer.walletName), ]); } } catch (error) { console.log(` Warning: TX ${i + 1} failed: ${error.message}`); } await new Promise((resolve) => setTimeout(resolve, 300)); } // Mine final confirmation await rpcClient.callRpc("generatetoaddress", [ config.confirmationBlocks, await bitcoinService.getNewAddress(signers[0].walletName), ]); console.log( `✓ Created ${transactionHistory.good.length} good privacy transactions`, ); } /** * Create transactions with MODERATE privacy patterns * - No address reuse * - Some UTXO mixing (multiple inputs sometimes) * - Consolidation transactions */ async function createModeratePrivacyTransactions(walletData) { console.log("\nCreating MODERATE privacy transactions..."); console.log(" Pattern: Some consolidation, no address reuse"); const { signers, addresses } = walletData; let addressIndex = 0; for (let i = 0; i < config.transactionsPerWallet; i++) { const signerIndex = i % signers.length; const signer = signers[signerIndex]; // Always use fresh address const toAddress = addresses[addressIndex++]; // Mix of amounts let amount; let txType; if (i % 4 === 0 && i > 0) { // Consolidation-style transaction (larger amounts) amount = config.amounts.large * (1 + Math.random()); txType = "consolidation"; } else if (i % 3 === 0) { // Fragmentation-style (smaller amounts) amount = config.amounts.small * (0.5 + Math.random()); txType = "fragmentation"; } else { // Normal spend amount = config.amounts.medium * (0.8 + Math.random() * 0.4); txType = "simple_spend"; } try { const txid = await bitcoinService.sendToAddress( signer.walletName, toAddress, parseFloat(amount.toFixed(8)), ); transactionHistory.moderate.push({ index: i, txid, from: signer.walletName, to: toAddress, amount, type: txType, }); console.log( ` TX ${i + 1} (${txType}): ${amount.toFixed(8)} BTC -> ${toAddress.substring(0, 20)}...`, ); if (i > 0 && i % 4 === 0) { await rpcClient.callRpc("generatetoaddress", [ 1, await bitcoinService.getNewAddress(signer.walletName), ]); } } catch (error) { console.log(` Warning: TX ${i + 1} failed: ${error.message}`); } await new Promise((resolve) => setTimeout(resolve, 300)); } await rpcClient.callRpc("generatetoaddress", [ config.confirmationBlocks, await bitcoinService.getNewAddress(signers[0].walletName), ]); console.log( `✓ Created ${transactionHistory.moderate.length} moderate privacy transactions`, ); } /** * Create transactions with BAD privacy patterns * - Heavy address reuse * - UTXO mixing * - Predictable amounts (round numbers) */ async function createBadPrivacyTransactions(walletData) { console.log("\nCreating BAD privacy transactions..."); console.log(" Pattern: Address reuse, UTXO mixing, round amounts"); const { signers, addresses } = walletData; // Use only a few addresses repeatedly (bad privacy!) const reusedAddresses = addresses.slice(0, 3); for (let i = 0; i < config.transactionsPerWallet; i++) { const signerIndex = i % signers.length; const signer = signers[signerIndex]; // Reuse addresses heavily const toAddress = reusedAddresses[i % reusedAddresses.length]; // Use round amounts (bad for privacy) const roundAmounts = [0.01, 0.05, 0.1, 0.001, 0.005]; const amount = roundAmounts[i % roundAmounts.length]; try { const txid = await bitcoinService.sendToAddress( signer.walletName, toAddress, amount, ); transactionHistory.bad.push({ index: i, txid, from: signer.walletName, to: toAddress, amount, type: "reuse", addressReused: true, }); console.log( ` TX ${i + 1} (REUSED): ${amount} BTC -> ${toAddress.substring(0, 20)}...`, ); // Create additional "mixing" transaction back to same address if (i % 2 === 0 && i > 0) { try { const mixTxid = await bitcoinService.sendToAddress( signer.walletName, toAddress, roundAmounts[(i + 1) % roundAmounts.length], ); transactionHistory.bad.push({ index: `${i}-mix`, txid: mixTxid, from: signer.walletName, to: toAddress, amount: roundAmounts[(i + 1) % roundAmounts.length], type: "mixing", addressReused: true, }); console.log(` TX ${i + 1}-MIX: Additional to same address (BAD!)`); } catch (mixError) { // Ignore mix errors } } if (i > 0 && i % 5 === 0) { await rpcClient.callRpc("generatetoaddress", [ 1, await bitcoinService.getNewAddress(signer.walletName), ]); } } catch (error) { console.log(` Warning: TX ${i + 1} failed: ${error.message}`); } await new Promise((resolve) => setTimeout(resolve, 300)); } await rpcClient.callRpc("generatetoaddress", [ config.confirmationBlocks, await bitcoinService.getNewAddress(signers[0].walletName), ]); console.log( `✓ Created ${transactionHistory.bad.length} bad privacy transactions`, ); } /** * Save Caravan configs to files */ async function saveCaravanConfigs() { console.log("\nSaving Caravan wallet configurations..."); const fs = require("fs-extra"); const path = require("path"); const configDir = path.join( process.env.HOME || "~", ".caravan-regtest", "caravan-wallets", ); await fs.ensureDir(configDir); for (const [level, data] of Object.entries(createdWallets)) { if (data.config) { const filename = `${config.baseName}_${level}_config.json`; const filepath = path.join(configDir, filename); await fs.writeJson(filepath, data.config, { spaces: 2 }); console.log(` Saved: ${filepath}`); } } } /** * Generate summary report */ function generateReport() { console.log("\n" + "=".repeat(60)); console.log("HEALTH PRIVACY TEST SUMMARY"); console.log("=".repeat(60)); for (const [level, txs] of Object.entries(transactionHistory)) { const wallet = createdWallets[level]; console.log(`\n${level.toUpperCase()} Privacy Wallet:`); console.log(` Watcher: ${wallet.watcher?.watcherName || "N/A"}`); console.log( ` Signers: ${wallet.signers?.map((s) => s.walletName).join(", ") || "N/A"}`, ); console.log(` Addresses derived: ${wallet.addresses?.length || 0}`); console.log(` Transactions created: ${txs.length}`); if (level === "bad") { const reusedCount = txs.filter((t) => t.addressReused).length; console.log(` Address reuse count: ${reusedCount}`); } // Count transaction types const typeCounts = {}; txs.forEach((tx) => { typeCounts[tx.type] = (typeCounts[tx.type] || 0) + 1; }); console.log(` Transaction types: ${JSON.stringify(typeCounts)}`); } console.log("\n" + "=".repeat(60)); console.log("EXPECTED HEALTH SCORES:"); console.log("=".repeat(60)); console.log(" GOOD Privacy: Score should be ~0.7-0.9 (healthy)"); console.log(" MODERATE Privacy: Score should be ~0.4-0.6 (some concerns)"); console.log(" BAD Privacy: Score should be ~0.1-0.3 (poor)"); console.log( "\nImport the configs into Caravan to analyze with Health Package!", ); } /** * Main execution function */ async function runHealthPrivacyTest() { console.log("╔══════════════════════════════════════════════════════════╗"); console.log("║ CARAVAN HEALTH PRIVACY TEST SCENARIO ║"); console.log("║ Creates wallets with different privacy characteristics ║"); console.log("╚══════════════════════════════════════════════════════════╝\n"); try { // Create all three wallet types await createMultisigWallet("good"); await createMultisigWallet("moderate"); await createMultisigWallet("bad"); // Create transactions for each await createGoodPrivacyTransactions(createdWallets.good); await createModeratePrivacyTransactions(createdWallets.moderate); await createBadPrivacyTransactions(createdWallets.bad); // Save configs await saveCaravanConfigs(); // Generate report generateReport(); return { success: true, wallets: { good: createdWallets.good.config?.name, moderate: createdWallets.moderate.config?.name, bad: createdWallets.bad.config?.name, }, transactions: { good: transactionHistory.good.length, moderate: transactionHistory.moderate.length, bad: transactionHistory.bad.length, }, }; } catch (error) { console.error(`\nError in health privacy test: ${error.message}`); throw error; } } // Execute the test runHealthPrivacyTest() .then((result) => { console.log("\n✓ Health Privacy Test completed successfully!"); console.log(JSON.stringify(result, null, 2)); }) .catch((error) => { console.error("\n✗ Health Privacy Test failed:", error.message); });