Skip to content

Commit 3325ca1

Browse files
committed
txbatcher: support multiple batches, with different fee policies
1 parent 2467b1d commit 3325ca1

8 files changed

+146
-78
lines changed

electrum/gui/qt/confirm_tx_dialog.py

+28-13
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from PyQt6.QtCore import Qt
3131
from PyQt6.QtGui import QIcon
3232

33-
from PyQt6.QtWidgets import QHBoxLayout, QVBoxLayout, QLabel, QGridLayout, QPushButton, QToolButton, QMenu
33+
from PyQt6.QtWidgets import QHBoxLayout, QVBoxLayout, QLabel, QGridLayout, QPushButton, QToolButton, QMenu, QComboBox
3434

3535
from electrum.i18n import _
3636
from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates
@@ -79,6 +79,8 @@ def __init__(self, *, title='',
7979
# preview is disabled for lightning channel funding
8080
self._allow_preview = allow_preview
8181
self.is_preview = False
82+
self._base_tx = None # for batching
83+
self.batching_candidates = self.wallet.get_unconfirmed_base_tx_for_batching([], []) # todo: update it
8284

8385
self.locktime_e = LockTimeEdit(self)
8486
self.locktime_e.valueEdited.connect(self.trigger_update)
@@ -112,7 +114,7 @@ def __init__(self, *, title='',
112114
self.main_window.gui_object.timer.timeout.connect(self.timer_actions)
113115

114116
def is_batching(self):
115-
return self.config.WALLET_BATCH_RBF and not self.main_window.utxo_list.is_coincontrol_active()
117+
return self._base_tx and not self.main_window.utxo_list.is_coincontrol_active()
116118

117119
def should_show_io(self):
118120
return self.config.GUI_QT_TX_EDITOR_SHOW_IO
@@ -151,6 +153,12 @@ def update_fee_target(self):
151153
def update_feerate_label(self):
152154
self.feerate_label.setText(self.feerate_e.text() + ' ' + self.feerate_e.base_unit())
153155

156+
def update_fee_controls(self):
157+
self.fee_slider.setEnabled(not self.is_batching())
158+
self.fee_combo.setEnabled(not self.is_batching())
159+
self.fee_slider.setVisible(not self.is_batching())
160+
self.fee_combo.setVisible(not self.is_batching())
161+
154162
def create_fee_controls(self):
155163

156164
self.fee_label = QLabel('')
@@ -380,6 +388,15 @@ def create_buttons_bar(self):
380388
self.ok_button.clicked.connect(self.on_send)
381389
self.ok_button.setDefault(True)
382390
buttons = Buttons(CancelButton(self), self.preview_button, self.ok_button)
391+
392+
if self.batching_candidates:
393+
batching_combo = QComboBox()
394+
batching_combo.addItems([_('Not batching')] + [_('Batch with') + ' ' + x.txid()[0:10] for x in self.batching_candidates])
395+
buttons.insertWidget(0, batching_combo)
396+
def on_batching_combo(x):
397+
self._base_tx = self.batching_candidates[x - 1] if x > 0 else None
398+
self.update_batching()
399+
batching_combo.currentIndexChanged.connect(on_batching_combo)
383400
return buttons
384401

385402
def create_top_bar(self, text):
@@ -419,7 +436,6 @@ def add_cv_action(configvar: 'ConfigVarWithConfig', action: Callable[[], None]):
419436
_('This may result in higher transactions fees.')
420437
]))
421438
self.use_multi_change_menu.setEnabled(self.wallet.use_change)
422-
add_cv_action(self.config.cv.WALLET_BATCH_RBF, self.toggle_batch_rbf)
423439
add_cv_action(self.config.cv.WALLET_MERGE_DUPLICATE_OUTPUTS, self.toggle_merge_duplicate_outputs)
424440
add_cv_action(self.config.cv.WALLET_SPEND_CONFIRMED_ONLY, self.toggle_confirmed_only)
425441
add_cv_action(self.config.cv.WALLET_COIN_CHOOSER_OUTPUT_ROUNDING, self.toggle_output_rounding)
@@ -456,9 +472,8 @@ def toggle_multiple_change(self):
456472
self.wallet.db.put('multiple_change', self.wallet.multiple_change)
457473
self.trigger_update()
458474

459-
def toggle_batch_rbf(self):
460-
b = not self.config.WALLET_BATCH_RBF
461-
self.config.WALLET_BATCH_RBF = b
475+
def update_batching(self):
476+
self.update_fee_controls()
462477
self.set_io_visible()
463478
self.resize_to_fit_content()
464479
self.trigger_update()
@@ -581,7 +596,7 @@ def get_messages(self):
581596
messages.append(_('This transaction will spend unconfirmed coins.'))
582597
# warn if we merge from mempool
583598
if self.is_batching():
584-
messages.append(_('Transaction batching is active. The fee will be bumped automatically if needed'))
599+
messages.append(_('Transaction batching is active. The fee policy of the selected batch will be used'))
585600
else:
586601
# warn if we use multiple change outputs
587602
num_change = sum(int(o.is_change) for o in self.tx.outputs())
@@ -652,17 +667,17 @@ def _update_amount_label(self):
652667
def update_tx(self, *, fallback_to_zero_fee: bool = False):
653668
fee_estimator = self.get_fee_estimator()
654669
confirmed_only = self.config.WALLET_SPEND_CONFIRMED_ONLY
655-
is_batching = self.is_batching()
670+
base_tx = self._base_tx
656671
try:
657-
self.tx = self.make_tx(fee_estimator, confirmed_only=confirmed_only, is_batching=is_batching)
672+
self.tx = self.make_tx(fee_estimator, confirmed_only=confirmed_only, base_tx=base_tx)
658673
self.not_enough_funds = False
659674
self.no_dynfee_estimates = False
660675
except NotEnoughFunds:
661676
self.not_enough_funds = True
662677
self.tx = None
663678
if fallback_to_zero_fee:
664679
try:
665-
self.tx = self.make_tx(0, confirmed_only=confirmed_only, is_batching=is_batching)
680+
self.tx = self.make_tx(0, confirmed_only=confirmed_only, base_tx=base_tx)
666681
except BaseException:
667682
return
668683
else:
@@ -671,7 +686,7 @@ def update_tx(self, *, fallback_to_zero_fee: bool = False):
671686
self.no_dynfee_estimates = True
672687
self.tx = None
673688
try:
674-
self.tx = self.make_tx(0, confirmed_only=confirmed_only, is_batching=is_batching)
689+
self.tx = self.make_tx(0, confirmed_only=confirmed_only, base_tx=base_tx)
675690
except NotEnoughFunds:
676691
self.not_enough_funds = True
677692
return
@@ -686,7 +701,7 @@ def update_tx(self, *, fallback_to_zero_fee: bool = False):
686701
def can_pay_assuming_zero_fees(self, confirmed_only) -> bool:
687702
# called in send_tab.py
688703
try:
689-
tx = self.make_tx(0, confirmed_only=confirmed_only, is_batching=False)
704+
tx = self.make_tx(0, confirmed_only=confirmed_only, base_tx=None)
690705
except NotEnoughFunds:
691706
return False
692707
else:
@@ -709,7 +724,7 @@ def create_grid(self):
709724
grid.addWidget(HelpLabel(_("Mining Fee") + ": ", msg), 1, 0)
710725
grid.addLayout(self.fee_hbox, 1, 1, 1, 3)
711726

712-
grid.addWidget(HelpLabel(_("Fee target") + ": ", self.fee_combo.help_msg), 3, 0)
727+
grid.addWidget(HelpLabel(_("Fee policy") + ": ", self.fee_combo.help_msg), 3, 0)
713728
grid.addLayout(self.fee_target_hbox, 3, 1, 1, 3)
714729

715730
grid.setColumnStretch(4, 1)

electrum/gui/qt/main_window.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1422,7 +1422,7 @@ def confirm_tx_dialog(self, make_tx, output_value, allow_preview=True):
14221422
text = self.send_tab.get_text_not_enough_funds_mentioning_frozen()
14231423
self.show_message(text)
14241424
return
1425-
return d.run(), d.is_preview, d.is_batching()
1425+
return d.run(), d.is_preview, d._base_tx
14261426

14271427
@protected
14281428
def _open_channel(self, connect_str, funding_sat, push_amt, funding_tx, password):

electrum/gui/qt/send_tab.py

+7-7
Original file line numberDiff line numberDiff line change
@@ -326,9 +326,8 @@ def pay_onchain_dialog(
326326
if get_coins is None:
327327
get_coins = self.window.get_coins
328328

329-
def make_tx(fee_est, *, confirmed_only=False, is_batching=False):
330-
base_tx = self.wallet.txbatcher.get_base_tx() if is_batching else None
331-
merge_duplicate_outputs = self.config.WALLET_MERGE_DUPLICATE_OUTPUTS if not is_batching else False
329+
def make_tx(fee_est, *, confirmed_only=False, base_tx=None):
330+
merge_duplicate_outputs = self.config.WALLET_MERGE_DUPLICATE_OUTPUTS if not base_tx else False
332331
coins = get_coins(nonlocal_only=nonlocal_only, confirmed_only=confirmed_only)
333332
return self.wallet.make_unsigned_transaction(
334333
coins=coins,
@@ -344,7 +343,7 @@ def make_tx(fee_est, *, confirmed_only=False, is_batching=False):
344343
is_max = any(parse_max_spend(outval) for outval in output_values)
345344
output_value = '!' if is_max else sum(output_values)
346345

347-
tx, is_preview, is_batching = self.window.confirm_tx_dialog(make_tx, output_value)
346+
tx, is_preview, base_tx = self.window.confirm_tx_dialog(make_tx, output_value)
348347
if tx is None:
349348
# user cancelled
350349
return
@@ -361,7 +360,7 @@ def make_tx(fee_est, *, confirmed_only=False, is_batching=False):
361360
self.show_error(str(e))
362361
return
363362

364-
if is_batching:
363+
if base_tx:
365364
self.save_pending_invoice()
366365
funding_output = PartialTxOutput.from_address_and_value(swap.lockup_address, swap_dummy_output.value)
367366
coro = sm.wait_for_htlcs_and_broadcast(transport, swap=swap, invoice=invoice, output=funding_output)
@@ -378,10 +377,11 @@ def make_tx(fee_est, *, confirmed_only=False, is_batching=False):
378377
tx.swap_invoice = invoice
379378
tx.swap_payment_hash = swap.payment_hash
380379

381-
if is_batching:
380+
if base_tx:
382381
self.save_pending_invoice()
382+
key = self.wallet.txbatcher.find_key_of_txid(tx.txid())
383383
for output in outputs:
384-
self.wallet.txbatcher.add_batch_payment(output)
384+
self.wallet.txbatcher.add_batch_payment(output, key)
385385
return
386386

387387
if is_preview:

electrum/lnwatcher.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -286,4 +286,4 @@ async def maybe_redeem(self, sweep_info: 'SweepInfo') -> None:
286286
# careful, this prevents revocation as well
287287
if not self.lnworker.enable_htlc_settle_onchain:
288288
return
289-
self.lnworker.wallet.txbatcher.add_sweep_info(sweep_info)
289+
self.lnworker.wallet.txbatcher.add_sweep_info(sweep_info, 'default')

electrum/submarine_swaps.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,7 @@ async def _claim_swap(self, swap: SwapData) -> None:
417417
txout=None,
418418
name='swap claim',
419419
)
420-
self.wallet.txbatcher.add_sweep_info(sweep_info)
420+
self.wallet.txbatcher.add_sweep_info(sweep_info, 'default')
421421

422422
def get_swap_tx_fee(self):
423423
return self.get_fee(SWAP_TX_SIZE)
@@ -450,7 +450,7 @@ async def hold_invoice_callback(self, payment_hash: bytes) -> None:
450450
swap = self.swaps[key]
451451
if not swap.is_funded():
452452
output = self.create_funding_output(swap)
453-
self.wallet.txbatcher.add_batch_payment(output)
453+
self.wallet.txbatcher.add_batch_payment(output, 'default')
454454
swap._payment_pending = True
455455
else:
456456
self.logger.info(f'key not in swaps {key}')
@@ -737,7 +737,7 @@ async def callback(payment_hash):
737737
if tx:
738738
await self.broadcast_funding_tx(swap, tx)
739739
else:
740-
self.wallet.txbatcher.add_batch_payment(output)
740+
self.wallet.txbatcher.add_batch_payment(output, 'default')
741741

742742
self.lnworker.register_hold_invoice(payment_hash, callback)
743743

electrum/txbatcher.py

+73-22
Original file line numberDiff line numberDiff line change
@@ -70,26 +70,80 @@
7070
# In order to generalize that logic to payments, callers would need to pass a unique ID along with
7171
# the payment output, so that we can prevent paying twice.
7272

73-
from .json_db import locked
73+
from .json_db import locked # todo: move to util.py
74+
75+
76+
class FeePolicy:
77+
# todo: fee slider should have an instance of this object instead of side-effecting config
78+
def estimate_fee(self, size: int, allow_fallback_to_static_rates=True):
79+
return 42
7480

7581
class TxBatcher(Logger):
7682

7783
SLEEP_INTERVAL = 1
78-
RETRY_DELAY = 60
7984

8085
def __init__(self, wallet):
86+
self.batch_txids = wallet.db.get_stored_item("tx_batches", { })
87+
self.txbatchers = {}
88+
self.wallet = wallet
89+
for key in self.batch_txids.keys():
90+
self._maybe_create_batch(key, policy=FeePolicy())
91+
92+
def _maybe_create_batch(self, key, policy):
93+
if key not in self.txbatchers:
94+
self.txbatchers[key] = TxBatch(self.wallet, policy, self.batch_txids, key)
95+
96+
def add_batch_payment(self, output: 'PartialTxOutput', key):
97+
self._maybe_create_batch(key, policy=FeePolicy())
98+
self.txbatchers[key].add_batch_payment(output)
99+
100+
def add_sweep_info(self, sweep_info: 'SweepInfo', key):
101+
self._maybe_create_batch(key, policy=FeePolicy())
102+
self.txbatchers[key].add_sweep_info(sweep_info)
103+
104+
def find_batch_of_txid(self, txid) -> str:
105+
# used by gui
106+
for k, v in self.batch_txids.items():
107+
if txid in v:
108+
return k
109+
return 'batch:' + txid
110+
111+
def is_mine(self, txid):
112+
for k, v in self.batch_txids.items():
113+
if txid in v:
114+
return True
115+
return False
116+
117+
@log_exceptions
118+
async def run(self):
119+
while True:
120+
await asyncio.sleep(self.SLEEP_INTERVAL)
121+
password = self.wallet.get_unlocked_password()
122+
if self.wallet.has_keystore_encryption() and not password:
123+
continue
124+
for txbatcher in self.txbatchers.values():
125+
await txbatcher.run_iteration(password)
126+
127+
class TxBatch(Logger):
128+
129+
RETRY_DELAY = 60
130+
131+
def __init__(self, wallet, fee_policy, batch_txids, key):
81132
Logger.__init__(self)
82133
self.wallet = wallet
83-
self.config = wallet.config
134+
self.key = key
135+
self.fee_policy = fee_policy
84136
self.lock = threading.RLock()
85137
self.batch_payments = [] # list of payments we need to make
86138
self.batch_inputs = {} # list of inputs we need to sweep
87139
# list of tx that were broadcast. Each tx is a RBF replacement of the previous one. Ony one can get mined.
88-
self._batch_txids = wallet.db.get_stored_item("batch_txids", [])
140+
self._batch_txids = batch_txids # fixme: check persistence
141+
if self.key not in self._batch_txids:
142+
self._batch_txids[self.key] = []
89143
self._base_tx = None # current batch tx. last element of batch_txids
90144

91-
if self._batch_txids:
92-
last_txid = self._batch_txids[-1]
145+
if self._batch_txids[self.key]:
146+
last_txid = self._batch_txids[self.key][-1]
93147
tx = self.wallet.adb.get_transaction(last_txid)
94148
if tx:
95149
tx = PartialTransaction.from_tx(tx)
@@ -101,7 +155,7 @@ def __init__(self, wallet):
101155
self._unconfirmed_sweeps = set() # list of inputs we are sweeping (until spending tx is confirmed)
102156

103157
def is_mine(self, txid):
104-
return txid in self._batch_txids
158+
return txid in self._batch_txids[self.key]
105159

106160
@locked
107161
def add_batch_payment(self, output: 'PartialTxOutput'):
@@ -139,7 +193,7 @@ def get_base_tx(self) -> Optional[Transaction]:
139193
return self._base_tx
140194

141195
def _find_confirmed_base_tx(self) -> Optional[Transaction]:
142-
for txid in self._batch_txids:
196+
for txid in self._batch_txids[self.key]:
143197
tx_mined_status = self.wallet.adb.get_tx_height(txid)
144198
if tx_mined_status.conf > 0:
145199
tx = self.wallet.adb.get_transaction(txid)
@@ -184,19 +238,14 @@ def _should_bump_fee(self, base_tx) -> bool:
184238
if not self.is_mine(base_tx.txid()):
185239
return False
186240
base_tx_fee = base_tx.get_fee()
187-
recommended_fee = self.config.estimate_fee(base_tx.estimated_size(), allow_fallback_to_static_rates=True)
241+
recommended_fee = self.fee_policy.estimate_fee(base_tx.estimated_size(), allow_fallback_to_static_rates=True)
188242
should_bump_fee = base_tx_fee * 1.1 < recommended_fee
189243
if should_bump_fee:
190244
self.logger.info(f'base tx fee too low {base_tx_fee} < {recommended_fee}. we will bump the fee')
191245
return should_bump_fee
192246

193-
@log_exceptions
194-
async def run(self):
195-
while True:
196-
await asyncio.sleep(self.SLEEP_INTERVAL)
197-
password = self.wallet.get_unlocked_password()
198-
if self.wallet.has_keystore_encryption() and not password:
199-
continue
247+
248+
async def run_iteration(self, password):
200249
await self._maybe_broadcast_legacy_htlc_txs()
201250
tx = self._find_confirmed_base_tx()
202251
if tx:
@@ -214,22 +263,24 @@ async def run(self):
214263
else:
215264
self.wallet.add_future_tx(v, wanted_height)
216265
if not to_pay and not to_sweep_now and not self._should_bump_fee(base_tx):
217-
continue
266+
return
218267
try:
219268
tx = self._create_batch_tx(base_tx, to_sweep_now, to_pay, password)
220269
except Exception as e:
221270
self.logger.exception(f'Cannot create batch transaction: {repr(e)}')
222271
if base_tx:
223272
self._start_new_batch(base_tx)
224-
continue
225-
await asyncio.sleep(self.RETRY_DELAY)
226-
continue
273+
return
274+
else:
275+
raise
276+
#await asyncio.sleep(self.RETRY_DELAY)
277+
#return
227278
self.logger.info(f'created tx with {len(tx.inputs())} inputs and {len(tx.outputs())} outputs')
228279
self.logger.info(f'{str(tx)}')
229280
if await self.wallet.network.try_broadcasting(tx, 'batch'):
230281
self.wallet.adb.add_transaction(tx)
231282
if tx.has_change():
232-
self._batch_txids.append(tx.txid())
283+
self._batch_txids[self.key].append(tx.txid())
233284
self._base_tx = tx
234285
else:
235286
self.logger.info(f'starting new batch because current base tx does not have change')
@@ -288,7 +339,7 @@ def _start_new_batch(self, tx):
288339
use_change = tx and tx.has_change() and any([txout in self.batch_payments for txout in tx.outputs()])
289340
self.batch_payments = self._to_pay_after(tx)
290341
self.batch_inputs = self._to_sweep_after(tx)
291-
self._batch_txids.clear()
342+
self._batch_txids[self.key] = []
292343
self._base_tx = None
293344
self._parent_tx = tx if use_change else None
294345

0 commit comments

Comments
 (0)