diff --git a/doc/API.md b/doc/API.md index 096abb0b..c55fc7fb 100644 --- a/doc/API.md +++ b/doc/API.md @@ -26,6 +26,7 @@ Note that all addresses are bech32-encoded *version 0* native Segwit `scriptPubK | [`setspendtx`](#setspendtx) | Announce and broadcast this Spend transaction | | [`gethistory`](#gethistory) | Retrieve history of funds | | [`emergency`](#emergency) | Broadcast all Emergency signed transactions | +| [`cpfp`](#cpfp) | Manually trigger the cpfp for transactions. | @@ -483,6 +484,20 @@ of inflows and outflows net of any change amount (that is technically a transact None; the `result` field will be set to the empty object `{}`. Any value should be disregarded for forward compatibility. +### `cpfp` + +#### Request + +| Field | Type | Description | +| -------------- | ------ | ---------------------------------------------- | +| `txids` | array | Array of Txids that must be CPFPed | +| `feerate` | float | The new target feerate. | + +#### Response + +None; the `result` field will be set to the empty object `{}`. Any value should be +disregarded for forward compatibility. + ## User flows diff --git a/src/bitcoind/mod.rs b/src/bitcoind/mod.rs index 834a8b0a..48a0ee18 100644 --- a/src/bitcoind/mod.rs +++ b/src/bitcoind/mod.rs @@ -3,10 +3,19 @@ pub mod poller; pub mod utils; use crate::config::BitcoindConfig; -use crate::{database::DatabaseError, revaultd::RevaultD, threadmessages::BitcoindMessageOut}; +use crate::{ + commands::CommandError, + database::{ + interface::{db_spend_transaction, db_unvault_transaction_by_txid}, + DatabaseError, + }, + revaultd::RevaultD, + threadmessages::BitcoindMessageOut, +}; use interface::{BitcoinD, WalletTransaction}; -use poller::poller_main; +use poller::{poller_main, should_cpfp, ToBeCpfped}; use revault_tx::bitcoin::{Network, Txid}; +use utils::cpfp_package; use std::{ sync::{ @@ -187,6 +196,61 @@ fn wallet_transaction(bitcoind: &BitcoinD, txid: Txid) -> Option>, + bitcoind: Arc>, + txids: Vec, + feerate: f64, +) -> Result, CommandError> { + let db_path = revaultd.read().unwrap().db_file(); + assert!(revaultd.read().unwrap().is_manager()); + + let mut cpfp_txs = Vec::with_capacity(txids.len()); + let mut cpfp_txids = Vec::with_capacity(txids.len()); + + // sats/vbyte -> sats/WU + let sats_wu = feerate / 4.0; + + // sats/WU -> sats/kWU + let sats_kwu = (sats_wu * 1000.0) as u64; + + for txid in txids.iter() { + let spend_tx = db_spend_transaction(&db_path, &txid).expect("Database must be available"); + + if let Some(unwrap_spend_tx) = spend_tx { + // If the transaction is of type SpendTransaction + let psbt = unwrap_spend_tx.psbt; + if should_cpfp(&bitcoind.read().unwrap(), &psbt, sats_kwu) { + cpfp_txs.push(ToBeCpfped::Spend(psbt)); + cpfp_txids.push(txid.clone()); + } + } else { + let unvault_tx = match db_unvault_transaction_by_txid(&db_path, &txid) + .expect("Database must be available") + { + Some(tx) => tx, + None => return Err(CommandError::InvalidParams("Unknown Txid.".to_string())), + }; + // The transaction type is asserted to be UnvaultTransaction + let psbt = unvault_tx.psbt.assert_unvault(); + if should_cpfp(&bitcoind.read().unwrap(), &psbt, sats_kwu) { + cpfp_txs.push(ToBeCpfped::Unvault(psbt)); + cpfp_txids.push(txid.clone()); + } + } + } + + if cpfp_txids.len() > 0 { + match cpfp_package(&revaultd, &bitcoind.read().unwrap(), cpfp_txs, sats_kwu) { + Err(err) => return Err(CommandError::Bitcoind(err)), + Ok(txids) => return Ok(txids), + } + } else { + log::info!("Nothing to CPFP in the given list."); + } + Ok(cpfp_txids) +} + /// The bitcoind event loop. /// Listens for bitcoind requests (wallet / chain) and poll bitcoind every 30 seconds, /// updating our state accordingly. @@ -208,7 +272,8 @@ pub fn bitcoind_main_loop( let _bitcoind = bitcoind.clone(); let _sync_progress = sync_progress.clone(); let _shutdown = shutdown.clone(); - move || poller_main(revaultd, _bitcoind, _sync_progress, _shutdown) + let _revaultd = revaultd.clone(); + move || poller_main(_revaultd, _bitcoind, _sync_progress, _shutdown) }); for msg in rx { @@ -252,6 +317,23 @@ pub fn bitcoind_main_loop( )) })?; } + BitcoindMessageOut::CPFPTransaction(txids, feerate, resp_tx) => { + log::trace!("Received 'cpfptransaction' from main thread"); + + let _bitcoind = bitcoind.clone(); + let _revaultd = revaultd.clone(); + resp_tx + .send( + cpfp(_revaultd, _bitcoind, txids, feerate) + .map(|_v| {}) + .map_err(|e| { + BitcoindError::Custom(format!("Error CPFPing transactions: {}", e)) + }), + ) + .map_err(|e| { + BitcoindError::Custom(format!("Sending transaction for CPFP: {}", e)) + })?; + } } } diff --git a/src/bitcoind/poller.rs b/src/bitcoind/poller.rs index 977f4378..f2d8a499 100644 --- a/src/bitcoind/poller.rs +++ b/src/bitcoind/poller.rs @@ -3,7 +3,7 @@ use crate::{ bitcoind::{ interface::{BitcoinD, DepositsState, SyncInfo, UnvaultsState, UtxoInfo}, utils::{ - cancel_txids, emer_txid, populate_deposit_cache, populate_unvaults_cache, + cancel_txids, cpfp_package, emer_txid, populate_deposit_cache, populate_unvaults_cache, presigned_transactions, unemer_txid, unvault_txid, unvault_txin_from_deposit, vault_deposit_utxo, }, @@ -31,20 +31,16 @@ use crate::{ revaultd::{BlockchainTip, RevaultD, VaultStatus}, }; use revault_tx::{ - bitcoin::{consensus::encode, secp256k1, Amount, OutPoint, Txid}, - error::TransactionCreationError, + bitcoin::{secp256k1, Amount, OutPoint, Txid}, miniscript::descriptor::{DescriptorSecretKey, DescriptorXKey, KeyMap, Wildcard}, scripts::CpfpDescriptor, - transactions::{ - CpfpTransaction, CpfpableTransaction, RevaultTransaction, SpendTransaction, - UnvaultTransaction, - }, + transactions::{CpfpableTransaction, RevaultTransaction, SpendTransaction, UnvaultTransaction}, txins::{CpfpTxIn, RevaultTxIn}, - txouts::{CpfpTxOut, RevaultTxOut}, + txouts::RevaultTxOut, }; use std::{ - collections::{HashMap, HashSet}, + collections::HashMap, path::{Path, PathBuf}, sync::{ atomic::{AtomicBool, Ordering}, @@ -408,7 +404,7 @@ fn mark_confirmed_emers( Ok(()) } -enum ToBeCpfped { +pub enum ToBeCpfped { Spend(SpendTransaction), Unvault(UnvaultTransaction), } @@ -447,101 +443,12 @@ impl ToBeCpfped { } } -// CPFP a bunch of transactions, bumping their feerate by at least `target_feerate`. -// `target_feerate` is expressed in sat/kWU. -// All the transactions' feerate MUST be below `target_feerate`. -fn cpfp_package( - revaultd: &Arc>, +/// `target_feerate` is in sats/kWU +pub fn should_cpfp( bitcoind: &BitcoinD, - to_be_cpfped: Vec, + tx: &impl CpfpableTransaction, target_feerate: u64, -) -> Result<(), BitcoindError> { - let revaultd = revaultd.read().unwrap(); - let cpfp_descriptor = &revaultd.cpfp_descriptor; - - // First of all, compute all the information we need from the to-be-cpfped transactions. - let mut txids = HashSet::with_capacity(to_be_cpfped.len()); - let mut package_weight = 0; - let mut package_fees = Amount::from_sat(0); - let mut txins = Vec::with_capacity(to_be_cpfped.len()); - for tx in to_be_cpfped.iter() { - txids.insert(tx.txid()); - package_weight += tx.max_weight(); - package_fees += tx.fees(); - assert!(((package_fees.as_sat() * 1000 / package_weight) as u64) < target_feerate); - match tx.cpfp_txin(cpfp_descriptor, &revaultd.secp_ctx) { - Some(txin) => txins.push(txin), - None => { - log::error!("No CPFP txin for tx '{}'", tx.txid()); - return Ok(()); - } - } - } - let tx_feerate = (package_fees.as_sat() * 1_000 / package_weight) as u64; // to sats/kWU - assert!(tx_feerate < target_feerate); - let added_feerate = target_feerate - tx_feerate; - - // Then construct the child PSBT - let confirmed_cpfp_utxos: Vec<_> = bitcoind - .list_unspent_cpfp()? - .into_iter() - .filter_map(|l| { - // Not considering our own outputs nor UTXOs still in mempool - if txids.contains(&l.outpoint.txid) || l.confirmations < 1 { - None - } else { - let txout = CpfpTxOut::new( - Amount::from_sat(l.txo.value), - &revaultd.derived_cpfp_descriptor(l.derivation_index.expect("Must be here")), - ); - Some(CpfpTxIn::new(l.outpoint, txout)) - } - }) - .collect(); - let psbt = match CpfpTransaction::from_txins( - txins, - package_weight, - package_fees, - added_feerate, - confirmed_cpfp_utxos, - ) { - Ok(tx) => tx, - Err(TransactionCreationError::InsufficientFunds) => { - // Well, we're poor. - log::error!( - "We wanted to feebump transactions '{:?}', but we don't have enough funds!", - txids - ); - return Ok(()); - } - Err(e) => { - log::error!("Error while creating CPFP transaction: '{}'", e); - return Ok(()); - } - }; - - // Finally, sign and (try to) broadcast the CPFP transaction - let (complete, psbt_signed) = bitcoind.sign_psbt(psbt.psbt())?; - if !complete { - log::error!( - "Bitcoind returned a non-finalized CPFP PSBT: {}", - base64::encode(encode::serialize(&psbt_signed)) - ); - return Ok(()); - } - - let final_tx = psbt_signed.extract_tx(); - if let Err(e) = bitcoind.broadcast_transaction(&final_tx) { - log::error!("Error broadcasting '{:?}' CPFP tx: {}", txids, e); - } else { - log::info!("CPFPed transactions with ids '{:?}'", txids); - } - - Ok(()) -} - -// `target_feerate` is in sats/kWU -fn should_cpfp(bitcoind: &BitcoinD, tx: &impl CpfpableTransaction, target_feerate: u64) -> bool { +) -> bool { bitcoind .get_wallet_transaction(&tx.txid()) // In the unlikely (actually, shouldn't happen but hey) case where @@ -599,7 +506,14 @@ fn maybe_cpfp_txs( // TODO: std transaction max size check and split // TODO: smarter RBF (especially opportunistically with the fee delta) if !to_cpfp.is_empty() { - cpfp_package(revaultd, bitcoind, to_cpfp, current_feerate)?; + match cpfp_package(revaultd, bitcoind, to_cpfp, current_feerate) { + Err(e) => { + log::error!("Error broadcasting CPFP: {}", e); + } + Ok(txids) => { + log::info!("CPFPed transactions with ids '{:?}'", txids); + } + } } else { log::debug!("Nothing to CPFP"); } diff --git a/src/bitcoind/utils.rs b/src/bitcoind/utils.rs index a0d9f218..a30a32fe 100644 --- a/src/bitcoind/utils.rs +++ b/src/bitcoind/utils.rs @@ -1,5 +1,8 @@ use crate::{ - bitcoind::{interface::UtxoInfo, BitcoindError}, + bitcoind::{ + interface::{BitcoinD, UtxoInfo}, + BitcoindError, ToBeCpfped, + }, database::{ interface::{ db_deposits, db_emer_transaction, db_unvault_emer_transaction, db_unvault_from_deposit, @@ -10,18 +13,21 @@ use crate::{ revaultd::{RevaultD, VaultStatus}, }; use revault_tx::{ + bitcoin::consensus::encode, bitcoin::{Amount, OutPoint, TxOut, Txid}, + error::TransactionCreationError, miniscript::DescriptorTrait, + transactions::CpfpTransaction, transactions::{ transaction_chain, transaction_chain_manager, CancelTransaction, EmergencyTransaction, RevaultTransaction, UnvaultEmergencyTransaction, UnvaultTransaction, }, - txins::{DepositTxIn, RevaultTxIn, UnvaultTxIn}, - txouts::{DepositTxOut, RevaultTxOut}, + txins::{CpfpTxIn, DepositTxIn, RevaultTxIn, UnvaultTxIn}, + txouts::{CpfpTxOut, DepositTxOut, RevaultTxOut}, }; use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, sync::{Arc, RwLock}, }; @@ -288,3 +294,93 @@ pub fn emer_txid( Ok(None) } + +/// CPFP a bunch of transactions, bumping their feerate by at least `target_feerate`. +/// `target_feerate` is expressed in sat/kWU. +/// All the transactions' feerate MUST be below `target_feerate`. +pub fn cpfp_package( + revaultd: &Arc>, + bitcoind: &BitcoinD, + to_be_cpfped: Vec, + target_feerate: u64, +) -> Result, BitcoindError> { + let revaultd = revaultd.read().unwrap(); + let cpfp_descriptor = &revaultd.cpfp_descriptor; + // First of all, compute all the information we need from the to-be-cpfped transactions. + let mut txids = HashSet::with_capacity(to_be_cpfped.len()); + let mut package_weight = 0; + let mut package_fees = Amount::from_sat(0); + let mut txins = Vec::with_capacity(to_be_cpfped.len()); + for tx in to_be_cpfped.iter() { + txids.insert(tx.txid()); + package_weight += tx.max_weight(); + package_fees += tx.fees(); + assert!(((package_fees.as_sat() * 1000 / package_weight) as u64) < target_feerate); + match tx.cpfp_txin(cpfp_descriptor, &revaultd.secp_ctx) { + Some(txin) => txins.push(txin), + None => { + log::error!("No CPFP txin for tx '{}'", tx.txid()); + return Ok(txids.into_iter().collect()); + } + } + } + let tx_feerate = (package_fees.as_sat() * 1_000 / package_weight) as u64; // to sats/kWU + assert!(tx_feerate < target_feerate); + let added_feerate = target_feerate - tx_feerate; + // Then construct the child PSBT + let confirmed_cpfp_utxos: Vec<_> = bitcoind + .list_unspent_cpfp()? + .into_iter() + .filter_map(|l| { + // Not considering our own outputs nor UTXOs still in mempool + if txids.contains(&l.outpoint.txid) || l.confirmations < 1 { + None + } else { + let txout = CpfpTxOut::new( + Amount::from_sat(l.txo.value), + &revaultd.derived_cpfp_descriptor(l.derivation_index.expect("Must be here")), + ); + Some(CpfpTxIn::new(l.outpoint, txout)) + } + }) + .collect(); + let psbt = match CpfpTransaction::from_txins( + txins, + package_weight, + package_fees, + added_feerate, + confirmed_cpfp_utxos, + ) { + Ok(tx) => tx, + Err(TransactionCreationError::InsufficientFunds) => { + // Well, we're poor. + return Err(BitcoindError::RevaultTx( + revault_tx::Error::TransactionCreation(TransactionCreationError::InsufficientFunds), + )); + } + Err(e) => { + return Err(BitcoindError::RevaultTx( + revault_tx::Error::TransactionCreation(e), + )); + } + }; + // Finally, sign and (try to) broadcast the CPFP transaction + let (complete, psbt_signed) = bitcoind.sign_psbt(psbt.psbt())?; + if !complete { + return Err(BitcoindError::Custom( + format!( + "Bitcoind returned a non-finalized CPFP PSBT: {}", + base64::encode(encode::serialize(&psbt_signed)) + ) + .to_string(), + )); + } + let final_tx = psbt_signed.extract_tx(); + if let Err(e) = bitcoind.broadcast_transaction(&final_tx) { + return Err(BitcoindError::Custom( + format!("Error broadcasting '{:?}' CPFP tx: {}", txids, e).to_string(), + )); + } + log::info!("CPFPed transactions with ids '{:?}'", txids); + Ok(txids.into_iter().collect()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 97e5040f..c9835e40 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1443,6 +1443,23 @@ impl DaemonControl { let revaultd = self.revaultd.read().unwrap(); gethistory(&revaultd, &self.bitcoind_conn, start, end, limit, kind) } + + /// Manually trigger a CPFP for the given transaction ID. + /// + /// ## Errors + /// - we don't have access to a CPFP private key + /// - the caller is not a manager + pub fn manual_cpfp(&self, txids: &Vec, feerate: f64) -> Result<(), CommandError> { + let revaultd = self.revaultd.read().unwrap(); + + if revaultd.cpfp_key.is_none() { + return Err(CommandError::MissingCpfpKey); + } + manager_only!(revaultd); + + self.bitcoind_conn.cpfp_tx(txids.to_vec(), feerate)?; + Ok(()) + } } /// Descriptors the daemon was configured with diff --git a/src/database/interface.rs b/src/database/interface.rs index afa6e0b3..977863fb 100644 --- a/src/database/interface.rs +++ b/src/database/interface.rs @@ -606,6 +606,41 @@ pub fn db_unvault_emer_transaction( .map(|mut rows| rows.pop()) } +/// Get the Unvault transaction out of an Unvault txid +pub fn db_unvault_transaction_by_txid( + db_path: &Path, + txid: &Txid, +) -> Result, DatabaseError> { + Ok(db_query( + db_path, + "SELECT vaults.*, ptx.id, ptx.psbt, ptx.fullysigned FROM presigned_transactions as ptx \ + INNER JOIN vaults ON vaults.id = ptx.vault_id \ + WHERE ptx.txid = (?1) and type = (?2)", + params![txid.to_vec(), TransactionType::Unvault as u32], + |row| { + let db_vault: DbVault = row.try_into()?; + let offset = 15; + + // FIXME: there is probably a more extensible way to implement the from()s so we don't + // have to change all those when adding a column + let id: u32 = row.get(offset)?; + let psbt: Vec = row.get(offset + 1)?; + let psbt = UnvaultTransaction::from_psbt_serialized(&psbt).expect("We store it"); + let is_fully_signed = row.get(offset + 2)?; + let db_tx = DbTransaction { + id, + vault_id: db_vault.id, + tx_type: TransactionType::Unvault, + psbt: RevaultTx::Unvault(psbt), + is_fully_signed, + }; + + Ok(db_tx) + }, + )? + .pop()) +} + /// Get a vault and its Unvault transaction out of an Unvault txid pub fn db_vault_by_unvault_txid( db_path: &Path, diff --git a/src/jsonrpc/api.rs b/src/jsonrpc/api.rs index 0f696cac..68e40ea1 100644 --- a/src/jsonrpc/api.rs +++ b/src/jsonrpc/api.rs @@ -214,6 +214,15 @@ pub trait RpcApi { end: u32, limit: u64, ) -> jsonrpc_core::Result; + + // Manually cpfp the given transaction id. + #[rpc(meta, name = "cpfp")] + fn cpfp( + &self, + meta: Self::Metadata, + txids: Vec, + feerate: f64, + ) -> jsonrpc_core::Result; } macro_rules! parse_vault_status { @@ -301,6 +310,10 @@ impl RpcApi for RpcImpl { "emergency": [ ], + "cpfp": [ + "txids", + "feerate", + ], })) } @@ -513,4 +526,16 @@ impl RpcApi for RpcImpl { "events": events, })) } + + // manual CPFP command + // feerate will be in sat/vbyte + fn cpfp( + &self, + meta: Self::Metadata, + txids: Vec, + feerate: f64, + ) -> jsonrpc_core::Result { + meta.daemon_control.manual_cpfp(&txids, feerate)?; + Ok(json!({})) + } } diff --git a/src/threadmessages.rs b/src/threadmessages.rs index ba86bef7..49217fa6 100644 --- a/src/threadmessages.rs +++ b/src/threadmessages.rs @@ -41,6 +41,7 @@ pub enum BitcoindMessageOut { Vec, SyncSender>, ), + CPFPTransaction(Vec, f64, SyncSender>), } /// Interface to communicate with bitcoind client thread. @@ -49,6 +50,7 @@ pub trait BitcoindThread { fn broadcast(&self, transactions: Vec) -> Result<(), BitcoindError>; fn shutdown(&self); fn sync_progress(&self) -> f64; + fn cpfp_tx(&self, txids: Vec, feerate: f64) -> Result<(), BitcoindError>; } /// Interface to the bitcoind thread using synchronous MPSCs @@ -98,6 +100,18 @@ impl<'a> BitcoindThread for BitcoindSender { bitrep_rx.recv().expect("Receiving from bitcoind thread") } + + fn cpfp_tx(&self, txids: Vec, feerate: f64) -> Result<(), BitcoindError> { + let (bitrep_tx, bitrep_rx) = sync_channel(0); + self.0 + .send(BitcoindMessageOut::CPFPTransaction( + txids, feerate, bitrep_tx, + )) + .expect("Sending to bitcoind thread"); + bitrep_rx.recv().expect("Receiving from bitcoind thread")?; + + Ok(()) + } } impl From> for BitcoindSender { diff --git a/src/utils.rs b/src/utils.rs index 7a52c2b2..c0519cb4 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -178,5 +178,8 @@ addr = "127.0.0.1:8332" fn sync_progress(&self) -> f64 { 1.0 } + fn cpfp_tx(&self, _txid: Vec, _feerate: f64) -> Result<(), BitcoindError> { + Ok(()) + } } } diff --git a/tests/servers/coordinatord b/tests/servers/coordinatord index d4ebc106..3b19b1ba 160000 --- a/tests/servers/coordinatord +++ b/tests/servers/coordinatord @@ -1 +1 @@ -Subproject commit d4ebc10638f23549fd1f80f3265c39e6c52418b5 +Subproject commit 3b19b1ba5947919e5c85e1b26cfc156d529e13fa diff --git a/tests/servers/cosignerd b/tests/servers/cosignerd index bcb4b64c..644ead5d 160000 --- a/tests/servers/cosignerd +++ b/tests/servers/cosignerd @@ -1 +1 @@ -Subproject commit bcb4b64c8382a4de93fbdb66e799b070ff4b37be +Subproject commit 644ead5d5fd6f1c994e8d45015ee564a3b56e16b diff --git a/tests/servers/miradord b/tests/servers/miradord index 011527e7..e589c355 160000 --- a/tests/servers/miradord +++ b/tests/servers/miradord @@ -1 +1 @@ -Subproject commit 011527e7fcfa77521b9f86e34a886ebce37efdfd +Subproject commit e589c35551972abfc6ba9468894400a83879c416 diff --git a/tests/test_framework/revault_network.py b/tests/test_framework/revault_network.py index 0a840f85..8dfa68e8 100644 --- a/tests/test_framework/revault_network.py +++ b/tests/test_framework/revault_network.py @@ -3,6 +3,7 @@ import os import random +from bip380.descriptors import Descriptor from ephemeral_port_reserve import reserve from nacl.public import PrivateKey as Curve25519Private from test_framework import serializations @@ -541,6 +542,13 @@ def fundmany(self, amounts=[]): return created_vaults + def fund_cpfp(self, amount): + """Send a coin worth this value to the CPFP descriptor""" + der_desc = Descriptor.from_str(str(self.cpfp_desc)) + der_desc.derive(0) + decoded = self.bitcoind.rpc.decodescript(der_desc.script_pubkey.hex()) + self.bitcoind.rpc.sendtoaddress(decoded["address"], amount) + def secure_vault(self, vault): """Make all stakeholders share signatures for all revocation txs""" deposit = f"{vault['txid']}:{vault['vout']}" diff --git a/tests/test_rpc.py b/tests/test_rpc.py index 3ef54809..979f104e 100644 --- a/tests/test_rpc.py +++ b/tests/test_rpc.py @@ -11,12 +11,7 @@ from fixtures import * from test_framework import serializations -from test_framework.utils import ( - POSTGRES_IS_SETUP, - TIMEOUT, - RpcError, - wait_for, -) +from test_framework.utils import POSTGRES_IS_SETUP, TIMEOUT, RpcError, wait_for, COIN def test_getinfo(revaultd_manager, bitcoind): @@ -1348,3 +1343,251 @@ def test_gethistory(revault_network, bitcoind, executor): ) == 5 ) + + +def get_unvault_txids(wallet, vaults): + unvault_txids = [] + for vault in vaults: + deposit = f"{vault['txid']}:{vault['vout']}" + unvault_psbt = serializations.PSBT() + unvault_b64 = wallet.rpc.listpresignedtransactions([deposit])[ + "presigned_transactions" + ][0]["unvault"] + unvault_psbt.deserialize(unvault_b64) + unvault_psbt.tx.calc_sha256() + unvault_txids.append(unvault_psbt.tx.hash) + return unvault_txids + + +@pytest.mark.skipif(not POSTGRES_IS_SETUP, reason="Needs Postgres for servers db") +def test_manual_cpfp_single_transaction(revault_network, bitcoind): + CSV = 12 + revault_network.deploy( + 2, + 1, + csv=CSV, + bitcoind_rpc_mocks={"estimatesmartfee": {"feerate": 0.0005}}, # 50 sats/vbyte + ) + man = revault_network.mans()[0] + bitcoind.generate_block(CSV) + vault = revault_network.fund(50) + revault_network.fund_cpfp(150) + + # Broadcast the unvaults and get their txids + revault_network.activate_fresh_vaults([vault]) + spend_psbt = revault_network.broadcast_unvaults_anyhow([vault], priority=True) + spend_txid = spend_psbt.tx.hash + + unvault_txid = get_unvault_txids(man, [vault])[0] + for w in revault_network.participants(): + wait_for( + lambda: len(w.rpc.listvaults(["unvaulting"])["vaults"]) == 1, + ) + + # Unvault's default feerate is not very attractive for a miner.. + entry = bitcoind.rpc.getmempoolentry(unvault_txid) + assert entry["descendantcount"] == 1 + package_feerate = entry["fees"]["descendant"] * COIN / entry["descendantsize"] + assert int(package_feerate) == 24 + + # .. We CPFP it.. + man.rpc.cpfp([unvault_txid], 35) + wait_for(lambda: len(bitcoind.rpc.getrawmempool()) >= 2) + + # .. And now it is! + entry = bitcoind.rpc.getmempoolentry(unvault_txid) + assert entry["descendantcount"] == 2 + package_feerate = entry["fees"]["descendant"] * COIN / entry["descendantsize"] + assert int(package_feerate) >= 35 + + ## Repeating the above for spend transaction + + bitcoind.generate_block(CSV) + man.wait_for_log(f"Succesfully broadcasted Spend tx '{spend_txid}'") + + for w in revault_network.participants(): + wait_for( + lambda: len(w.rpc.listvaults(["spending"])["vaults"]) == 1, + ) + + # Manual CPFP trigger for spend_txid. + man.rpc.cpfp([spend_txid], 70) + man.wait_for_log( + f"CPFPed transactions with ids '{{{spend_txid}}}'", + ) + wait_for(lambda: len(bitcoind.rpc.getrawmempool()) >= 2) + entry = bitcoind.rpc.getmempoolentry(spend_txid) + assert entry["descendantcount"] == 2 + package_feerate = entry["fees"]["descendant"] * COIN / entry["descendantsize"] + assert int(package_feerate) >= 70 + + +@pytest.mark.skipif(not POSTGRES_IS_SETUP, reason="Needs Postgres for servers db") +def test_manual_cpfp_batch_transactions(revault_network, bitcoind): + CSV = 12 + revault_network.deploy( + 2, + 1, + csv=CSV, + bitcoind_rpc_mocks={"estimatesmartfee": {"feerate": 0.0005}}, # 50 sats/vbyte + ) + man = revault_network.mans()[0] + bitcoind.generate_block(CSV) + + # Need atleast two vaults for batch + vaults = revault_network.fundmany([10, 10]) + revault_network.fund_cpfp(300) + + # Broadcast the unvaults and get their txids + revault_network.activate_fresh_vaults(vaults) + spend_psbts = [ + revault_network.broadcast_unvaults_anyhow([vault], priority=True) + for vault in vaults + ] + spend_txids = [spend_psbt.tx.hash for spend_psbt in spend_psbts] + + unvault_txids = get_unvault_txids(man, vaults) + for w in revault_network.participants(): + wait_for( + lambda: len(w.rpc.listvaults(["unvaulting"])["vaults"]) == len(vaults), + ) + + # Unvault's default feerate is not very attractive for a miner.. + for unvault_txid in unvault_txids: + entry = bitcoind.rpc.getmempoolentry(unvault_txid) + assert entry["descendantcount"] == 1 + package_feerate = entry["fees"]["descendant"] * COIN / entry["descendantsize"] + assert int(package_feerate) == 24 + + # .. We CPFP it.. + man.rpc.cpfp(unvault_txids, 30) + wait_for(lambda: len(bitcoind.rpc.getrawmempool()) == len(unvault_txids) + 1) + + # .. And now it is! + for unvault_txid in unvault_txids: + entry = bitcoind.rpc.getmempoolentry(unvault_txid) + assert entry["descendantcount"] == 2 + package_feerate = entry["fees"]["descendant"] * COIN / entry["descendantsize"] + assert int(package_feerate) >= 30 + + ## Repeating the above for spend transaction + + bitcoind.generate_block(CSV) + man.wait_for_log(f"Succesfully broadcasted Spend tx") + + for w in revault_network.participants(): + wait_for( + lambda: len(w.rpc.listvaults(["spending"])["vaults"]) == len(vaults), + ) + + # Manual CPFP trigger for spend_txid. + man.rpc.cpfp(spend_txids, 70) + man.wait_for_log( + f"CPFPed transactions", + ) + + wait_for(lambda: len(bitcoind.rpc.getrawmempool()) == len(spend_txids) + 1) + + for spend_txid in spend_txids: + entry = bitcoind.rpc.getmempoolentry(spend_txid) + assert entry["descendantcount"] == 2 + package_feerate = entry["fees"]["descendant"] * COIN / entry["descendantsize"] + assert int(package_feerate) >= 70 + + +## Sanity checks +@pytest.mark.skipif(not POSTGRES_IS_SETUP, reason="Needs Postgres for servers db") +def test_manual_cpfp_invalid_txids(revault_network, bitcoind): + CSV = 12 + revault_network.deploy( + 2, + 1, + csv=CSV, + bitcoind_rpc_mocks={"estimatesmartfee": {"feerate": 0.0005}}, # 50 sats/vbyte + ) + man = revault_network.mans()[0] + + txid = "4808cd1c4cd6d0d3abbfd5c47bb4869dbc25ffcb1dcd5effdef31396c17a68cf" + # unknown transaction should throw an error. + with pytest.raises(RpcError) as err: + man.rpc.cpfp([txid], 50) + + # removed one character 4 + txid = "808cd1c4cd6d0d3abbfd5c47bb4869dbc25ffcb1dcd5effdef31396c17a68cf" + # invalid transaction should throw an error. + with pytest.raises(RpcError) as err: + man.rpc.cpfp([txid], 50) + + +@pytest.mark.skipif(not POSTGRES_IS_SETUP, reason="Needs Postgres for servers db") +def test_manual_cpfp_already_mined(revault_network, bitcoind): + CSV = 12 + revault_network.deploy( + 2, + 1, + csv=CSV, + bitcoind_rpc_mocks={"estimatesmartfee": {"feerate": 0.00001}}, # 1 sats/vbyte + ) + man = revault_network.mans()[0] + vaults = [revault_network.fund(1)] + + # Broadcast the unvaults and get their txids + revault_network.activate_fresh_vaults(vaults) + spend_psbt = revault_network.broadcast_unvaults_anyhow(vaults, priority=True) + + unvault_txids = get_unvault_txids(man, vaults) + spend_txid = spend_psbt.tx.hash + + # Confirming the unvaults + bitcoind.generate_block(1, wait_for_mempool=unvault_txids) + for w in revault_network.participants(): + wait_for( + lambda: len(w.rpc.listvaults(["unvaulted"])["vaults"]) == len(vaults), + ) + + bitcoind.generate_block(CSV - 1) + man.wait_for_log(f"Succesfully broadcasted Spend tx '{spend_txid}'") + + for w in revault_network.participants(): + wait_for( + lambda: len(w.rpc.listvaults(["spending"])["vaults"]) == len(vaults), + ) + + # Mining the spend and unvault transactions + bitcoind.generate_block(CSV, wait_for_mempool=[]) + + # Manual CPFP trigger for already mined spend_txid. + cpfped_txs = man.rpc.cpfp([spend_txid], 50) + man.wait_for_log( + f"Nothing to CPFP in the given list.", + ) + assert len(cpfped_txs) == 0 + + +@pytest.mark.skipif(not POSTGRES_IS_SETUP, reason="Needs Postgres for servers db") +def test_manual_cpfp_lower_feerate(revault_network, bitcoind): + CSV = 12 + revault_network.deploy( + 2, + 1, + csv=CSV, + bitcoind_rpc_mocks={"estimatesmartfee": {"feerate": 0.0005}}, # 50 sats/vbyte + ) + man = revault_network.mans()[0] + vaults = revault_network.fundmany([1, 2, 3]) + + # Broadcast the unvaults and get their txids + revault_network.activate_fresh_vaults(vaults) + revault_network.broadcast_unvaults_anyhow(vaults, priority=True) + + unvault_txids = get_unvault_txids(man, vaults) + for w in revault_network.participants(): + wait_for( + lambda: len(w.rpc.listvaults(["unvaulting"])["vaults"]) == len(vaults), + ) + + # If the feerate isn't significantly lower than the estimate, we won't feebump. + # Note the Unvault txs have a fixed 24sat/vb feerate. + cpfped_txs = man.rpc.cpfp([unvault_txids[0]], 20) + man.wait_for_log("Nothing to CPFP in the given list.") + assert len(cpfped_txs) == 0