1
0

Qt: add closing warning if we have an unconfirmed local commitment tx with htlcs

add htlc direction (offered, received) to the htlc sweep_info name
regtest: add test_reedeem_received_htlcs
This commit is contained in:
ThomasV
2025-05-08 19:28:05 +02:00
parent 7d6c21f233
commit 0607a406ce
8 changed files with 81 additions and 13 deletions

View File

@@ -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.')

View File

@@ -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,
))

View File

@@ -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 {}

View File

@@ -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(

View File

@@ -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)

View File

@@ -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

View File

@@ -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'])

View File

@@ -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