• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

spesmilo / electrum / 5556191130025984

12 May 2025 08:24AM UTC coverage: 59.709% (-0.006%) from 59.715%
5556191130025984

Pull #9798

CirrusCI

ecdsa
Qt: add closing warning if we have an unconfirmed local commitment tx with htlcs
Pull Request #9798: warn user if closing GUI when anchor channel ctx is unconfirmed

3 of 10 new or added lines in 2 files covered. (30.0%)

1 existing line in 1 file now uncovered.

21525 of 36050 relevant lines covered (59.71%)

2.98 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

37.95
/electrum/lnwatcher.py
1
# Copyright (C) 2018 The Electrum developers
2
# Distributed under the MIT software license, see the accompanying
3
# file LICENCE or http://www.opensource.org/licenses/mit-license.php
4

5
from typing import TYPE_CHECKING
5✔
6

7
from .util import TxMinedInfo, BelowDustLimit
5✔
8
from .util import EventListener, event_listener, log_exceptions, ignore_exceptions
5✔
9
from .transaction import Transaction, TxOutpoint
5✔
10
from .logging import Logger
5✔
11

12

13
if TYPE_CHECKING:
5✔
14
    from .network import Network
×
15
    from .lnsweep import SweepInfo
×
16
    from .lnworker import LNWallet
×
17
    from .lnchannel import AbstractChannel
×
18

19

20
class LNWatcher(Logger, EventListener):
5✔
21

22
    LOGGING_SHORTCUT = 'W'
5✔
23

24
    def __init__(self, lnworker: 'LNWallet'):
5✔
25
        self.lnworker = lnworker
5✔
26
        Logger.__init__(self)
5✔
27
        self.adb = lnworker.wallet.adb
5✔
28
        self.config = lnworker.config
5✔
29
        self.callbacks = {}  # address -> lambda function
5✔
30
        self.network = None
5✔
31
        self.register_callbacks()
5✔
32
        # status gets populated when we run
33
        self.channel_status = {}
5✔
34
        self._pending_force_closes = set()
5✔
35

36
    def start_network(self, network: 'Network'):
5✔
37
        self.network = network
×
38

39
    def stop(self):
5✔
40
        self.unregister_callbacks()
×
41

42
    def get_channel_status(self, outpoint):
5✔
43
        return self.channel_status.get(outpoint, 'unknown')
×
44

45
    def remove_callback(self, address):
5✔
46
        self.callbacks.pop(address, None)
×
47

48
    def add_callback(self, address, callback):
5✔
49
        self.adb.add_address(address)
5✔
50
        self.callbacks[address] = callback
5✔
51

52
    async def trigger_callbacks(self, *, requires_synchronizer=True):
5✔
53
        if requires_synchronizer and not self.adb.synchronizer:
5✔
54
            self.logger.info("synchronizer not set yet")
5✔
55
            return
5✔
56
        for address, callback in list(self.callbacks.items()):
×
57
            await callback()
×
58

59
    @event_listener
5✔
60
    async def on_event_blockchain_updated(self, *args):
5✔
61
        # we invalidate the cache on each new block because
62
        # some processes affect the list of sweep transactions
63
        # (hold invoice preimage revealed, MPP completed, etc)
64
        for chan in self.lnworker.channels.values():
×
65
            chan._sweep_info.clear()
×
66
        await self.trigger_callbacks()
×
67

68
    @event_listener
5✔
69
    async def on_event_wallet_updated(self, wallet):
5✔
70
        # called if we add local tx
71
        if wallet.adb != self.adb:
5✔
72
            return
5✔
73
        await self.trigger_callbacks()
×
74

75
    @event_listener
5✔
76
    async def on_event_adb_added_verified_tx(self, adb, tx_hash):
5✔
77
        if adb != self.adb:
5✔
78
            return
5✔
79
        await self.trigger_callbacks()
5✔
80

81
    @event_listener
5✔
82
    async def on_event_adb_set_up_to_date(self, adb):
5✔
83
        if adb != self.adb:
5✔
84
            return
5✔
85
        await self.trigger_callbacks()
5✔
86

87
    def add_channel(self, chan: 'AbstractChannel') -> None:
5✔
88
        outpoint = chan.funding_outpoint.to_str()
5✔
89
        address = chan.get_funding_address()
5✔
90
        callback = lambda: self.check_onchain_situation(address, outpoint)
5✔
91
        if chan.need_to_subscribe():
5✔
92
            self.add_callback(address, callback)
5✔
93

94
    def unwatch_channel(self, address, funding_outpoint):
5✔
95
        self.logger.info(f'unwatching {funding_outpoint}')
×
96
        self.remove_callback(address)
×
97

98
    @ignore_exceptions
5✔
99
    @log_exceptions
5✔
100
    async def check_onchain_situation(self, address, funding_outpoint):
5✔
101
        # early return if address has not been added yet
102
        if not self.adb.is_mine(address):
×
103
            return
×
104
        # inspect_tx_candidate might have added new addresses, in which case we return early
105
        funding_txid = funding_outpoint.split(':')[0]
×
106
        funding_height = self.adb.get_tx_height(funding_txid)
×
107
        closing_txid = self.adb.get_spender(funding_outpoint)
×
108
        closing_height = self.adb.get_tx_height(closing_txid)
×
109
        if closing_txid:
×
110
            closing_tx = self.adb.get_transaction(closing_txid)
×
111
            if closing_tx:
×
112
                keep_watching = await self.sweep_commitment_transaction(funding_outpoint, closing_tx)
×
113
            else:
114
                self.logger.info(f"channel {funding_outpoint} closed by {closing_txid}. still waiting for tx itself...")
×
115
                keep_watching = True
×
116
        else:
117
            keep_watching = True
×
118
        await self.update_channel_state(
×
119
            funding_outpoint=funding_outpoint,
120
            funding_txid=funding_txid,
121
            funding_height=funding_height,
122
            closing_txid=closing_txid,
123
            closing_height=closing_height,
124
            keep_watching=keep_watching)
125
        if not keep_watching:
×
126
            self.unwatch_channel(address, funding_outpoint)
×
127

128
    def diagnostic_name(self):
5✔
129
        return f"{self.lnworker.wallet.diagnostic_name()}-LNW"
5✔
130

131
    async def update_channel_state(
5✔
132
            self, *, funding_outpoint: str, funding_txid: str,
133
            funding_height: TxMinedInfo, closing_txid: str,
134
            closing_height: TxMinedInfo, keep_watching: bool) -> None:
135
        chan = self.lnworker.channel_by_txo(funding_outpoint)
×
136
        if not chan:
×
137
            return
×
138
        chan.update_onchain_state(
×
139
            funding_txid=funding_txid,
140
            funding_height=funding_height,
141
            closing_txid=closing_txid,
142
            closing_height=closing_height,
143
            keep_watching=keep_watching)
144
        await self.lnworker.handle_onchain_state(chan)
×
145

146
    async def sweep_commitment_transaction(self, funding_outpoint, closing_tx) -> bool:
5✔
147
        """This function is called when a channel was closed. In this case
148
        we need to check for redeemable outputs of the commitment transaction
149
        or spenders down the line (HTLC-timeout/success transactions).
150

151
        Returns whether we should continue to monitor.
152

153
        Side-effécts:
154
          - sets defaults labels
155
          - populates wallet._accounting_addresses
156
        """
157
        chan = self.lnworker.channel_by_txo(funding_outpoint)
×
158
        if not chan:
×
159
            return False
×
160
        # detect who closed and get information about how to claim outputs
NEW
161
        is_local_ctx, sweep_info_dict = chan.get_ctx_sweep_info(closing_tx)
×
162
        keep_watching = False if sweep_info_dict else not self.adb.is_deeply_mined(closing_tx.txid())
×
163
        # create and broadcast transactions
164
        for prevout, sweep_info in sweep_info_dict.items():
×
165
            prev_txid, prev_index = prevout.split(':')
×
166
            name = sweep_info.name + ' ' + chan.get_id_for_log()
×
167
            self.lnworker.wallet.set_default_label(prevout, name)
×
168
            if not self.adb.get_transaction(prev_txid):
×
169
                # do not keep watching if prevout does not exist
170
                self.logger.info(f'prevout does not exist for {name}: {prevout}')
×
171
                continue
×
172
            was_added = self.maybe_redeem(sweep_info)
×
173
            spender_txid = self.adb.get_spender(prevout)
×
174
            spender_tx = self.adb.get_transaction(spender_txid) if spender_txid else None
×
175
            if spender_tx:
×
176
                # the spender might be the remote, revoked or not
177
                htlc_sweepinfo = chan.maybe_sweep_htlcs(closing_tx, spender_tx)
×
178
                for prevout2, htlc_sweep_info in htlc_sweepinfo.items():
×
179
                    htlc_was_added = self.maybe_redeem(htlc_sweep_info)
×
180
                    htlc_tx_spender = self.adb.get_spender(prevout2)
×
181
                    self.lnworker.wallet.set_default_label(prevout2, htlc_sweep_info.name)
×
182
                    if htlc_tx_spender:
×
183
                        keep_watching |= not self.adb.is_deeply_mined(htlc_tx_spender)
×
184
                        self.maybe_add_accounting_address(htlc_tx_spender, htlc_sweep_info)
×
185
                    else:
186
                        keep_watching |= htlc_was_added
×
187
                keep_watching |= not self.adb.is_deeply_mined(spender_txid)
×
188
                self.maybe_extract_preimage(chan, spender_tx, prevout)
×
189
                self.maybe_add_accounting_address(spender_txid, sweep_info)
×
190
            else:
191
                keep_watching |= was_added
×
NEW
192
                if was_added and is_local_ctx and sweep_info.name == 'first-stage-htlc-anchors':
×
NEW
193
                    self._pending_force_closes.add(chan)
×
UNCOV
194
        return keep_watching
×
195

196
    def get_pending_force_closes(self):
5✔
NEW
197
        return self._pending_force_closes
×
198

199
    def maybe_redeem(self, sweep_info: 'SweepInfo') -> bool:
5✔
200
        """ returns False if it was dust """
201
        try:
×
202
            self.lnworker.wallet.txbatcher.add_sweep_input('lnwatcher', sweep_info, self.config.FEE_POLICY_LIGHTNING)
×
203
        except BelowDustLimit:
×
204
            return False
×
205
        return True
×
206

207
    def maybe_extract_preimage(self, chan: 'AbstractChannel', spender_tx: Transaction, prevout: str):
5✔
208
        if not spender_tx.is_complete():
×
209
            self.logger.info('spender tx is unsigned')
×
210
            return
×
211
        txin_idx = spender_tx.get_input_idx_that_spent_prevout(TxOutpoint.from_str(prevout))
×
212
        assert txin_idx is not None
×
213
        spender_txin = spender_tx.inputs()[txin_idx]
×
214
        chan.extract_preimage_from_htlc_txin(
×
215
            spender_txin,
216
            is_deeply_mined=self.adb.is_deeply_mined(spender_tx.txid()),
217
        )
218

219
    def maybe_add_accounting_address(self, spender_txid: str, sweep_info: 'SweepInfo'):
5✔
220
        spender_tx = self.adb.get_transaction(spender_txid) if spender_txid else None
×
221
        if not spender_tx:
×
222
            return
×
223
        for i, txin in enumerate(spender_tx.inputs()):
×
224
            if txin.prevout == sweep_info.txin.prevout:
×
225
                break
×
226
        else:
227
            return
×
228
        if sweep_info.name in ['first-stage-htlc', 'first-stage-htlc-anchors']:
×
229
            # always consider ours
230
            pass
×
231
        else:
232
            witness = txin.witness_elements()
×
233
            for sig in witness:
×
234
                # fixme: verify sig is ours
235
                witness2 = sweep_info.txin.make_witness(sig)
×
236
                if txin.witness == witness2:
×
237
                    break
×
238
            else:
239
                self.logger.info(f"signature not found {sweep_info.name}, {txin.prevout.to_str()}")
×
240
                return
×
241
        self.logger.info(f'adding txin address {sweep_info.name}, {txin.prevout.to_str()}')
×
242
        prev_txid, prev_index = txin.prevout.to_str().split(':')
×
243
        prev_tx = self.adb.get_transaction(prev_txid)
×
244
        txout = prev_tx.outputs()[int(prev_index)]
×
245
        self.lnworker.wallet._accounting_addresses.add(txout.address)
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc