diff --git a/doc/API.md b/doc/API.md index 47097bff7..aeb549004 100644 --- a/doc/API.md +++ b/doc/API.md @@ -17,6 +17,7 @@ Commands must be sent as valid JSONRPC 2.0 requests, ending with a `\n`. | [`listspendtxs`](#listspendtxs) | List all stored Spend transactions | | [`delspendtx`](#delspendtx) | Delete a stored Spend transaction | | [`broadcastspend`](#broadcastspend) | Finalize a stored Spend PSBT, and broadcast it | +| [`rbfpsbt`](#rbfpsbt) | Create a new RBF Spend transaction | | [`startrescan`](#startrescan) | Start rescanning the block chain from a given date | | [`listconfirmed`](#listconfirmed) | List of confirmed transactions of incoming and outgoing funds | | [`listtransactions`](#listtransactions) | List of transactions with the given txids | @@ -249,6 +250,24 @@ This command does not return anything for now. | Field | Type | Description | | -------------- | --------- | ---------------------------------------------------- | +### `rbfpsbt` + +TODO: Add comment that increasing the fee on a self-send may add other coins to the transaction. + +#### Request + +| Field | Type | Description | +| -------- | ------ | ---------------------------------------------------------------------------------------- | +| `txid` | string | Hex encoded txid of the Spend transaction to be replaced. | +| `feerate` | integer | Target feerate for the transaction, in satoshis per virtual byte. | +| `is_cancel` | bool | If `false`, the non-change outputs of the transaction to be replaced will be preserved. If `true`, non-change outputs will be removed and the only output of the replacement transaction will be change. | + +#### Response + +| Field | Type | Description | +| -------------- | --------- | ---------------------------------------------------- | +| `psbt` | string | PSBT of the spending transaction, encoded as base64. | + ### `startrescan` #### Request diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 8fbb363fb..e91f8b322 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -50,6 +50,10 @@ const MAX_FEERATE: u64 = 1_000; // Timestamp in the header of the genesis block. Used for sanity checks. const MAINNET_GENESIS_TIME: u32 = 1231006505; +// In order to use RBF, the sum of descendant counts of the replacement transaction's +// directly conflicting transactions must not exceed this value. +const RBF_MAX_DESCENDANT_COUNT: u64 = 100; + #[derive(Debug, Clone, PartialEq, Eq)] pub enum CommandError { NoOutpointForSelfSend, @@ -79,6 +83,7 @@ pub enum CommandError { /// Overflowing or unhardened derivation index. InvalidDerivationIndex, CoinSelectionError(InsufficientFunds), + RbfError(RbfErrorInfo), } impl fmt::Display for CommandError { @@ -144,7 +149,8 @@ impl fmt::Display for CommandError { "No coin currently spendable through this timelocked recovery path." ), Self::InvalidDerivationIndex => write!(f, "Unhardened or overflowing BIP32 derivation index."), - Self::CoinSelectionError(e) => write!(f, "Coin selection error: '{}'", e), + Self::CoinSelectionError(e) => write!(f, "Coin selection error: '{}'", e), + Self::RbfError(e) => write!(f, "RBF error: '{}'.", e) } } } @@ -171,6 +177,38 @@ pub enum InsaneFeeInfo { TooHighFeerate(u64), } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RbfErrorInfo { + TxAlreadyConfirmed(bitcoin::Txid), + TxNotYetBroadcast(bitcoin::Txid), + TooLowFeerate(u64), + TooManyDescendants(u64), + FailedToCreateReplacement, +} + +impl fmt::Display for RbfErrorInfo { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match *self { + Self::TxAlreadyConfirmed(txid) => { + write!(f, "Transaction '{}' has already been confirmed.", txid) + } + Self::TxNotYetBroadcast(txid) => { + write!(f, "Transaction '{}' has not yet been broadcast.", txid) + } + Self::TooLowFeerate(r) => write!(f, "Feerate too low: {}.", r), + Self::TooManyDescendants(n) => write!( + f, + "The directly conflicting transactions have too many descendants: {}.", + n + ), + Self::FailedToCreateReplacement => write!( + f, + "Could not create transaction satisfying replacement policy." + ), + } + } +} + /// A candidate for coin selection when creating a transaction. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct CandidateCoin { @@ -736,6 +774,372 @@ impl DaemonControl { .map_err(CommandError::TxBroadcast) } + // This is like `create_spend` with slightly different parameters. + fn create_rbf_spend( + &self, + destinations: &HashMap, + candidate_coins: &[CandidateCoin], + feerate_vb: u64, + min_fee: u64, + change_index: Option, + ) -> Result { + // TODO: avoid duplicating code. + let is_self_send = destinations.is_empty(); + + // TODO: Can we omit checks for now as this is a private method? + // If the candidates are empty, we will get insufficient funds error so no need to add explicit check. + // For self-send, the candidate coins must all be mandatory. This check will pass if candidates slice is empty. + // if is_self_send + // && candidate_coins.iter().any(|c| !c.must_select) + // { + // // TODO: Use different error type. + // return Err(CommandError::NoOutpointForSelfSend); + // } + // if feerate_vb < 1 { + // return Err(CommandError::InvalidFeerate(feerate_vb)); + // } + let mut db_conn = self.db.connection(); + + // Create transaction with no inputs and no outputs. + let mut tx = bitcoin::Transaction { + version: 2, + lock_time: absolute::LockTime::Blocks(absolute::Height::ZERO), // TODO: randomized anti fee sniping + input: Vec::with_capacity(candidate_coins.iter().filter(|c| c.must_select).count()), + output: Vec::with_capacity(destinations.len()), + }; + // Add the destinations outputs to the transaction and PSBT. At the same time record the + // total output value to later compute fees, and sanity check each output's value. + // let mut out_value = bitcoin::Amount::from_sat(0); + // Add the destinations outputs to the transaction and PSBT. At the same time + // sanity check each output's value. + let mut psbt_outs = Vec::with_capacity(destinations.len()); + for (address, amount) in destinations { + //let address = self.validate_address(address.clone())?; + + //let amount = bitcoin::Amount::from_sat(*value_sat); + check_output_value(*amount)?; + + tx.output.push(bitcoin::TxOut { + value: amount.to_sat(), + script_pubkey: address.script_pubkey(), + }); + // If it's an address of ours, signal it as change to signing devices by adding the + // BIP32 derivation path to the PSBT output. + let bip32_derivation = + if let Some((index, is_change)) = db_conn.derivation_index_by_address(address) { + let desc = if is_change { + self.config.main_descriptor.change_descriptor() + } else { + self.config.main_descriptor.receive_descriptor() + }; + desc.derive(index, &self.secp).bip32_derivations() + } else { + Default::default() + }; + psbt_outs.push(PsbtOut { + bip32_derivation, + ..PsbtOut::default() + }); + } + assert_eq!(tx.output.is_empty(), is_self_send); + + // Now compute whether we'll need a change output while automatically selecting coins to be + // used as input if necessary. + // We need to get the size of a potential change output to select coins / determine whether + // we should include one, so get a change address and create a dummy txo for this purpose. + let current_change_index = db_conn.change_index(); + let (change_index, increment_db) = if let Some(index) = change_index { + if index < current_change_index { + (index, false) + } else { + // This includes if index is bigger than current change index, which we don't expect. + (index, true) + } + } else { + (current_change_index, true) + }; + let change_desc = self + .config + .main_descriptor + .change_descriptor() + .derive(change_index, &self.secp); + let mut change_txo = bitcoin::TxOut { + value: std::u64::MAX, + script_pubkey: change_desc.script_pubkey(), + }; + // Now select the coins necessary using the provided candidates and determine whether + // there is any leftover to create a change output. + let (selected_coins, change_amount) = { + // At this point the transaction still has no input and no change output, as expected + // by the coins selection helper function. + assert!(tx.input.is_empty()); + assert_eq!(tx.output.len(), destinations.len()); + // TODO: Introduce general conversion error type. + let feerate_vb: f32 = { + let fr: u16 = feerate_vb.try_into().map_err(|_| { + CommandError::InsaneFees(InsaneFeeInfo::TooHighFeerate(feerate_vb)) + })?; + fr + } + .try_into() + .expect("u16 must fit in f32"); + let max_sat_wu = self + .config + .main_descriptor + .max_sat_weight() + .try_into() + .expect("Weight must fit in a u32"); + select_coins_for_spend( + candidate_coins, + tx.clone(), + change_txo.clone(), + feerate_vb, + min_fee, + max_sat_wu, + ) + .map_err(CommandError::CoinSelectionError)? + }; + // If necessary, add a change output. + if change_amount.to_sat() > 0 { + // Don't forget to update our next change index! + if increment_db { + let next_index = change_index + .increment() + .expect("Must not get into hardened territory"); + db_conn.set_change_index(next_index, &self.secp); + } + + check_output_value(change_amount)?; + + // TODO: shuffle once we have Taproot + change_txo.value = change_amount.to_sat(); + tx.output.push(change_txo); + psbt_outs.push(PsbtOut { + bip32_derivation: change_desc.bip32_derivations(), + ..PsbtOut::default() + }); + } else if is_self_send { + return Err(CommandError::InsufficientFunds( + selected_coins.iter().map(|c| c.amount).sum(), + None, + feerate_vb, + )); + } + + // Iterate through selected coins and add necessary information to the PSBT inputs. + let mut psbt_ins = Vec::with_capacity(selected_coins.len()); + let mut spent_txs = HashMap::with_capacity(selected_coins.len()); + for coin in &selected_coins { + // Fetch the transaction that created it if necessary + if let hash_map::Entry::Vacant(e) = spent_txs.entry(coin.outpoint) { + let tx = self + .bitcoin + .wallet_transaction(&coin.outpoint.txid) + .ok_or(CommandError::FetchingTransaction(coin.outpoint))?; + e.insert(tx.0); + } + + tx.input.push(bitcoin::TxIn { + previous_output: coin.outpoint, + sequence: bitcoin::Sequence::ENABLE_RBF_NO_LOCKTIME, + // TODO: once we move to Taproot, anti-fee-sniping using nSequence + ..bitcoin::TxIn::default() + }); + + // Populate the PSBT input with the information needed by signers. + let coin_desc = self.derived_desc(coin); + let witness_script = Some(coin_desc.witness_script()); + let witness_utxo = Some(bitcoin::TxOut { + value: coin.amount.to_sat(), + script_pubkey: coin_desc.script_pubkey(), + }); + let non_witness_utxo = spent_txs.get(&coin.outpoint).cloned(); + let bip32_derivation = coin_desc.bip32_derivations(); + psbt_ins.push(PsbtIn { + witness_script, + witness_utxo, + bip32_derivation, + non_witness_utxo, + ..PsbtIn::default() + }); + } + + // Finally, create the PSBT with all inputs and outputs, sanity check it and return it. + let psbt = Psbt { + unsigned_tx: tx, + version: 0, + xpub: BTreeMap::new(), + proprietary: BTreeMap::new(), + unknown: BTreeMap::new(), + inputs: psbt_ins, + outputs: psbt_outs, + }; + sanity_check_psbt(&self.config.main_descriptor, &psbt)?; + // TODO: maybe check for common standardness rules (max size, ..)? + + Ok(CreateSpendResult { psbt }) + } + + /// Create PSBT to replace the given transaction. + pub fn rbf_psbt( + &self, + txid: &bitcoin::Txid, + feerate_vb: u64, + is_cancel: bool, + ) -> Result { + // The transaction to be replaced must be one of ours and so we can assume it signals replaceability. + // The inputs in the replacement transaction will be either those coins used as inputs in the transaction + // to be replaced or "confirmed" coins. Therefore, we can assume the transaction to be replaced is + // the only one directly conflicting with the replacement transaction. + let mut db_conn = self.db.connection(); + + let prev_psbt = db_conn + .spend_tx(txid) + .ok_or(CommandError::UnknownSpend(*txid))?; + let prev_coins = { + let prev_outpoints: &Vec = &prev_psbt + .unsigned_tx + .input + .iter() + .map(|txin| txin.previous_output) + .collect(); + let prev_coins = db_conn.coins_by_outpoints(prev_outpoints); + assert_eq!(prev_outpoints.len(), prev_coins.len()); + prev_coins + }; + if prev_coins + .iter() + .any(|(_, coin)| coin.spend_block.is_some()) + { + return Err(CommandError::RbfError(RbfErrorInfo::TxAlreadyConfirmed( + *txid, + ))); + } + let mempool_entry = self + .bitcoin + .mempool_entry(txid) + .ok_or(CommandError::RbfError(RbfErrorInfo::TxNotYetBroadcast( + *txid, + )))?; + // Check sum of descendant counts of all directly conflicting transactions is not too high. + // We are assuming we only have one directly conflicting transaction. + if mempool_entry.descendant_count > RBF_MAX_DESCENDANT_COUNT { + return Err(CommandError::RbfError(RbfErrorInfo::TooManyDescendants( + mempool_entry.descendant_count, + ))); + } + let prev_feerate_vb = { + let prev_abs_fee = prev_psbt + .fee() + .expect("fee must be Ok as transaction is already in mempool"); + let prev_tx_size: u64 = prev_psbt + .clone() + .extract_tx() + .vsize() + .try_into() + .expect("vsize must fit in a u64"); + prev_abs_fee + .checked_div(prev_tx_size) + .ok_or(CommandError::InsaneFees(InsaneFeeInfo::InvalidFeerate))? + }; + // Check replacement transaction's feerate is greater than previous feerate (the only directly conflicting transaction). + // TODO: Should we throw error or just set feerate_vb = prev_feerate_vb + 1 and check `rbf_psbt` below? + if feerate_vb < prev_feerate_vb.to_sat().checked_add(1).unwrap() { + return Err(CommandError::RbfError(RbfErrorInfo::TooLowFeerate( + feerate_vb, + ))); + } + let prev_txos = &prev_psbt.unsigned_tx.output; + let mut destinations: HashMap = + HashMap::with_capacity(prev_txos.len()); + let mut change_index: Option = None; + for txo in prev_txos { + let address = bitcoin::Address::from_script( + &txo.script_pubkey, + self.config.bitcoin_config.network, + ) + .map_err(CommandError::Address)?; + match db_conn.derivation_index_by_address(&address) { + Some((ind, true)) => { + // TODO: check if there is more than one change output? + println!("found change at address {} and index {}", address, ind); + change_index = Some(ind); + } + _ => { + // Non-change outputs are handled in the same way whether they are ours or not. + if !is_cancel { + destinations.insert(address, bitcoin::Amount::from_sat(txo.value)); + } + } + }; + } + // We take all previous inputs as mandatory candidates and, if not self-send, include + // confirmed coins as optional. + // TODO: For `is_cancel`, shall we include just one previous input? + let mut candidate_coins: Vec = prev_coins + .into_values() + .map(|c| CandidateCoin { + coin: c, + must_select: true, + }) + .collect(); + if !destinations.is_empty() { + candidate_coins.extend( + db_conn + .coins(&[CoinStatus::Confirmed], &[]) + .into_values() + .map(|c| CandidateCoin { + coin: c, + must_select: false, + }), + ); + } + // Try with increasing feerate until fee paid by replacement transaction is high enough. + // Replacement fee must be at least: + // sum of fees paid by original transactions + incremental feerate * replacement size. + for i in 0..100_u64 { + println!("trying feerate of {} with i {}", feerate_vb + i, i); + let rbf_psbt = self.create_rbf_spend( + &destinations, + &candidate_coins, + feerate_vb.checked_add(i).unwrap(), + mempool_entry.fees.descendant.to_sat(), + change_index, + )?; + let rbf_vsize = { + // TODO: Make this a function (can be used also in `sanity_check`). + let witness_factor: u64 = + descriptors::WITNESS_FACTOR.try_into().expect("it must fit"); + let max_sat_weight: u64 = self + .config + .main_descriptor + .max_sat_weight() + .try_into() + .expect("it must fit"); + let num_inputs: u64 = rbf_psbt.psbt.inputs.len().try_into().expect("it must fit"); + let rbf_weight = + rbf_psbt.psbt.unsigned_tx.weight().to_wu() + (max_sat_weight * num_inputs); + rbf_weight + .checked_add(witness_factor - 1) + .unwrap() + .checked_div(witness_factor) + .unwrap() + }; + println!("replacement vsize estimated at {}", rbf_vsize); + if rbf_psbt + .psbt + .fee() + .expect("has already been sanity checked") + >= mempool_entry.fees.descendant + bitcoin::Amount::from_sat(rbf_vsize) + { + return Ok(rbf_psbt); + } + } + Err(CommandError::RbfError( + RbfErrorInfo::FailedToCreateReplacement, + )) + } + /// Trigger a rescan of the block chain for transactions involving our main descriptor between /// the given date and the current tip. /// The date must be after the genesis block time and before the current tip blocktime. diff --git a/src/jsonrpc/api.rs b/src/jsonrpc/api.rs index 225d3f9bc..c43903353 100644 --- a/src/jsonrpc/api.rs +++ b/src/jsonrpc/api.rs @@ -87,6 +87,28 @@ fn broadcast_spend(control: &DaemonControl, params: Params) -> Result Result { + let txid = params + .get(0, "txid") + .ok_or_else(|| Error::invalid_params("Missing 'txid' parameter."))? + .as_str() + .and_then(|s| bitcoin::Txid::from_str(s).ok()) + .ok_or_else(|| Error::invalid_params("Invalid 'txid' parameter."))?; + let feerate: u64 = params + .get(1, "feerate") + .ok_or_else(|| Error::invalid_params("Missing 'feerate' parameter."))? + .as_u64() + .ok_or_else(|| Error::invalid_params("Invalid 'feerate' parameter."))?; + let is_cancel: bool = params + .get(2, "is_cancel") + .ok_or_else(|| Error::invalid_params("Missing 'is_cancel' parameter."))? + .as_bool() + .ok_or_else(|| Error::invalid_params("Invalid 'is_cancel' parameter."))?; + + let res = control.rbf_psbt(&txid, feerate, is_cancel)?; + Ok(serde_json::json!(&res)) +} + fn list_coins(control: &DaemonControl, params: Option) -> Result { let statuses_arg = params .as_ref() @@ -331,6 +353,12 @@ pub fn handle_request(control: &DaemonControl, req: Request) -> Result { + let params = req.params.ok_or_else(|| { + Error::invalid_params("Missing 'txid', 'feerate' and 'is_cancel' parameters.") + })?; + rbf_psbt(control, params)? + } "getinfo" => serde_json::json!(&control.get_info()), "getnewaddress" => serde_json::json!(&control.get_new_address()), "listcoins" => { diff --git a/src/jsonrpc/mod.rs b/src/jsonrpc/mod.rs index 14865c013..bf0b92203 100644 --- a/src/jsonrpc/mod.rs +++ b/src/jsonrpc/mod.rs @@ -165,6 +165,7 @@ impl From for Error { | commands::CommandError::InsaneRescanTimestamp(..) | commands::CommandError::AlreadyRescanning | commands::CommandError::InvalidDerivationIndex + | commands::CommandError::RbfError(..) | commands::CommandError::RecoveryNotAvailable => { Error::new(ErrorCode::InvalidParams, e.to_string()) } diff --git a/tests/test_rpc.py b/tests/test_rpc.py index 37c758989..7df612708 100644 --- a/tests/test_rpc.py +++ b/tests/test_rpc.py @@ -997,3 +997,150 @@ def test_labels(lianad, bitcoind): assert addr not in res assert sec_addr not in res assert res[random_address] == "this address is random" + + +def test_rbf_psbt(lianad, bitcoind): + """Test the use of RBF to replace transactions.""" + + # Get three coins. + destinations = { + lianad.rpc.getnewaddress()["address"]: 0.003, + lianad.rpc.getnewaddress()["address"]: 0.004, + lianad.rpc.getnewaddress()["address"]: 0.005, + } + txid = bitcoind.rpc.sendmany("", destinations) + bitcoind.generate_block(1, wait_for_mempool=txid) + wait_for(lambda: len(lianad.rpc.listcoins(["confirmed"])["coins"]) == 3) + coins = lianad.rpc.listcoins(["confirmed"])["coins"] + + # Create a spend that will later be replaced. + first_outpoints = [c["outpoint"] for c in coins[:2]] + destinations = { + bitcoind.rpc.getnewaddress(): 650_000, + } + first_res = lianad.rpc.createspend(destinations, first_outpoints, 1) + first_psbt = PSBT.from_base64(first_res["psbt"]) + # The transaction has a change output. + assert len(first_psbt.o) == len(first_psbt.tx.vout) == 2 + first_txid = first_psbt.tx.txid().hex() + # We cannot RBF yet as first PSBT has not been saved. + with pytest.raises(RpcError, match=f"Unknown spend transaction '{first_txid}'."): + lianad.rpc.rbfpsbt(first_txid, 1, False) + # Now save the PSBT. + lianad.rpc.updatespend(first_res["psbt"]) + # We still cannot use RBF as it has not been broadcast. + with pytest.raises( + RpcError, match=f"Transaction '{first_txid}' has not yet been broadcast." + ): + lianad.rpc.rbfpsbt(first_txid, 1, False) + # Signing the spend still won't enable RBF. + first_psbt = lianad.signer.sign_psbt(first_psbt) + lianad.rpc.updatespend(first_psbt.to_base64()) + with pytest.raises( + RpcError, match=f"Transaction '{first_txid}' has not yet been broadcast." + ): + lianad.rpc.rbfpsbt(first_txid, 1, False) + # Now broadcast the spend and wait for it to be detected. + lianad.rpc.broadcastspend(first_txid) + wait_for( + lambda: all( + c["spend_info"] is not None and c["spend_info"]["txid"] == first_txid + for c in lianad.rpc.listcoins([], first_outpoints)["coins"] + ) + ) + # We can now use RBF, but the feerate must be higher than that of the first transaction. + with pytest.raises(RpcError, match=f"Feerate too low: 1."): + lianad.rpc.rbfpsbt(first_txid, 1, False) + # Using a higher feerate works. + rbf_1_res = lianad.rpc.rbfpsbt(first_txid, 2, False) + rbf_1_psbt = PSBT.from_base64(rbf_1_res["psbt"]) + # The inputs are the same in both (no new inputs needed in the replacement). + assert sorted( + psbt_in.map[PSBT_IN_NON_WITNESS_UTXO] for psbt_in in first_psbt.i + ) == sorted(psbt_in.map[PSBT_IN_NON_WITNESS_UTXO] for psbt_in in rbf_1_psbt.i) + # Check non-change output is the same in both. + assert first_psbt.tx.vout[0].nValue == rbf_1_psbt.tx.vout[0].nValue + assert first_psbt.tx.vout[0].scriptPubKey == rbf_1_psbt.tx.vout[0].scriptPubKey + # Change address is the same but change amount will be lower in the replacement to pay higher fee. + assert first_psbt.tx.vout[1].nValue > rbf_1_psbt.tx.vout[1].nValue + assert first_psbt.tx.vout[1].scriptPubKey == rbf_1_psbt.tx.vout[1].scriptPubKey + # Broadcast the replacement and wait for it to be detected. + rbf_1_txid = sign_and_broadcast_psbt(lianad, rbf_1_psbt) + wait_for( + lambda: all( + c["spend_info"] is not None and c["spend_info"]["txid"] == rbf_1_txid + for c in lianad.rpc.listcoins([], first_outpoints)["coins"] + ) + ) + # If we try to RBF the first transaction again, it will be considered as not yet broadcast + # as it's in the DB but no longer in the mempool. + with pytest.raises( + RpcError, match=f"Transaction '{first_txid}' has not yet been broadcast." + ): + lianad.rpc.rbfpsbt(first_txid, 1, False) + # Add a new transaction spending the change from the first RBF. + desc_1_destinations = { + bitcoind.rpc.getnewaddress(): 500_000, + } + desc_1_outpoints = [f"{rbf_1_txid}:1", coins[2]["outpoint"]] + desc_1_res = lianad.rpc.createspend(desc_1_destinations, desc_1_outpoints, 1) + desc_1_psbt = PSBT.from_base64(desc_1_res["psbt"]) + assert len(desc_1_psbt.tx.vout) == 2 + desc_1_txid = sign_and_broadcast_psbt(lianad, desc_1_psbt) + wait_for( + lambda: all( + c["spend_info"] is not None and c["spend_info"]["txid"] == desc_1_txid + for c in lianad.rpc.listcoins([], desc_1_outpoints)["coins"] + ) + ) + # Add a new transaction spending the change from the first descendant. + desc_2_destinations = { + bitcoind.rpc.getnewaddress(): 25_000, + } + desc_2_outpoints = [f"{desc_1_txid}:1"] + desc_2_res = lianad.rpc.createspend(desc_2_destinations, desc_2_outpoints, 1) + desc_2_psbt = PSBT.from_base64(desc_2_res["psbt"]) + assert len(desc_2_psbt.tx.vout) == 2 + desc_2_txid = sign_and_broadcast_psbt(lianad, desc_2_psbt) + wait_for( + lambda: all( + c["spend_info"] is not None and c["spend_info"]["txid"] == desc_2_txid + for c in lianad.rpc.listcoins([], desc_2_outpoints)["coins"] + ) + ) + # Now replace the first RBF, which will also remove its descendants. + rbf_2_res = lianad.rpc.rbfpsbt(rbf_1_txid, 4, False) + rbf_2_psbt = PSBT.from_base64(rbf_2_res["psbt"]) + # The inputs are the same in both (no new inputs needed in the replacement). + assert sorted( + psbt_in.map[PSBT_IN_NON_WITNESS_UTXO] for psbt_in in rbf_1_psbt.i + ) == sorted(psbt_in.map[PSBT_IN_NON_WITNESS_UTXO] for psbt_in in rbf_2_psbt.i) + # Check non-change output is the same in both. + assert rbf_1_psbt.tx.vout[0].nValue == rbf_2_psbt.tx.vout[0].nValue + assert rbf_1_psbt.tx.vout[0].scriptPubKey == rbf_2_psbt.tx.vout[0].scriptPubKey + # Change address is the same but change amount will be lower in the replacement to pay higher fee. + assert rbf_1_psbt.tx.vout[1].nValue > rbf_2_psbt.tx.vout[1].nValue + assert rbf_1_psbt.tx.vout[1].scriptPubKey == rbf_2_psbt.tx.vout[1].scriptPubKey + + # Broadcast the replacement and wait for it to be detected. + rbf_2_txid = sign_and_broadcast_psbt(lianad, rbf_2_psbt) + wait_for( + lambda: all( + c["spend_info"] is not None and c["spend_info"]["txid"] == rbf_2_txid + for c in lianad.rpc.listcoins([], first_outpoints)["coins"] + ) + ) + # The unconfirmed coins used in the descendant transactions have been removed so that + # only one of the input coins remains, and its spend info has been wiped so that it is as before. + assert lianad.rpc.listcoins([], desc_1_outpoints + desc_2_outpoints)["coins"] == [ + coins[2] + ] + # Now confirm the replacement transaction. + bitcoind.generate_block(1, wait_for_mempool=rbf_2_txid) + wait_for( + lambda: all( + c["spend_info"]["txid"] == rbf_2_txid + and c["spend_info"]["height"] is not None + for c in lianad.rpc.listcoins([], first_outpoints)["coins"] + ) + )