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
JavaScript
;
/**
* 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;