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
JavaScript
"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;