From df129ad3bec911ee60435e6b44d14b6d7b505811 Mon Sep 17 00:00:00 2001 From: SomberNight Date: Wed, 17 Jan 2024 02:50:32 +0000 Subject: [PATCH] wallet: add config setting "wallet_fullrbf" --- electrum/simple_config.py | 9 +++++++ electrum/tests/test_wallet_vertical.py | 34 ++++++++++++++++++++++++++ electrum/wallet.py | 25 +++++++++++-------- 3 files changed, 58 insertions(+), 10 deletions(-) diff --git a/electrum/simple_config.py b/electrum/simple_config.py index ed5f25ec84f3..bb0604573cac 100644 --- a/electrum/simple_config.py +++ b/electrum/simple_config.py @@ -947,6 +947,15 @@ def _default_swapserver_url(self) -> str: _('If you check this box, your unconfirmed transactions will be consolidated into a single transaction.') + '\n' + _('This will save fees, but might have unwanted effects in terms of privacy')), ) + WALLET_FULLRBF = ConfigVar( + 'wallet_fullrbf', default=False, type_=bool, + short_desc=lambda: _('Allow replacing non-RBF transactions'), + long_desc=lambda: ( + _("Allow replacing any transaction, not just those that signal BIP-125 replace-by-fee.\n" + "Note that to broadcast replacements for non-RBF transactions, you need to connect\n" + "to an electrum server that allows as such. Further, only a small percentage of miners\n" + "accept such replacements, so it might take a long time for the transaction to get mined.")), + ) WALLET_MERGE_DUPLICATE_OUTPUTS = ConfigVar( 'wallet_merge_duplicate_outputs', default=False, type_=bool, short_desc=lambda: _('Merge duplicate outputs'), diff --git a/electrum/tests/test_wallet_vertical.py b/electrum/tests/test_wallet_vertical.py index d55a8bc8d8d3..4ea200e72d5d 100644 --- a/electrum/tests/test_wallet_vertical.py +++ b/electrum/tests/test_wallet_vertical.py @@ -1330,6 +1330,40 @@ async def _bump_fee_p2wpkh_insane_high_target_fee(self, *, config): tx.version = 2 self.assertEqual('6b03c00f47cb145ffb632c3ce54dece29b9a980949ef5c574321f7fc83fa2238', tx.txid()) + async def test_bump_fee_fullrbf(self): + wallet = self.create_standard_wallet_from_seed('gallery elegant struggle ramp mouse crush divide later maze life asthma crop', + config=self.config) + + # bootstrap wallet + funding_tx = Transaction('0200000000010134db753b70b109e3b2794029264155ee5848e014523f2f3907ef31851b25192a0000000000fdffffff02a086010000000000160014b518986cf2f8e3832c8b6a123dc7a1c7e446ffba38bb020000000000160014bfbd54bc8f5122583342613ee627553e6b8d858502463043021f533e885dd1f5fdde1c3686d37b34ae6c481cbcb3260aadc4522abd7681a7000220745f5456b42cbe2166437ae2a7652e1a9d7b2646dcb92bf6c30320ad8f86c46d0121021a49a14049ba577a877ed2fce0eb865b448fdd968a55d862be1e36e546cd42db7f432700') + funding_txid = funding_tx.txid() + self.assertEqual('745b125f426f25bf2a88f4986de9a749df14148ca827ebd3bd6c4ec46bb268f8', funding_txid) + wallet.adb.receive_tx_callback(funding_tx, TX_HEIGHT_UNCONFIRMED) + + orig_tx = Transaction('02000000000101f868b26bc44e6cbdd3eb27a88c1414df49a7e96d98f4882abf256f425f125b740000000000feffffff02789b00000000000016001407d3bd97fa803b9ef65b55eb1f7e51da7f88310d60ea000000000000160014ec72d69efd49847d2bc5cc71ee99199d4ae30cc80247304402202984488d2f2c3e2bfba9a4f858c3bdeac753e557682e9b0144a236c5e440eed602200fe7d94d195deef8efddfe8543a2ba79c7d18fd3f0f55a633cbe36c71fac4c5c012102b2a282a8ae615f9299319231ae4a4d61d8673ec782e09b254162abf5845376a582432700') + self.assertEqual('bee4b88d69e3707ef1eb7630fbc2d9355126c12d750f5738e260bed52ba7721e', orig_tx.txid()) + wallet.adb.receive_tx_callback(orig_tx, TX_HEIGHT_UNCONFIRMED) + self.assertFalse(orig_tx.is_rbf_enabled()) # note: orig_tx does not signal RBF + + self.config.WALLET_FULLRBF = False + with self.assertRaises(CannotBumpFee): + tx = wallet.bump_fee( + tx=tx_from_any(orig_tx.serialize()), + new_fee_rate=60, + strategy=BumpFeeStrategy.PRESERVE_PAYMENT, + ) + + self.config.WALLET_FULLRBF = True + tx = wallet.bump_fee( + tx=tx_from_any(orig_tx.serialize()), + new_fee_rate=60, + strategy=BumpFeeStrategy.PRESERVE_PAYMENT, + ) + tx.locktime = 2573187 + tx.version = 2 + self.assertEqual('d9e5269786e8a5a83ec82cacbf8449402968b46a963ee76ee86a5bcc2ee4a143', tx.txid()) + self.assertTrue(tx.is_rbf_enabled()) + @mock.patch.object(wallet.Abstract_Wallet, 'save_db') async def test_cpfp_p2pkh(self, mock_save_db): wallet = self.create_standard_wallet_from_seed('fold object utility erase deputy output stadium feed stereo usage modify bean') diff --git a/electrum/wallet.py b/electrum/wallet.py index 28a0bb55d7f9..2ee0209708b9 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -922,8 +922,8 @@ def get_tx_info(self, tx: Transaction) -> TxWalletDetails: size = tx.estimated_size() fee_per_byte = fee / size exp_n = self.config.fee_to_depth(fee_per_byte) - can_bump = (is_any_input_ismine or is_swap) and tx.is_rbf_enabled() - can_dscancel = (is_any_input_ismine and tx.is_rbf_enabled() + can_bump = (is_any_input_ismine or is_swap) and self.can_rbf_tx(tx) + can_dscancel = (is_any_input_ismine and self.can_rbf_tx(tx, is_dscancel=True) and not all([self.is_mine(txout.address) for txout in tx.outputs()])) try: self.cpfp(tx, 0) @@ -937,7 +937,7 @@ def get_tx_info(self, tx: Transaction) -> TxWalletDetails: num_blocks_remainining = max(0, num_blocks_remainining) status = _('Local (future: {})').format(_('in {} blocks').format(num_blocks_remainining)) can_broadcast = self.network is not None - can_bump = (is_any_input_ismine or is_swap) and tx.is_rbf_enabled() + can_bump = (is_any_input_ismine or is_swap) and self.can_rbf_tx(tx) else: status = _("Signed") can_broadcast = self.network is not None @@ -956,7 +956,7 @@ def get_tx_info(self, tx: Transaction) -> TxWalletDetails: amount = None if is_lightning_funding_tx: - can_bump = False # would change txid + assert not can_bump # would change txid return TxWalletDetails( txid=tx_hash, @@ -1708,11 +1708,8 @@ def get_unconfirmed_base_tx_for_batching(self, outputs, coins) -> Optional[Trans # all inputs should be is_mine if not all([self.is_mine(self.adb.get_txin_address(txin)) for txin in tx.inputs()]): continue - # do not mutate LN funding txs, as that would change their txid - if self.is_lightning_funding_tx(txid): - continue # tx must have opted-in for RBF (even if local, for consistency) - if not tx.is_rbf_enabled(): + if not self.can_rbf_tx(tx): continue # reject merge if we need to spend outputs from the base tx remaining_amount = sum(c.value_sats() for c in coins if c.prevout.txid.hex() != tx.txid()) @@ -2078,7 +2075,7 @@ def bump_fee( tx = PartialTransaction.from_tx(tx) assert isinstance(tx, PartialTransaction) tx.remove_signatures() - if not tx.is_rbf_enabled(): + if not self.can_rbf_tx(tx): raise CannotBumpFee(_('Transaction is final')) new_fee_rate = quantize_feerate(new_fee_rate) # strip excess precision tx.add_info_from_wallet(self) @@ -2302,6 +2299,14 @@ def _is_rbf_allowed_to_touch_tx_output(self, txout: TxOutput) -> bool: return False return True + def can_rbf_tx(self, tx: Transaction, *, is_dscancel: bool = False) -> bool: + # do not mutate LN funding txs, as that would change their txid + if not is_dscancel and self.is_lightning_funding_tx(tx.txid()): + return False + if self.config.WALLET_FULLRBF: + return True + return tx.is_rbf_enabled() + def cpfp(self, tx: Transaction, fee: int) -> Optional[PartialTransaction]: txid = tx.txid() for i, o in enumerate(tx.outputs()): @@ -2343,7 +2348,7 @@ def dscancel( assert isinstance(tx, PartialTransaction) tx.remove_signatures() - if not tx.is_rbf_enabled(): + if not self.can_rbf_tx(tx, is_dscancel=True): raise CannotDoubleSpendTx(_('Transaction is final')) new_fee_rate = quantize_feerate(new_fee_rate) # strip excess precision tx.add_info_from_wallet(self)