Skip to content

Commit 838189a

Browse files
authored
Merge pull request #9298 from spesmilo/batch_payments_manager
wallet: RBF batch payments manager
2 parents e09676b + bdb7a82 commit 838189a

12 files changed

+840
-326
lines changed

electrum/gui/qt/main_window.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,11 @@ def on_event_channel(self, *args):
461461
def on_event_banner(self, *args):
462462
self.console.showMessage(args[0])
463463

464+
@qt_event_listener
465+
def on_event_adb_set_future_tx(self, adb, txid):
466+
if adb == self.wallet.adb:
467+
self.history_model.refresh('set_future_tx')
468+
464469
@qt_event_listener
465470
def on_event_verified(self, *args):
466471
wallet, tx_hash, tx_mined_status = args

electrum/lnsweep.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ class SweepInfo(NamedTuple):
4343
cltv_abs: Optional[int] # set to None only if the script has no cltv
4444
txin: PartialTxInput
4545
txout: Optional[PartialTxOutput] # only for first-stage htlc tx
46+
can_be_batched: bool # todo: this could be more fine-grained
47+
4648

4749
def sweep_their_ctx_watchtower(
4850
chan: 'Channel',
@@ -251,7 +253,8 @@ def justice_txin(output_idx):
251253
csv_delay=0,
252254
cltv_abs=None,
253255
txin=txin,
254-
txout=None
256+
txout=None,
257+
can_be_batched=False,
255258
)
256259
return index_to_sweepinfo
257260

@@ -329,6 +332,7 @@ def sweep_our_ctx(
329332
cltv_abs=None,
330333
txin=txin,
331334
txout=None,
335+
can_be_batched=True,
332336
)
333337

334338
# to_local
@@ -350,6 +354,7 @@ def sweep_our_ctx(
350354
cltv_abs=None,
351355
txin=txin,
352356
txout=None,
357+
can_be_batched=True,
353358
)
354359
we_breached = ctn < chan.get_oldest_unrevoked_ctn(LOCAL)
355360
if we_breached:
@@ -384,7 +389,11 @@ def txs_htlc(
384389
csv_delay=0,
385390
cltv_abs=htlc_tx.locktime,
386391
txin=htlc_tx.inputs()[0],
387-
txout=htlc_tx.outputs()[0])
392+
txout=htlc_tx.outputs()[0],
393+
can_be_batched=False, # both parties can spend
394+
# actually, we might want to batch depending on the context
395+
# f(amount in htlc, remaining_time, number of available utxos for anchors)
396+
)
388397
else:
389398
# second-stage
390399
address = bitcoin.script_to_p2wsh(htlctx_witness_script)
@@ -404,6 +413,9 @@ def txs_htlc(
404413
cltv_abs=0,
405414
txin=sweep_txin,
406415
txout=None,
416+
# this is safe to batch, we are the only ones who can spend
417+
# (assuming we did not broadcast a revoked state)
418+
can_be_batched=True,
407419
)
408420

409421
# offered HTLCs, in our ctx --> "timeout"
@@ -541,6 +553,7 @@ def sweep_their_ctx_to_remote_backup(
541553
cltv_abs=None,
542554
txin=txin,
543555
txout=None,
556+
can_be_batched=True,
544557
)
545558

546559
# to_remote
@@ -562,6 +575,7 @@ def sweep_their_ctx_to_remote_backup(
562575
cltv_abs=None,
563576
txin=txin,
564577
txout=None,
578+
can_be_batched=True,
565579
)
566580
return txs
567581

@@ -619,6 +633,7 @@ def sweep_their_ctx(
619633
cltv_abs=None,
620634
txin=txin,
621635
txout=None,
636+
can_be_batched=True,
622637
)
623638

624639
# to_local is handled by lnwatcher
@@ -631,6 +646,7 @@ def sweep_their_ctx(
631646
cltv_abs=None,
632647
txin=txin,
633648
txout=None,
649+
can_be_batched=False,
634650
)
635651

636652
# to_remote
@@ -656,12 +672,14 @@ def sweep_their_ctx(
656672
our_payment_privkey=our_payment_privkey,
657673
has_anchors=chan.has_anchors()
658674
):
675+
# todo: we might not want to sweep this at all, if we add it to the wallet addresses
659676
txs[prevout] = SweepInfo(
660677
name='their_ctx_to_remote',
661678
csv_delay=csv_delay,
662679
cltv_abs=None,
663680
txin=txin,
664681
txout=None,
682+
can_be_batched=True,
665683
)
666684

667685
# HTLCs
@@ -701,6 +719,7 @@ def tx_htlc(
701719
cltv_abs=cltv_abs,
702720
txin=txin,
703721
txout=None,
722+
can_be_batched=False,
704723
)
705724
# received HTLCs, in their ctx --> "timeout"
706725
# offered HTLCs, in their ctx --> "success"

electrum/lnwatcher.py

Lines changed: 25 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,14 @@
22
# Distributed under the MIT software license, see the accompanying
33
# file LICENCE or http://www.opensource.org/licenses/mit-license.php
44

5-
from typing import NamedTuple, Iterable, TYPE_CHECKING
6-
import copy
7-
import asyncio
5+
from typing import TYPE_CHECKING
86
from enum import IntEnum, auto
9-
from typing import NamedTuple, Dict
107

11-
from . import util
12-
from .util import log_exceptions, ignore_exceptions, TxMinedInfo
8+
from .util import log_exceptions, ignore_exceptions, TxMinedInfo, BelowDustLimit
139
from .util import EventListener, event_listener
1410
from .address_synchronizer import AddressSynchronizer, TX_HEIGHT_LOCAL, TX_HEIGHT_UNCONF_PARENT, TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_FUTURE
15-
from .transaction import Transaction, TxOutpoint, PartialTransaction
11+
from .transaction import Transaction, TxOutpoint
1612
from .logging import Logger
17-
from .bitcoin import dust_threshold
18-
from .fee_policy import FeePolicy
1913

2014

2115
if TYPE_CHECKING:
@@ -46,7 +40,6 @@ def __init__(self, adb: 'AddressSynchronizer', network: 'Network'):
4640
self.register_callbacks()
4741
# status gets populated when we run
4842
self.channel_status = {}
49-
self.fee_policy = FeePolicy('eta:2')
5043

5144
async def stop(self):
5245
self.unregister_callbacks()
@@ -75,6 +68,13 @@ def add_callback(self, address, callback):
7568
async def on_event_blockchain_updated(self, *args):
7669
await self.trigger_callbacks()
7770

71+
@event_listener
72+
async def on_event_wallet_updated(self, wallet):
73+
# called if we add local tx
74+
if wallet.adb != self.adb:
75+
return
76+
await self.trigger_callbacks()
77+
7878
@event_listener
7979
async def on_event_adb_added_verified_tx(self, adb, tx_hash):
8080
if adb != self.adb:
@@ -141,6 +141,10 @@ def get_spender(self, outpoint) -> str:
141141
"""
142142
prev_txid, index = outpoint.split(':')
143143
spender_txid = self.adb.db.get_spent_outpoint(prev_txid, int(index))
144+
# discard local spenders
145+
tx_mined_status = self.adb.get_tx_height(spender_txid)
146+
if tx_mined_status.height in [TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE]:
147+
spender_txid = None
144148
if not spender_txid:
145149
return
146150
spender_tx = self.adb.get_transaction(spender_txid)
@@ -211,18 +215,6 @@ async def update_channel_state(self, *, funding_outpoint: str, funding_txid: str
211215
keep_watching=keep_watching)
212216
await self.lnworker.handle_onchain_state(chan)
213217

214-
def is_dust(self, sweep_info):
215-
if sweep_info.name in ['local_anchor', 'remote_anchor']:
216-
return False
217-
if sweep_info.txout is not None:
218-
return False
219-
value = sweep_info.txin._trusted_value_sats
220-
witness_size = len(sweep_info.txin.make_witness(71*b'\x00'))
221-
tx_size_vbytes = 84 + witness_size//4 # assumes no batching, sweep to p2wpkh
222-
self.logger.info(f'{sweep_info.name} size = {tx_size_vbytes}')
223-
fee = self.fee_policy.estimate_fee(tx_size_vbytes, network=self.network, allow_fallback_to_static_rates=True)
224-
return value - fee <= dust_threshold()
225-
226218
@log_exceptions
227219
async def sweep_commitment_transaction(self, funding_outpoint, closing_tx) -> bool:
228220
"""This function is called when a channel was closed. In this case
@@ -235,19 +227,16 @@ async def sweep_commitment_transaction(self, funding_outpoint, closing_tx) -> bo
235227
return False
236228
# detect who closed and get information about how to claim outputs
237229
sweep_info_dict = chan.sweep_ctx(closing_tx)
238-
self.logger.info(f"do_breach_remedy: {[x.name for x in sweep_info_dict.values()]}")
230+
#self.logger.info(f"do_breach_remedy: {[x.name for x in sweep_info_dict.values()]}")
239231
keep_watching = False if sweep_info_dict else not self.is_deeply_mined(closing_tx.txid())
240-
241232
# create and broadcast transactions
242233
for prevout, sweep_info in sweep_info_dict.items():
243-
if self.is_dust(sweep_info):
244-
continue
245234
prev_txid, prev_index = prevout.split(':')
246235
name = sweep_info.name + ' ' + chan.get_id_for_log()
247236
self.lnworker.wallet.set_default_label(prevout, name)
248237
if not self.adb.get_transaction(prev_txid):
249238
# do not keep watching if prevout does not exist
250-
self.logger.info(f'prevout does not exist for {name}: {prev_txid}')
239+
self.logger.info(f'prevout does not exist for {name}: {prevout}')
251240
continue
252241
spender_txid = self.get_spender(prevout)
253242
spender_tx = self.adb.get_transaction(spender_txid) if spender_txid else None
@@ -260,115 +249,20 @@ async def sweep_commitment_transaction(self, funding_outpoint, closing_tx) -> bo
260249
if htlc_tx_spender:
261250
keep_watching |= not self.is_deeply_mined(htlc_tx_spender)
262251
else:
263-
keep_watching = True
264-
await self.maybe_redeem(prevout2, htlc_sweep_info, name)
252+
keep_watching |= self.maybe_redeem(htlc_sweep_info)
265253
keep_watching |= not self.is_deeply_mined(spender_txid)
266254
self.maybe_extract_preimage(chan, spender_tx, prevout)
267255
else:
268-
keep_watching = True
269-
# broadcast or maybe update our own tx
270-
await self.maybe_redeem(prevout, sweep_info, name)
271-
256+
keep_watching |= self.maybe_redeem(sweep_info)
272257
return keep_watching
273258

274-
def get_redeem_tx(self, prevout: str, sweep_info: 'SweepInfo', name: str):
275-
# check if redeem tx needs to be updated
276-
# if it is in the mempool, we need to check fee rise
277-
txid = self.get_spender(prevout)
278-
old_tx = self.adb.get_transaction(txid)
279-
assert old_tx is not None or txid is None
280-
tx_depth = self.get_tx_mined_depth(txid) if txid else None
281-
if txid and tx_depth not in [TxMinedDepth.FREE, TxMinedDepth.MEMPOOL]:
282-
assert old_tx is not None
283-
return old_tx, None
284-
# fixme: deepcopy is needed because tx.serialize() is destructive
285-
inputs = [copy.deepcopy(sweep_info.txin)]
286-
outputs = [sweep_info.txout] if sweep_info.txout else []
287-
if sweep_info.name == 'first-stage-htlc':
288-
new_tx = PartialTransaction.from_io(inputs, outputs, locktime=sweep_info.cltv_abs, version=2)
289-
self.lnworker.wallet.sign_transaction(new_tx, password=None, ignore_warnings=True)
290-
else:
291-
# password is needed for 1st stage htlc tx with anchors because we add inputs
292-
password = self.lnworker.wallet.get_unlocked_password()
293-
new_tx = self.lnworker.wallet.create_transaction(
294-
fee_policy = self.fee_policy,
295-
inputs = inputs,
296-
outputs = outputs,
297-
password = password,
298-
locktime = sweep_info.cltv_abs,
299-
BIP69_sort=False,
300-
)
301-
if new_tx is None:
302-
self.logger.info(f'{name} could not claim output: {prevout}, dust')
303-
assert old_tx is not None
304-
return old_tx, None
305-
if txid is None:
306-
return None, new_tx
307-
elif tx_depth == TxMinedDepth.MEMPOOL:
308-
delta = new_tx.get_fee() - self.adb.get_tx_fee(txid)
309-
if delta > 1:
310-
self.logger.info(f'increasing fee of mempool tx {name}: {prevout}')
311-
return old_tx, new_tx
312-
else:
313-
assert old_tx is not None
314-
return old_tx, None
315-
elif tx_depth == TxMinedDepth.FREE:
316-
# return new tx, even if it is equal to old_tx,
317-
# because we need to test if it can be broadcast
318-
return old_tx, new_tx
319-
else:
320-
assert old_tx is not None
321-
return old_tx, None
322-
323-
async def maybe_redeem(self, prevout, sweep_info: 'SweepInfo', name: str) -> None:
324-
old_tx, new_tx = self.get_redeem_tx(prevout, sweep_info, name)
325-
if new_tx is None:
326-
return
327-
prev_txid, prev_index = prevout.split(':')
328-
can_broadcast = True
329-
local_height = self.network.get_local_height()
330-
if sweep_info.cltv_abs:
331-
wanted_height = sweep_info.cltv_abs
332-
if wanted_height - local_height > 0:
333-
can_broadcast = False
334-
# self.logger.debug(f"pending redeem for {prevout}. waiting for {name}: CLTV ({local_height=}, {wanted_height=})")
335-
if sweep_info.csv_delay:
336-
prev_height = self.adb.get_tx_height(prev_txid)
337-
if prev_height.height > 0:
338-
wanted_height = prev_height.height + sweep_info.csv_delay - 1
339-
else:
340-
wanted_height = local_height + sweep_info.csv_delay
341-
if wanted_height - local_height > 0:
342-
can_broadcast = False
343-
# self.logger.debug(
344-
# f"pending redeem for {prevout}. waiting for {name}: CSV "
345-
# f"({local_height=}, {wanted_height=}, {prev_height.height=}, {sweep_info.csv_delay=})")
346-
if can_broadcast:
347-
self.logger.info(f'we can broadcast: {name}')
348-
if await self.network.try_broadcasting(new_tx, name):
349-
tx_was_added = self.adb.add_transaction(new_tx, is_new=(old_tx is None))
350-
else:
351-
tx_was_added = False
352-
else:
353-
# we may have a tx with a different fee, in which case it will be replaced
354-
if not old_tx or (old_tx and old_tx.txid() != new_tx.txid()):
355-
try:
356-
tx_was_added = self.adb.add_transaction(new_tx, is_new=(old_tx is None))
357-
except Exception as e:
358-
self.logger.info(f'could not add future tx: {name}. prevout: {prevout} {str(e)}')
359-
tx_was_added = False
360-
if tx_was_added:
361-
self.logger.info(f'added redeem tx: {name}. prevout: {prevout}')
362-
else:
363-
tx_was_added = False
364-
# set future tx regardless of tx_was_added, because it is not persisted
365-
# (and wanted_height can change if input of CSV was not mined before)
366-
self.adb.set_future_tx(new_tx.txid(), wanted_height=wanted_height)
367-
if tx_was_added:
368-
self.lnworker.wallet.set_label(new_tx.txid(), name)
369-
if old_tx and old_tx.txid() != new_tx.txid():
370-
self.lnworker.wallet.set_label(old_tx.txid(), None)
371-
util.trigger_callback('wallet_updated', self.lnworker.wallet)
259+
def maybe_redeem(self, sweep_info: 'SweepInfo') -> bool:
260+
""" returns False if it was dust """
261+
try:
262+
self.lnworker.wallet.txbatcher.add_sweep_input('lnwatcher', sweep_info, self.config.FEE_POLICY_LIGHTNING)
263+
except BelowDustLimit:
264+
return False
265+
return True
372266

373267
def maybe_extract_preimage(self, chan: 'AbstractChannel', spender_tx: Transaction, prevout: str):
374268
txin_idx = spender_tx.get_input_idx_that_spent_prevout(TxOutpoint.from_str(prevout))

electrum/simple_config.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -675,7 +675,9 @@ def __setattr__(self, name, value):
675675
TEST_SHUTDOWN_FEE_RANGE = ConfigVar('test_shutdown_fee_range', default=None)
676676
TEST_SHUTDOWN_LEGACY = ConfigVar('test_shutdown_legacy', default=False, type_=bool)
677677

678-
FEE_POLICY = ConfigVar('fee_policy', default='eta:2', type_=str)
678+
FEE_POLICY = ConfigVar('fee_policy', default='eta:2', type_=str) # exposed to GUI
679+
FEE_POLICY_LIGHTNING = ConfigVar('fee_policy_lightning', default='eta:2', type_=str) # for txbatcher (sweeping)
680+
FEE_POLICY_SWAPS = ConfigVar('fee_policy_swaps', default='eta:2', type_=str) # for txbatcher (sweeping and sending if we are a swapserver)
679681

680682
RPC_USERNAME = ConfigVar('rpcuser', default=None, type_=str)
681683
RPC_PASSWORD = ConfigVar('rpcpassword', default=None, type_=str)

0 commit comments

Comments
 (0)