Skip to content

Commit ccb0cc3

Browse files
committed
batch payment manager:
The class TxBatcher handles the creation, broadcast and replacement of replaceable transactions. Callers (LNWatcher, SwapManager) use methods add_payment_output and add_sweep_info. Transactions created by TxBatcher may combine sweeps and outgoing payments. TxBatcher is always used when sweeping. When sending outgoing payments with the GUI, its use is conditional to the config variable WALLET_BATCH_RBF. This means that this config variable essentially becomes a GUI setting. Transactions created by TxBatcher will have their fee bumped automatically (this was only the case for sweeps before). wallet: - instead of reading config variables, make_unsigned_transaction takes new parameters: base_tx, send_change_to_lighting tests: - add tests in test_txbatcher.py - remove test_sswaps.py - force regtests to use MPP, so that we sweep transactions with several HTLCs. This forces the payment manager to aggregate HTLC tx inputs. GUI: - if coin control is active, disable batching - forward submarine swaps can be batched Note: If batching is enabled, the GUI will not merge duplicate outputs. To enable that, we would need extra logic that matches txbatcher._base_tx outputs to txbatcher.batch_payments (Note that this is also what we would need in order to make payments robust to reorgs, so it might be worth considering at one point.)
1 parent e1377c9 commit ccb0cc3

14 files changed

+800
-388
lines changed

electrum/gui/qt/confirm_tx_dialog.py

+45-29
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ def __init__(self, *, title='',
7777
self.no_dynfee_estimates = False
7878
self.needs_update = False
7979
# preview is disabled for lightning channel funding
80-
self.allow_preview = allow_preview
80+
self._allow_preview = allow_preview
8181
self.is_preview = False
8282

8383
self.locktime_e = LockTimeEdit(self)
@@ -103,14 +103,20 @@ def __init__(self, *, title='',
103103
vbox.addStretch(1)
104104
vbox.addLayout(buttons)
105105

106-
self.set_io_visible(self.config.GUI_QT_TX_EDITOR_SHOW_IO)
106+
self.set_io_visible()
107107
self.set_fee_edit_visible(self.config.GUI_QT_TX_EDITOR_SHOW_FEE_DETAILS)
108108
self.set_locktime_visible(self.config.GUI_QT_TX_EDITOR_SHOW_LOCKTIME)
109109
self.update_fee_target()
110110
self.resize(self.layout().sizeHint())
111111

112112
self.main_window.gui_object.timer.timeout.connect(self.timer_actions)
113113

114+
def is_batching(self):
115+
return self.config.WALLET_BATCH_RBF and not self.main_window.utxo_list.is_coincontrol_active()
116+
117+
def should_show_io(self):
118+
return self.config.GUI_QT_TX_EDITOR_SHOW_IO
119+
114120
def timer_actions(self):
115121
if self.needs_update:
116122
self.update()
@@ -363,10 +369,13 @@ def update_fee_fields(self):
363369
# feerate_label needs to be updated from feerate_e
364370
self.update_feerate_label()
365371

372+
def allow_preview(self):
373+
return self._allow_preview and not self.is_batching()
374+
366375
def create_buttons_bar(self):
367376
self.preview_button = QPushButton(_('Preview'))
368377
self.preview_button.clicked.connect(self.on_preview)
369-
self.preview_button.setVisible(self.allow_preview)
378+
self.preview_button.setVisible(self.allow_preview())
370379
self.ok_button = QPushButton(_('OK'))
371380
self.ok_button.clicked.connect(self.on_send)
372381
self.ok_button.setDefault(True)
@@ -450,7 +459,10 @@ def toggle_multiple_change(self):
450459
def toggle_batch_rbf(self):
451460
b = not self.config.WALLET_BATCH_RBF
452461
self.config.WALLET_BATCH_RBF = b
462+
self.set_io_visible()
463+
self.resize_to_fit_content()
453464
self.trigger_update()
465+
self.preview_button.setVisible(self.allow_preview())
454466

455467
def toggle_merge_duplicate_outputs(self):
456468
b = not self.config.WALLET_MERGE_DUPLICATE_OUTPUTS
@@ -470,7 +482,7 @@ def toggle_confirmed_only(self):
470482
def toggle_io_visibility(self):
471483
b = not self.config.GUI_QT_TX_EDITOR_SHOW_IO
472484
self.config.GUI_QT_TX_EDITOR_SHOW_IO = b
473-
self.set_io_visible(b)
485+
self.set_io_visible()
474486
self.resize_to_fit_content()
475487

476488
def toggle_fee_details(self):
@@ -485,7 +497,8 @@ def toggle_locktime(self):
485497
self.set_locktime_visible(b)
486498
self.resize_to_fit_content()
487499

488-
def set_io_visible(self, b):
500+
def set_io_visible(self):
501+
b = self.should_show_io()
489502
self.io_widget.setVisible(b)
490503

491504
def set_fee_edit_visible(self, b):
@@ -552,31 +565,33 @@ def get_messages(self):
552565
assert fee is not None
553566
amount = self.tx.output_value() if self.output_value == '!' else self.output_value
554567
tx_size = self.tx.estimated_size()
555-
fee_warning_tuple = self.wallet.get_tx_fee_warning(
556-
invoice_amt=amount, tx_size=tx_size, fee=fee)
557-
if fee_warning_tuple:
558-
allow_send, long_warning, short_warning = fee_warning_tuple
559-
if not allow_send:
560-
self.error = long_warning
561-
else:
562-
messages.append(long_warning)
563-
if self.tx.has_dummy_output(DummyAddress.SWAP):
568+
if not self.is_batching():
569+
fee_warning_tuple = self.wallet.get_tx_fee_warning(
570+
invoice_amt=amount, tx_size=tx_size, fee=fee)
571+
if fee_warning_tuple:
572+
allow_send, long_warning, short_warning = fee_warning_tuple
573+
if not allow_send:
574+
self.error = long_warning
575+
else:
576+
messages.append(long_warning)
577+
if self.tx.get_dummy_output(DummyAddress.SWAP):
564578
messages.append(_('This transaction will send funds to a submarine swap.'))
565579
# warn if spending unconf
566580
if any((txin.block_height is not None and txin.block_height<=0) for txin in self.tx.inputs()):
567581
messages.append(_('This transaction will spend unconfirmed coins.'))
568582
# warn if we merge from mempool
569-
if self.tx.rbf_merge_txid:
570-
messages.append(_('This payment will be merged with another existing transaction.'))
571-
# warn if we use multiple change outputs
572-
num_change = sum(int(o.is_change) for o in self.tx.outputs())
573-
num_ismine = sum(int(o.is_mine) for o in self.tx.outputs())
574-
if num_change > 1:
575-
messages.append(_('This transaction has {} change outputs.'.format(num_change)))
576-
# warn if there is no ismine output, as it might be problematic to RBF the tx later.
577-
# (though RBF is still possible by adding new inputs, if the wallet has more utxos)
578-
if num_ismine == 0:
579-
messages.append(_('Make sure you pay enough mining fees; you will not be able to bump the fee later.'))
583+
if self.is_batching():
584+
messages.append(_('Transaction batching is active. The fee will be bumped automatically if needed'))
585+
else:
586+
# warn if we use multiple change outputs
587+
num_change = sum(int(o.is_change) for o in self.tx.outputs())
588+
num_ismine = sum(int(o.is_mine) for o in self.tx.outputs())
589+
if num_change > 1:
590+
messages.append(_('This transaction has {} change outputs.'.format(num_change)))
591+
# warn if there is no ismine output, as it might be problematic to RBF the tx later.
592+
# (though RBF is still possible by adding new inputs, if the wallet has more utxos)
593+
if num_ismine == 0:
594+
messages.append(_('Make sure you pay enough mining fees; you will not be able to bump the fee later.'))
580595

581596
# TODO: warn if we send change back to input address
582597
return messages
@@ -637,16 +652,17 @@ def _update_amount_label(self):
637652
def update_tx(self, *, fallback_to_zero_fee: bool = False):
638653
fee_estimator = self.get_fee_estimator()
639654
confirmed_only = self.config.WALLET_SPEND_CONFIRMED_ONLY
655+
is_batching = self.is_batching()
640656
try:
641-
self.tx = self.make_tx(fee_estimator, confirmed_only=confirmed_only)
657+
self.tx = self.make_tx(fee_estimator, confirmed_only=confirmed_only, is_batching=is_batching)
642658
self.not_enough_funds = False
643659
self.no_dynfee_estimates = False
644660
except NotEnoughFunds:
645661
self.not_enough_funds = True
646662
self.tx = None
647663
if fallback_to_zero_fee:
648664
try:
649-
self.tx = self.make_tx(0, confirmed_only=confirmed_only)
665+
self.tx = self.make_tx(0, confirmed_only=confirmed_only, is_batching=is_batching)
650666
except BaseException:
651667
return
652668
else:
@@ -655,7 +671,7 @@ def update_tx(self, *, fallback_to_zero_fee: bool = False):
655671
self.no_dynfee_estimates = True
656672
self.tx = None
657673
try:
658-
self.tx = self.make_tx(0, confirmed_only=confirmed_only)
674+
self.tx = self.make_tx(0, confirmed_only=confirmed_only, is_batching=is_batching)
659675
except NotEnoughFunds:
660676
self.not_enough_funds = True
661677
return
@@ -670,7 +686,7 @@ def update_tx(self, *, fallback_to_zero_fee: bool = False):
670686
def can_pay_assuming_zero_fees(self, confirmed_only) -> bool:
671687
# called in send_tab.py
672688
try:
673-
tx = self.make_tx(0, confirmed_only=confirmed_only)
689+
tx = self.make_tx(0, confirmed_only=confirmed_only, is_batching=False)
674690
except NotEnoughFunds:
675691
return False
676692
else:

electrum/gui/qt/main_window.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,11 @@ def on_event_channel(self, *args):
460460
def on_event_banner(self, *args):
461461
self.console.showMessage(args[0])
462462

463+
@qt_event_listener
464+
def on_event_adb_set_future_tx(self, adb, txid):
465+
if adb == self.wallet.adb:
466+
self.history_model.refresh('set_future_tx')
467+
463468
@qt_event_listener
464469
def on_event_verified(self, *args):
465470
wallet, tx_hash, tx_mined_status = args
@@ -1417,7 +1422,7 @@ def confirm_tx_dialog(self, make_tx, output_value, allow_preview=True):
14171422
text = self.send_tab.get_text_not_enough_funds_mentioning_frozen()
14181423
self.show_message(text)
14191424
return
1420-
return d.run(), d.is_preview
1425+
return d.run(), d.is_preview, d.is_batching()
14211426

14221427
@protected
14231428
def _open_channel(self, connect_str, funding_sat, push_amt, funding_tx, password):

electrum/gui/qt/send_tab.py

+42-12
Original file line numberDiff line numberDiff line change
@@ -317,34 +317,64 @@ def pay_onchain_dialog(
317317
# we call get_coins inside make_tx, so that inputs can be changed dynamically
318318
if get_coins is None:
319319
get_coins = self.window.get_coins
320-
make_tx = lambda fee_est, *, confirmed_only=False: self.wallet.make_unsigned_transaction(
321-
coins=get_coins(nonlocal_only=nonlocal_only, confirmed_only=confirmed_only),
322-
outputs=outputs,
323-
fee=fee_est,
324-
is_sweep=is_sweep)
320+
321+
def make_tx(fee_est, *, confirmed_only=False, is_batching=False):
322+
base_tx = self.wallet.txbatcher.get_base_tx() if is_batching else None
323+
merge_duplicate_outputs = self.config.WALLET_MERGE_DUPLICATE_OUTPUTS if not is_batching else False
324+
coins = get_coins(nonlocal_only=nonlocal_only, confirmed_only=confirmed_only)
325+
return self.wallet.make_unsigned_transaction(
326+
coins=coins,
327+
outputs=outputs,
328+
base_tx=base_tx,
329+
fee=fee_est,
330+
is_sweep=is_sweep,
331+
send_change_to_lightning=self.config.WALLET_SEND_CHANGE_TO_LIGHTNING,
332+
merge_duplicate_outputs=merge_duplicate_outputs,
333+
)
334+
325335
output_values = [x.value for x in outputs]
326336
is_max = any(parse_max_spend(outval) for outval in output_values)
327337
output_value = '!' if is_max else sum(output_values)
328338

329-
tx, is_preview = self.window.confirm_tx_dialog(make_tx, output_value)
339+
tx, is_preview, is_batching = self.window.confirm_tx_dialog(make_tx, output_value)
330340
if tx is None:
331341
# user cancelled
332342
return
333343

334-
if tx.has_dummy_output(DummyAddress.SWAP):
344+
if swap_dummy_output := tx.get_dummy_output(DummyAddress.SWAP):
335345
sm = self.wallet.lnworker.swap_manager
336346
with self.window.create_sm_transport() as transport:
337347
if not self.window.initialize_swap_manager(transport):
338348
return
339-
coro = sm.request_swap_for_tx(transport, tx)
349+
coro = sm.request_swap_for_amount(transport, swap_dummy_output.value)
340350
try:
341-
swap, invoice, tx = self.window.run_coroutine_dialog(coro, _('Requesting swap invoice...'))
351+
swap, invoice = self.window.run_coroutine_dialog(coro, _('Requesting swap invoice...'))
342352
except SwapServerError as e:
343353
self.show_error(str(e))
344354
return
345-
assert not tx.has_dummy_output(DummyAddress.SWAP)
346-
tx.swap_invoice = invoice
347-
tx.swap_payment_hash = swap.payment_hash
355+
356+
if is_batching:
357+
self.save_pending_invoice()
358+
funding_output = PartialTxOutput.from_address_and_value(swap.lockup_address, swap_dummy_output.value)
359+
coro = sm.wait_for_htlcs_and_broadcast(transport, swap=swap, invoice=invoice, output=funding_output)
360+
try:
361+
funding_txid = self.window.run_coroutine_dialog(coro, _('Awaiting lightning payment...'))
362+
except UserCancelled:
363+
sm.cancel_normal_swap(swap)
364+
return
365+
self.window.on_swap_result(funding_txid, is_reverse=False)
366+
return
367+
else:
368+
tx.replace_output_address(DummyAddress.SWAP, swap.lockup_address)
369+
assert tx.get_dummy_output(DummyAddress.SWAP) is None
370+
tx.swap_invoice = invoice
371+
tx.swap_payment_hash = swap.payment_hash
372+
373+
if is_batching:
374+
self.save_pending_invoice()
375+
for output in outputs:
376+
self.wallet.txbatcher.add_batch_payment(output)
377+
return
348378

349379
if is_preview:
350380
self.window.show_transaction(tx, external_keypairs=external_keypairs, payment_identifier=payment_identifier)

electrum/gui/qt/utxo_list.py

+3
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,9 @@ def get_spend_list(self) -> Optional[Sequence[PartialTxInput]]:
223223
utxos = [self._utxo_dict[x] for x in self._spend_set]
224224
return copy.deepcopy(utxos) # copy so that side-effects don't affect utxo_dict
225225

226+
def is_coincontrol_active(self):
227+
return bool(self._spend_set)
228+
226229
def _maybe_reset_coincontrol(self, current_wallet_utxos: Sequence[PartialTxInput]) -> None:
227230
if not bool(self._spend_set):
228231
return

0 commit comments

Comments
 (0)