merge branch "lightning: fix anchor channel backup"
What changed, and why it matters
This commit fixes a bug in Electrum's Lightning channel backups for 'anchor' channels. Previously, the backup did not store enough secret key information, so users with non-deterministic Lightning wallets who restored from a backup could not recover funds if the remote party force-closed the channel. The fix adds a new backup version (v3) that includes the needed private key and warns users with older backups to export fresh ones. It is a recovery/bug-fix patch, not an exploitable vulnerability in live software.
Users with non-deterministic Lightning wallets and anchor channels should export fresh channel backups after upgrading to the fixed Electrum version. Operators should ensure backups are upgraded and heed the startup warnings. No immediate live-network mitigation is required because the issue affects recovery after data loss, not an in-memory exploit.
Security signals we found
Fixes incomplete backup data that could prevent fund recovery after remote force-close
Adds user-facing warnings for outdated/unusable channel backups
Changes backup serialization format and version
Adds private key material (payment_basepoint privkey) to anchor channel backups
Prevents downgrade to older backup versions
Refactors integer-to-minimal-byte utilities (no direct security impact)
Evidence from the diff
The patch introduces ChannelBackup version 3 (CHANNEL_BACKUP_VERSION_LATEST = 3) and stores local_payment_basepoint as a private key for anchor channels (or pubkey for static_remotekey). It updates LocalConfig.from_seed to accept channel_type and payment_basepoint, and sweep_their_ctx_to_remote_backup to use the stored payment_basepoint keypair when available. For older v2 backups on non-deterministic wallets, the code now detects that to_remote cannot be swept and shows startup warnings; importing such a backup raises a UserFacingException. GUI/QML and Qt layers gain startup warning plumbing. The change also refactors int_min_byte_len / int_to_bytes_minimal out of LnFeatures/ChannelType. Tests are added/updated to cover deterministic and non-deterministic anchor/SRK backup recovery scenarios.
Changed components
electrum/lnutil.pyelectrum/lnchannel.pyelectrum/lnsweep.pyelectrum/lnworker.pyelectrum/lnpeer.pyelectrum/wallet.pyelectrum/gui/qt/main_window.pyelectrum/gui/qt/__init__.pyelectrum/gui/qml/qewallet.pyelectrum/gui/qml/components/main.qmlInspect captured patch +768 / −170
### electrum/gui/qml/components/main.qml
@@ -698,6 +698,30 @@ ApplicationWindow
}
}
+ function showStartupWarnings() {
+ if (!Daemon.currentWallet)
+ return
+ let warnings = Daemon.currentWallet.startupWarnings
+ // show the warnings one after another, as the dialogs are not modal
+ function showWarning(i) {
+ if (i >= warnings.length)
+ return
+ let dialog = app.messageDialog.createObject(app, {
+ title: warnings[i].title,
+ iconSource: Qt.resolvedUrl('../../icons/warning.png'),
+ text: warnings[i].message
+ })
+ dialog.accepted.connect(function() {
+ Daemon.currentWallet.acknowledgeWarning(warnings[i].key)
+ })
+ dialog.closed.connect(function() {
+ showWarning(i + 1)
+ })
+ dialog.open()
+ }
+ showWarning(0)
+ }
+
Connections {
target: Daemon
function onWalletRequiresPassword(name, path) {
@@ -736,6 +760,7 @@ ApplicationWindow
}
function onWalletLoaded() {
app._loadingWalletContext = null // either biometric auth or manual auth was successful
+ showStartupWarnings()
}
}
### electrum/gui/qml/qewallet.py
@@ -528,6 +528,18 @@ def lightningNumPeers(self):
return self.wallet.lnworker.lnpeermgr.num_peers()
return 0
+ @pyqtProperty('QVariantList', notify=dataChanged)
+ def startupWarnings(self):
+ return [{
+ 'key': warning.key,
+ 'title': warning.title,
+ 'message': warning.message,
+ } for warning in self.wallet.get_startup_warnings()]
+
+ @pyqtSlot(str)
+ def acknowledgeWarning(self, key: str):
+ self.wallet.acknowledge_warning(key)
+
@pyqtSlot()
def enableLightning(self):
self.wallet.init_lightning(password=self.password)
@@ -793,6 +805,8 @@ def importPrivateKeys(self, keyslist):
def importChannelBackup(self, backup_str):
try:
self.wallet.lnworker.import_channel_backup(backup_str)
+ except UserFacingException as e:
+ self.importChannelBackupFailed.emit(str(e))
except Exception as e:
self._logger.debug(f'could not import channel backup: {repr(e)}')
self.importChannelBackupFailed.emit(f'Failed to import backup:\n\n{str(e)}')
### electrum/gui/qt/__init__.py
@@ -331,6 +331,7 @@ def _create_window_for_wallet(self, wallet):
self.build_tray_menu()
w.warn_if_testnet()
w.warn_if_watching_only()
+ w.show_startup_warnings()
return w
def count_wizards_in_progress(func):
### electrum/gui/qt/main_window.py
@@ -681,6 +681,11 @@ def on_cb(_x):
if cb_checked:
self.config.DONT_SHOW_TESTNET_WARNING = True
+ def show_startup_warnings(self):
+ for warning in self.wallet.get_startup_warnings():
+ self.show_warning(warning.message, title=warning.title)
+ self.wallet.acknowledge_warning(warning.key)
+
def open_wallet(self):
try:
wallet_folder = self.get_wallet_folder()
@@ -2314,6 +2319,8 @@ def import_channel_backup(self, encrypted: str):
return
try:
self.wallet.lnworker.import_channel_backup(encrypted)
+ except UserFacingException as e:
+ self.show_warning(str(e))
except Exception as e:
self.show_error("failed to import backup" + '\n' + str(e))
return
### electrum/lnchannel.py
@@ -571,12 +571,31 @@ def has_anchors(self) -> bool:
class ChannelBackup(AbstractChannel):
"""
+ * v0: added in first LN release, 4.0
+ - can be either for a pre-SRK (legacy) channel or an SRK channel
+ * v1: added in 4.4.6 (#8536), to fix sweeping local fclose
+ - implies SRK channel
+ * v2: added together with anchor chans, in 4.6
+ - can be either for an SRK or an anchors chan
+ * v3: added in 4.8.2 (#10852), to fix anchor chan to_remote sweep
+ - can be either for an SRK or an anchor chan
+
+ Channel types:
+ * legacy (pre-SRK):
+ - pre-SRK channels could only be opened strictly before first LN release
+ - pre-SRK support was removed in 4.3.1
+ - payment_basepoint was derived from backup (removed in #10852)
+ * static_remotekey:
+ - to_remote sweep not necessary due to wallet address
+ * anchors:
+ - sweep to_remote with local_payment_basepoint if it is a private key,
+ otherwise by deriving the key from the funding pubkeys (requires deterministic lightning)
+
current capabilities:
- detect force close
- request force close
- sweep my ctx to_local
- future:
- - will need to sweep their ctx to_remote
+ - sweep their ctx to_remote (anchor channels, with srk it is a wallet address)
"""
def __init__(self, cb: ChannelBackupStorage, *, lnworker: 'LNWallet'):
@@ -598,11 +617,7 @@ def __init__(self, cb: ChannelBackupStorage, *, lnworker: 'LNWallet'):
self.unconfirmed_closing_txid = None # not a state, only for GUI
def init_config(self, cb: ImportedChannelBackupStorage):
- local_payment_pubkey = cb.local_payment_pubkey
- if local_payment_pubkey is None:
- self.logger.warning(
- f"local_payment_pubkey missing from (old-type) channel backup. "
- f"You should export and re-import a newer backup.")
+ local_payment_basepoint = cb.local_payment_basepoint
multisig_funding_keypair = None
if multisig_funding_secret := cb.multisig_funding_privkey:
multisig_funding_keypair = Keypair(
@@ -612,11 +627,8 @@ def init_config(self, cb: ImportedChannelBackupStorage):
self.config[LOCAL] = LocalConfig.from_seed(
channel_seed=cb.channel_seed,
to_self_delay=cb.local_delay,
- # there are three cases of backups:
- # 1. legacy: payment_basepoint will be derived
- # 2. static_remotekey: to_remote sweep not necessary due to wallet address
- # 3. anchor outputs: sweep to_remote by deriving the key from the funding pubkeys
- static_remotekey=local_payment_pubkey,
+ channel_type=cb.channel_type,
+ payment_basepoint=local_payment_basepoint,
multisig_key=multisig_funding_keypair,
# dummy values
static_payment_key=None,
@@ -656,6 +668,20 @@ def init_config(self, cb: ImportedChannelBackupStorage):
announcement_bitcoin_sig=b'',
)
+ def can_sweep_their_ctx_to_remote(self) -> bool:
+ cb = self.cb
+ if not isinstance(cb, ImportedChannelBackupStorage):
+ return True # on-chain backups only exist for deterministic wallets
+ v = cb.backup_version
+ if v >= 3:
+ return True # v3+ backups contain the payment_basepoint secret needed for the to_remote sweep
+ elif v == 2:
+ # pre-v3: only sweepable if we still have the LN keys that created this backup
+ return (not self.has_anchors()) or cb.privkey == self.lnworker.node_keypair.privkey
+ # srk backups can sweep to_remote (but to_local was broken in v0), legacy channels are not considered
+ assert not self.has_anchors()
+ return True
+
def can_be_deleted(self):
return self.is_imported or self.is_redeemed()
@@ -678,8 +704,8 @@ def create_sweeptxs_for_their_ctx(self, ctx):
return sweep_their_ctx_to_remote_backup(chan=self, ctx=ctx, funding_tx=funding_tx)
def create_sweeptxs_for_our_ctx(self, ctx):
- if self.is_imported:
- return sweep_our_ctx(chan=self, ctx=ctx)
+ if self.is_imported and self.config[LOCAL].payment_basepoint.pubkey is not None:
+ return sweep_our_ctx(chan=self, ctx=ctx) # v0 backups miss payment_basepoint (see #8536/1a46460)
else:
return {}
@@ -726,6 +752,8 @@ def get_sweep_address(self) -> str:
return self.lnworker.wallet.get_new_sweep_address()
def has_anchors(self) -> Optional[bool]:
+ if isinstance(self.cb, ImportedChannelBackupStorage):
+ return bool(self.cb.channel_type & ChannelType.OPTION_ANCHORS)
return None
def is_zeroconf(self) -> bool:
@@ -745,18 +773,18 @@ def get_local_pubkey(self) -> bytes:
def get_close_options(self) -> Sequence[ChanCloseOption]:
ret = []
- if self.get_state() == ChannelState.FUNDED:
+ if self.get_state() == ChannelState.FUNDED and self.can_sweep_their_ctx_to_remote():
ret.append(ChanCloseOption.REQUEST_REMOTE_FCLOSE)
return ret
def get_wallet_addresses_channel_might_want_reserved(self) -> Sequence[str]:
if self.is_imported:
- # For v1 imported cbs, we have the local_payment_pubkey, which is
+ # For v1+ imported cbs, we have the local_payment_basepoint, which is
# directly used as p2wpkh() of static_remotekey channels.
- # (for v0 imported cbs, the correct local_payment_pubkey is missing, and so
- # we might calculate a different address here, which might not be wallet.is_mine,
- # but that should be harmless)
+ # (for v0 imported cbs, it is missing, so we have no address to reserve)
our_payment_pubkey = self.config[LOCAL].payment_basepoint.pubkey
+ if our_payment_pubkey is None:
+ return []
to_remote_address = make_commitment_output_to_remote_address(our_payment_pubkey, has_anchors=self.has_anchors())
return [to_remote_address]
else: # on-chain backup
### electrum/lnpeer.py
@@ -189,7 +189,7 @@ async def initialize(self):
await self.transport.handshake()
self.logger.info(f"handshake done for {self.transport.peer_addr or self.pubkey.hex()}")
features = self.features.for_init_message()
- flen = features.min_len()
+ flen = lnutil.int_min_byte_len(features)
self.send_message(
"init", gflen=0, flen=flen,
features=features,
@@ -1041,7 +1041,7 @@ async def channel_establishment_flow(
# if option_channel_type is negotiated: MUST set channel_type
# if it includes channel_type: MUST set it to a defined type representing the type it wants.
open_channel_tlvs['channel_type'] = {
- 'type': our_channel_type.to_bytes_minimal()
+ 'type': lnutil.int_to_bytes_minimal(our_channel_type)
}
if our_channel_type & ChannelType.OPTION_ANCHORS:
@@ -1398,7 +1398,7 @@ async def on_open_channel(self, payload):
'shutdown_scriptpubkey': local_config.upfront_shutdown_script
},
'channel_type': {
- 'type': channel_type.to_bytes_minimal(),
+ 'type': lnutil.int_to_bytes_minimal(channel_type),
},
}
@@ -1851,7 +1851,7 @@ def send_node_announcement(self, alias:str, color_hex:str):
timestamp = int(time.time())
node_id = privkey_to_pubkey(self.privkey)
features = self.features.for_node_announcement()
- flen = features.min_len()
+ flen = lnutil.int_min_byte_len(features)
rgb_color = bytes.fromhex(color_hex)
alias = bytes(alias, 'utf8')
alias += bytes(32 - len(alias))
### electrum/lnsweep.py
@@ -20,7 +20,7 @@
RevocationStore, extract_ctn_from_tx_and_chan, UnableToDeriveSecret, SENT, RECEIVED,
map_htlcs_to_ctx_output_idxs, Direction, make_commitment_output_to_remote_witness_script,
derive_payment_basepoint, ctx_has_anchors, SCRIPT_TEMPLATE_FUNDING, Keypair,
- derive_multisig_funding_key_if_we_opened, derive_multisig_funding_key_if_they_opened)
+ derive_multisig_funding_key_if_we_opened, derive_multisig_funding_key_if_they_opened, LocalConfig)
from .transaction import (Transaction, TxInput, PartialTxInput,
PartialTxOutput, TxOutpoint, script_GetOp, match_script_against_template)
from .logging import get_logger, Logger
@@ -571,55 +571,70 @@ def sweep_their_ctx_to_remote_backup(
*, chan: 'ChannelBackup',
ctx: Transaction,
funding_tx: Transaction,
-) -> Optional[Dict[str, SweepInfo]]:
- txs = {} # type: Dict[str, SweepInfo]
+) -> Dict[str, SweepInfo]:
"""If we only have a backup, and the remote force-closed with their ctx,
and anchors are enabled, we need to sweep to_remote."""
+ txs = {} # type: Dict[str, SweepInfo]
+ local_config = chan.config.get(LOCAL) # type: Optional[LocalConfig]
+ fp_idx = None # type: Optional[int]
if ctx_has_anchors(ctx):
- # for anchors we need to sweep to_remote
funding_pubkeys = extract_funding_pubkeys_from_ctx(ctx.inputs()[0])
- _logger.debug(f'checking their ctx for funding pubkeys: {[pk.hex() for pk in funding_pubkeys]}')
- # check which of the pubkey was ours
- for fp_idx, pubkey in enumerate(funding_pubkeys):
- candidate_basepoint = derive_payment_basepoint(chan.lnworker.static_payment_key.privkey, funding_pubkey=pubkey)
- candidate_to_remote_address = make_commitment_output_to_remote_address(candidate_basepoint.pubkey, has_anchors=True)
- if ctx.get_output_idxs_from_address(candidate_to_remote_address):
- our_payment_pubkey = candidate_basepoint
- to_remote_address = candidate_to_remote_address
- _logger.debug(f'found funding pubkey')
- break
+ # for anchors we need the payment_basepoint to spend the to_remote
+ if local_config and isinstance(local_config.payment_basepoint, Keypair):
+ _logger.debug("using payment_basepoint key from channel backup")
+ # if we have a channel backup v3+ the imported payment_basepoint is a private key for anchor channels
+ # so non-deterministic LNWallets can recover their to_remote outputs
+ our_payment_keypair = local_config.payment_basepoint
+ to_remote_address = make_commitment_output_to_remote_address(our_payment_keypair.pubkey, has_anchors=True)
+ if not ctx.get_output_idxs_from_address(to_remote_address):
+ _logger.debug(f"no to_remote output found for {to_remote_address=} from backup")
+ return {}
else:
- return
+ # check which of the pubkey was ours
+ # might be from a channel backup < v3, if LNWallet got seeded deterministically from an Electrum-type seed
+ # the basepoint derivation is deterministic too. If they used a nondeterministic seed their funds are lost.
+ _logger.debug(f'checking their ctx for funding pubkeys: {[pk.hex() for pk in funding_pubkeys]}')
+ for fp_idx, pubkey in enumerate(funding_pubkeys):
+ candidate_basepoint = derive_payment_basepoint(chan.lnworker.static_payment_key.privkey, funding_pubkey=pubkey)
+ candidate_to_remote_address = make_commitment_output_to_remote_address(candidate_basepoint.pubkey, has_anchors=True)
+ if ctx.get_output_idxs_from_address(candidate_to_remote_address):
+ our_payment_keypair = candidate_basepoint
+ to_remote_address = candidate_to_remote_address
+ _logger.debug(f'found funding pubkey')
+ break
+ else:
+ return {}
else:
# we are dealing with static_remotekey which is locked to a wallet address
return {}
- # remote anchor
- # derive funding_privkey ("multisig_key")
+ # get remote anchor funding_privkey ("multisig_key")
# note: for imported backups, we already have this as 'local_config.multisig_key'
# but for on-chain backups, we need to derive it.
- # For symmetry, we derive it now regardless of type
- our_funding_pubkey = funding_pubkeys[fp_idx]
- their_funding_pubkey = funding_pubkeys[1 - fp_idx]
- remote_node_id = chan.node_id # for onchain backups, this is only the prefix
- if chan.is_initiator():
- funding_kp_cand = derive_multisig_funding_key_if_we_opened(
- funding_root_secret=chan.lnworker.funding_root_keypair.privkey,
- remote_node_id_or_prefix=remote_node_id,
- nlocktime=funding_tx.locktime,
- )
- else:
- funding_kp_cand = derive_multisig_funding_key_if_they_opened(
- funding_root_secret=chan.lnworker.funding_root_keypair.privkey,
- remote_node_id_or_prefix=remote_node_id,
- remote_funding_pubkey=their_funding_pubkey,
- )
- assert funding_kp_cand.pubkey == our_funding_pubkey, f"funding pubkey mismatch1. {chan.is_initiator()=}"
- our_ms_funding_keypair = funding_kp_cand
- # sanity check funding_privkey, if we had it already (if backup is imported):
- if local_config := chan.config.get(LOCAL):
- assert our_ms_funding_keypair == local_config.multisig_key, f"funding pubkey mismatch2. {chan.is_initiator()=}"
+ our_ms_funding_keypair = None
+ if local_config and local_config.multisig_key.pubkey in funding_pubkeys:
+ _logger.debug("using multisig_key from channel backup to spend remote anchor")
+ our_ms_funding_keypair = local_config.multisig_key
+ elif fp_idx is not None:
+ _logger.debug("found no multisig_key for remote anchor in channel backup, deriving key")
+ our_funding_pubkey = funding_pubkeys[fp_idx]
+ their_funding_pubkey = funding_pubkeys[1 - fp_idx]
+ remote_node_id = chan.node_id # for onchain backups, this is only the prefix
+ if chan.is_initiator():
+ funding_kp_cand = derive_multisig_funding_key_if_we_opened(
+ funding_root_secret=chan.lnworker.funding_root_keypair.privkey,
+ remote_node_id_or_prefix=remote_node_id,
+ nlocktime=funding_tx.locktime,
+ )
+ else:
+ funding_kp_cand = derive_multisig_funding_key_if_they_opened(
+ funding_root_secret=chan.lnworker.funding_root_keypair.privkey,
+ remote_node_id_or_prefix=remote_node_id,
+ remote_funding_pubkey=their_funding_pubkey,
+ )
+ assert funding_kp_cand.pubkey == our_funding_pubkey, f"funding pubkey mismatch1. {chan.is_initiator()=}"
+ our_ms_funding_keypair = funding_kp_cand
if our_ms_funding_keypair:
if txin := sweep_ctx_anchor(ctx=ctx, multisig_key=our_ms_funding_keypair):
@@ -633,7 +648,7 @@ def sweep_their_ctx_to_remote_backup(
)
# to_remote
- our_payment_privkey = ecc.ECPrivkey(our_payment_pubkey.privkey)
+ our_payment_privkey = ecc.ECPrivkey(our_payment_keypair.privkey)
output_idxs = ctx.get_output_idxs_from_address(to_remote_address)
if output_idxs:
output_idx = output_idxs.pop()
### electrum/lnutil.py
@@ -5,7 +5,7 @@
import enum
from typing import (
NamedTuple, List, Tuple, Mapping, Optional, TYPE_CHECKING, Union, Dict, Set, Sequence, FrozenSet,
- TypedDict,
+ TypedDict, Literal
)
import sys
import time
@@ -80,6 +80,15 @@ def bytes_to_hex(arg: Optional[bytes]) -> Optional[str]:
return repr(arg.hex()) if arg is not None else None
+def int_min_byte_len(n: int) -> int:
+ """Returns the smallest number of bytes that can represent n (zero -> 0 bytes)."""
+ return (n.bit_length() + 7) // 8
+
+
+def int_to_bytes_minimal(n: int, byteorder: Literal['big', 'little'] = 'big') -> bytes:
+ return int.to_bytes(n, length=int_min_byte_len(n), byteorder=byteorder)
+
+
def json_to_keypair(arg: Union['OnlyPubkeyKeypair', dict]) -> Union['OnlyPubkeyKeypair', 'Keypair']:
return arg if isinstance(arg, OnlyPubkeyKeypair) else Keypair(**arg) if len(arg) == 2 else OnlyPubkeyKeypair(**arg)
@@ -245,8 +254,10 @@ def keypair_generator(family: 'LnKeyFamily') -> 'Keypair':
kwargs['htlc_basepoint'] = keypair_generator(LnKeyFamily.HTLC_BASE)
kwargs['delayed_basepoint'] = keypair_generator(LnKeyFamily.DELAY_BASE)
kwargs['revocation_basepoint'] = keypair_generator(LnKeyFamily.REVOCATION_BASE)
- static_remotekey = kwargs.pop('static_remotekey')
static_payment_key = kwargs.pop('static_payment_key')
+ channel_type = kwargs.pop('channel_type')
+ payment_basepoint = kwargs.pop('payment_basepoint', None) # type: bytes | None
+ assert bool(static_payment_key) + bool(payment_basepoint) <= 1
if static_payment_key:
# We derive the payment_basepoint from a static secret (derived from
# the wallet seed) and a public nonce that is revealed
@@ -256,12 +267,20 @@ def keypair_generator(family: 'LnKeyFamily') -> 'Keypair':
static_payment_secret=static_payment_key.privkey,
funding_pubkey=kwargs['multisig_key'].pubkey
)
- elif static_remotekey: # we automatically sweep to a wallet address
- kwargs['payment_basepoint'] = OnlyPubkeyKeypair(static_remotekey)
+ elif payment_basepoint: # channel backup (or new SRK chan in unit tests)
+ if len(payment_basepoint) == 32: # privkey
+ assert channel_type & ChannelType.OPTION_ANCHORS
+ privkey = ecc.ECPrivkey(payment_basepoint)
+ kwargs['payment_basepoint'] = Keypair(privkey=privkey.get_secret_bytes(), pubkey=privkey.get_public_key_bytes())
+ else:
+ assert len(payment_basepoint) == 33 # pubkey
+ kwargs['payment_basepoint'] = OnlyPubkeyKeypair(payment_basepoint)
else:
- # we expect all our channels to use option_static_remotekey, so ending up here likely indicates an issue...
- kwargs['payment_basepoint'] = keypair_generator(LnKeyFamily.PAYMENT_BASE)
+ # v0 channel backup for srk channel: the real basepoint is a wallet pubkey that is
+ # not part of the backup and cannot be derived, see: https://github.com/spesmilo/electrum/pull/8536
+ kwargs['payment_basepoint'] = OnlyPubkeyKeypair(None)
+ assert ecc.ECPubkey.is_pubkey_bytes(kwargs['payment_basepoint'].pubkey)
return LocalConfig(**kwargs)
def validate_params(self, *, funding_sat: int, config: 'SimpleConfig', peer_features: 'LnFeatures') -> None:
@@ -299,8 +318,8 @@ class ChannelConstraints(StoredObject):
funding_txn_minimum_depth = attr.ib(type=int)
-CHANNEL_BACKUP_VERSION_LATEST = 2
-KNOWN_CHANNEL_BACKUP_VERSIONS = (0, 1, 2, )
+CHANNEL_BACKUP_VERSION_LATEST = 3
+KNOWN_CHANNEL_BACKUP_VERSIONS = (0, 1, 2, 3, )
assert CHANNEL_BACKUP_VERSION_LATEST in KNOWN_CHANNEL_BACKUP_VERSIONS
@@ -333,26 +352,37 @@ def from_json_dict(**kwargs) -> 'OnchainChannelBackupStorage':
return OnchainChannelBackupStorage(**kwargs)
-@dataclasses.dataclass(frozen=True)
+@dataclasses.dataclass(frozen=True, kw_only=True)
class ImportedChannelBackupStorage(ChannelBackupStorage):
+ backup_version: int = CHANNEL_BACKUP_VERSION_LATEST
node_id: bytes # remote node pubkey
privkey: bytes # local node privkey
host: str
port: int
channel_seed: bytes
+ channel_type: int
local_delay: int
remote_delay: int
remote_payment_pubkey: bytes
remote_revocation_pubkey: bytes
- local_payment_pubkey: Optional[bytes]
+ # can either be a pubkey or a privkey (for anchor channels)
+ local_payment_basepoint: Optional[bytes]
multisig_funding_privkey: Optional[bytes]
+ def __post_init__(self):
+ # strip the variation flags, they are irrelevant for the backup
+ channel_type = int(self.channel_type) & ~(ChannelType.OPTION_SCID_ALIAS | ChannelType.OPTION_ZEROCONF)
+ object.__setattr__(self, 'channel_type', channel_type)
+
def to_bytes(self) -> bytes:
+ if self.backup_version != CHANNEL_BACKUP_VERSION_LATEST:
+ raise Exception("cannot re-serialize old-version channel backup")
vds = BCDataStream()
vds.write_uint16(CHANNEL_BACKUP_VERSION_LATEST)
vds.write_boolean(self.is_initiator)
vds.write_bytes(self.privkey, 32)
vds.write_bytes(self.channel_seed, 32)
+ vds.write_string(int_to_bytes_minimal(self.channel_type))
vds.write_bytes(self.node_id, 33)
vds.write_bytes(bfh(self.funding_txid), 32)
vds.write_uint16(self.funding_index)
@@ -363,7 +393,13 @@ def to_bytes(self) -> bytes:
vds.write_uint16(self.remote_delay)
vds.write_string(self.host)
vds.write_uint16(self.port)
- vds.write_bytes(self.local_payment_pubkey, 33)
+ if len(self.local_payment_basepoint) == 32: # private key
+ assert self.channel_type == ChannelType.OPTION_STATIC_REMOTEKEY | ChannelType.OPTION_ANCHORS
+ vds.write_bytes(b"\x00" + self.local_payment_basepoint, 33)
+ else:
+ assert len(self.local_payment_basepoint) == 33 # pubkey
+ assert self.channel_type == ChannelType.OPTION_STATIC_REMOTEKEY
+ vds.write_bytes(self.local_payment_basepoint, 33)
vds.write_bytes(self.multisig_funding_privkey, 32)
return bytes(vds.input)
@@ -377,6 +413,10 @@ def from_bytes(s: bytes) -> 'ImportedChannelBackupStorage':
is_initiator = vds.read_boolean()
privkey = vds.read_bytes(32)
channel_seed = vds.read_bytes(32)
+ channel_type = None
+ if version >= 3:
+ channel_type_length = vds.read_compact_size()
+ channel_type = ChannelType.from_bytes(vds.read_bytes(channel_type_length), byteorder='big')
node_id = vds.read_bytes(33)
funding_txid = vds.read_bytes(32).hex()
funding_index = vds.read_uint16()
@@ -387,18 +427,42 @@ def from_bytes(s: bytes) -> 'ImportedChannelBackupStorage':
remote_delay = vds.read_uint16()
host = vds.read_string()
port = vds.read_uint16()
+ local_payment_basepoint = None # type: Optional[bytes]
if version >= 1:
- local_payment_pubkey = vds.read_bytes(33)
- else:
- local_payment_pubkey = None
+ local_payment_basepoint = vds.read_bytes(33)
+ if local_payment_basepoint[0] == 0: # private key
+ local_payment_basepoint = local_payment_basepoint[1:]
if version >= 2:
multisig_funding_privkey = vds.read_bytes(32)
else:
multisig_funding_privkey = None
+
+ # guess channel_type for version<3:
+ if channel_type is None:
+ if version == 0:
+ # Could technically be either SRK or pre-SRK, but pre-SRK channels
+ # could never be opened in a released version, so we ignore that case.
+ channel_type = int(ChannelType.OPTION_STATIC_REMOTEKEY)
+ elif version == 1: # can only be SRK
+ channel_type = int(ChannelType.OPTION_STATIC_REMOTEKEY)
+ else:
+ assert version == 2, version
+ # can be either SRK or anchors
+ assert multisig_funding_privkey is not None
+ node = BIP32Node.from_rootseed(channel_seed, xtype='standard')
+ srk_multisig_key = generate_keypair(node, LnKeyFamily.MULTISIG)
+ if multisig_funding_privkey == srk_multisig_key.privkey:
+ channel_type = int(ChannelType.OPTION_STATIC_REMOTEKEY) # SRK
+ else:
+ channel_type = int(ChannelType.OPTION_STATIC_REMOTEKEY | ChannelType.OPTION_ANCHORS) # anchors
+ assert channel_type is not None
+
return ImportedChannelBackupStorage(
+ backup_version=version,
is_initiator=is_initiator,
privkey=privkey,
channel_seed=channel_seed,
+ channel_type=channel_type,
node_id=node_id,
funding_txid=funding_txid,
funding_index=funding_index,
@@ -409,7 +473,7 @@ def from_bytes(s: bytes) -> 'ImportedChannelBackupStorage':
remote_delay=remote_delay,
host=host,
port=port,
- local_payment_pubkey=local_payment_pubkey,
+ local_payment_basepoint=local_payment_basepoint,
multisig_funding_privkey=multisig_funding_privkey,
)
@@ -1627,10 +1691,6 @@ def for_channel_announcement(self) -> 'LnFeatures':
features |= (1 << flag)
return features
- def min_len(self) -> int:
- b = int.bit_length(self)
- return b // 8 + int(bool(b % 8))
-
def supports(self, feature: 'LnFeatures') -> bool:
"""Returns whether given feature is enabled.
@@ -1718,12 +1778,6 @@ def complies_with_features(self, peer_features: LnFeatures) -> bool:
return False
return True
- def to_bytes_minimal(self):
- # MUST use the smallest bitmap possible to represent the channel type.
- bit_length = self.value.bit_length()
- byte_length = bit_length // 8 + int(bool(bit_length % 8))
- return self.to_bytes(byte_length, byteorder='big')
-
@property
def name_minimal(self):
if self.name:
### electrum/lnworker.py
@@ -95,7 +95,7 @@
if TYPE_CHECKING:
from .network import Network
- from .wallet import Abstract_Wallet
+ from .wallet import Abstract_Wallet, WalletWarning
from .channel_db import ChannelDB
from .simple_config import SimpleConfig
@@ -1126,6 +1126,45 @@ def has_anchor_channels(self) -> bool:
return any(chan.has_anchors() and not chan.is_closed()
for chan in self.channels.values())
+ def get_lightning_startup_warnings(self) -> Sequence['WalletWarning']:
+ from .wallet import WalletWarning
+ warnings = []
+ if any(isinstance(cb.cb, ImportedChannelBackupStorage) and cb.cb.backup_version == 0 for cb in self.channel_backups.values()):
+ warnings.append(WalletWarning(
+ key='ln_chan_backup_pre_v1_gh-8536',
+ title=_('Outdated channel backups') + ' [gh-8536]',
+ show_once=False, # show on every startup
+ message=''.join([
+ _("This wallet contains old (v0) channel backups that can only be used to recover channel funds "
+ "in some scenarios. They were exported with an older version of Electrum."), ' ',
+ _("Please import new backups, exported by the wallet these channels belong to."),
+ ])))
+ if not self.has_deterministic_node_id() and self.has_anchor_channels():
+ # backups exported before we started storing the payment_basepoint privkey
+ # (backup v3) cannot sweep the to_remote output of an anchor channel
+ warnings.append(WalletWarning(
+ key='ln_chan_backups_pre_v3_gh-10852-1',
+ title=_('Outdated channel backups') + ' [gh-10852-1]',
+ show_once=True,
+ message=''.join([
+ _("The Lightning channels of this wallet cannot be recovered from seed."), ' ',
+ _("Channel backups that were exported with an older version of Electrum "
+ "cannot be used to request a force close of these channels."), '\n\n',
+ _("Please export new channel backups and store them in a safe place."),
+ ])))
+ if any(not cb.can_sweep_their_ctx_to_remote() for cb in self.channel_backups.values()):
+ warnings.append(WalletWarning(
+ key='ln_chan_backups_pre_v3_gh-10852-2',
+ title=_('Unusable channel backups') + ' [gh-10852-2]',
+ show_once=False, # show on every startup
+ message=''.join([
+ _("This wallet contains old (v2) channel backups that cannot be used to request a force close, "
+ "because they were exported with an older version of Electrum."), ' ',
+ _("Please import new backups, exported by the wallet these channels belong to."), '\n\n',
+ _("If you have lost access to that wallet, please open an issue on GitHub."),
+ ])))
+ return warnings
+
@property
def features(self) -> 'LnFeatures':
return self.lnpeermgr.features
@@ -1724,15 +1763,15 @@ def make_local_config_for_new_channel(
channel_type.check_combinations() # test if raises
if channel_type & ChannelType.OPTION_ANCHORS: # anchors
static_payment_key = self.static_payment_key
- static_remotekey = None
+ payment_basepoint = None
else: # static_remotekey
assert channel_type & channel_type.OPTION_STATIC_REMOTEKEY
assert self.config.TEST_LN_OPEN_SRK_CHANNELS
wallet = self.wallet
assert wallet.txin_type == 'p2wpkh'
addr = wallet.get_new_sweep_address()
static_payment_key = None
- static_remotekey = bytes.fromhex(wallet.get_public_key(addr))
+ payment_basepoint = bytes.fromhex(wallet.get_public_key(addr))
if multisig_funding_keypair:
for chan in self.channels.values(): # check against all chans of lnworker, for sanity
@@ -1751,7 +1790,8 @@ def make_local_config_for_new_channel(
max_htlc_value_in_flight_msat = self.network.config.LIGHTNING_MAX_HTLC_VALUE_IN_FLIGHT_MSAT or funding_sat * 1000
local_config = LocalConfig.from_seed(
channel_seed=channel_seed,
- static_remotekey=static_remotekey,
+ channel_type=channel_type,
+ payment_basepoint=payment_basepoint,
static_payment_key=static_payment_key,
multisig_key=multisig_funding_keypair,
upfront_shutdown_script=upfront_shutdown_script,
@@ -3740,6 +3780,10 @@ def create_channel_backup(self, channel_id: bytes):
assert chan.is_static_remotekey_enabled()
peer_addresses = list(chan.get_peer_addresses())
peer_addr = peer_addresses[0] if peer_addresses else None
+ if chan.has_anchors():
+ local_payment_basepoint = chan.config[LOCAL].payment_basepoint.privkey
+ else:
+ local_payment_basepoint = chan.config[LOCAL].payment_basepoint.pubkey
return ImportedChannelBackupStorage(
node_id=chan.node_id,
privkey=self.node_keypair.privkey,
@@ -3750,11 +3794,12 @@ def create_channel_backup(self, channel_id: bytes):
port=peer_addr.port if peer_addr else 0,
is_initiator=chan.constraints.is_initiator,
channel_seed=chan.config[LOCAL].channel_seed,
+ channel_type=int(chan.storage['channel_type']),
local_delay=chan.config[LOCAL].to_self_delay,
remote_delay=chan.config[REMOTE].to_self_delay,
remote_revocation_pubkey=chan.config[REMOTE].revocation_basepoint.pubkey,
remote_payment_pubkey=chan.config[REMOTE].payment_basepoint.pubkey,
- local_payment_pubkey=chan.config[LOCAL].payment_basepoint.pubkey,
+ local_payment_basepoint=local_payment_basepoint,
multisig_funding_privkey=chan.config[LOCAL].multisig_key.privkey,
)
@@ -3806,6 +3851,9 @@ def import_channel_backup(self, data):
channel_id = cb_storage.channel_id()
if channel_id.hex() in self.db.get_dict("channels"):
raise Exception('Channel already in wallet')
+ if existing_backup := self._channel_backups.get(channel_id):
+ if existing_backup.is_imported and existing_backup.cb.backup_version > cb_storage.backup_version:
+ raise util.UserFacingException(_("You already have a newer version of this backup in your wallet."))
self.logger.info(f'importing channel backup: {channel_id.hex()}')
d = self.db.get_dict("imported_channel_backups")
d[channel_id.hex()] = cb_blob.hex()
@@ -3816,6 +3864,15 @@ def import_channel_backup(self, data):
self.wallet.save_db()
util.trigger_callback('channels_updated', self.wallet)
self.lnwatcher.add_channel(cb)
+ if not cb.can_sweep_their_ctx_to_remote():
+ # the user has lost their channel state and cannot locally force close. If they'd request a remote fclose
+ # they wouldn't be able to claim their to_remote output. However, they could collaborate with the channel
+ # counterparty (likely one of the hardcoded trampolines) and manually construct a transaction to spend
+ # the channel funding UTXO as they do have the multisig key in their backup ("manual collaborative close").
+ raise util.UserFacingException(
+ _("The channel backup you imported cannot be used to request a force close. Please generate a new backup. "
+ "If you lost your wallet data, please open an issue on GitHub.")
+ )
def has_conflicting_backup_with(self, remote_node_id: bytes):
""" Returns whether we have an active channel with this node on another device, using same local node id. """
### electrum/transaction.py
@@ -589,7 +589,7 @@ def read_string(self, encoding='ascii'):
return self.read_bytes(length).decode(encoding)
- def write_string(self, string, encoding='ascii'):
+ def write_string(self, string: str | bytes | bytearray, encoding='ascii'):
string = to_bytes(string, encoding)
# Length-encoded as with read-string
self.write_compact_size(len(string))
### electrum/util.py
@@ -669,7 +669,7 @@ def to_string(x, enc) -> str:
raise TypeError("Not a string or bytes like object")
-def to_bytes(something, encoding='utf8') -> bytes:
+def to_bytes(something: str | bytes | bytearray, encoding='utf8') -> bytes:
"""
cast string to bytes() like object, but for python2 support it's bytearray copy
"""
### electrum/wallet.py
@@ -379,6 +379,13 @@ class TxWalletDetails(NamedTuple):
is_related_to_wallet: bool
+class WalletWarning(NamedTuple):
+ key: str # stable identifier, used to remember that the user has seen this warning
+ title: str
+ message: str
+ show_once: bool # if True acceptance is persisted and the warning won't be shown again
+
+
@dataclass(kw_only=True, slots=True, frozen=True)
class PiechartBalance:
confirmed: int # confirmed and matured and NOT frozen
@@ -517,6 +524,22 @@ def save_backup(self, backup_dir):
new_db.write()
return new_path
+ def get_startup_warnings(self) -> Sequence[WalletWarning]:
+ """Warnings that should be shown to the user once, when the wallet is opened in a GUI."""
+ warnings = [] # type: List[WalletWarning]
+ if self.lnworker:
+ warnings += self.lnworker.get_lightning_startup_warnings()
+ acknowledged = self.db.get('acknowledged_warnings', [])
+ return [warning for warning in warnings if warning.key not in acknowledged or warning.show_once is False]
+
+ def acknowledge_warning(self, key: str) -> None:
+ """Remember that the user has seen this warning, so that it is not shown again."""
+ acknowledged = self.db.get('acknowledged_warnings', [])
+ if key in acknowledged:
+ return
+ self.db.put('acknowledged_warnings', list(acknowledged) + [key])
+ self.save_db()
+
def has_lightning(self) -> bool:
return bool(self.lnworker)
### tests/lnhelpers.py
@@ -1,6 +1,7 @@
import asyncio
import copy
import os
+import socket
from decimal import Decimal
from pprint import pformat
from typing import NamedTuple, Tuple, Dict, Mapping, TYPE_CHECKING, Sequence
@@ -34,6 +35,12 @@
from . import ElectrumTestCase
+def find_free_port() -> int:
+ with socket.socket() as s:
+ s.bind(("127.0.0.1", 0))
+ return s.getsockname()[1]
+
+
high_fee_channel = {
'local_balance_msat': 10 * bitcoin.COIN * 1000 // 2,
'remote_balance_msat': 10 * bitcoin.COIN * 1000 // 2,
### tests/regtest.py
@@ -58,12 +58,6 @@ class TestLightningAB(TestLightning):
def test_collaborative_close(self):
self.run_shell(['collaborative_close'])
- def test_backup(self):
- self.run_shell(['backup'])
-
- def test_backup_local_forceclose(self):
- self.run_shell(['backup_local_forceclose'])
-
def test_breach(self):
self.run_shell(['breach'])
### tests/regtest/regtest.sh
@@ -251,64 +251,6 @@ if [[ $1 == "breach" ]]; then
fi
-if [[ $1 == "backup" ]]; then
- # Alice has two channels with Bob.
- # - chan1 has on-chain op_return backups,
- # - chan2 has an imported backup.
- # Alice restores from seed, and also imports backup for chan2.
- # Test "request_force_close" works for both channels.
- wait_for_balance alice 1
- echo "alice opens channel"
- bob_node=$($bob nodeid)
- channel1=$($alice open_channel $bob_node 0.15 --password='')
- new_blocks 1 # cannot open multiple chans with same node in same block
- $alice setconfig use_recoverable_channels False
- channel2=$($alice open_channel $bob_node 0.15 --password='')
- new_blocks 3
- wait_until_channel_open alice # FIXME wait for *both* channels?
- backup=$($alice export_channel_backup $channel2)
- seed=$($alice getseed --password='')
- $alice stop
- mv /tmp/alice/regtest/wallets/default_wallet /tmp/alice/regtest/wallets/default_wallet.old
- $alice -o restore "$seed"
- $alice daemon -d
- $alice load_wallet
- $alice import_channel_backup $backup
- $alice wait_for_sync
- echo "request force close $channel1"
- $alice request_force_close $channel1
- echo "request force close $channel2"
- $alice request_force_close $channel2
- new_blocks 1
- wait_for_balance alice 0.997
-fi
-
-
-if [[ $1 == "backup_local_forceclose" ]]; then
- # Alice does a local-force-close, and then restores from seed before sweeping CSV-locked coins
- wait_for_balance alice 1
- echo "alice opens channel"
- bob_node=$($bob nodeid)
- $alice setconfig use_recoverable_channels False
- channel=$($alice open_channel $bob_node 0.15 --password='')
- new_blocks 3
- wait_until_channel_open alice
- backup=$($alice export_channel_backup $channel)
- echo "local force close $channel"
- $alice close_channel $channel --force
- sleep 0.5
- seed=$($alice getseed --password='')
- $alice stop
- mv /tmp/alice/regtest/wallets/default_wallet /tmp/alice/regtest/wallets/default_wallet.old
- new_blocks 150
- $alice -o restore "$seed"
- $alice daemon -d
- $alice load_wallet
- $alice import_channel_backup $backup
- wait_for_balance alice 0.998
-fi
-
-
if [[ $1 == "collaborative_close" ]]; then
wait_for_balance alice 1
echo "alice opens channel"
### tests/test_lnutil.py
@@ -1,3 +1,4 @@
+import dataclasses
import os
import json
from typing import Dict, List
@@ -11,11 +12,12 @@
ScriptHtlc, calc_fees_for_commitment_tx, UpdateAddHtlc, LnFeatures, ln_compare_features,
IncompatibleLightningFeatures, ChannelType, offered_htlc_trim_threshold_sat, received_htlc_trim_threshold_sat,
ImportedChannelBackupStorage, OnchainChannelBackupStorage, list_enabled_ln_feature_bits, PaymentFeeBudget,
- LnFeatureContexts
+ LnFeatureContexts, Keypair, OnlyPubkeyKeypair, LOCAL
)
from electrum.util import bfh, MyEncoder
from electrum.transaction import Transaction, PartialTransaction, Sighash
from electrum.lnworker import LNWallet
+from electrum.lnchannel import ChannelBackup
from electrum.wallet import Standard_Wallet
from electrum.wallet_db import WalletDB, FINAL_SEED_VERSION
from electrum.simple_config import SimpleConfig
@@ -1117,6 +1119,7 @@ async def test_decode_imported_channel_backup_v0(self):
decoded_cb = ImportedChannelBackupStorage.from_encrypted_str(encrypted_cb, password=wallet1.get_fingerprint())
self.assertEqual(
ImportedChannelBackupStorage(
+ backup_version=0,
funding_txid='97767fdefef3152319363b772914d71e5eb70e793b835c13dce20037d3ac13fe',
funding_index=1,
funding_address='tb1qfsxllwl2edccpar9jas9wsxd4vhcewlxqwmn0w27kurkme3jvkdqn4msdp',
@@ -1126,15 +1129,19 @@ async def test_decode_imported_channel_backup_v0(self):
host='lightning.electrum.org',
port=9739,
channel_seed=bfh('ce9bad44ff8521d9f57fd202ad7cdedceb934f0056f42d0f3aa7a576b505332a'),
+ channel_type=int(ChannelType.OPTION_STATIC_REMOTEKEY),
local_delay=1008,
remote_delay=720,
remote_payment_pubkey=bfh('02a1bbc818e2e88847016a93c223eb4adef7bb8becb3709c75c556b6beb3afe7bd'),
remote_revocation_pubkey=bfh('022f28b7d8d1f05768ada3df1b0966083b8058e1e7197c57393e302ec118d7f0ae'),
- local_payment_pubkey=None,
+ local_payment_basepoint=None,
multisig_funding_privkey=None,
),
decoded_cb,
)
+ chan_backup = ChannelBackup(decoded_cb, lnworker=None)
+ self.assertEqual(OnlyPubkeyKeypair(None), chan_backup.config[LOCAL].payment_basepoint)
+ self.assertEqual([], chan_backup.get_wallet_addresses_channel_might_want_reserved())
@as_testnet
async def test_decode_imported_channel_backup_v1(self):
@@ -1145,6 +1152,7 @@ async def test_decode_imported_channel_backup_v1(self):
decoded_cb = ImportedChannelBackupStorage.from_encrypted_str(encrypted_cb, password=wallet1.get_fingerprint())
self.assertEqual(
ImportedChannelBackupStorage(
+ backup_version=1,
funding_txid='97767fdefef3152319363b772914d71e5eb70e793b835c13dce20037d3ac13fe',
funding_index=1,
funding_address='tb1qfsxllwl2edccpar9jas9wsxd4vhcewlxqwmn0w27kurkme3jvkdqn4msdp',
@@ -1154,15 +1162,60 @@ async def test_decode_imported_channel_backup_v1(self):
host='195.201.207.61',
port=9739,
channel_seed=bfh('ce9bad44ff8521d9f57fd202ad7cdedceb934f0056f42d0f3aa7a576b505332a'),
+ channel_type=int(ChannelType.OPTION_STATIC_REMOTEKEY),
local_delay=1008,
remote_delay=720,
remote_payment_pubkey=bfh('02a1bbc818e2e88847016a93c223eb4adef7bb8becb3709c75c556b6beb3afe7bd'),
remote_revocation_pubkey=bfh('022f28b7d8d1f05768ada3df1b0966083b8058e1e7197c57393e302ec118d7f0ae'),
- local_payment_pubkey=bfh('0308d686712782a44b0cef220485ad83dae77853a5bf8501a92bb79056c9dcb25a'),
+ local_payment_basepoint=bfh('0308d686712782a44b0cef220485ad83dae77853a5bf8501a92bb79056c9dcb25a'),
multisig_funding_privkey=None,
),
decoded_cb,
)
+ with self.assertRaisesRegex(Exception, "cannot re-serialize old-version channel backup"):
+ decoded_cb.to_bytes() # to bytes refuses to serialize old version
+ chan_backup = ChannelBackup(decoded_cb, lnworker=None)
+ self.assertEqual(
+ OnlyPubkeyKeypair(bfh('0308d686712782a44b0cef220485ad83dae77853a5bf8501a92bb79056c9dcb25a')),
+ chan_backup.config[LOCAL].payment_basepoint,
+ )
+
+ @as_testnet
+ async def test_decode_imported_channel_backup_v3(self):
+ encrypted_cb = "channel_backup:ARHqOO7du8fTSdHVOr5BaL3EGiFJT4RZ6kDLlPNJ3nHB0X+u6zVGE4jEkyt8JlsEJmLOsxGl4dH12/FgoAUJlr4A6h0xM9xO0XUkhz3+rWtx6/DPw16cqrfnSr90zJw67jeQHQVHfmAZVgKR+BEsGnGY/4cx6OEtobQSTFfwdVq6G5NgPxokP5828eklQ0lDmtYxXz+TPCrx97Nkrs5wdAInJKVVYtmVXw6AbenxsrRwKW7NtuuZ6D1iYkfFEvQ8XDYXDbietqzOLrGIjBUrz+SyYIlhdRON4kb+RN0t4e0V05aqeTDTlETIKg7/gBIgYhEySBv702/VQjW1kXvoNifjUj5mHbpAbTLm/vBNGM73cG0h6S8yTQ2b6wGBMLeS3Lf6SdSssv7lS+LOR7bIvtfNOtMVREPjXhTVk4ACwo23bzd7pWv5Is/XRf5T2gqSZvM9aozYieBgD4BXMK7aA/7bUKxYGa0G9DYbWOFKGFcKYHTvdENWJfZ17xIb1eeHws9uzcY="
+ wallet_vpub = "vpub5UQGRCM7BGYjn1ttbgxRW9yMXAwWvTXD4LSxs3F9EvEZxYdB3AwYsXG3vKtyJyjuRQKFQBaVZ7cMqNVHFZsk2Rm5HoRNcAJnPPNiEhxW8et"
+ decoded_cb = ImportedChannelBackupStorage.from_encrypted_str(encrypted_cb, password=wallet_vpub)
+ reference = ImportedChannelBackupStorage(
+ backup_version=3,
+ funding_txid='4d6aa822ba3ded69d27c4d245658cb3dbba511621e411be095deef4118baf5d5',
+ funding_index=0,
+ funding_address='tb1qzx6xzdavjlawmkd6j43vqxclvw6lj7asegpmjq0k6e3xlztzhyeqpkuwmg',
+ is_initiator=True,
+ node_id=bfh('03933884aaf1d6b108397e5efe5c86bcf2d8ca8d2f700eda99db9214fc2712b134'),
+ privkey=bfh('a73df0a2ac3dc4764254a3b0227a72768f229cf419f80d1653efc07584517e30'),
+ host='13.248.222.197',
+ port=9735,
+ channel_seed=bfh('7c5e51f115741b92c9746dc0e6f2f31f2830ec003f35d74a1abd0d106a3e07e9'),
+ channel_type=ChannelType.OPTION_STATIC_REMOTEKEY | ChannelType.OPTION_ANCHORS,
+ local_delay=1008,
+ remote_delay=720,
+ remote_payment_pubkey=bfh('02039b955bef1e02d5b5e8f592d9ed15ea2be49f5ee979c88071b1485cfe78a6da'),
+ remote_revocation_pubkey=bfh('026c9e1e2ce5ae47a36df3bb90dbeb6d7874086e3a4ab1ea2a6211331dbd285973'),
+ local_payment_basepoint=bfh('f406b0899040d762a326035631c2ce38b22c2db18102ab9e3c666c87d9e0ab65'),
+ multisig_funding_privkey=bfh('a21d6110f9af75def01f2145ef1338dfa53c2fb2a58dd9ae0ecf3358ee663783'),
+ )
+ self.assertEqual(reference, decoded_cb)
+ # non-persistent negotiation bits get stripped from channel_type on construction
+ cb_with_alias = dataclasses.replace(reference, channel_type=reference.channel_type | ChannelType.OPTION_SCID_ALIAS)
+ self.assertEqual(reference.channel_type, cb_with_alias.channel_type)
+ chan_backup = ChannelBackup(decoded_cb, lnworker=None)
+ self.assertEqual(
+ Keypair(
+ privkey=bfh('f406b0899040d762a326035631c2ce38b22c2db18102ab9e3c666c87d9e0ab65'),
+ pubkey=bfh('0393145d5cb5d8a73cdffa3caaf3204ae0b2cf0c7a1a4b62c85d2182fefaaeee5e'),
+ ),
+ chan_backup.config[LOCAL].payment_basepoint,
+ )
def test_onchain_channel_backup_json_roundtrip(self):
cb = OnchainChannelBackupStorage(
### tests/test_lnwallet.py
@@ -4,28 +4,40 @@
import time
from unittest import mock
from decimal import Decimal
-from typing import Optional, Sequence
+from typing import Callable, Optional, Sequence
+
+import electrum_ecc as ecc
from electrum.address_synchronizer import TX_HEIGHT_LOCAL
-from electrum import bitcoin
+from electrum import bitcoin, keystore
+from electrum.bitcoin import COIN
import electrum.trampoline
from electrum.channel_db import UpdateStatus
-from electrum.lnutil import RECEIVED, SENT, MIN_FINAL_CLTV_DELTA_ACCEPTED, serialize_htlc_key, LnFeatures, HTLCOwner, PaymentFailure
+from electrum.lnutil import (
+ RECEIVED, SENT, MIN_FINAL_CLTV_DELTA_ACCEPTED, serialize_htlc_key, LnFeatures, HTLCOwner, PaymentFailure,
+ LOCAL, REMOTE, ImportedChannelBackupStorage, make_commitment_output_to_anchor_address,
+)
from electrum.logging import console_stderr_handler
from electrum.lnmsg import decode_msg
from electrum.lnrouter import RouteEdge
from electrum.bolt11 import encode_bolt11_invoice, BOLT11Addr
from electrum.lntransport import LNPeerAddr
from electrum.invoices import LN_EXPIRY_NEVER, PR_UNPAID, PR_INFLIGHT, Invoice
from electrum.lnpeer import Peer
-from electrum.lnchannel import Channel, ChannelState
+from electrum.lnchannel import Channel, ChannelBackup, ChannelState
from electrum.lnonion import OnionPacket, OnionRoutingFailure, OnionFailureCode
from electrum.mpp_split import SplitConfig, SplitConfigRating
-from electrum.crypto import sha256
+from electrum.crypto import sha256, pw_encode_with_version_and_mac
from electrum.simple_config import SimpleConfig
+from electrum.transaction import Transaction, TxOutpoint, BCDataStream
+from electrum.util import bfh, UserFacingException
+from electrum.storage import WalletStorage
+from electrum.wallet import Abstract_Wallet
+from electrum.wallet_db import WalletDB
from . import ElectrumTestCase, lnhelpers
-from .lnhelpers import create_test_channels
+from .lnhelpers import create_test_channels, find_free_port
+from .toyserver.testcase import SEED, ToyInstance, ToyServerTestCase
class TestLNWallet(ElectrumTestCase):
@@ -618,3 +630,343 @@ async def test_missing_channel_update_from_failed_htlc(self):
pubkey_b = graph.workers['bob'].node_keypair.pubkey
pubkey_d = graph.workers['dave'].node_keypair.pubkey
self.assertEqual(amount_msat, hint_bd.cannot_send(pubkey_b < pubkey_d))
+
+
+class TestChannelBackup(ToyServerTestCase):
+ """Alice and Bob are two LNWallets sharing a ToyServer and talking to each other over a real BOLT-08 transport."""
+ FUNDING_SAT = 5_000_000
+
+ async def asyncSetUp(self):
+ await super().asyncSetUp()
+ self.alice_instance = await self.create_instance("alice")
+ self.bob_instance = await self.create_instance("bob")
+ self.bob_instance.config.LIGHTNING_LISTEN = f"127.0.0.1:{find_free_port()}"
+ self.bob = self.create_wallet("bob", instance=self.bob_instance)
+ await self.wait_until(lambda: self.bob.lnworker.lnpeermgr.listen_server is not None)
+
+ def create_wallet(self, name: str, **kwargs) -> Abstract_Wallet:
+ # increase gap limit for lightning
+ return super().create_wallet(name, gap_limit=10, gap_limit_for_change=10, **kwargs)
+
+ def create_deterministic_wallet(self, instance: ToyInstance) -> Abstract_Wallet:
+ return self.create_wallet(instance.name, instance=instance)
+
+ def create_non_deterministic_xprv_wallet(self, instance: ToyInstance) -> Abstract_Wallet:
+ xprv = keystore.from_seed(SEED, passphrase=instance.name).get_master_private_key(None)
+ wallet = self.create_wallet(instance.name, text=xprv, instance=instance)
+ wallet.init_lightning(password=None)
+ self.assertFalse(wallet.lnworker.has_deterministic_node_id())
+ return wallet
+
+ def create_config(self, name: Optional[str] = None) -> SimpleConfig:
+ config = super().create_config(name)
+ config.LIGHTNING_USE_RECOVERABLE_CHANNELS = False # onchain backup tests opt in explicitly
+ return config
+
+ async def open_channel(self, from_wallet: Abstract_Wallet, to_wallet: Abstract_Wallet) -> Channel:
+ to_nodeid = to_wallet.lnworker.node_keypair.pubkey
+ peer = await from_wallet.lnworker.lnpeermgr.add_peer(f"{to_nodeid.hex()}@{to_wallet.config.LIGHTNING_LISTEN}")
+ chan, funding_tx = await from_wallet.lnworker.open_channel_with_peer(peer, self.FUNDING_SAT, password=None)
+ await self.mine_blocks(chan.funding_txn_minimum_depth() + 1)
+ await self.wait_until(lambda: chan.is_open())
+ chan_bob = to_wallet.lnworker.get_channel_by_id(chan.channel_id)
+ await self.wait_until(lambda: chan_bob.is_open())
+ return chan
+
+ async def fund_and_open_channel(self, alice: Abstract_Wallet, *, anchors: bool) -> Channel:
+ """Fund alice from the faucet, and open a channel from her to bob."""
+ if not anchors: # both peers have to agree on the channel type
+ self.alice_instance.config.TEST_LN_OPEN_SRK_CHANNELS = True
+ self.bob_instance.config.TEST_LN_OPEN_SRK_CHANNELS = True
+ await self.pay_to_address(alice.get_receiving_address(), 1 * COIN)
+ await self.mine_blocks(1)
+ chan = await self.open_channel(alice, self.bob)
+ self.assertEqual(anchors, chan.has_anchors())
+ return chan
+
+ async def claim_to_remote_from_their_ctx(self, alice: Abstract_Wallet, chan: Channel) -> Transaction:
+ """Wait for bob to force-close with his own ctx, and for alice to get hold of what is hers in it.
+ chan is alice's channel from before she lost her wallet db, we only read from it here."""
+ ctx = await self.wait_for_spender_of(TxOutpoint.from_str(chan.funding_outpoint.to_str()))
+ chan_bob = self.bob.lnworker.get_channel_by_id(chan.channel_id)
+ if chan.has_anchors():
+ # anchor spend needs to be checked before ctx confirms, otherwise TxBatcher will drop it.
+ anchor_address = make_commitment_output_to_anchor_address(chan.config[LOCAL].multisig_key.pubkey)
+ anchor_idx = ctx.get_output_idxs_from_address(anchor_address).pop()
+ await self.wait_for_spender_of(TxOutpoint.from_str(f"{ctx.txid()}:{anchor_idx}"))
+ await self.mine_blocks(1) # mine to_remote csv delay
+ await self.wait_until(lambda: chan_bob.get_state() == ChannelState.CLOSED)
+ self.assertEqual(ctx.txid(), chan_bob.get_closing_height()[0])
+ to_remote_idx = max(range(len(ctx.outputs())), key=lambda i: ctx.outputs()[i].value)
+ if not chan.has_anchors(): # to_remote is one of alice's wallet addresses, nothing to sweep
+ self.assertTrue(alice.is_mine(ctx.outputs()[to_remote_idx].address))
+ return ctx
+ await self.wait_for_spender_of(TxOutpoint.from_str(f"{ctx.txid()}:{to_remote_idx}"))
+ return ctx
+
+ async def assert_balance_recovered(
+ self,
+ alice: Abstract_Wallet,
+ cb: ChannelBackup, *,
+ balance_before: int, # onchain balance before closing chan
+ chan_balance_sat: int,
+ ) -> None:
+ """Alice got (most of) her channel balance back on-chain, and the backup is settled."""
+ await self.mine_blocks(1)
+ confirmed_balance, unconfirmed_balance, _ = alice.get_balance()
+ self.assertEqual(0, unconfirmed_balance)
+ self.assertGreater(confirmed_balance, balance_before + chan_balance_sat * 0.9)
+ self.assertTrue(cb.is_closed())
+
+ async def _test_request_fclose_from_chan_backup(
+ self, *,
+ create_alice_cb: Callable[[ToyInstance], Abstract_Wallet],
+ anchors: bool,
+ ) -> None:
+ """Alice exports a channel backup, then loses her wallet db. She restores her wallet, imports
+ the backup, and asks bob to force-close, so that she can claim her to_remote output."""
+ alice = create_alice_cb(self.alice_instance)
+ chan = await self.fund_and_open_channel(alice, anchors=anchors)
+ chan_id = chan.channel_id
+ alice_balance_sat = chan.balance(LOCAL) // 1000
+ self.assertGreater(alice_balance_sat, self.FUNDING_SAT * 0.9)
+ backup = alice.lnworker.export_channel_backup(chan_id)
+ self.assertIsInstance(backup, str)
+ self.assertTrue(backup.startswith('channel_backup:'))
+
+ # alice loses her wallet db, and restores the wallet from her seed
+ await self.stop_wallet(alice)
+ alice = create_alice_cb(self.alice_instance)
+ await self.sync()
+ self.assertEqual({}, dict(alice.lnworker.channels))
+ self.assertEqual({}, dict(alice.lnworker.channel_backups))
+ onchain_balance_before = sum(alice.get_balance())
+
+ # alice imports the channel backup, and asks bob to force-close
+ alice.lnworker.import_channel_backup(backup)
+ cb = alice.lnworker.channel_backups[chan_id]
+ self.assertEqual(anchors, cb.has_anchors())
+ self.assertEqual(alice.lnworker.has_deterministic_node_id(), alice.lnworker.node_keypair.privkey == cb.cb.privkey)
+ await self.wait_until(lambda: cb.get_state() == ChannelState.FUNDED)
+ await alice.lnworker.request_force_close(chan_id)
+
+ # bob force-closes with his own ctx, and alice claims her balance out of it
+ await self.claim_to_remote_from_their_ctx(alice, chan)
+ await self.assert_balance_recovered(alice, cb, balance_before=onchain_balance_before, chan_balance_sat=alice_balance_sat)
+
+ async def test_request_fclose_from_anchor_chan_backup_deterministic_lightning(self):
+ await self._test_request_fclose_from_chan_backup(create_alice_cb=self.create_deterministic_wallet, anchors=True)
+
+ async def test_request_fclose_from_anchor_chan_backup_non_deterministic_lightning(self):
+ await self._test_request_fclose_from_chan_backup(create_alice_cb=self.create_non_deterministic_xprv_wallet, anchors=True)
+
+ async def test_request_fclose_from_srk_chan_backup_deterministic_lightning(self):
+ await self._test_request_fclose_from_chan_backup(create_alice_cb=self.create_deterministic_wallet, anchors=False)
+
+ async def test_request_fclose_from_srk_chan_backup_non_deterministic_lightning(self):
+ await self._test_request_fclose_from_chan_backup(create_alice_cb=self.create_non_deterministic_xprv_wallet, anchors=False)
+
+ async def test_save_backup_includes_channel_backups(self):
+ """Wallet backup files contain a channel backup blob for each open channel."""
+ alice = self.create_deterministic_wallet(self.alice_instance)
+ chan = await self.fund_and_open_channel(alice, anchors=True)
+ chan_id = chan.channel_id
+ alice.storage = WalletStorage(os.path.join(alice.config.get_datadir_wallet_path(), 'alice_wallet'))
+ backup_path = alice.save_backup(self.electrum_path)
+ with open(backup_path) as f:
+ backup_db = WalletDB(f.read(), storage=None, upgrade=False)
+ cb_blob = backup_db.get_dict('imported_channel_backups')[chan_id.hex()]
+ self.assertEqual(
+ alice.lnworker.create_channel_backup(chan_id),
+ ImportedChannelBackupStorage.from_bytes(bfh(cb_blob)),
+ )
+ # check full channels are removed from file backups:
+ self.assertEqual(1, len(alice.db.get_dict('channels')))
+ self.assertEqual(0, len(backup_db.get_dict('channels')))
+
+ @staticmethod
+ def serialize_v2_channel_backup(cb: ImportedChannelBackupStorage) -> bytes:
+ vds = BCDataStream()
+ vds.write_uint16(2) # version
+ vds.write_boolean(cb.is_initiator)
+ vds.write_bytes(cb.privkey, 32)
+ vds.write_bytes(cb.channel_seed, 32)
+ vds.write_bytes(cb.node_id, 33)
+ vds.write_bytes(bfh(cb.funding_txid), 32)
+ vds.write_uint16(cb.funding_index)
+ vds.write_string(cb.funding_address)
+ vds.write_bytes(cb.remote_payment_pubkey, 33)
+ vds.write_bytes(cb.remote_revocation_pubkey, 33)
+ vds.write_uint16(cb.local_delay)
+ vds.write_uint16(cb.remote_delay)
+ vds.write_string(cb.host)
+ vds.write_uint16(cb.port)
+ # v2 stores the payment pubkey (v3 stores the privkey for anchor chans)
+ vds.write_bytes(ecc.ECPrivkey(cb.local_payment_basepoint).get_public_key_bytes(), 33)
+ vds.write_bytes(cb.multisig_funding_privkey, 32)
+ return bytes(vds.input)
+
+ async def test_request_fclose_from_v2_anchor_chan_backup_deterministic_wallet(self):
+ """Test recovery with a v2 channel backup (payment pubkey only) on a deterministic lnwallet with anchor channel"""
+ alice = self.create_deterministic_wallet(self.alice_instance)
+ chan = await self.fund_and_open_channel(alice, anchors=True)
+ chan_id = chan.channel_id
+ alice_balance_sat = chan.balance(LOCAL) // 1000
+ backup = alice.lnworker.export_channel_backup(chan_id)
+
+ # alice loses her wallet db, and restores the wallet from her seed
+ await self.stop_wallet(alice)
+ alice = self.create_deterministic_wallet(self.alice_instance)
+ await self.sync()
+ onchain_balance_before = sum(alice.get_balance())
+
+ # downgrade the backup to v2: no channel_type, payment pubkey instead of privkey
+ cb_storage = ImportedChannelBackupStorage.from_encrypted_str(backup, password=alice.get_fingerprint())
+ v2_blob = self.serialize_v2_channel_backup(cb_storage)
+ backup_v2 = 'channel_backup:' + pw_encode_with_version_and_mac(v2_blob, alice.get_fingerprint())
+ alice.lnworker.import_channel_backup(backup_v2)
+ self.assertEqual(v2_blob.hex(), alice.lnworker.db.get_dict("imported_channel_backups")[chan_id.hex()])
+ cb = alice.lnworker.channel_backups[chan_id]
+ self.assertEqual(2, cb.cb.backup_version)
+ self.assertTrue(cb.has_anchors()) # inferred from the multisig key
+ self.assertTrue(cb.can_sweep_their_ctx_to_remote())
+
+ await self.wait_until(lambda: cb.get_state() == ChannelState.FUNDED)
+ await alice.lnworker.request_force_close(chan_id)
+
+ # bob force-closes with his own ctx, and alice sweeps to_remote and her anchor
+ await self.claim_to_remote_from_their_ctx(alice, chan)
+ await self.assert_balance_recovered(alice, cb, balance_before=onchain_balance_before, chan_balance_sat=alice_balance_sat)
+
+ async def _test_local_fclose_then_sweep_to_local_from_chan_backup(
+ self, *,
+ create_alice_cb: Callable[[ToyInstance], Abstract_Wallet],
+ anchors: bool,
+ ) -> None:
+ """Alice force-closes with her own ctx, then loses her wallet db. She restores her wallet,
+ imports the channel backup, and sweeps her to_local output once the CSV delay expired."""
+ csv_delay = 5 # demanded of alice by bob, hence set on bob's config
+ self.bob_instance.config.LIGHTNING_TO_SELF_DELAY_CSV = csv_delay
+ alice = create_alice_cb(self.alice_instance)
+ chan = await self.fund_and_open_channel(alice, anchors=anchors)
+ self.assertEqual(csv_delay, chan.config[REMOTE].to_self_delay)
+ chan_id = chan.channel_id
+ funding_outpoint = TxOutpoint.from_str(chan.funding_outpoint.to_str())
+ alice_balance_sat = chan.balance(LOCAL) // 1000
+ backup = alice.lnworker.export_channel_backup(chan_id)
+
+ # alice force-closes with her own ctx, and loses her wallet db before she can sweep it
+ ctx_txid = await alice.lnworker.force_close_channel(chan_id)
+ await self.stop_wallet(alice)
+ await self.mine_blocks(1)
+ ctx = await self.wait_for_spender_of(funding_outpoint)
+ self.assertEqual(ctx_txid, ctx.txid())
+
+ # alice restores her wallet and imports the channel backup
+ alice = create_alice_cb(self.alice_instance)
+ await self.sync()
+ self.assertEqual({}, dict(alice.lnworker.channels))
+ onchain_balance_before = sum(alice.get_balance())
+ alice.lnworker.import_channel_backup(backup)
+ cb = alice.lnworker.channel_backups[chan_id]
+ self.assertEqual(anchors, cb.has_anchors())
+
+ # once the CSV delay expired, alice claims her balance out of her old ctx
+ await self.mine_blocks(csv_delay)
+ to_local_idx = max(range(len(ctx.outputs())), key=lambda i: ctx.outputs()[i].value)
+ await self.wait_for_spender_of(TxOutpoint.from_str(f"{ctx.txid()}:{to_local_idx}"))
+ await self.assert_balance_recovered(alice, cb, balance_before=onchain_balance_before, chan_balance_sat=alice_balance_sat)
+
+ async def test_local_fclose_from_anchor_chan_backup_deterministic_lightning(self):
+ await self._test_local_fclose_then_sweep_to_local_from_chan_backup(
+ create_alice_cb=self.create_deterministic_wallet,
+ anchors=True,
+ )
+
+ async def test_local_fclose_from_anchor_chan_backup_non_deterministic_lightning(self):
+ await self._test_local_fclose_then_sweep_to_local_from_chan_backup(
+ create_alice_cb=self.create_non_deterministic_xprv_wallet,
+ anchors=True,
+ )
+
+ async def test_local_fclose_from_srk_chan_backup_deterministic_lightning(self):
+ await self._test_local_fclose_then_sweep_to_local_from_chan_backup(
+ create_alice_cb=self.create_deterministic_wallet,
+ anchors=False,
+ )
+
+ async def test_local_fclose_from_srk_chan_backup_non_deterministic_lightning(self):
+ await self._test_local_fclose_then_sweep_to_local_from_chan_backup(
+ create_alice_cb=self.create_non_deterministic_xprv_wallet,
+ anchors=False,
+ )
+
+ async def _test_request_fclose_from_onchain_chan_backup(self, *, anchors: bool) -> None:
+ """Alice has a deterministic LNWallet, and her funding txs contain an OP_RETURN onchain backup.
+ She loses her wallet db, restores from her seed, discovers the backup in the funding tx,
+ and recovers her balance by asking bob to force-close."""
+ self.alice_instance.config.LIGHTNING_USE_RECOVERABLE_CHANNELS = True
+ alice = self.create_deterministic_wallet(self.alice_instance)
+ self.assertTrue(alice.lnworker.has_recoverable_channels())
+ chan = await self.fund_and_open_channel(alice, anchors=anchors)
+ chan_id = chan.channel_id
+ alice_balance_sat = chan.balance(LOCAL) // 1000
+
+ # alice loses her wallet db, restores from seed, and finds the backup in the funding tx
+ await self.stop_wallet(alice)
+ alice = self.create_deterministic_wallet(self.alice_instance)
+ await self.sync()
+ self.assertEqual({}, dict(alice.lnworker.channels))
+ onchain_balance_before = sum(alice.get_balance())
+ await self.wait_until(lambda: chan_id in alice.lnworker.channel_backups)
+ cb = alice.lnworker.channel_backups[chan_id]
+ self.assertFalse(cb.is_imported)
+ await self.wait_until(lambda: cb.get_state() == ChannelState.FUNDED)
+
+ # the onchain backup only contains a node id prefix: bob must be findable as a hardcoded node
+ bob_host, bob_port = self.bob_instance.config.LIGHTNING_LISTEN.rsplit(':', 1)
+ electrum.trampoline._TRAMPOLINE_NODES_UNITTESTS = {
+ 'bob': LNPeerAddr(host=bob_host, port=int(bob_port), pubkey=self.bob.lnworker.node_keypair.pubkey),
+ }
+ self.addCleanup(lambda: electrum.trampoline._TRAMPOLINE_NODES_UNITTESTS.clear())
+ await alice.lnworker.request_force_close(chan_id)
+
+ # bob force-closes with his own ctx, and alice claims her balance out of it
+ await self.claim_to_remote_from_their_ctx(alice, chan)
+ await self.assert_balance_recovered(alice, cb, balance_before=onchain_balance_before, chan_balance_sat=alice_balance_sat)
+
+ async def test_request_fclose_from_anchor_onchain_chan_backup(self):
+ await self._test_request_fclose_from_onchain_chan_backup(anchors=True)
+
+ async def test_request_fclose_from_srk_onchain_chan_backup(self):
+ await self._test_request_fclose_from_onchain_chan_backup(anchors=False)
+
+ async def test_imported_channel_backup_upgrade(self):
+ """Alice has a v2 backup in her wallet, she then imports a v3 backup for the same channel."""
+ alice = self.create_deterministic_wallet(self.alice_instance)
+ chan = await self.fund_and_open_channel(alice, anchors=True)
+ chan_id = chan.channel_id
+ backup = alice.lnworker.export_channel_backup(chan_id)
+
+ # alice loses her wallet db, and restores the wallet from her seed
+ await self.stop_wallet(alice)
+ alice = self.create_deterministic_wallet(self.alice_instance)
+
+ # downgrade the backup to v2
+ cb_storage = ImportedChannelBackupStorage.from_encrypted_str(backup, password=alice.get_fingerprint())
+ v2_blob = self.serialize_v2_channel_backup(cb_storage)
+ backup_v2 = 'channel_backup:' + pw_encode_with_version_and_mac(v2_blob, alice.get_fingerprint())
+ alice.lnworker.import_channel_backup(backup_v2)
+ self.assertEqual(v2_blob.hex(), alice.lnworker.db.get_dict("imported_channel_backups")[chan_id.hex()])
+ cb = alice.lnworker.channel_backups[chan_id]
+ self.assertEqual(2, cb.cb.backup_version)
+
+ # now import the newer backup
+ alice.lnworker.import_channel_backup(backup)
+ self.assertEqual(cb_storage.to_bytes().hex(), alice.lnworker.db.get_dict("imported_channel_backups")[chan_id.hex()])
+ cb = alice.lnworker.channel_backups[chan_id]
+ self.assertGreater(cb.cb.backup_version, 2)
+
+ # now try importing an older version again, this should not work
+ with self.assertRaises(UserFacingException):
+ alice.lnworker.import_channel_backup(backup_v2)
### tests/toyserver/testcase.py
@@ -58,7 +58,7 @@ async def asyncSetUp(self):
async def asyncTearDown(self):
for wallet in self.wallets:
- await wallet.stop()
+ await self.stop_wallet(wallet)
for instance in self.instances.values():
await instance.network.stop()
await self.server.stop()
@@ -90,12 +90,12 @@ async def create_instance(self, name: str) -> ToyInstance:
await network.connect(self.server, client_name=name)
return instance
- def create_wallet(self, name: str, *, instance: ToyInstance, **kwargs) -> Abstract_Wallet:
+ def create_wallet(self, name: str, *, instance: ToyInstance, text: str = SEED, **kwargs) -> Abstract_Wallet:
"""Add an in-memory wallet to given instance and put it online. Calling it twice with the same name returns
the same wallet but without the previous wallets data (simulating data loss)."""
with mock.patch.object(Abstract_Wallet, 'basename', lambda w: name): # mock basename so name is shown in logs
wallet = restore_wallet_from_text__for_unittest(
- SEED,
+ text,
passphrase=name,
path=None,
config=instance.config,
@@ -107,6 +107,12 @@ def create_wallet(self, name: str, *, instance: ToyInstance, **kwargs) -> Abstra
instance.wallets.append(wallet)
return wallet
+ async def stop_wallet(self, wallet: Abstract_Wallet) -> None:
+ owners = [instance for instance in self.instances.values() if wallet in instance.wallets]
+ assert len(owners) == 1, f"wallet {wallet.basename()} is owned by {len(owners)} instances"
+ owners[0].wallets.remove(wallet)
+ await wallet.stop()
+
# --- chain and wallet helpers ---
async def wait_until(self, predicate: Callable[[], bool], *, timeout: int = 20) -> None:
### tests/toyserver/toynetwork.py
@@ -6,14 +6,22 @@
from electrum import blockchain, util
from electrum.blockchain import Blockchain
+from electrum.fee_policy import FeeTimeEstimates, FEE_ETA_TARGETS
from electrum.interface import Interface, ServerAddr
from electrum.simple_config import SimpleConfig
from electrum.transaction import Transaction
from electrum.util import OldTaskGroup
+from electrum.wallet import Abstract_Wallet
from .toyserver import ToyServer
+class MockDaemon:
+
+ def get_wallets(self) -> dict[str, Abstract_Wallet]:
+ return {}
+
+
class ToyNetwork:
"""Client-side stand-in for Network, driving a single Interface against a ToyServer.
@@ -31,7 +39,17 @@ def __init__(self, *, config: SimpleConfig):
self.debug = True
self.bhi_lock = asyncio.Lock()
self.interface = None # type: Interface | None
+
self.relay_fee = None # type: int | None # sat/kbyte, set from the server on connect
+ self.fee_estimates = FeeTimeEstimates()
+ for target in FEE_ETA_TARGETS[:-1]:
+ self.fee_estimates.set_data(target, 50_000 // target)
+
+ self.daemon = MockDaemon()
+ self.channel_db = None
+ self.path_finder = None
+ self.lngossip = None
+ self.is_proxy_tor = False
async def connect(self, server: ToyServer, *, client_name: str | None = None) -> Interface:
"""connect to server, and wait until we have synced its headers"""
@@ -62,6 +80,8 @@ async def switch_unwanted_fork_interface(self):
pass
async def switch_lagging_interface(self):
pass
+ def start_gossip(self):
+ pass
def blockchain(self) -> Blockchain:
return self.interface.blockchain
def get_local_height(self) -> int:Why this scored 53/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.