lnwatcher: introduce loop to trigger callbacks
What changed, and why it matters
This commit adds a background polling loop to Electrum's Lightning watcher so that time-sensitive callbacks still run even when no new blockchain events occur for a while. It is a defensive reliability fix, not an obvious security patch, but stale watcher callbacks could in theory delay detection of channel problems. The change itself does not introduce a known vulnerability.
No immediate action required. Treat as a normal reliability improvement. Reviewers may want to confirm the polling loop exits cleanly on wallet stop and that the 10-minute delay is appropriate for time-sensitive Lightning operations.
Security signals we found
defensive fix for stale callback problem
time-based callback reliability in Lightning watcher
no input validation, crypto, or network parsing changes
async task lifecycle cleanup (OldTaskGroup, async stop)
Evidence from the diff
LNWatcher previously only triggered callbacks in response to blockchain events (new blocks, verified transactions). Callbacks that operate on wall-clock time could become stale during long periods without blocks. The patch introduces an asyncio taskgroup and a polling loop that calls trigger_callbacks() at least every MAX_CALLBACK_TRIGGER_DELAY_SEC (600 s, with a shorter initial delay after wallet startup). It also makes stop() async so the taskgroup can be cancelled cleanly, and removes a one-shot trigger_callbacks() spawn from LNWallet.start_network().
Changed components
electrum/lnwatcher.pyelectrum/lnworker.pytests/test_lnwallet.pyInspect captured patch +42 / −6
diff --git a/electrum/lnwatcher.py b/electrum/lnwatcher.py
index 2a9d0fa..f6e4c7b 100644
--- a/electrum/lnwatcher.py
+++ b/electrum/lnwatcher.py
@@ -2,11 +2,14 @@
# Distributed under the MIT software license, see the accompanying
# file LICENCE or http://www.opensource.org/licenses/mit-license.php
+import asyncio
from typing import TYPE_CHECKING, Optional, Dict, Callable, Awaitable
from . import util
-from .util import TxMinedInfo, BelowDustLimit, NoDynamicFeeEstimates
-from .util import EventListener, event_listener, log_exceptions, ignore_exceptions
+from .util import (
+ TxMinedInfo, BelowDustLimit, NoDynamicFeeEstimates, OldTaskGroup, EventListener, event_listener, log_exceptions,
+ ignore_exceptions, now
+)
from .transaction import Transaction, TxOutpoint
from .logging import Logger
from .address_synchronizer import TX_HEIGHT_LOCAL
@@ -20,6 +23,8 @@ if TYPE_CHECKING:
class LNWatcher(Logger, EventListener):
+ MAX_CALLBACK_TRIGGER_DELAY_SEC = 600
+ CALLBACK_LOOP_POLL_INTERVAL_SEC = 5
def __init__(self, lnworker: 'LNWallet'):
self.lnworker = lnworker
@@ -30,13 +35,43 @@ class LNWatcher(Logger, EventListener):
self.network = None
self.register_callbacks()
self._pending_force_closes = set()
+ self.taskgroup = OldTaskGroup()
+ self._last_callback_trigger_ts = 0
def start_network(self, network: 'Network'):
+ assert not self.network, "already started?"
self.network = network
+ asyncio.run_coroutine_threadsafe(self._main_loop(), util.get_asyncio_loop())
- def stop(self):
+ async def stop(self):
+ await self.taskgroup.cancel_remaining()
self.unregister_callbacks()
+ async def _main_loop(self):
+ self.logger.debug("starting taskgroup")
+ try:
+ async with self.taskgroup as group:
+ await group.spawn(self._callback_loop()) # keeps group alive
+ except Exception:
+ self.logger.exception("taskgroup crashed")
+ finally:
+ self.logger.debug("taskgroup stopped")
+
+ async def _callback_loop(self):
+ """
+ Triggers the callbacks if no event has triggered them within the
+ last MAX_CALLBACK_TRIGGER_DELAY_SEC (e.g. during a prolonged time without new blocks)
+ """
+ ts_start = now()
+ while True:
+ max_delay = self.MAX_CALLBACK_TRIGGER_DELAY_SEC
+ if now() - ts_start < max_delay:
+ max_delay /= 10 # if wallet just recently opened, be much more eager
+ time_since_last_cb_trigger = now() - self._last_callback_trigger_ts
+ if time_since_last_cb_trigger > max_delay:
+ await self.trigger_callbacks()
+ await asyncio.sleep(self.CALLBACK_LOOP_POLL_INTERVAL_SEC)
+
def remove_callback(self, address: str) -> None:
self.callbacks.pop(address, None)
@@ -59,7 +94,7 @@ class LNWatcher(Logger, EventListener):
async def trigger_callbacks(self, *, requires_synchronizer: bool = True):
if requires_synchronizer and not self.adb.synchronizer:
- self.logger.info("synchronizer not set yet")
+ self.logger.debug("synchronizer not set yet")
return
for address, callback in list(self.callbacks.items()):
try:
@@ -68,6 +103,7 @@ class LNWatcher(Logger, EventListener):
self.logger.exception(f"LNWatcher callback failed {address=}")
# send callback to GUI
util.trigger_callback('wallet_updated', self.lnworker.wallet)
+ self._last_callback_trigger_ts = now()
@event_listener
async def on_event_blockchain_updated(self, *args):
diff --git a/electrum/lnworker.py b/electrum/lnworker.py
index 92ae704..c5adad5 100644
--- a/electrum/lnworker.py
+++ b/electrum/lnworker.py
@@ -1216,7 +1216,6 @@ class LNWallet(Logger):
self.onion_message_manager.start_network(network=network)
for coro in [
- self.lnwatcher.trigger_callbacks(), # shortcut (don't block) if funding tx locked and verified
self.reestablish_peers_and_channels(),
self.sync_with_remote_watchtower(),
]:
@@ -1231,7 +1230,7 @@ class LNWallet(Logger):
await self.wait_for_received_pending_htlcs_to_get_removed()
await self.lnpeermgr.stop()
if self.lnwatcher:
- self.lnwatcher.stop()
+ await self.lnwatcher.stop()
self.lnwatcher = None
if self.swap_manager and self.swap_manager.network: # may not be present in tests
await self.swap_manager.stop()
diff --git a/tests/test_lnwallet.py b/tests/test_lnwallet.py
index 8c82866..5a71a6b 100644
--- a/tests/test_lnwallet.py
+++ b/tests/test_lnwallet.py
@@ -339,6 +339,7 @@ class TestLNWallet(ElectrumTestCase):
wallet.close_channel = mock.AsyncMock(side_effect=Exception("peer disconnected"))
wallet.remove_channel = mock.Mock()
wallet.lnwatcher = mock.Mock()
+ wallet.lnwatcher.stop = mock.AsyncMock()
wallet.lnwatcher.adb = mock.Mock()
wallet.lnwatcher.adb.remove_transaction = mock.Mock()
Why this scored 24/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.