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

660 lines (659 loc) 31 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.WalletCommands = void 0; const prompts_1 = require("@inquirer/prompts"); const ora_1 = __importDefault(require("ora")); const terminal_1 = require("../utils/terminal"); // Back option constant const BACK_OPTION = "__BACK__"; /** * Wallet commands for managing Bitcoin wallets */ class WalletCommands { constructor(bitcoinService) { this.bitcoinService = bitcoinService; } /** * Add a back option to selection choices */ addBackOption(choices, backLabel = "Back to menu") { return [...choices, { name: terminal_1.colors.muted(backLabel), value: BACK_OPTION }]; } /** * Check if a value is the back option */ isBackOption(value) { return value === BACK_OPTION; } /** * Custom text input with back option (using 'q' to go back) */ async inputWithBack(options) { const message = options.message + " (or 'q' to go back)"; const result = await (0, prompts_1.input)({ message, default: options.default, validate: (input) => { if (input.toLowerCase() === "q") return true; if (options.validate) return options.validate(input); return true; }, }); if (result.toLowerCase() === "q") { return BACK_OPTION; } return result; } /** * Handle spinner errors and return false to indicate operation should be canceled */ handleSpinnerError(spinner, errorMessage, error) { spinner.stop(); // Clear the spinner line completely process.stdout.write("\r\x1b[K"); console.error((0, terminal_1.formatError)(errorMessage)); if (error && error.message) { console.error((0, terminal_1.formatError)(`Details: ${error.message}`)); } console.log(terminal_1.colors.info("Press Enter to return to menu...")); return false; } /** * List all wallets on the node */ async listWallets() { (0, terminal_1.displayCommandTitle)("Available Wallets"); try { const spinner = (0, ora_1.default)("Fetching wallets...").start(); let wallets; try { wallets = await this.bitcoinService.listWallets(); spinner.succeed("Wallets fetched successfully"); } catch (error) { return this.handleSpinnerError(spinner, "Error fetching wallets", error); } if (wallets.length === 0) { console.log((0, terminal_1.formatWarning)("No wallets found.")); return []; } // Prepare table data const tableRows = []; for (let i = 0; i < wallets.length; i++) { const wallet = wallets[i]; try { const spinner = (0, ora_1.default)(`Loading wallet info for ${wallet}...`).start(); const walletInfo = await this.bitcoinService.getWalletInfo(wallet); spinner.succeed(`Loaded info for ${wallet}`); tableRows.push([ `${i + 1}`, terminal_1.colors.highlight(wallet), (0, terminal_1.formatBitcoin)(walletInfo.balance), walletInfo.private_keys_enabled ? terminal_1.colors.success("Full") : terminal_1.colors.warning("Watch-only"), walletInfo.descriptors ? terminal_1.colors.success("Yes") : terminal_1.colors.muted("Legacy"), ]); } catch (error) { tableRows.push([ `${i + 1}`, terminal_1.colors.highlight(wallet), terminal_1.colors.muted("Unknown"), terminal_1.colors.muted("Unknown"), terminal_1.colors.muted("Unknown"), ]); } } // Display table const table = (0, terminal_1.createTable)(["#", "Wallet Name", "Balance", "Type", "Descriptors"], tableRows); console.log(table); console.log(`\nTotal wallets: ${terminal_1.colors.highlight(wallets.length.toString())}`); return wallets; } catch (error) { console.error((0, terminal_1.formatError)("Error listing wallets:"), error); return false; } } /** * Create a new wallet */ async createWallet() { (0, terminal_1.displayCommandTitle)("Create New Wallet"); try { // Get wallet name with back option const walletName = await this.inputWithBack({ message: "Enter a name for the new wallet:", validate: (input) => input.trim() !== "" ? true : "Please enter a valid wallet name", }); // Check if user wants to go back if (this.isBackOption(walletName)) { return false; } // Add back option to wallet type selection const walletTypeChoices = [ { name: terminal_1.colors.highlight("Standard wallet (with private keys)"), value: "standard", }, { name: terminal_1.colors.highlight("Watch-only wallet (no private keys)"), value: "watch-only", }, { name: terminal_1.colors.highlight("Blank wallet (no keys or addresses)"), value: "blank", }, ]; const walletType = await (0, prompts_1.select)({ message: "What type of wallet would you like to create?", choices: this.addBackOption(walletTypeChoices), }); // Check if user wants to go back if (this.isBackOption(walletType)) { return false; } const disablePrivateKeys = walletType === "watch-only" || walletType === "blank"; const blank = walletType === "blank"; console.log(terminal_1.colors.info(`\nCreating ${walletType} wallet "${walletName}"...`)); const spinner = (0, ora_1.default)("Creating wallet...").start(); let result; try { result = await this.bitcoinService.createWallet(walletName, { disablePrivateKeys, blank, }); spinner.succeed("Wallet created"); } catch (error) { return this.handleSpinnerError(spinner, "Error creating wallet", error); } if (result) { console.log((0, terminal_1.boxText)((0, terminal_1.formatSuccess)(`Wallet "${walletName}" created successfully!`), { title: "Wallet Created", titleColor: terminal_1.colors.success })); // Generate a new address if the wallet has private keys if (walletType === "standard") { const addressSpinner = (0, ora_1.default)("Generating address...").start(); try { const address = await this.bitcoinService.getNewAddress(walletName); addressSpinner.succeed("Address generated"); console.log((0, terminal_1.boxText)(`Wallet: ${terminal_1.colors.highlight(walletName)}\nAddress: ${terminal_1.colors.highlight(address)}`, { title: "Generated Address", titleColor: terminal_1.colors.info })); } catch (error) { addressSpinner.fail("Could not generate address"); console.log((0, terminal_1.formatWarning)("This wallet might not support address generation.")); } } return walletName; } return null; } catch (error) { console.error((0, terminal_1.formatError)("Error creating wallet:"), error); return false; } } /** * Show wallet details */ async showWalletDetails(walletName) { (0, terminal_1.displayCommandTitle)("Wallet Details"); try { if (!walletName) { const walletsSpinner = (0, ora_1.default)("Loading wallets...").start(); let wallets; try { wallets = await this.listWallets(); walletsSpinner.succeed("Wallets loaded"); // Check if listing wallets was cancelled or failed if (wallets === false) { return false; } } catch (error) { return this.handleSpinnerError(walletsSpinner, "Error loading wallets", error); } if (wallets.length === 0) { console.log((0, terminal_1.formatWarning)("No wallets found.")); return false; } // Add back option to wallet selection const walletChoices = wallets.map((w) => ({ name: terminal_1.colors.highlight(w), value: w, })); walletName = await (0, prompts_1.select)({ message: "Select a wallet to view:", choices: this.addBackOption(walletChoices), }); // Check if user wants to go back if (this.isBackOption(walletName)) { return false; } } console.log(terminal_1.colors.info(`\nFetching details for wallet: ${walletName}`)); // Get wallet info with proper error handling const spinner = (0, ora_1.default)("Loading wallet information...").start(); let walletInfo; try { walletInfo = await this.bitcoinService.getWalletInfo(walletName); spinner.succeed("Wallet information loaded"); } catch (error) { return this.handleSpinnerError(spinner, "Error loading wallet information", error); } // Format the wallet information const infoText = ` ${(0, terminal_1.keyValue)("Balance", (0, terminal_1.formatBitcoin)(walletInfo.balance))} ${(0, terminal_1.keyValue)("Unconfirmed Balance", (0, terminal_1.formatBitcoin)(walletInfo.unconfirmed_balance))} ${(0, terminal_1.keyValue)("Immature Balance", (0, terminal_1.formatBitcoin)(walletInfo.immature_balance))} ${(0, terminal_1.keyValue)("Private Keys Enabled", walletInfo.private_keys_enabled ? "Yes" : "No")} ${(0, terminal_1.keyValue)("HD Seed", walletInfo.hdseedid ? walletInfo.hdseedid : "N/A")} ${(0, terminal_1.keyValue)("TX Count", walletInfo.txcount)} ${(0, terminal_1.keyValue)("Key Pool Size", walletInfo.keypoolsize)}`; console.log((0, terminal_1.boxText)(infoText, { title: `Wallet: ${walletName}`, titleColor: terminal_1.colors.header, })); // Show options with back option const generateOptions = [ { name: terminal_1.colors.highlight("Yes, generate address"), value: "yes" }, { name: terminal_1.colors.highlight("No, skip"), value: "no" }, ]; const generateOption = await (0, prompts_1.select)({ message: "Generate a new address?", choices: this.addBackOption(generateOptions), }); // Check if user wants to go back if (this.isBackOption(generateOption)) { return false; } if (generateOption === "yes") { const addressSpinner = (0, ora_1.default)("Generating new address...").start(); let address; try { // Wallet name is now guaranteed to be a string here address = await this.bitcoinService.getNewAddress(walletName); addressSpinner.succeed("Address generated"); } catch (error) { return this.handleSpinnerError(addressSpinner, `Error generating address for wallet ${walletName}`, error); } // Get address info with proper error handling try { const infoSpinner = (0, ora_1.default)("Fetching address information...").start(); const addressInfo = await this.bitcoinService.getAddressInfo(walletName, address); infoSpinner.succeed("Address information loaded"); // Format address information const addressText = ` ${(0, terminal_1.keyValue)("Address", addressInfo.address || "N/A")} ${(0, terminal_1.keyValue)("Type", addressInfo.scriptPubKey ? addressInfo.scriptPubKey || "N/A" : "Unknown")} ${(0, terminal_1.keyValue)("HD Path", addressInfo.hdkeypath || "N/A")} ${(0, terminal_1.keyValue)("Public Key", addressInfo.pubkey ? (0, terminal_1.truncate)(addressInfo.pubkey, 10) : "N/A")}`; console.log((0, terminal_1.boxText)(addressText, { title: "Generated Address", titleColor: terminal_1.colors.info, })); } catch (error) { console.error((0, terminal_1.formatWarning)("Could not retrieve detailed address info")); console.log(terminal_1.colors.info(`Address generated: ${address}`)); } } return walletInfo; } catch (error) { console.error((0, terminal_1.formatError)(`Error getting wallet details: ${error.message}`)); return false; } } /** * Fund a wallet with new coins (using mining) */ async fundWallet(walletName) { (0, terminal_1.displayCommandTitle)("Fund Wallet"); try { if (!walletName) { const walletsSpinner = (0, ora_1.default)("Loading wallets...").start(); let wallets; try { wallets = await this.listWallets(); walletsSpinner.succeed("Wallets loaded"); // Check if listing wallets was cancelled if (wallets === false) { return false; } } catch (error) { return this.handleSpinnerError(walletsSpinner, "Error loading wallets", error); } if (wallets.length === 0) { console.log((0, terminal_1.formatWarning)("No wallets found.")); return false; } // Add back option to wallet selection walletName = await (0, prompts_1.select)({ message: "Select a wallet to fund:", choices: this.addBackOption(wallets.map((w) => ({ name: terminal_1.colors.highlight(w), value: w, }))), }); // Check if user wants to go back if (this.isBackOption(walletName)) { return false; } } console.log(terminal_1.colors.info(`\nPreparing to fund wallet: ${walletName}`)); // Check wallet balance with proper error handling const infoSpinner = (0, ora_1.default)("Checking current balance...").start(); let walletInfoBefore; try { walletInfoBefore = await this.bitcoinService.getWalletInfo(walletName); infoSpinner.succeed("Balance checked"); } catch (error) { return this.handleSpinnerError(infoSpinner, "Error checking wallet balance", error); } console.log((0, terminal_1.keyValue)("Current balance", (0, terminal_1.formatBitcoin)(walletInfoBefore.balance))); console.log((0, terminal_1.keyValue)("Immature balance", (0, terminal_1.formatBitcoin)(walletInfoBefore.immature_balance))); // Get a new address with proper error handling const addressSpinner = (0, ora_1.default)("Generating address for mining...").start(); let address; try { address = await this.bitcoinService.getNewAddress(walletName); addressSpinner.succeed(`Using address: ${address}`); } catch (error) { return this.handleSpinnerError(addressSpinner, "Error generating address", error); } // Allow user to specify amount with back option const fundingMethodChoices = [ { name: terminal_1.colors.highlight("By number of blocks"), value: "blocks", }, { name: terminal_1.colors.highlight("By target amount (approximate)"), value: "amount", }, ]; const fundingMethod = await (0, prompts_1.select)({ message: "How would you like to specify mining?", choices: this.addBackOption(fundingMethodChoices), }); // Check if user wants to go back if (this.isBackOption(fundingMethod)) { return false; } let numBlocks; if (fundingMethod === "blocks") { const blocks = await this.inputWithBack({ message: "How many blocks to mine?", default: "1", validate: (input) => { const num = parseInt(input); return !isNaN(num) && num > 0 ? true : "Please enter a positive number"; }, }); // Check if user wants to go back if (this.isBackOption(blocks)) { return false; } numBlocks = parseInt(blocks); } else { const targetAmount = await this.inputWithBack({ message: "How much BTC would you like to mine (approximate)?", default: "50", validate: (input) => { const num = parseFloat(input); return !isNaN(num) && num > 0 ? true : "Please enter a positive number"; }, }); // Check if user wants to go back if (this.isBackOption(targetAmount)) { return false; } // Each block gives ~50 BTC in regtest mode numBlocks = Math.ceil(parseFloat(targetAmount) / 50); console.log(terminal_1.colors.info(`\nMining approximately ${numBlocks} blocks to get ~${targetAmount} BTC...`)); } console.log((0, terminal_1.divider)()); console.log(terminal_1.colors.info(`Mining ${numBlocks} block(s) to address ${address}...`)); // Mine the blocks with proper error handling const miningSpinner = (0, ora_1.default)(`Mining ${numBlocks} blocks...`).start(); let blockHashes; try { blockHashes = await this.bitcoinService.generateToAddress(numBlocks, address); miningSpinner.succeed(`Successfully mined ${blockHashes.length} blocks!`); } catch (error) { return this.handleSpinnerError(miningSpinner, "Error mining blocks", error); } console.log((0, terminal_1.keyValue)("Latest block hash", (0, terminal_1.truncate)(blockHashes[blockHashes.length - 1], 10))); // Check updated wallet balance const afterSpinner = (0, ora_1.default)("Checking updated balance...").start(); let walletInfoAfter; try { walletInfoAfter = await this.bitcoinService.getWalletInfo(walletName); afterSpinner.succeed("Balance updated"); } catch (error) { return this.handleSpinnerError(afterSpinner, "Error checking updated balance", error); } // Format results const resultText = ` ${(0, terminal_1.keyValue)("New balance", (0, terminal_1.formatBitcoin)(walletInfoAfter.balance))} ${(0, terminal_1.keyValue)("New immature balance", (0, terminal_1.formatBitcoin)(walletInfoAfter.immature_balance))} ${(0, terminal_1.keyValue)("Added (mature)", (0, terminal_1.formatBitcoin)(walletInfoAfter.balance - walletInfoBefore.balance))} ${(0, terminal_1.keyValue)("Added (immature)", (0, terminal_1.formatBitcoin)(walletInfoAfter.immature_balance - walletInfoBefore.immature_balance))}`; console.log((0, terminal_1.boxText)(resultText, { title: "Mining Results", titleColor: terminal_1.colors.success, })); console.log((0, terminal_1.formatWarning)("Newly mined coins require 100 confirmations before they can be spent.")); return { blockHashes, newBalance: walletInfoAfter.balance }; } catch (error) { console.error((0, terminal_1.formatError)(`Error funding wallet: ${error.message}`)); return false; } } /** * Send funds between wallets */ async sendFunds() { (0, terminal_1.displayCommandTitle)("Send Funds"); try { const walletsSpinner = (0, ora_1.default)("Loading wallets...").start(); let wallets; try { wallets = await this.listWallets(); walletsSpinner.succeed("Wallets loaded"); // Check if listing wallets was cancelled if (wallets === false) { return false; } } catch (error) { return this.handleSpinnerError(walletsSpinner, "Error loading wallets", error); } if (wallets.length < 1) { console.log((0, terminal_1.formatWarning)("Not enough wallets found. You need at least one wallet.")); return false; } // Select source wallet with back option const sourceWallet = await (0, prompts_1.select)({ message: "Select source wallet:", choices: this.addBackOption(wallets.map((w) => ({ name: terminal_1.colors.highlight(w), value: w, }))), }); // Check if user wants to go back if (this.isBackOption(sourceWallet)) { return false; } // Get wallet info with proper error handling const infoSpinner = (0, ora_1.default)(`Loading information for ${sourceWallet}...`).start(); let sourceInfo; try { sourceInfo = await this.bitcoinService.getWalletInfo(sourceWallet); infoSpinner.succeed("Wallet information loaded"); } catch (error) { return this.handleSpinnerError(infoSpinner, "Error loading wallet information", error); } console.log((0, terminal_1.keyValue)("Source wallet balance", (0, terminal_1.formatBitcoin)(sourceInfo.balance))); if (sourceInfo.balance <= 0) { console.log((0, terminal_1.formatWarning)("Source wallet has no funds. Please fund it first.")); return false; } // Ask for destination with back option const destTypeChoices = [ { name: terminal_1.colors.highlight("Another wallet"), value: "wallet" }, { name: terminal_1.colors.highlight("External address"), value: "address" }, ]; const destType = await (0, prompts_1.select)({ message: "Send to:", choices: this.addBackOption(destTypeChoices), }); // Check if user wants to go back if (this.isBackOption(destType)) { return false; } let destinationAddress; if (destType === "wallet") { // Filter out the source wallet const destWallets = wallets.filter((w) => w !== sourceWallet); if (destWallets.length === 0) { console.log((0, terminal_1.formatWarning)("No other wallets found. Create another wallet first.")); return false; } // Select destination wallet with back option const destWallet = await (0, prompts_1.select)({ message: "Select destination wallet:", choices: this.addBackOption(destWallets.map((w) => ({ name: terminal_1.colors.highlight(w), value: w, }))), }); // Check if user wants to go back if (this.isBackOption(destWallet)) { return false; } // Get a new address from the destination wallet const addressSpinner = (0, ora_1.default)(`Generating address from wallet ${destWallet}...`).start(); try { destinationAddress = await this.bitcoinService.getNewAddress(destWallet); addressSpinner.succeed("Address generated"); } catch (error) { return this.handleSpinnerError(addressSpinner, "Error generating address", error); } console.log((0, terminal_1.keyValue)("Generated address", destinationAddress)); } else { // Get external address with back option const address = await this.inputWithBack({ message: "Enter destination address:", validate: (input) => input.trim() !== "" ? true : "Please enter a valid address", }); // Check if user wants to go back if (this.isBackOption(address)) { return false; } destinationAddress = address; } // Ask for amount with back option const amount = await this.inputWithBack({ message: "Enter amount to send (BTC):", validate: (input) => { const num = parseFloat(input); if (isNaN(num) || num <= 0) { return "Please enter a valid positive amount"; } if (num > sourceInfo.balance) { return `Amount exceeds balance (${(0, terminal_1.formatBitcoin)(sourceInfo.balance)})`; } return true; }, }); // Check if user wants to go back if (this.isBackOption(amount)) { return false; } const amountNum = parseFloat(amount); console.log(terminal_1.colors.info(`\nSending ${(0, terminal_1.formatBitcoin)(amountNum)} from ${sourceWallet} to ${destinationAddress}...`)); // Send transaction with proper error handling const txSpinner = (0, ora_1.default)("Sending transaction...").start(); let txid; try { txid = await this.bitcoinService.sendToAddress(sourceWallet, destinationAddress, amountNum); txSpinner.succeed("Transaction sent successfully"); } catch (error) { return this.handleSpinnerError(txSpinner, "Error sending transaction", error); } console.log((0, terminal_1.boxText)(`${(0, terminal_1.keyValue)("Transaction ID", (0, terminal_1.truncate)(txid, 15))}`, { title: "Transaction Sent", titleColor: terminal_1.colors.success, })); // Ask about mining with back option const mineOptions = [ { name: terminal_1.colors.highlight("Yes, mine a block"), value: "yes" }, { name: terminal_1.colors.highlight("No, skip mining"), value: "no" }, ]; const mineBlock = await (0, prompts_1.select)({ message: "Mine a block to confirm the transaction?", choices: this.addBackOption(mineOptions), }); // Check if user wants to go back if (this.isBackOption(mineBlock)) { return false; } if (mineBlock === "yes") { // Use the source wallet for mining const mineAddressSpinner = (0, ora_1.default)("Generating address for mining...").start(); let address; try { address = await this.bitcoinService.getNewAddress(sourceWallet); mineAddressSpinner.succeed("Address generated for mining"); } catch (error) { return this.handleSpinnerError(mineAddressSpinner, "Error generating mining address", error); } // Mine block with proper error handling const mineSpinner = (0, ora_1.default)("Mining block...").start(); let blockHashes; try { blockHashes = await this.bitcoinService.generateToAddress(1, address); mineSpinner.succeed("Block mined successfully"); } catch (error) { return this.handleSpinnerError(mineSpinner, "Error mining block", error); } console.log((0, terminal_1.keyValue)("Block hash", blockHashes[0])); } return { txid }; } catch (error) { console.error((0, terminal_1.formatError)(`Error sending funds: ${error.message}`)); return false; } } } exports.WalletCommands = WalletCommands;