Skip to content

Commit b8ae9a5

Browse files
committed
merge branch "lightning: fix anchor channel backup"
(PR #10852) Add missing information to lightning channel backup so users of non-deterministic lightning wallets trying to recover anchor channels are able to sweep the to_remote output of a remote ctx. Also shows a warning to affected users, urging them to export a new backup. Fixes #10785
2 parents 2e8f967 + 409d9ba commit b8ae9a5

19 files changed

Lines changed: 768 additions & 170 deletions

electrum/gui/qml/components/main.qml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,30 @@ ApplicationWindow
698698
}
699699
}
700700

701+
function showStartupWarnings() {
702+
if (!Daemon.currentWallet)
703+
return
704+
let warnings = Daemon.currentWallet.startupWarnings
705+
// show the warnings one after another, as the dialogs are not modal
706+
function showWarning(i) {
707+
if (i >= warnings.length)
708+
return
709+
let dialog = app.messageDialog.createObject(app, {
710+
title: warnings[i].title,
711+
iconSource: Qt.resolvedUrl('../../icons/warning.png'),
712+
text: warnings[i].message
713+
})
714+
dialog.accepted.connect(function() {
715+
Daemon.currentWallet.acknowledgeWarning(warnings[i].key)
716+
})
717+
dialog.closed.connect(function() {
718+
showWarning(i + 1)
719+
})
720+
dialog.open()
721+
}
722+
showWarning(0)
723+
}
724+
701725
Connections {
702726
target: Daemon
703727
function onWalletRequiresPassword(name, path) {
@@ -736,6 +760,7 @@ ApplicationWindow
736760
}
737761
function onWalletLoaded() {
738762
app._loadingWalletContext = null // either biometric auth or manual auth was successful
763+
showStartupWarnings()
739764
}
740765
}
741766

electrum/gui/qml/qewallet.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,18 @@ def lightningNumPeers(self):
528528
return self.wallet.lnworker.lnpeermgr.num_peers()
529529
return 0
530530

531+
@pyqtProperty('QVariantList', notify=dataChanged)
532+
def startupWarnings(self):
533+
return [{
534+
'key': warning.key,
535+
'title': warning.title,
536+
'message': warning.message,
537+
} for warning in self.wallet.get_startup_warnings()]
538+
539+
@pyqtSlot(str)
540+
def acknowledgeWarning(self, key: str):
541+
self.wallet.acknowledge_warning(key)
542+
531543
@pyqtSlot()
532544
def enableLightning(self):
533545
self.wallet.init_lightning(password=self.password)
@@ -793,6 +805,8 @@ def importPrivateKeys(self, keyslist):
793805
def importChannelBackup(self, backup_str):
794806
try:
795807
self.wallet.lnworker.import_channel_backup(backup_str)
808+
except UserFacingException as e:
809+
self.importChannelBackupFailed.emit(str(e))
796810
except Exception as e:
797811
self._logger.debug(f'could not import channel backup: {repr(e)}')
798812
self.importChannelBackupFailed.emit(f'Failed to import backup:\n\n{str(e)}')

electrum/gui/qt/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,7 @@ def _create_window_for_wallet(self, wallet):
331331
self.build_tray_menu()
332332
w.warn_if_testnet()
333333
w.warn_if_watching_only()
334+
w.show_startup_warnings()
334335
return w
335336

336337
def count_wizards_in_progress(func):

electrum/gui/qt/main_window.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,11 @@ def on_cb(_x):
681681
if cb_checked:
682682
self.config.DONT_SHOW_TESTNET_WARNING = True
683683

684+
def show_startup_warnings(self):
685+
for warning in self.wallet.get_startup_warnings():
686+
self.show_warning(warning.message, title=warning.title)
687+
self.wallet.acknowledge_warning(warning.key)
688+
684689
def open_wallet(self):
685690
try:
686691
wallet_folder = self.get_wallet_folder()
@@ -2314,6 +2319,8 @@ def import_channel_backup(self, encrypted: str):
23142319
return
23152320
try:
23162321
self.wallet.lnworker.import_channel_backup(encrypted)
2322+
except UserFacingException as e:
2323+
self.show_warning(str(e))
23172324
except Exception as e:
23182325
self.show_error("failed to import backup" + '\n' + str(e))
23192326
return

electrum/lnchannel.py

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -571,12 +571,31 @@ def has_anchors(self) -> bool:
571571

572572
class ChannelBackup(AbstractChannel):
573573
"""
574+
* v0: added in first LN release, 4.0
575+
- can be either for a pre-SRK (legacy) channel or an SRK channel
576+
* v1: added in 4.4.6 (#8536), to fix sweeping local fclose
577+
- implies SRK channel
578+
* v2: added together with anchor chans, in 4.6
579+
- can be either for an SRK or an anchors chan
580+
* v3: added in 4.8.2 (#10852), to fix anchor chan to_remote sweep
581+
- can be either for an SRK or an anchor chan
582+
583+
Channel types:
584+
* legacy (pre-SRK):
585+
- pre-SRK channels could only be opened strictly before first LN release
586+
- pre-SRK support was removed in 4.3.1
587+
- payment_basepoint was derived from backup (removed in #10852)
588+
* static_remotekey:
589+
- to_remote sweep not necessary due to wallet address
590+
* anchors:
591+
- sweep to_remote with local_payment_basepoint if it is a private key,
592+
otherwise by deriving the key from the funding pubkeys (requires deterministic lightning)
593+
574594
current capabilities:
575595
- detect force close
576596
- request force close
577597
- sweep my ctx to_local
578-
future:
579-
- will need to sweep their ctx to_remote
598+
- sweep their ctx to_remote (anchor channels, with srk it is a wallet address)
580599
"""
581600

582601
def __init__(self, cb: ChannelBackupStorage, *, lnworker: 'LNWallet'):
@@ -598,11 +617,7 @@ def __init__(self, cb: ChannelBackupStorage, *, lnworker: 'LNWallet'):
598617
self.unconfirmed_closing_txid = None # not a state, only for GUI
599618

600619
def init_config(self, cb: ImportedChannelBackupStorage):
601-
local_payment_pubkey = cb.local_payment_pubkey
602-
if local_payment_pubkey is None:
603-
self.logger.warning(
604-
f"local_payment_pubkey missing from (old-type) channel backup. "
605-
f"You should export and re-import a newer backup.")
620+
local_payment_basepoint = cb.local_payment_basepoint
606621
multisig_funding_keypair = None
607622
if multisig_funding_secret := cb.multisig_funding_privkey:
608623
multisig_funding_keypair = Keypair(
@@ -612,11 +627,8 @@ def init_config(self, cb: ImportedChannelBackupStorage):
612627
self.config[LOCAL] = LocalConfig.from_seed(
613628
channel_seed=cb.channel_seed,
614629
to_self_delay=cb.local_delay,
615-
# there are three cases of backups:
616-
# 1. legacy: payment_basepoint will be derived
617-
# 2. static_remotekey: to_remote sweep not necessary due to wallet address
618-
# 3. anchor outputs: sweep to_remote by deriving the key from the funding pubkeys
619-
static_remotekey=local_payment_pubkey,
630+
channel_type=cb.channel_type,
631+
payment_basepoint=local_payment_basepoint,
620632
multisig_key=multisig_funding_keypair,
621633
# dummy values
622634
static_payment_key=None,
@@ -656,6 +668,20 @@ def init_config(self, cb: ImportedChannelBackupStorage):
656668
announcement_bitcoin_sig=b'',
657669
)
658670

671+
def can_sweep_their_ctx_to_remote(self) -> bool:
672+
cb = self.cb
673+
if not isinstance(cb, ImportedChannelBackupStorage):
674+
return True # on-chain backups only exist for deterministic wallets
675+
v = cb.backup_version
676+
if v >= 3:
677+
return True # v3+ backups contain the payment_basepoint secret needed for the to_remote sweep
678+
elif v == 2:
679+
# pre-v3: only sweepable if we still have the LN keys that created this backup
680+
return (not self.has_anchors()) or cb.privkey == self.lnworker.node_keypair.privkey
681+
# srk backups can sweep to_remote (but to_local was broken in v0), legacy channels are not considered
682+
assert not self.has_anchors()
683+
return True
684+
659685
def can_be_deleted(self):
660686
return self.is_imported or self.is_redeemed()
661687

@@ -678,8 +704,8 @@ def create_sweeptxs_for_their_ctx(self, ctx):
678704
return sweep_their_ctx_to_remote_backup(chan=self, ctx=ctx, funding_tx=funding_tx)
679705

680706
def create_sweeptxs_for_our_ctx(self, ctx):
681-
if self.is_imported:
682-
return sweep_our_ctx(chan=self, ctx=ctx)
707+
if self.is_imported and self.config[LOCAL].payment_basepoint.pubkey is not None:
708+
return sweep_our_ctx(chan=self, ctx=ctx) # v0 backups miss payment_basepoint (see #8536/1a46460)
683709
else:
684710
return {}
685711

@@ -726,6 +752,8 @@ def get_sweep_address(self) -> str:
726752
return self.lnworker.wallet.get_new_sweep_address()
727753

728754
def has_anchors(self) -> Optional[bool]:
755+
if isinstance(self.cb, ImportedChannelBackupStorage):
756+
return bool(self.cb.channel_type & ChannelType.OPTION_ANCHORS)
729757
return None
730758

731759
def is_zeroconf(self) -> bool:
@@ -745,18 +773,18 @@ def get_local_pubkey(self) -> bytes:
745773

746774
def get_close_options(self) -> Sequence[ChanCloseOption]:
747775
ret = []
748-
if self.get_state() == ChannelState.FUNDED:
776+
if self.get_state() == ChannelState.FUNDED and self.can_sweep_their_ctx_to_remote():
749777
ret.append(ChanCloseOption.REQUEST_REMOTE_FCLOSE)
750778
return ret
751779

752780
def get_wallet_addresses_channel_might_want_reserved(self) -> Sequence[str]:
753781
if self.is_imported:
754-
# For v1 imported cbs, we have the local_payment_pubkey, which is
782+
# For v1+ imported cbs, we have the local_payment_basepoint, which is
755783
# directly used as p2wpkh() of static_remotekey channels.
756-
# (for v0 imported cbs, the correct local_payment_pubkey is missing, and so
757-
# we might calculate a different address here, which might not be wallet.is_mine,
758-
# but that should be harmless)
784+
# (for v0 imported cbs, it is missing, so we have no address to reserve)
759785
our_payment_pubkey = self.config[LOCAL].payment_basepoint.pubkey
786+
if our_payment_pubkey is None:
787+
return []
760788
to_remote_address = make_commitment_output_to_remote_address(our_payment_pubkey, has_anchors=self.has_anchors())
761789
return [to_remote_address]
762790
else: # on-chain backup

electrum/lnpeer.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ async def initialize(self):
189189
await self.transport.handshake()
190190
self.logger.info(f"handshake done for {self.transport.peer_addr or self.pubkey.hex()}")
191191
features = self.features.for_init_message()
192-
flen = features.min_len()
192+
flen = lnutil.int_min_byte_len(features)
193193
self.send_message(
194194
"init", gflen=0, flen=flen,
195195
features=features,
@@ -1041,7 +1041,7 @@ async def channel_establishment_flow(
10411041
# if option_channel_type is negotiated: MUST set channel_type
10421042
# if it includes channel_type: MUST set it to a defined type representing the type it wants.
10431043
open_channel_tlvs['channel_type'] = {
1044-
'type': our_channel_type.to_bytes_minimal()
1044+
'type': lnutil.int_to_bytes_minimal(our_channel_type)
10451045
}
10461046

10471047
if our_channel_type & ChannelType.OPTION_ANCHORS:
@@ -1398,7 +1398,7 @@ async def on_open_channel(self, payload):
13981398
'shutdown_scriptpubkey': local_config.upfront_shutdown_script
13991399
},
14001400
'channel_type': {
1401-
'type': channel_type.to_bytes_minimal(),
1401+
'type': lnutil.int_to_bytes_minimal(channel_type),
14021402
},
14031403
}
14041404

@@ -1851,7 +1851,7 @@ def send_node_announcement(self, alias:str, color_hex:str):
18511851
timestamp = int(time.time())
18521852
node_id = privkey_to_pubkey(self.privkey)
18531853
features = self.features.for_node_announcement()
1854-
flen = features.min_len()
1854+
flen = lnutil.int_min_byte_len(features)
18551855
rgb_color = bytes.fromhex(color_hex)
18561856
alias = bytes(alias, 'utf8')
18571857
alias += bytes(32 - len(alias))

electrum/lnsweep.py

Lines changed: 54 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
RevocationStore, extract_ctn_from_tx_and_chan, UnableToDeriveSecret, SENT, RECEIVED,
2121
map_htlcs_to_ctx_output_idxs, Direction, make_commitment_output_to_remote_witness_script,
2222
derive_payment_basepoint, ctx_has_anchors, SCRIPT_TEMPLATE_FUNDING, Keypair,
23-
derive_multisig_funding_key_if_we_opened, derive_multisig_funding_key_if_they_opened)
23+
derive_multisig_funding_key_if_we_opened, derive_multisig_funding_key_if_they_opened, LocalConfig)
2424
from .transaction import (Transaction, TxInput, PartialTxInput,
2525
PartialTxOutput, TxOutpoint, script_GetOp, match_script_against_template)
2626
from .logging import get_logger, Logger
@@ -571,55 +571,70 @@ def sweep_their_ctx_to_remote_backup(
571571
*, chan: 'ChannelBackup',
572572
ctx: Transaction,
573573
funding_tx: Transaction,
574-
) -> Optional[Dict[str, SweepInfo]]:
575-
txs = {} # type: Dict[str, SweepInfo]
574+
) -> Dict[str, SweepInfo]:
576575
"""If we only have a backup, and the remote force-closed with their ctx,
577576
and anchors are enabled, we need to sweep to_remote."""
578577

578+
txs = {} # type: Dict[str, SweepInfo]
579+
local_config = chan.config.get(LOCAL) # type: Optional[LocalConfig]
580+
fp_idx = None # type: Optional[int]
579581
if ctx_has_anchors(ctx):
580-
# for anchors we need to sweep to_remote
581582
funding_pubkeys = extract_funding_pubkeys_from_ctx(ctx.inputs()[0])
582-
_logger.debug(f'checking their ctx for funding pubkeys: {[pk.hex() for pk in funding_pubkeys]}')
583-
# check which of the pubkey was ours
584-
for fp_idx, pubkey in enumerate(funding_pubkeys):
585-
candidate_basepoint = derive_payment_basepoint(chan.lnworker.static_payment_key.privkey, funding_pubkey=pubkey)
586-
candidate_to_remote_address = make_commitment_output_to_remote_address(candidate_basepoint.pubkey, has_anchors=True)
587-
if ctx.get_output_idxs_from_address(candidate_to_remote_address):
588-
our_payment_pubkey = candidate_basepoint
589-
to_remote_address = candidate_to_remote_address
590-
_logger.debug(f'found funding pubkey')
591-
break
583+
# for anchors we need the payment_basepoint to spend the to_remote
584+
if local_config and isinstance(local_config.payment_basepoint, Keypair):
585+
_logger.debug("using payment_basepoint key from channel backup")
586+
# if we have a channel backup v3+ the imported payment_basepoint is a private key for anchor channels
587+
# so non-deterministic LNWallets can recover their to_remote outputs
588+
our_payment_keypair = local_config.payment_basepoint
589+
to_remote_address = make_commitment_output_to_remote_address(our_payment_keypair.pubkey, has_anchors=True)
590+
if not ctx.get_output_idxs_from_address(to_remote_address):
591+
_logger.debug(f"no to_remote output found for {to_remote_address=} from backup")
592+
return {}
592593
else:
593-
return
594+
# check which of the pubkey was ours
595+
# might be from a channel backup < v3, if LNWallet got seeded deterministically from an Electrum-type seed
596+
# the basepoint derivation is deterministic too. If they used a nondeterministic seed their funds are lost.
597+
_logger.debug(f'checking their ctx for funding pubkeys: {[pk.hex() for pk in funding_pubkeys]}')
598+
for fp_idx, pubkey in enumerate(funding_pubkeys):
599+
candidate_basepoint = derive_payment_basepoint(chan.lnworker.static_payment_key.privkey, funding_pubkey=pubkey)
600+
candidate_to_remote_address = make_commitment_output_to_remote_address(candidate_basepoint.pubkey, has_anchors=True)
601+
if ctx.get_output_idxs_from_address(candidate_to_remote_address):
602+
our_payment_keypair = candidate_basepoint
603+
to_remote_address = candidate_to_remote_address
604+
_logger.debug(f'found funding pubkey')
605+
break
606+
else:
607+
return {}
594608
else:
595609
# we are dealing with static_remotekey which is locked to a wallet address
596610
return {}
597611

598-
# remote anchor
599-
# derive funding_privkey ("multisig_key")
612+
# get remote anchor funding_privkey ("multisig_key")
600613
# note: for imported backups, we already have this as 'local_config.multisig_key'
601614
# but for on-chain backups, we need to derive it.
602-
# For symmetry, we derive it now regardless of type
603-
our_funding_pubkey = funding_pubkeys[fp_idx]
604-
their_funding_pubkey = funding_pubkeys[1 - fp_idx]
605-
remote_node_id = chan.node_id # for onchain backups, this is only the prefix
606-
if chan.is_initiator():
607-
funding_kp_cand = derive_multisig_funding_key_if_we_opened(
608-
funding_root_secret=chan.lnworker.funding_root_keypair.privkey,
609-
remote_node_id_or_prefix=remote_node_id,
610-
nlocktime=funding_tx.locktime,
611-
)
612-
else:
613-
funding_kp_cand = derive_multisig_funding_key_if_they_opened(
614-
funding_root_secret=chan.lnworker.funding_root_keypair.privkey,
615-
remote_node_id_or_prefix=remote_node_id,
616-
remote_funding_pubkey=their_funding_pubkey,
617-
)
618-
assert funding_kp_cand.pubkey == our_funding_pubkey, f"funding pubkey mismatch1. {chan.is_initiator()=}"
619-
our_ms_funding_keypair = funding_kp_cand
620-
# sanity check funding_privkey, if we had it already (if backup is imported):
621-
if local_config := chan.config.get(LOCAL):
622-
assert our_ms_funding_keypair == local_config.multisig_key, f"funding pubkey mismatch2. {chan.is_initiator()=}"
615+
our_ms_funding_keypair = None
616+
if local_config and local_config.multisig_key.pubkey in funding_pubkeys:
617+
_logger.debug("using multisig_key from channel backup to spend remote anchor")
618+
our_ms_funding_keypair = local_config.multisig_key
619+
elif fp_idx is not None:
620+
_logger.debug("found no multisig_key for remote anchor in channel backup, deriving key")
621+
our_funding_pubkey = funding_pubkeys[fp_idx]
622+
their_funding_pubkey = funding_pubkeys[1 - fp_idx]
623+
remote_node_id = chan.node_id # for onchain backups, this is only the prefix
624+
if chan.is_initiator():
625+
funding_kp_cand = derive_multisig_funding_key_if_we_opened(
626+
funding_root_secret=chan.lnworker.funding_root_keypair.privkey,
627+
remote_node_id_or_prefix=remote_node_id,
628+
nlocktime=funding_tx.locktime,
629+
)
630+
else:
631+
funding_kp_cand = derive_multisig_funding_key_if_they_opened(
632+
funding_root_secret=chan.lnworker.funding_root_keypair.privkey,
633+
remote_node_id_or_prefix=remote_node_id,
634+
remote_funding_pubkey=their_funding_pubkey,
635+
)
636+
assert funding_kp_cand.pubkey == our_funding_pubkey, f"funding pubkey mismatch1. {chan.is_initiator()=}"
637+
our_ms_funding_keypair = funding_kp_cand
623638

624639
if our_ms_funding_keypair:
625640
if txin := sweep_ctx_anchor(ctx=ctx, multisig_key=our_ms_funding_keypair):
@@ -633,7 +648,7 @@ def sweep_their_ctx_to_remote_backup(
633648
)
634649

635650
# to_remote
636-
our_payment_privkey = ecc.ECPrivkey(our_payment_pubkey.privkey)
651+
our_payment_privkey = ecc.ECPrivkey(our_payment_keypair.privkey)
637652
output_idxs = ctx.get_output_idxs_from_address(to_remote_address)
638653
if output_idxs:
639654
output_idx = output_idxs.pop()

0 commit comments

Comments
 (0)