diff --git a/electrum/gui/messages.py b/electrum/gui/messages.py index bdc7f600f..8c3837f91 100644 --- a/electrum/gui/messages.py +++ b/electrum/gui/messages.py @@ -74,6 +74,11 @@ MSG_REVERSE_SWAP_FUNDING_MEMPOOL = ( "you will not get back the already pre-paid mining fees.") ) +MSG_FORCE_CLOSE_WARNING = ( + _('You will need to come back online after the commitment transaction is confirmed, in order to broadcast second-stage htlc transactions.') + ' ' + + _('If you remain offline for more than {} blocks, your channel counterparty will be able to sweep those funds.') +) + MSG_FORWARD_SWAP_WARNING = ( _('You will need to come back online after the funding transaction is confirmed, in order to settle the swap.') + ' ' + _('If you remain offline for more than {} blocks, your channel will be force closed and you might lose the funds you sent in the swap.') diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 6aacc4bb7..edf9cba3d 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -274,6 +274,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): # wallet closing warning callbacks self.closing_warning_callbacks = [] # type: List[Callable[[], Optional[str]]] self.register_closing_warning_callback(self._check_ongoing_submarine_swaps_callback) + self.register_closing_warning_callback(self._check_ongoing_force_closures) # banner may already be there if self.network and self.network.banner: self.console.showMessage(self.network.banner) @@ -2730,6 +2731,23 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): self.logger.debug(f"registering wallet closing warning callback") self.closing_warning_callbacks.append(warning_callback) + def _check_ongoing_force_closures(self) -> Optional[str]: + from electrum.lnutil import MIN_FINAL_CLTV_DELTA_FOR_INVOICE + if not self.wallet.has_lightning(): + return None + if not self.network: + return None + force_closes = self.wallet.lnworker.lnwatcher.get_pending_force_closes() + if not force_closes: + return + # fixme: this is inaccurate, we need local_height - cltv_of_htlc + cltv_delta = MIN_FINAL_CLTV_DELTA_FOR_INVOICE + msg = '\n\n'.join([ + _("Pending channel force-close"), + messages.MSG_FORCE_CLOSE_WARNING.format(cltv_delta), + ]) + return msg + def _check_ongoing_submarine_swaps_callback(self) -> Optional[str]: """Callback that will return a warning string if there are unconfirmed swap funding txs.""" from electrum.submarine_swaps import MIN_FINAL_CLTV_DELTA_FOR_CLIENT, LOCKTIME_DELTA_REFUND @@ -2749,10 +2767,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): locktime = min(swap.locktime for swap in ongoing_swaps) delta = locktime - self.wallet.adb.get_local_height() warning = messages.MSG_REVERSE_SWAP_WARNING.format(delta) - return "".join(( - f"{str(len(ongoing_swaps))} ", - _("pending submarine swap") if len(ongoing_swaps) == 1 else _("pending submarine swaps"), - "\n\n", + return "\n\n".join(( + _("Pending submarine swap"), warning, )) diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py index 5e39e36f4..ee5b8f283 100644 --- a/electrum/lnchannel.py +++ b/electrum/lnchannel.py @@ -303,12 +303,14 @@ class AbstractChannel(Logger, ABC): def get_remote_scid_alias(self) -> Optional[bytes]: return None - def sweep_ctx(self, ctx: Transaction) -> Dict[str, SweepInfo]: + def get_ctx_sweep_info(self, ctx: Transaction) -> Tuple[bool, Dict[str, SweepInfo]]: txid = ctx.txid() + is_local = False if self._sweep_info.get(txid) is None: our_sweep_info = self.create_sweeptxs_for_our_ctx(ctx) their_sweep_info = self.create_sweeptxs_for_their_ctx(ctx) if our_sweep_info: + is_local = True self._sweep_info[txid] = our_sweep_info self.logger.info(f'we (local) force closed') elif their_sweep_info: @@ -317,7 +319,7 @@ class AbstractChannel(Logger, ABC): else: self._sweep_info[txid] = {} self.logger.info(f'not sure who closed.') - return self._sweep_info[txid] + return is_local, self._sweep_info[txid] def maybe_sweep_htlcs(self, ctx: Transaction, htlc_tx: Transaction) -> Dict[str, SweepInfo]: return {} diff --git a/electrum/lnsweep.py b/electrum/lnsweep.py index eda61ab6c..08bc7d399 100644 --- a/electrum/lnsweep.py +++ b/electrum/lnsweep.py @@ -385,7 +385,7 @@ def sweep_our_ctx( htlc_relative_idx=htlc_relative_idx) if actual_htlc_tx is None: - name = 'first-stage-htlc-anchors' if chan.has_anchors() else 'first-stage-htlc' + name = 'offered-htlc' if htlc_direction == SENT else 'received-htlc' prevout = ctx.txid() + f':{ctx_output_idx}' csv_delay = 1 if chan.has_anchors() else 0 txs[prevout] = SweepInfo( diff --git a/electrum/lnwatcher.py b/electrum/lnwatcher.py index 28e8f771c..91f088a1b 100644 --- a/electrum/lnwatcher.py +++ b/electrum/lnwatcher.py @@ -8,6 +8,7 @@ from .util import TxMinedInfo, BelowDustLimit from .util import EventListener, event_listener, log_exceptions, ignore_exceptions from .transaction import Transaction, TxOutpoint from .logging import Logger +from .address_synchronizer import TX_HEIGHT_LOCAL if TYPE_CHECKING: @@ -31,6 +32,7 @@ class LNWatcher(Logger, EventListener): self.register_callbacks() # status gets populated when we run self.channel_status = {} + self._pending_force_closes = set() def start_network(self, network: 'Network'): self.network = network @@ -140,6 +142,8 @@ class LNWatcher(Logger, EventListener): closing_txid=closing_txid, closing_height=closing_height, keep_watching=keep_watching) + if closing_height.conf > 0: + self._pending_force_closes.discard(chan) await self.lnworker.handle_onchain_state(chan) async def sweep_commitment_transaction(self, funding_outpoint, closing_tx) -> bool: @@ -157,7 +161,7 @@ class LNWatcher(Logger, EventListener): if not chan: return False # detect who closed and get information about how to claim outputs - sweep_info_dict = chan.sweep_ctx(closing_tx) + is_local_ctx, sweep_info_dict = chan.get_ctx_sweep_info(closing_tx) keep_watching = False if sweep_info_dict else not self.adb.is_deeply_mined(closing_tx.txid()) # create and broadcast transactions for prevout, sweep_info in sweep_info_dict.items(): @@ -188,8 +192,12 @@ class LNWatcher(Logger, EventListener): self.maybe_add_accounting_address(spender_txid, sweep_info) else: keep_watching |= was_added + self.maybe_add_pending_forceclose(chan, spender_txid, is_local_ctx, sweep_info, was_added) return keep_watching + def get_pending_force_closes(self): + return self._pending_force_closes + def maybe_redeem(self, sweep_info: 'SweepInfo') -> bool: """ returns False if it was dust """ try: @@ -219,7 +227,7 @@ class LNWatcher(Logger, EventListener): break else: return - if sweep_info.name in ['first-stage-htlc', 'first-stage-htlc-anchors']: + if sweep_info.name in ['offered-htlc', 'received-htlc']: # always consider ours pass else: @@ -237,3 +245,10 @@ class LNWatcher(Logger, EventListener): prev_tx = self.adb.get_transaction(prev_txid) txout = prev_tx.outputs()[int(prev_index)] self.lnworker.wallet._accounting_addresses.add(txout.address) + + def maybe_add_pending_forceclose(self, chan, spender_txid, is_local_ctx, sweep_info, was_added): + """ we are waiting for ctx to be confirmed and there are received htlcs """ + if was_added and is_local_ctx and sweep_info.name == 'received-htlc' and chan.has_anchors(): + tx_mined_status = self.adb.get_tx_height(spender_txid) + if tx_mined_status.height == TX_HEIGHT_LOCAL: + self._pending_force_closes.add(chan) diff --git a/electrum/txbatcher.py b/electrum/txbatcher.py index 7468d526c..c5be42d06 100644 --- a/electrum/txbatcher.py +++ b/electrum/txbatcher.py @@ -106,7 +106,7 @@ class TxBatcher(Logger): assert sweep_info.csv_delay >= (sweep_info.txin.get_block_based_relative_locktime() or 0) if sweep_info.txin and sweep_info.txout: # todo: don't use name, detect sighash - if sweep_info.name == 'first-stage-htlc': + if sweep_info.name in ['received-htlc', 'offered-htlc'] and sweep_info.csv_delay == 0: if sweep_info.txin.prevout not in self._legacy_htlcs: self.logger.info(f'received {sweep_info.name}') self._legacy_htlcs[sweep_info.txin.prevout] = sweep_info diff --git a/tests/regtest.py b/tests/regtest.py index 97851c2af..87aff21a8 100644 --- a/tests/regtest.py +++ b/tests/regtest.py @@ -70,8 +70,11 @@ class TestLightningAB(TestLightning): def test_extract_preimage(self): self.run_shell(['extract_preimage']) - def test_redeem_htlcs(self): - self.run_shell(['redeem_htlcs']) + def test_redeem_received_htlcs(self): + self.run_shell(['redeem_received_htlcs']) + + def test_redeem_offered_htlcs(self): + self.run_shell(['redeem_offered_htlcs']) def test_breach_with_unspent_htlc(self): self.run_shell(['breach_with_unspent_htlc']) diff --git a/tests/regtest/regtest.sh b/tests/regtest/regtest.sh index 390cb15fb..d60465ef5 100755 --- a/tests/regtest/regtest.sh +++ b/tests/regtest/regtest.sh @@ -342,7 +342,8 @@ if [[ $1 == "extract_preimage" ]]; then fi -if [[ $1 == "redeem_htlcs" ]]; then +if [[ $1 == "redeem_offered_htlcs" ]]; then + # alice force closes and redeems using htlc timeout $bob enable_htlc_settle false wait_for_balance alice 1 echo "alice opens channel" @@ -384,6 +385,32 @@ if [[ $1 == "redeem_htlcs" ]]; then fi +if [[ $1 == "redeem_received_htlcs" ]]; then + # bob force closes and redeems with the preimage + $bob enable_htlc_settle false + wait_for_balance alice 1 + echo "alice opens channel" + bob_node=$($bob nodeid) + $alice open_channel $bob_node 0.15 --password='' + new_blocks 3 + wait_until_channel_open alice + # alice pays bob + invoice=$($bob add_request 0.04 --lightning --memo "test" | jq -r ".lightning_invoice") + $alice lnpay $invoice --timeout=1 || true + unsettled=$($alice list_channels | jq '.[] | .local_unsettled_sent') + if [[ "$unsettled" == "0" ]]; then + echo 'enable_htlc_settle did not work' + exit 1 + fi + $alice stop + chan_id=$($bob list_channels | jq -r ".[0].channel_point") + $bob close_channel $chan_id --force + # if we exit here, bob GUI will show a warning + new_blocks 1 + wait_for_balance bob 1.039 +fi + + if [[ $1 == "breach_with_unspent_htlc" ]]; then $bob enable_htlc_settle false wait_for_balance alice 1