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

383 lines (382 loc) • 15.8 kB
"use strict"; /** * Test Scenario Service for Caravan-X * Handles loading and applying pre-configured test scenarios */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.ScenarioService = void 0; const fs = __importStar(require("fs-extra")); const path = __importStar(require("path")); const chalk_1 = __importDefault(require("chalk")); const ora_1 = __importDefault(require("ora")); const config_1 = require("../types/config"); const caravan_1 = require("../types/caravan"); class ScenarioService { constructor(bitcoinService, caravanService, transactionService, rpc, scenariosDir) { this.bitcoinService = bitcoinService; this.caravanService = caravanService; this.transactionService = transactionService; this.rpc = rpc; this.scenariosDir = scenariosDir; // Ensure scenarios directory exists fs.ensureDirSync(this.scenariosDir); } /** * Get all available scenarios (built-in + custom) */ async listScenarios() { const builtInScenarios = Object.values(config_1.BUILT_IN_SCENARIOS); const customScenarios = await this.loadCustomScenarios(); return [...builtInScenarios, ...customScenarios]; } /** * Get a specific scenario by ID */ async getScenario(scenarioId) { // Check built-in scenarios first if (config_1.BUILT_IN_SCENARIOS[scenarioId]) { return config_1.BUILT_IN_SCENARIOS[scenarioId]; } // Check custom scenarios const customScenarios = await this.loadCustomScenarios(); return customScenarios.find((s) => s.id === scenarioId) || null; } /** * Apply a test scenario */ async applyScenario(scenarioId) { const scenario = await this.getScenario(scenarioId); if (!scenario) { throw new Error(`Scenario not found: ${scenarioId}`); } console.log(chalk_1.default.bold.cyan(`\nšŸŽ¬ Applying Scenario: ${scenario.name}`)); console.log(chalk_1.default.dim(scenario.description)); console.log(chalk_1.default.dim("─".repeat(60))); try { // Step 1: Setup blockchain to target height await this.setupBlockchain(scenario.blockHeight); // Step 2: Create wallets await this.createScenarioWallets(scenario.wallets); // Step 3: Execute transactions await this.executeScenarioTransactions(scenario.transactions, scenario.wallets); console.log(chalk_1.default.bold.green("\nāœ… Scenario applied successfully!")); console.log(chalk_1.default.cyan(`\nCurrent block height: ${scenario.blockHeight}`)); console.log(chalk_1.default.cyan(`Wallets created: ${scenario.wallets.map((w) => w.name).join(", ")}`)); } catch (error) { console.log(chalk_1.default.bold.red("\nāŒ Failed to apply scenario")); throw error; } } /** * Setup blockchain to target height */ async setupBlockchain(targetHeight) { const spinner = (0, ora_1.default)("Setting up blockchain...").start(); try { const blockchainInfo = await this.rpc.callRpc("getblockchaininfo"); const currentHeight = blockchainInfo.blocks; if (currentHeight < targetHeight) { // Need to mine more blocks const blocksToMine = targetHeight - currentHeight; spinner.text = `Mining ${blocksToMine} blocks to reach height ${targetHeight}...`; // Create a temporary wallet for mining if it doesn't exist const wallets = await this.rpc.listWallets(); let miningWallet = "mining_temp"; if (!wallets.includes(miningWallet)) { await this.rpc.createWallet(miningWallet, false, false); } // Get a mining address const address = await this.rpc.getNewAddress(miningWallet); // Mine blocks await this.rpc.generateToAddress(blocksToMine, address); spinner.succeed(`Blockchain setup complete (height: ${targetHeight})`); } else if (currentHeight > targetHeight) { spinner.warn(`Current height (${currentHeight}) is greater than target (${targetHeight}). Skipping...`); } else { spinner.succeed("Blockchain already at target height"); } } catch (error) { spinner.fail("Failed to setup blockchain"); throw error; } } /** * Create wallets for the scenario */ async createScenarioWallets(wallets) { console.log(chalk_1.default.bold("\nšŸ“ Creating Wallets")); for (const wallet of wallets) { const spinner = (0, ora_1.default)(`Creating ${wallet.name}...`).start(); try { if (wallet.type === "singlesig") { // Create regular wallet await this.createSinglesigWallet(wallet); spinner.succeed(`Created singlesig wallet: ${wallet.name}`); } else if (wallet.type === "multisig") { // Create multisig wallet await this.createMultisigWallet(wallet); spinner.succeed(`Created multisig wallet: ${wallet.name}`); } // Fund the wallet if balance is specified if (wallet.balance && wallet.balance > 0) { await this.fundWallet(wallet.name, wallet.balance); spinner.text = `Funded ${wallet.name} with ${wallet.balance} BTC`; } } catch (error) { spinner.fail(`Failed to create wallet: ${wallet.name}`); throw error; } } } /** * Create a singlesig wallet for the scenario */ async createSinglesigWallet(wallet) { // Check if wallet already exists const existingWallets = await this.rpc.listWallets(); if (existingWallets.includes(wallet.name)) { // Wallet exists, skip return; } // Determine descriptor type based on address type let descriptors = true; const addressType = wallet.addressType || "bech32"; // Create wallet with appropriate descriptor await this.rpc.createWallet(wallet.name, false, false); } /** * Create a multisig wallet for the scenario */ async createMultisigWallet(wallet) { if (!wallet.quorum) { throw new Error("Multisig wallet must have quorum specified"); } // Generate keys for the multisig const { requiredSigners, totalSigners } = wallet.quorum; const extendedPublicKeys = []; // Generate xpubs for each signer for (let i = 0; i < totalSigners; i++) { const signerWallet = `${wallet.name}_signer_${i + 1}`; // Create signer wallet const existingWallets = await this.rpc.listWallets(); if (!existingWallets.includes(signerWallet)) { await this.rpc.createWallet(signerWallet, false, false); } // Get wallet info const walletInfo = await this.rpc.getWalletInfo(signerWallet); // For simplicity, we'll use a standard derivation path const xpub = await this.getXpubFromWallet(signerWallet); extendedPublicKeys.push({ name: `Signer ${i + 1}`, xpub, bip32Path: "m/48'/1'/0'/2'", // Standard for P2WSH multisig on testnet/regtest method: "text", }); } // Create Caravan wallet config const addressType = this.getAddressTypeEnum(wallet.addressType || "P2WSH"); await this.caravanService.createCaravanWalletConfig({ name: wallet.name, addressType, network: caravan_1.Network.REGTEST, requiredSigners, totalSigners, extendedPublicKeys, startingAddressIndex: 0, }); // Create watch-only wallet for the multisig const caravanConfig = await this.caravanService.getCaravanWallet(wallet.name); if (caravanConfig) { await this.caravanService.createWatchWalletForCaravan(caravanConfig); } } /** * Get xpub from a wallet */ async getXpubFromWallet(walletName) { // Get wallet descriptors const descriptors = await this.rpc.callRpc("listdescriptors", [], walletName); // Extract xpub from the first descriptor (simplified) const descriptor = descriptors.descriptors[0]; const match = descriptor.desc.match(/\[(.*?)\](.*?)\/\*/); if (match && match[2]) { return match[2]; } // Fallback: generate a dummy xpub (in production, use proper derivation) return "tpubD6NzVbkrYhZ4XgiXtGrdW5XDAPFCL9h7we1vwNCpn8tGbBcgfVYjXyhWo4E1xkh56hjod1RhGjxbaTLV3X4FyWuejifB9jusQ46QzG87VKp"; } /** * Convert address type string to enum */ getAddressTypeEnum(addressType) { const typeMap = { P2WSH: caravan_1.AddressType.P2WSH, P2SH: caravan_1.AddressType.P2SH, "P2SH-P2WSH": caravan_1.AddressType.P2SH_P2WSH, }; return typeMap[addressType] || caravan_1.AddressType.P2WSH; } /** * Fund a wallet */ async fundWallet(walletName, amount) { // Get address from wallet const address = await this.rpc.getNewAddress(walletName); // Get a funded wallet to send from (create if doesn't exist) const wallets = await this.rpc.listWallets(); let funderWallet = "scenario_funder"; if (!wallets.includes(funderWallet)) { await this.rpc.createWallet(funderWallet, false, false); // Mine some blocks to the funder wallet const funderAddress = await this.rpc.getNewAddress(funderWallet); await this.rpc.generateToAddress(101, funderAddress); } // Send funds await this.rpc.sendToAddress(funderWallet, address, amount); // Mine a block to confirm const mineAddress = await this.rpc.getNewAddress(funderWallet); await this.rpc.generateToAddress(1, mineAddress); } /** * Execute transactions for the scenario */ async executeScenarioTransactions(transactions, wallets) { if (transactions.length === 0) { return; } console.log(chalk_1.default.bold("\nšŸ’ø Executing Transactions")); for (const tx of transactions) { const spinner = (0, ora_1.default)(`${tx.from} → ${tx.to}: ${tx.amount} BTC`).start(); try { // Get recipient address const toAddress = await this.rpc.getNewAddress(tx.to); // Create and send transaction const txid = await this.rpc.sendToAddress(tx.from, toAddress, tx.amount); // Mine blocks to confirm if specified if (tx.confirmed) { const mineAddress = await this.rpc.getNewAddress(tx.from); await this.rpc.generateToAddress(1, mineAddress); spinner.succeed(`${tx.from} → ${tx.to}: ${tx.amount} BTC (confirmed)`); } else { spinner.succeed(`${tx.from} → ${tx.to}: ${tx.amount} BTC (unconfirmed)`); } // Add RBF or CPFP flags if specified if (tx.rbf) { spinner.text += " [RBF enabled]"; } if (tx.cpfp) { spinner.text += " [CPFP ready]"; } } catch (error) { spinner.fail(`Failed transaction: ${tx.from} → ${tx.to}`); throw error; } } } /** * Load custom scenarios from the scenarios directory */ async loadCustomScenarios() { try { const files = await fs.readdir(this.scenariosDir); const jsonFiles = files.filter((file) => file.endsWith(".json")); const scenarios = []; for (const file of jsonFiles) { try { const scenarioPath = path.join(this.scenariosDir, file); const scenario = await fs.readJson(scenarioPath); // Validate scenario structure if (this.isValidScenario(scenario)) { scenarios.push(scenario); } } catch (error) { console.error(`Error loading scenario ${file}:`, error); } } return scenarios; } catch (error) { console.error("Error loading custom scenarios:", error); return []; } } /** * Validate scenario structure */ isValidScenario(scenario) { return (scenario && typeof scenario.id === "string" && typeof scenario.name === "string" && typeof scenario.blockHeight === "number" && Array.isArray(scenario.wallets) && Array.isArray(scenario.transactions)); } /** * Save a custom scenario */ async saveScenario(scenario) { const filename = `${scenario.id}.json`; const filePath = path.join(this.scenariosDir, filename); await fs.writeJson(filePath, scenario, { spaces: 2 }); } /** * Delete a custom scenario */ async deleteScenario(scenarioId) { // Don't allow deleting built-in scenarios if (config_1.BUILT_IN_SCENARIOS[scenarioId]) { throw new Error("Cannot delete built-in scenarios"); } const filename = `${scenarioId}.json`; const filePath = path.join(this.scenariosDir, filename); if (await fs.pathExists(filePath)) { await fs.remove(filePath); } } } exports.ScenarioService = ScenarioService;