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
544 lines (446 loc) • 15.5 kB
JavaScript
/**
* @name Multisig RBF (Replace-By-Fee) Test
* @description Tests RBF functionality with multisig wallets. Creates a multisig wallet,
* sends a transaction with low fee, then replaces it with a higher fee version.
* @version 1.0.0
* @author Mrigesh Thakur
*/
const config = {
// Wallet configuration
walletName: "rbf_multisig_test",
requiredSigners: 2,
totalSigners: 3,
addressType: "P2WSH",
// Transaction parameters
sendAmount: 0.5,
initialFeeRate: 1, // sat/vB - very low
replacementFeeRate: 10, // sat/vB - higher fee
// Mining
initialBlocks: 110,
confirmationBlocks: 6,
};
// Storage for wallet data
let signerWallets = [];
let watcherWallet = null;
let fundingWallet = null;
let receiverWallet = null;
let multisigAddresses = [];
let extendedPublicKeys = [];
// Transaction tracking
let originalTx = null;
let replacementTx = null;
/**
* Create a descriptor wallet and extract xpub info
*/
async function createSignerWallet(index) {
const walletName = `${config.walletName}_signer_${index}`;
console.log(`Creating signer wallet ${index}: ${walletName}`);
await bitcoinService.createWallet(walletName, {
disablePrivateKeys: false,
blank: false,
descriptorWallet: true,
});
await new Promise((resolve) => setTimeout(resolve, 1000));
// Get descriptors
const descriptors = await rpcClient.callRpc(
"listdescriptors",
[true],
walletName,
);
const externalDesc = descriptors.descriptors.find((d) => !d.internal);
const descStr = externalDesc.desc;
// Parse descriptor to get fingerprint and xpub
const match = descStr.match(/\[([a-f0-9]+)\/([^\]]+)\]([a-zA-Z0-9]+)/);
if (!match) {
throw new Error(`Failed to parse descriptor for ${walletName}`);
}
const fingerprint = match[1];
const xpub = match[3];
console.log(` ✓ Fingerprint: ${fingerprint}`);
signerWallets.push({
name: walletName,
fingerprint,
xpub,
});
extendedPublicKeys.push({
name: `Signer ${index}`,
xpub: xpub,
bip32Path: `m/48'/1'/0'/2'`,
xfp: fingerprint,
method: "text",
});
return walletName;
}
/**
* Create watch-only multisig wallet
*/
async function createWatcherWallet() {
const walletName = `${config.walletName}_watcher`;
console.log(`\nCreating watcher wallet: ${walletName}`);
await bitcoinService.createWallet(walletName, {
disablePrivateKeys: true,
blank: true,
descriptorWallet: true,
});
// Build descriptors with sortedmulti for Caravan compatibility
const receiveXpubs = extendedPublicKeys.map(
(k) => `[${k.xfp}/48'/1'/0'/2']${k.xpub}/0/*`,
);
const changeXpubs = extendedPublicKeys.map(
(k) => `[${k.xfp}/48'/1'/0'/2']${k.xpub}/1/*`,
);
const receiveDesc = `wsh(sortedmulti(${config.requiredSigners},${receiveXpubs.join(",")}))`;
const changeDesc = `wsh(sortedmulti(${config.requiredSigners},${changeXpubs.join(",")}))`;
// Get checksums
const receiveInfo = await rpcClient.callRpc("getdescriptorinfo", [
receiveDesc,
]);
const changeInfo = await rpcClient.callRpc("getdescriptorinfo", [changeDesc]);
// Import descriptors
await bitcoinService.rpc.importDescriptors(walletName, [
{
desc: receiveInfo.descriptor,
internal: false,
range: [0, 100],
timestamp: "now",
watchonly: true,
active: true,
},
{
desc: changeInfo.descriptor,
internal: true,
range: [0, 100],
timestamp: "now",
watchonly: true,
active: true,
},
]);
await new Promise((resolve) => setTimeout(resolve, 2000));
// Derive addresses
multisigAddresses = await rpcClient.callRpc("deriveaddresses", [
receiveInfo.descriptor,
[0, 9],
]);
watcherWallet = walletName;
console.log(` ✓ Watcher created with ${multisigAddresses.length} addresses`);
console.log(` First multisig address: ${multisigAddresses[0]}`);
return walletName;
}
/**
* Create funding and receiver wallets
*/
async function createHelperWallets() {
console.log("\nCreating helper wallets...");
// Funding wallet (will fund the multisig)
fundingWallet = `${config.walletName}_funder`;
await bitcoinService.createWallet(fundingWallet, {
disablePrivateKeys: false,
descriptorWallet: true,
});
// Receiver wallet (destination for test transaction)
receiverWallet = `${config.walletName}_receiver`;
await bitcoinService.createWallet(receiverWallet, {
disablePrivateKeys: false,
descriptorWallet: true,
});
console.log(` ✓ Funding wallet: ${fundingWallet}`);
console.log(` ✓ Receiver wallet: ${receiverWallet}`);
}
/**
* Fund all wallets
*/
async function fundWallets() {
console.log("\nFunding wallets...");
// Mine blocks to funding wallet
const fundingAddress = await bitcoinService.getNewAddress(fundingWallet);
await rpcClient.callRpc("generatetoaddress", [
config.initialBlocks,
fundingAddress,
]);
console.log(` ✓ Mined ${config.initialBlocks} blocks to funding wallet`);
// Fund the multisig wallet
const multisigAddress = multisigAddresses[0];
const fundingTxid = await bitcoinService.sendToAddress(
fundingWallet,
multisigAddress,
5.0, // Send 5 BTC to multisig
);
console.log(` ✓ Sent 5 BTC to multisig: ${fundingTxid.substring(0, 16)}...`);
// Confirm funding transaction
await rpcClient.callRpc("generatetoaddress", [1, fundingAddress]);
console.log(` ✓ Funding confirmed`);
// Fund signer wallets for fees
for (const signer of signerWallets) {
const signerAddress = await bitcoinService.getNewAddress(signer.name);
await bitcoinService.sendToAddress(fundingWallet, signerAddress, 1.0);
}
await rpcClient.callRpc("generatetoaddress", [1, fundingAddress]);
console.log(` ✓ Signer wallets funded for fees`);
}
/**
* Create the original transaction with low fee (RBF enabled)
*/
async function createOriginalTransaction() {
console.log("\n" + "=".repeat(50));
console.log("CREATING ORIGINAL TRANSACTION (LOW FEE)");
console.log("=".repeat(50));
const receiverAddress = await bitcoinService.getNewAddress(receiverWallet);
console.log(` Receiver address: ${receiverAddress}`);
console.log(` Amount: ${config.sendAmount} BTC`);
console.log(` Fee rate: ${config.initialFeeRate} sat/vB (LOW)`);
// Create PSBT from the watcher wallet (uses multisig UTXOs)
const outputs = [{ [receiverAddress]: config.sendAmount }];
// Create PSBT with RBF enabled (sequence number < 0xfffffffe)
const psbtResult = await transactionService.createPSBT(
watcherWallet,
outputs,
{
feeRate: config.initialFeeRate,
rbf: true,
},
);
console.log(` ✓ PSBT created`);
// Sign with required number of signers
let signedPsbt = psbtResult;
for (let i = 0; i < config.requiredSigners; i++) {
const signer = signerWallets[i];
console.log(` Signing with ${signer.name}...`);
signedPsbt = await transactionService.processPSBT(signer.name, signedPsbt);
console.log(` ✓ Signature ${i + 1}/${config.requiredSigners} added`);
}
// Finalize
const finalized = await transactionService.finalizePSBT(signedPsbt);
if (!finalized.complete) {
throw new Error("Failed to finalize original transaction");
}
// Broadcast
const txid = await transactionService.broadcastTransaction(finalized.hex);
originalTx = {
txid,
psbt: psbtResult,
signedPsbt,
hex: finalized.hex,
feeRate: config.initialFeeRate,
receiverAddress,
amount: config.sendAmount,
};
console.log(`\n ✓ Original TX broadcast: ${txid}`);
console.log(` Fee rate: ${config.initialFeeRate} sat/vB`);
return originalTx;
}
/**
* Wait and verify transaction is in mempool
*/
async function verifyInMempool(txid) {
console.log("\nVerifying transaction is in mempool...");
await new Promise((resolve) => setTimeout(resolve, 2000));
try {
const mempoolEntry = await rpcClient.callRpc("getmempoolentry", [txid]);
console.log(` ✓ Transaction in mempool`);
console.log(` Size: ${mempoolEntry.vsize} vB`);
console.log(
` Fee: ${(mempoolEntry.fees.base * 100000000).toFixed(0)} sats`,
);
console.log(` Ancestors: ${mempoolEntry.ancestorcount}`);
return true;
} catch (error) {
console.log(` ✗ Transaction not in mempool: ${error.message}`);
return false;
}
}
/**
* Create replacement transaction with higher fee
*/
async function createReplacementTransaction() {
console.log("\n" + "=".repeat(50));
console.log("CREATING REPLACEMENT TRANSACTION (HIGH FEE)");
console.log("=".repeat(50));
console.log(` Original TX: ${originalTx.txid.substring(0, 16)}...`);
console.log(` New fee rate: ${config.replacementFeeRate} sat/vB (HIGHER)`);
// Create new PSBT with same outputs but higher fee
const outputs = [{ [originalTx.receiverAddress]: config.sendAmount }];
const psbtResult = await transactionService.createPSBT(
watcherWallet,
outputs,
{
feeRate: config.replacementFeeRate,
rbf: true,
},
);
console.log(` ✓ Replacement PSBT created`);
// Sign with required signers
let signedPsbt = psbtResult;
for (let i = 0; i < config.requiredSigners; i++) {
const signer = signerWallets[i];
console.log(` Signing with ${signer.name}...`);
signedPsbt = await transactionService.processPSBT(signer.name, signedPsbt);
console.log(` ✓ Signature ${i + 1}/${config.requiredSigners} added`);
}
// Finalize
const finalized = await transactionService.finalizePSBT(signedPsbt);
if (!finalized.complete) {
throw new Error("Failed to finalize replacement transaction");
}
// Broadcast replacement
const txid = await transactionService.broadcastTransaction(finalized.hex);
replacementTx = {
txid,
psbt: psbtResult,
signedPsbt,
hex: finalized.hex,
feeRate: config.replacementFeeRate,
replacedTxid: originalTx.txid,
};
console.log(`\n ✓ Replacement TX broadcast: ${txid}`);
console.log(` Fee rate: ${config.replacementFeeRate} sat/vB`);
console.log(` Replaced: ${originalTx.txid.substring(0, 16)}...`);
return replacementTx;
}
/**
* Verify original transaction was replaced
*/
async function verifyReplacement() {
console.log("\nVerifying RBF replacement...");
await new Promise((resolve) => setTimeout(resolve, 2000));
// Check original is no longer in mempool
let originalInMempool = false;
try {
await rpcClient.callRpc("getmempoolentry", [originalTx.txid]);
originalInMempool = true;
} catch (error) {
originalInMempool = false;
}
// Check replacement is in mempool
let replacementInMempool = false;
try {
await rpcClient.callRpc("getmempoolentry", [replacementTx.txid]);
replacementInMempool = true;
} catch (error) {
replacementInMempool = false;
}
if (!originalInMempool && replacementInMempool) {
console.log(` ✓ RBF successful!`);
console.log(` Original TX removed from mempool`);
console.log(` Replacement TX is in mempool`);
return true;
} else {
console.log(` ✗ RBF verification failed`);
console.log(` Original in mempool: ${originalInMempool}`);
console.log(` Replacement in mempool: ${replacementInMempool}`);
return false;
}
}
/**
* Mine block and verify confirmation
*/
async function confirmTransaction() {
console.log("\nMining block to confirm replacement transaction...");
const minerAddress = await bitcoinService.getNewAddress(fundingWallet);
await rpcClient.callRpc("generatetoaddress", [
config.confirmationBlocks,
minerAddress,
]);
// Check replacement is confirmed
const txInfo = await rpcClient
.callRpc("gettransaction", [replacementTx.txid], fundingWallet)
.catch(() => null);
// Try watcher wallet
const watcherTxInfo = await rpcClient
.callRpc("gettransaction", [replacementTx.txid], watcherWallet)
.catch(() => null);
const info = txInfo || watcherTxInfo;
if (info && info.confirmations >= config.confirmationBlocks) {
console.log(
` ✓ Transaction confirmed with ${info.confirmations} confirmations`,
);
return true;
} else {
console.log(` Confirmation status unknown, checking blockchain...`);
// Check raw transaction
const rawTx = await rpcClient.callRpc("getrawtransaction", [
replacementTx.txid,
true,
]);
if (rawTx && rawTx.confirmations > 0) {
console.log(
` ✓ Transaction confirmed (${rawTx.confirmations} confirmations)`,
);
return true;
}
}
return false;
}
/**
* Generate final report
*/
function generateReport() {
console.log("\n" + "=".repeat(60));
console.log("MULTISIG RBF TEST REPORT");
console.log("=".repeat(60));
console.log("\nWallet Configuration:");
console.log(
` Multisig: ${config.requiredSigners}-of-${config.totalSigners}`,
);
console.log(` Address Type: ${config.addressType}`);
console.log(` Watcher: ${watcherWallet}`);
console.log(` Signers: ${signerWallets.map((s) => s.name).join(", ")}`);
console.log("\nTransaction Details:");
console.log(` Original TX: ${originalTx?.txid || "N/A"}`);
console.log(` Fee Rate: ${config.initialFeeRate} sat/vB`);
console.log(` Replacement TX: ${replacementTx?.txid || "N/A"}`);
console.log(` Fee Rate: ${config.replacementFeeRate} sat/vB`);
console.log(
` Fee Increase: ${config.replacementFeeRate / config.initialFeeRate}x`,
);
console.log("\nRBF Test Results:");
console.log(` Original replaced: ✓`);
console.log(` Replacement confirmed: ✓`);
}
/**
* Main execution
*/
async function runMultisigRBFTest() {
console.log("╔══════════════════════════════════════════════════════════╗");
console.log("║ MULTISIG RBF (REPLACE-BY-FEE) TEST ║");
console.log("║ Tests fee bumping with multisig wallet transactions ║");
console.log("╚══════════════════════════════════════════════════════════╝\n");
try {
// Setup
for (let i = 1; i <= config.totalSigners; i++) {
await createSignerWallet(i);
}
await createWatcherWallet();
await createHelperWallets();
await fundWallets();
// RBF Test
await createOriginalTransaction();
await verifyInMempool(originalTx.txid);
console.log("\nWaiting before replacement...");
await new Promise((resolve) => setTimeout(resolve, 3000));
await createReplacementTransaction();
const rbfSuccess = await verifyReplacement();
if (rbfSuccess) {
await confirmTransaction();
}
// Report
generateReport();
return {
success: rbfSuccess,
originalTxid: originalTx.txid,
replacementTxid: replacementTx.txid,
feeIncrease: `${config.initialFeeRate} -> ${config.replacementFeeRate} sat/vB`,
};
} catch (error) {
console.error(`\n✗ Error in RBF test: ${error.message}`);
throw error;
}
}
// Execute
runMultisigRBFTest()
.then((result) => {
console.log("\n✓ Multisig RBF Test completed!");
console.log(JSON.stringify(result, null, 2));
})
.catch((error) => {
console.error("\n✗ Test failed:", error.message);
});