1
0

tx batching in GUI:

- discard config.WALLET_BATCH_RBF
 - allow the user to choose base_tx from a list of batching
   candidates in ConfirmTxDialog
This commit is contained in:
ThomasV
2025-03-03 15:48:13 +01:00
parent 3b369abf16
commit ab14c3e138
6 changed files with 88 additions and 77 deletions

View File

@@ -30,7 +30,7 @@ from typing import TYPE_CHECKING, Optional, Union, Callable
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QIcon
from PyQt6.QtWidgets import QHBoxLayout, QVBoxLayout, QLabel, QGridLayout, QPushButton, QToolButton, QMenu
from PyQt6.QtWidgets import QHBoxLayout, QVBoxLayout, QLabel, QGridLayout, QPushButton, QToolButton, QMenu, QComboBox
from electrum.i18n import _
from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates
@@ -57,11 +57,14 @@ from .locktimeedit import LockTimeEdit
class TxEditor(WindowModalDialog):
def __init__(self, *, title='',
window: 'ElectrumWindow',
make_tx,
output_value: Union[int, str],
allow_preview=True):
def __init__(
self, *, title='',
window: 'ElectrumWindow',
make_tx,
output_value: Union[int, str],
allow_preview=True,
batching_candidates=None,
):
WindowModalDialog.__init__(self, window, title=title)
self.main_window = window
@@ -82,6 +85,8 @@ class TxEditor(WindowModalDialog):
# preview is disabled for lightning channel funding
self.allow_preview = allow_preview
self.is_preview = False
self._base_tx = None # for batching
self.batching_candidates = batching_candidates
self.locktime_e = LockTimeEdit(self)
self.locktime_e.valueEdited.connect(self.trigger_update)
@@ -106,7 +111,7 @@ class TxEditor(WindowModalDialog):
vbox.addStretch(1)
vbox.addLayout(buttons)
self.set_io_visible(self.config.GUI_QT_TX_EDITOR_SHOW_IO)
self.set_io_visible()
self.set_fee_edit_visible(self.config.GUI_QT_TX_EDITOR_SHOW_FEE_DETAILS)
self.set_locktime_visible(self.config.GUI_QT_TX_EDITOR_SHOW_LOCKTIME)
self.update_fee_target()
@@ -114,6 +119,9 @@ class TxEditor(WindowModalDialog):
self.main_window.gui_object.timer.timeout.connect(self.timer_actions)
def is_batching(self) -> bool:
return self._base_tx is not None
def timer_actions(self):
if self.needs_update:
self.update()
@@ -365,6 +373,15 @@ class TxEditor(WindowModalDialog):
self.ok_button.clicked.connect(self.on_send)
self.ok_button.setDefault(True)
buttons = Buttons(CancelButton(self), self.preview_button, self.ok_button)
if self.batching_candidates is not None and len(self.batching_candidates) > 0:
batching_combo = QComboBox()
batching_combo.addItems([_('Do not batch')] + [_('Batch with') + ' ' + tx.txid()[0:10] for tx in self.batching_candidates])
buttons.insertWidget(0, batching_combo)
def on_batching_combo(x):
self._base_tx = self.batching_candidates[x - 1] if x > 0 else None
self.update_batching()
batching_combo.currentIndexChanged.connect(on_batching_combo)
return buttons
def create_top_bar(self, text):
@@ -404,7 +421,6 @@ class TxEditor(WindowModalDialog):
_('This may result in higher transactions fees.')
]))
self.use_multi_change_menu.setEnabled(self.wallet.use_change)
add_cv_action(self.config.cv.WALLET_BATCH_RBF, self.toggle_batch_rbf)
add_cv_action(self.config.cv.WALLET_MERGE_DUPLICATE_OUTPUTS, self.toggle_merge_duplicate_outputs)
add_cv_action(self.config.cv.WALLET_SPEND_CONFIRMED_ONLY, self.toggle_confirmed_only)
add_cv_action(self.config.cv.WALLET_COIN_CHOOSER_OUTPUT_ROUNDING, self.toggle_output_rounding)
@@ -441,9 +457,7 @@ class TxEditor(WindowModalDialog):
self.wallet.db.put('multiple_change', self.wallet.multiple_change)
self.trigger_update()
def toggle_batch_rbf(self):
b = not self.config.WALLET_BATCH_RBF
self.config.WALLET_BATCH_RBF = b
def update_batching(self):
self.trigger_update()
def toggle_merge_duplicate_outputs(self):
@@ -462,9 +476,8 @@ class TxEditor(WindowModalDialog):
self.trigger_update()
def toggle_io_visibility(self):
b = not self.config.GUI_QT_TX_EDITOR_SHOW_IO
self.config.GUI_QT_TX_EDITOR_SHOW_IO = b
self.set_io_visible(b)
self.config.GUI_QT_TX_EDITOR_SHOW_IO = not self.config.GUI_QT_TX_EDITOR_SHOW_IO
self.set_io_visible()
self.resize_to_fit_content()
def toggle_fee_details(self):
@@ -479,8 +492,8 @@ class TxEditor(WindowModalDialog):
self.set_locktime_visible(b)
self.resize_to_fit_content()
def set_io_visible(self, b):
self.io_widget.setVisible(b)
def set_io_visible(self):
self.io_widget.setVisible(self.config.GUI_QT_TX_EDITOR_SHOW_IO)
def set_fee_edit_visible(self, b):
detailed = [self.feerounding_icon, self.feerate_e, self.fee_e]
@@ -560,7 +573,7 @@ class TxEditor(WindowModalDialog):
if any((txin.block_height is not None and txin.block_height<=0) for txin in self.tx.inputs()):
messages.append(_('This transaction will spend unconfirmed coins.'))
# warn if we merge from mempool
if self.tx.rbf_merge_txid:
if self.is_batching():
messages.append(_('This payment will be merged with another existing transaction.'))
# warn if we use multiple change outputs
num_change = sum(int(o.is_change) for o in self.tx.outputs())
@@ -603,7 +616,7 @@ class TxEditor(WindowModalDialog):
class ConfirmTxDialog(TxEditor):
help_text = '' #_('Set the mining fee of your transaction')
def __init__(self, *, window: 'ElectrumWindow', make_tx, output_value: Union[int, str], allow_preview=True):
def __init__(self, *, window: 'ElectrumWindow', make_tx, output_value: Union[int, str], allow_preview=True, batching_candidates=None):
TxEditor.__init__(
self,
@@ -611,8 +624,9 @@ class ConfirmTxDialog(TxEditor):
make_tx=make_tx,
output_value=output_value,
title=_("New Transaction"), # todo: adapt title for channel funding tx, swaps
allow_preview=allow_preview)
allow_preview=allow_preview, # false for channel funding
batching_candidates=batching_candidates,
)
self.trigger_update()
def _update_amount_label(self):
@@ -631,8 +645,9 @@ class ConfirmTxDialog(TxEditor):
def update_tx(self, *, fallback_to_zero_fee: bool = False):
fee_policy = self.get_fee_policy()
confirmed_only = self.config.WALLET_SPEND_CONFIRMED_ONLY
base_tx = self._base_tx
try:
self.tx = self.make_tx(fee_policy, confirmed_only=confirmed_only)
self.tx = self.make_tx(fee_policy, confirmed_only=confirmed_only, base_tx=base_tx)
self.not_enough_funds = False
self.no_dynfee_estimates = False
except NotEnoughFunds:
@@ -640,7 +655,7 @@ class ConfirmTxDialog(TxEditor):
self.tx = None
if fallback_to_zero_fee:
try:
self.tx = self.make_tx(FixedFeePolicy(0), confirmed_only=confirmed_only)
self.tx = self.make_tx(FixedFeePolicy(0), confirmed_only=confirmed_only, base_tx=base_tx)
except BaseException:
return
else:
@@ -650,7 +665,7 @@ class ConfirmTxDialog(TxEditor):
self.no_dynfee_estimates = True
self.tx = None
try:
self.tx = self.make_tx(FixedFeePolicy(0), confirmed_only=confirmed_only)
self.tx = self.make_tx(FixedFeePolicy(0), confirmed_only=confirmed_only, base_tx=base_tx)
except NotEnoughFunds:
self.not_enough_funds = True
return
@@ -665,7 +680,7 @@ class ConfirmTxDialog(TxEditor):
def can_pay_assuming_zero_fees(self, confirmed_only) -> bool:
# called in send_tab.py
try:
tx = self.make_tx(FixedFeePolicy(0), confirmed_only=confirmed_only)
tx = self.make_tx(FixedFeePolicy(0), confirmed_only=confirmed_only, base_tx=None)
except NotEnoughFunds:
return False
else:
@@ -688,7 +703,7 @@ class ConfirmTxDialog(TxEditor):
grid.addWidget(HelpLabel(_("Mining Fee") + ": ", msg), 1, 0)
grid.addLayout(self.fee_hbox, 1, 1, 1, 3)
grid.addWidget(HelpLabel(_("Fee target") + ": ", self.fee_combo.help_msg), 3, 0)
grid.addWidget(HelpLabel(_("Fee policy") + ": ", self.fee_combo.help_msg), 3, 0)
grid.addLayout(self.fee_target_hbox, 3, 1, 1, 3)
grid.setColumnStretch(4, 1)

View File

@@ -1385,11 +1385,13 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
WaitingDialog(self, msg, task, on_success, on_failure)
def mktx_for_open_channel(self, *, funding_sat, node_id):
make_tx = lambda fee_policy, *, confirmed_only=False: self.wallet.lnworker.mktx_for_open_channel(
coins = self.get_coins(nonlocal_only=True, confirmed_only=confirmed_only),
funding_sat=funding_sat,
node_id=node_id,
fee_policy=fee_policy)
def make_tx(fee_policy, *, confirmed_only=False, base_tx=None):
assert base_tx is None
return self.wallet.lnworker.mktx_for_open_channel(
coins = self.get_coins(nonlocal_only=True, confirmed_only=confirmed_only),
funding_sat=funding_sat,
node_id=node_id,
fee_policy=fee_policy)
return make_tx
def open_channel(self, connect_str, funding_sat, push_amt):
@@ -1409,8 +1411,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
return
self._open_channel(connect_str, funding_sat, push_amt, funding_tx)
def confirm_tx_dialog(self, make_tx, output_value, allow_preview=True):
d = ConfirmTxDialog(window=self, make_tx=make_tx, output_value=output_value, allow_preview=allow_preview)
def confirm_tx_dialog(self, make_tx, output_value, allow_preview=True, batching_candidates=None):
d = ConfirmTxDialog(window=self, make_tx=make_tx, output_value=output_value, allow_preview=allow_preview, batching_candidates=batching_candidates)
if d.not_enough_funds:
# note: use confirmed_only=False here, regardless of config setting,
# as the user needs to get to ConfirmTxDialog to change the config setting

View File

@@ -325,16 +325,23 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
# we call get_coins inside make_tx, so that inputs can be changed dynamically
if get_coins is None:
get_coins = self.window.get_coins
make_tx = lambda fee_policy, *, confirmed_only=False: self.wallet.make_unsigned_transaction(
coins=get_coins(nonlocal_only=nonlocal_only, confirmed_only=confirmed_only),
fee_policy=fee_policy,
outputs=outputs,
is_sweep=is_sweep)
def make_tx(fee_policy, *, confirmed_only=False, base_tx=False):
coins = get_coins(nonlocal_only=nonlocal_only, confirmed_only=confirmed_only)
return self.wallet.make_unsigned_transaction(
fee_policy=fee_policy,
coins=coins,
outputs=outputs,
base_tx=base_tx,
is_sweep=is_sweep,
send_change_to_lightning=self.config.WALLET_SEND_CHANGE_TO_LIGHTNING,
)
output_values = [x.value for x in outputs]
is_max = any(parse_max_spend(outval) for outval in output_values)
output_value = '!' if is_max else sum(output_values)
tx, is_preview = self.window.confirm_tx_dialog(make_tx, output_value)
candidates = self.wallet.get_candidates_for_batching(outputs, []) # coins not used
tx, is_preview = self.window.confirm_tx_dialog(make_tx, output_value, batching_candidates=candidates)
if tx is None:
# user cancelled
return

View File

@@ -575,13 +575,6 @@ class SimpleConfig(Logger):
NETWORK_TIMEOUT = ConfigVar('network_timeout', default=None, type_=int)
NETWORK_BOOKMARKED_SERVERS = ConfigVar('network_bookmarked_servers', default=None)
WALLET_BATCH_RBF = ConfigVar(
'batch_rbf', default=False, type_=bool,
short_desc=lambda: _('Batch unconfirmed transactions'),
long_desc=lambda: (
_('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_MERGE_DUPLICATE_OUTPUTS = ConfigVar(
'wallet_merge_duplicate_outputs', default=False, type_=bool,
short_desc=lambda: _('Merge duplicate outputs'),

View File

@@ -1718,16 +1718,17 @@ class Abstract_Wallet(ABC, Logger, EventListener):
def dust_threshold(self):
return dust_threshold(self.network)
def get_unconfirmed_base_tx_for_batching(self, outputs, coins) -> Optional[Transaction]:
candidate = None
def get_candidates_for_batching(self, outputs, coins) -> Sequence[Transaction]:
candidates = []
domain = self.get_addresses()
for hist_item in self.adb.get_history(domain):
# tx should not be mined yet
if hist_item.tx_mined_status.conf > 0: continue
# conservative future proofing of code: only allow known unconfirmed types
if hist_item.tx_mined_status.height not in (TX_HEIGHT_UNCONFIRMED,
TX_HEIGHT_UNCONF_PARENT,
TX_HEIGHT_LOCAL):
if hist_item.tx_mined_status.height not in (
TX_HEIGHT_UNCONFIRMED,
TX_HEIGHT_UNCONF_PARENT,
TX_HEIGHT_LOCAL):
continue
# tx should be "outgoing" from wallet
if hist_item.delta >= 0:
@@ -1753,12 +1754,8 @@ class Abstract_Wallet(ABC, Logger, EventListener):
output_amount = sum(o.value for o in outputs)
if output_amount > remaining_amount + change_amount:
continue
# prefer txns already in mempool (vs local)
if hist_item.tx_mined_status.height == TX_HEIGHT_LOCAL:
candidate = tx
continue
return tx
return candidate
candidates.append(tx)
return candidates
def get_change_addresses_for_new_transaction(
self, preferred_change_addr=None, *, allow_reusing_used_change_addrs: bool = True,
@@ -1843,8 +1840,6 @@ class Abstract_Wallet(ABC, Logger, EventListener):
if inputs:
input_set = set(txin.prevout for txin in inputs)
coins = [coin for coin in coins if (coin.prevout not in input_set)]
if base_tx is None and self.config.WALLET_BATCH_RBF:
base_tx = self.get_unconfirmed_base_tx_for_batching(outputs, coins)
# prevent side-effect with '!'
outputs = copy.deepcopy(outputs)

View File

@@ -1893,8 +1893,6 @@ class TestWalletSending(ElectrumTestCase):
async def _rbf_batching(self, *, simulate_moving_txs, config):
wallet = self.create_standard_wallet_from_seed('frost repair depend effort salon ring foam oak cancel receive save usage',
config=config)
wallet.config.WALLET_BATCH_RBF = True
# bootstrap wallet (incoming funding_tx1)
funding_tx1 = Transaction('01000000000102acd6459dec7c3c51048eb112630da756f5d4cb4752b8d39aa325407ae0885cba020000001716001455c7f5e0631d8e6f5f05dddb9f676cec48845532fdffffffd146691ef6a207b682b13da5f2388b1f0d2a2022c8cfb8dc27b65434ec9ec8f701000000171600147b3be8a7ceaf15f57d7df2a3d216bc3c259e3225fdffffff02a9875b000000000017a914ea5a99f83e71d1c1dfc5d0370e9755567fe4a141878096980000000000160014d4ca56fcbad98fb4dcafdc573a75d6a6fffb09b702483045022100dde1ba0c9a2862a65791b8d91295a6603207fb79635935a67890506c214dd96d022046c6616642ef5971103c1db07ac014e63fa3b0e15c5729eacdd3e77fcb7d2086012103a72410f185401bb5b10aaa30989c272b554dc6d53bda6da85a76f662723421af024730440220033d0be8f74e782fbcec2b396647c7715d2356076b442423f23552b617062312022063c95cafdc6d52ccf55c8ee0f9ceb0f57afb41ea9076eb74fe633f59c50c6377012103b96a4954d834fbcfb2bbf8cf7de7dc2b28bc3d661c1557d1fd1db1bfc123a94abb391400')
funding_txid1 = funding_tx1.txid()
@@ -1944,7 +1942,7 @@ class TestWalletSending(ElectrumTestCase):
# no new input will be needed. just a new output, and change decreased.
outputs = [PartialTxOutput.from_address_and_value('tb1qy6xmdj96v5dzt3j08hgc05yk3kltqsnmw4r6ry', 2_500_000)]
coins = wallet.get_spendable_coins(domain=None)
tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(20000))
tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(20000), base_tx=tx)
tx.set_rbf(True)
tx.locktime = 1325499
tx.version = 1
@@ -1975,7 +1973,7 @@ class TestWalletSending(ElectrumTestCase):
# new input will be needed!
outputs = [PartialTxOutput.from_address_and_value('2NCVwbmEpvaXKHpXUGJfJr9iB5vtRN3vcut', 6_000_000)]
coins = wallet.get_spendable_coins(domain=None)
tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(100000))
tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(100000), base_tx=tx)
tx.set_rbf(True)
tx.locktime = 1325499
tx.version = 1
@@ -2023,25 +2021,26 @@ class TestWalletSending(ElectrumTestCase):
# create outgoing tx2
outputs = [PartialTxOutput.from_address_and_value("tb1qkfn0fude7z789uys2u7sf80kd4805zpvs3na0h", 90_000)]
for batch_rbf in (False, True):
with self.subTest(batch_rbf=batch_rbf):
coins = wallet.get_spendable_coins(domain=None)
self.assertEqual(2, len(coins))
coins = wallet.get_spendable_coins(domain=None)
self.assertEqual(2, len(coins))
candidates = wallet.get_candidates_for_batching(outputs, coins)
self.assertEqual(candidates, [])
with self.assertRaises(NotEnoughFunds):
wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(1000), base_tx=toself_tx)
tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(1000))
tx.set_rbf(True)
tx.locktime = 2423302
tx.version = 2
wallet.sign_transaction(tx, password=None)
self.assertEqual('02000000000102bbef0182c2c746bd28517b6fd27ba9eef9c7fb5982efd27bd612cc5a28615a3a0000000000fdffffffbbef0182c2c746bd28517b6fd27ba9eef9c7fb5982efd27bd612cc5a28615a3a0100000000fdffffff02602200000000000016001413fabce9be995554a722fc4e1c5ae53ebfd58164905f010000000000160014b266f4f1b9f0bc72f090573d049df66d4efa082c0247304402205c50b9ddb1b3ead6214d7d9707c74ba29ff547880d017aae2459db156bf85b9b022041134562fffa3dccf1ac05d9b07da62a8d57dd158d25d22d1965a011325e64aa012102c72b815ba00ccb0b469cc61a0ceb843d974e630cf34abcfac178838f1974f68f02473044022049774c32b0ad046b7acdb4acc38107b6b1be57c0d167643a48cbc045850c86c202205189ed61342fc52a377c2865a879c4c2606de98eebd6bf4d73874d62329668c70121033484c8ed83c359d1c3e569accb04b77988daab9408fc82869051c10d0749ac2006fa2400', str(tx))
wallet.config.WALLET_BATCH_RBF = batch_rbf
tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee_policy=FixedFeePolicy(1000))
tx.set_rbf(True)
tx.locktime = 2423302
tx.version = 2
wallet.sign_transaction(tx, password=None)
self.assertEqual('02000000000102bbef0182c2c746bd28517b6fd27ba9eef9c7fb5982efd27bd612cc5a28615a3a0000000000fdffffffbbef0182c2c746bd28517b6fd27ba9eef9c7fb5982efd27bd612cc5a28615a3a0100000000fdffffff02602200000000000016001413fabce9be995554a722fc4e1c5ae53ebfd58164905f010000000000160014b266f4f1b9f0bc72f090573d049df66d4efa082c0247304402205c50b9ddb1b3ead6214d7d9707c74ba29ff547880d017aae2459db156bf85b9b022041134562fffa3dccf1ac05d9b07da62a8d57dd158d25d22d1965a011325e64aa012102c72b815ba00ccb0b469cc61a0ceb843d974e630cf34abcfac178838f1974f68f02473044022049774c32b0ad046b7acdb4acc38107b6b1be57c0d167643a48cbc045850c86c202205189ed61342fc52a377c2865a879c4c2606de98eebd6bf4d73874d62329668c70121033484c8ed83c359d1c3e569accb04b77988daab9408fc82869051c10d0749ac2006fa2400',
str(tx))
async def test_rbf_batching__merge_duplicate_outputs(self):
"""txos paying to the same address might be merged into a single output with a larger value"""
wallet = self.create_standard_wallet_from_seed('response era cable net spike again observe dumb wage wonder sail tortoise',
config=self.config)
wallet.config.WALLET_BATCH_RBF = True
# bootstrap wallet (incoming funding_tx0): for 500k sat
funding_tx = Transaction('02000000000101013548c9019890e27ce9e58766de05f18ea40ede70751fb6cd7a3a1715ece0a30100000000fdffffff0220a1070000000000160014542266519a44eb9b903761d40c6fe1055d33fa05485a080000000000160014bc69f7d82c403a9f35dfb6d1a4531d6b19cab0e3024730440220346b200f21c3024e1d51fb4ecddbdbd68bd24ae7b9dfd501519f6dcbeb7c052402200617e3ce7b0eb308e30caf23894fb0388b68fb1c15dd0681dd13ae5e735f148101210360d0c9ef15b8b6a16912d341ad218a4e4e4e07e9347f4a2dbc7ca8d974f8bc9ec1ad2600')
@@ -2067,7 +2066,7 @@ class TestWalletSending(ElectrumTestCase):
# second payment to dest_addr (merged)
outputs2 = [PartialTxOutput.from_address_and_value(dest_addr, 100_000)]
coins = wallet.get_spendable_coins(domain=None)
tx2 = wallet.make_unsigned_transaction(coins=coins, outputs=outputs2, fee_policy=FixedFeePolicy(3000))
tx2 = wallet.make_unsigned_transaction(coins=coins, outputs=outputs2, fee_policy=FixedFeePolicy(3000), base_tx=tx1)
tx2.set_rbf(True)
tx2.locktime = 2534850
tx2.version = 2
@@ -2086,7 +2085,7 @@ class TestWalletSending(ElectrumTestCase):
# second payment to dest_addr (not merged, just duplicate outputs)
outputs2 = [PartialTxOutput.from_address_and_value(dest_addr, 100_000)]
coins = wallet.get_spendable_coins(domain=None)
tx3 = wallet.make_unsigned_transaction(coins=coins, outputs=outputs2, fee_policy=FixedFeePolicy(3000))
tx3 = wallet.make_unsigned_transaction(coins=coins, outputs=outputs2, fee_policy=FixedFeePolicy(3000), base_tx=tx1)
tx3.set_rbf(True)
tx3.locktime = 2534850
tx3.version = 2