Browse Source

Deterministic NodeID:

- use_recoverable_channel is a user setting, available
   only in standard wallets with a 'segwit' seed_type
 - if enabled, 'lightning_xprv' is derived from seed
 - otherwise, wallets use the existing 'lightning_privkey2'

Recoverable channels:
 - channel recovery data is added funding tx using an OP_RETURN
 - recovery data = 4 magic bytes + node id[0:16]
 - recovery data is chacha20 encrypted using funding_address as nonce.
   (this will allow to fund multiple channels in the same tx)

GUI:
  - whether channels are recoverable is shown in wallet info dialog.
  - if the wallet can have recoverable channels but has an old node_id,
    users are told to close their channels and restore from seed
    to have that feature.
patch-4
ThomasV 4 years ago
parent
commit
64a931f21e
  1. 2
      electrum/base_wizard.py
  2. 4
      electrum/bitcoin.py
  3. 7
      electrum/channel_db.py
  4. 3
      electrum/commands.py
  5. 20
      electrum/gui/kivy/main.kv
  6. 60
      electrum/gui/kivy/main_window.py
  7. 2
      electrum/gui/kivy/uix/dialogs/lightning_channels.py
  8. 4
      electrum/gui/kivy/uix/dialogs/lightning_open_channel.py
  9. 8
      electrum/gui/kivy/uix/dialogs/settings.py
  10. 11
      electrum/gui/kivy/uix/ui_screens/status.kv
  11. 14
      electrum/gui/messages.py
  12. 9
      electrum/gui/qt/channels_list.py
  13. 60
      electrum/gui/qt/main_window.py
  14. 12
      electrum/gui/qt/settings_dialog.py
  15. 5
      electrum/keystore.py
  16. 36
      electrum/lnchannel.py
  17. 17
      electrum/lnpeer.py
  18. 29
      electrum/lnutil.py
  19. 129
      electrum/lnworker.py
  20. 2
      electrum/tests/regtest/regtest.sh
  21. 2
      electrum/tests/test_wallet.py
  22. 16
      electrum/wallet.py
  23. 19
      electrum/wallet_db.py

2
electrum/base_wizard.py

@ -545,6 +545,8 @@ class BaseWizard(Logger):
def create_keystore(self, seed, passphrase):
k = keystore.from_seed(seed, passphrase, self.wallet_type == 'multisig')
if self.wallet_type == 'standard' and self.seed_type == 'segwit':
self.data['lightning_xprv'] = k.get_lightning_xprv(None)
self.on_keystore(k)
def on_bip43(self, seed, passphrase, derivation, script_type):

4
electrum/bitcoin.py

@ -295,6 +295,10 @@ def push_script(data: str) -> str:
return _op_push(data_len) + bh2u(data)
def make_op_return(x:bytes) -> bytes:
return bytes([opcodes.OP_RETURN]) + bytes.fromhex(push_script(x.hex()))
def add_number_to_script(i: int) -> bytes:
return bfh(push_script(script_num_to_hex(i)))

7
electrum/channel_db.py

@ -867,6 +867,13 @@ class ChannelDB(SqlDB):
with self.lock:
return self._policies.copy()
def get_node_by_prefix(self, prefix):
with self.lock:
for k in self._addresses.keys():
if k.startswith(prefix):
return k
raise Exception('node not found')
def to_dict(self) -> dict:
""" Generates a graph representation in terms of a dictionary.

3
electrum/commands.py

@ -54,6 +54,7 @@ from .address_synchronizer import TX_HEIGHT_LOCAL
from .mnemonic import Mnemonic
from .lnutil import SENT, RECEIVED
from .lnutil import LnFeatures
from .lnutil import extract_nodeid
from .lnpeer import channel_id_from_funding_tx
from .plugin import run_hook
from .version import ELECTRUM_VERSION
@ -997,9 +998,11 @@ class Commands:
funding_sat = satoshis(amount)
push_sat = satoshis(push_amount)
coins = wallet.get_spendable_coins(None)
node_id, rest = extract_nodeid(connection_string)
funding_tx = wallet.lnworker.mktx_for_open_channel(
coins=coins,
funding_sat=funding_sat,
node_id=node_id,
fee_est=None)
chan, funding_tx = await wallet.lnworker._open_channel_coroutine(
connect_str=connection_string,

20
electrum/gui/kivy/main.kv

@ -98,6 +98,26 @@
id: lbl2
text: root.value
<BoxButton@BoxLayout>
text: ''
value: ''
size_hint_y: None
height: max(lbl1.height, lbl2.height)
TopLabel
id: lbl1
text: root.text
pos_hint: {'top':1}
Button
id: lbl2
text: root.value
background_color: (0,0,0,0)
bold: True
size_hint_y: None
text_size: self.width, None
height: self.texture_size[1]
on_release:
root.callback()
<OutputItem>
address: ''
value: ''

60
electrum/gui/kivy/main_window.py

@ -208,6 +208,10 @@ class ElectrumWindow(App, Logger):
def on_use_unconfirmed(self, instance, x):
self.electrum_config.set_key('confirmed_only', not self.use_unconfirmed, True)
use_recoverable_channels = BooleanProperty(True)
def on_use_recoverable_channels(self, instance, x):
self.electrum_config.set_key('use_recoverable_channels', self.use_recoverable_channels, True)
def switch_to_send_screen(func):
# try until send_screen is available
def wrapper(self, *args):
@ -1352,3 +1356,59 @@ class ElectrumWindow(App, Logger):
self.show_error("failed to import backup" + '\n' + str(e))
return
self.lightning_channels_dialog()
def lightning_status(self):
if self.wallet.has_lightning():
if self.wallet.lnworker.has_deterministic_node_id():
status = _('Enabled')
else:
status = _('Enabled, non-recoverable channels')
if self.wallet.db.get('seed_type') == 'segwit':
msg = _("Your channels cannot be recovered from seed, because they were created with an old version of Electrum. "
"This means that you must save a backup of your wallet everytime you create a new channel.\n\n"
"If you want this wallet to have recoverable channels, you must close your existing channels and restore this wallet from seed")
else:
msg = _("Your channels cannot be recovered from seed. "
"This means that you must save a backup of your wallet everytime you create a new channel.\n\n"
"If you want to have recoverable channels, you must create a new wallet with an Electrum seed")
else:
if self.wallet.can_have_lightning():
status = _('Not enabled')
else:
status = _("Not available for this wallet.")
return status
def on_lightning_status(self, root):
if self.wallet.has_lightning():
if self.wallet.lnworker.has_deterministic_node_id():
pass
else:
if self.wallet.db.get('seed_type') == 'segwit':
msg = _("Your channels cannot be recovered from seed, because they were created with an old version of Electrum. "
"This means that you must save a backup of your wallet everytime you create a new channel.\n\n"
"If you want this wallet to have recoverable channels, you must close your existing channels and restore this wallet from seed")
else:
msg = _("Your channels cannot be recovered from seed. "
"This means that you must save a backup of your wallet everytime you create a new channel.\n\n"
"If you want to have recoverable channels, you must create a new wallet with an Electrum seed")
self.show_info(msg)
else:
if self.wallet.can_have_lightning():
root.dismiss()
msg = _(
"Warning: this wallet type does not support channel recovery from seed. "
"You will need to backup your wallet everytime you create a new wallet. "
"Create lightning keys?")
d = Question(msg, self._enable_lightning, title=_('Enable Lightning?'))
d.open()
else:
pass
def _enable_lightning(self, b):
if not b:
return
wallet_path = self.get_wallet_path()
self.wallet.init_lightning()
self.show_info(_('Lightning keys have been initialized.'))
self.stop_wallet()
self.load_wallet_by_name(wallet_path)

2
electrum/gui/kivy/uix/dialogs/lightning_channels.py

@ -528,7 +528,7 @@ class ChannelDetailsPopup(Popup, Logger):
to_self_delay = self.chan.config[REMOTE].to_self_delay
help_text = ' '.join([
_('If you force-close this channel, the funds you have in it will not be available for {} blocks.').format(to_self_delay),
_('During that time, funds will not be recoverabe from your seed, and may be lost if you lose your device.'),
_('During that time, funds will not be recoverable from your seed, and may be lost if you lose your device.'),
_('To prevent that, please save this channel backup.'),
_('It may be imported in another wallet with the same seed.')
])

4
electrum/gui/kivy/uix/dialogs/lightning_open_channel.py

@ -9,7 +9,7 @@ from electrum.util import bh2u
from electrum.bitcoin import COIN
import electrum.simple_config as config
from electrum.logging import Logger
from electrum.lnutil import ln_dummy_address
from electrum.lnutil import ln_dummy_address, extract_nodeid
from .label_dialog import LabelDialog
from .confirm_tx_dialog import ConfirmTxDialog
@ -178,9 +178,11 @@ class LightningOpenChannelDialog(Factory.Popup, Logger):
self.dismiss()
lnworker = self.app.wallet.lnworker
coins = self.app.wallet.get_spendable_coins(None, nonlocal_only=True)
node_id, rest = extract_nodeid(conn_str)
make_tx = lambda rbf: lnworker.mktx_for_open_channel(
coins=coins,
funding_sat=amount,
node_id=node_id,
fee_est=None)
on_pay = lambda tx: self.app.protected('Create a new channel?', self.do_open_channel, (tx, conn_str))
d = ConfirmTxDialog(

8
electrum/gui/kivy/uix/dialogs/settings.py

@ -16,6 +16,7 @@ from .choice_dialog import ChoiceDialog
Builder.load_string('''
#:import partial functools.partial
#:import _ electrum.gui.kivy.i18n._
#:import messages electrum.gui.messages
<SettingsDialog@Popup>
id: settings
@ -80,6 +81,13 @@ Builder.load_string('''
description: _('Change your password') if app._use_single_password else _("Change your password for this wallet.")
action: root.change_password
CardSeparator
SettingsItem:
status: _('Yes') if app.use_recoverable_channels else _('No')
title: _('Use recoverable channels') + ': ' + self.status
description: _("Add channel recovery data to funding transaction.")
message: _(messages.MSG_RECOVERABLE_CHANNELS)
action: partial(root.boolean_dialog, 'use_recoverable_channels', _('Use recoverable_channels'), self.message)
CardSeparator
SettingsItem:
status: _('Trampoline') if not app.use_gossip else _('Gossip')
title: _('Lightning Routing') + ': ' + self.status

11
electrum/gui/kivy/uix/ui_screens/status.kv

@ -31,22 +31,21 @@ Popup:
BoxLabel:
text: _("Wallet type:")
value: app.wallet.wallet_type
BoxLabel:
BoxButton:
text: _("Lightning:")
value: (_('Enabled') if app.wallet.has_lightning() else _('Disabled')) if app.wallet.can_have_lightning() else _('Not available')
value: app.lightning_status()
callback: lambda: app.on_lightning_status(root)
BoxLabel:
text: _("Balance") + ':'
value: app.format_amount_and_units(root.confirmed + root.unconfirmed + root.unmatured + root.lightning)
BoxLabel:
text: _("Onchain") + ':'
text: ' - ' + _("Onchain") + ':'
value: app.format_amount_and_units(root.confirmed + root.unconfirmed + root.unmatured)
opacity: 1 if root.lightning else 0
BoxLabel:
text: _("Lightning") + ':'
text: ' - ' + _("Lightning") + ':'
opacity: 1 if root.lightning else 0
value: app.format_amount_and_units(root.lightning)
GridLayout:
cols: 1
height: self.minimum_height

14
electrum/gui/messages.py

@ -0,0 +1,14 @@
# note: qt and kivy use different i18n methods
MSG_RECOVERABLE_CHANNELS = """
Add extra data to your channel funding transactions, so that a static backup can be
recovered from your seed.
Note that static backups only allow you to request a force-close with the remote node.
This assumes that the remote node is still online, did not lose its data, and accepts
to force close the channel.
If this is enabled, other nodes cannot open a channel to you. Channel recovery data
is encrypted, so that only your wallet can decrypt it. However, blockchain analysis
will be able to tell that the transaction was probably created by Electrum.
"""

9
electrum/gui/qt/channels_list.py

@ -345,13 +345,14 @@ class ChannelsList(MyTreeView):
def new_channel_with_warning(self):
if not self.parent.wallet.lnworker.channels:
warning1 = _("Lightning support in Electrum is experimental. "
warning = _("Lightning support in Electrum is experimental. "
"Do not put large amounts in lightning channels.")
warning2 = _("Funds stored in lightning channels are not recoverable from your seed. "
"You must backup your wallet file everytime you create a new channel.")
if not self.parent.wallet.lnworker.has_recoverable_channels():
warning += _("Funds stored in lightning channels are not recoverable from your seed. "
"You must backup your wallet file everytime you create a new channel.")
answer = self.parent.question(
_('Do you want to create your first channel?') + '\n\n' +
_('WARNINGS') + ': ' + '\n\n' + warning1 + '\n\n' + warning2)
_('WARNING') + ': ' + '\n\n' + warning)
if answer:
self.new_channel_dialog()
else:

60
electrum/gui/qt/main_window.py

@ -1796,23 +1796,24 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
WaitingDialog(self, _('Broadcasting transaction...'),
broadcast_thread, broadcast_done, self.on_error)
def mktx_for_open_channel(self, funding_sat):
def mktx_for_open_channel(self, funding_sat, node_id):
coins = self.get_coins(nonlocal_only=True)
make_tx = lambda fee_est: self.wallet.lnworker.mktx_for_open_channel(
coins=coins,
funding_sat=funding_sat,
node_id=node_id,
fee_est=fee_est)
return make_tx
def open_channel(self, connect_str, funding_sat, push_amt):
try:
extract_nodeid(connect_str)
node_id, rest = extract_nodeid(connect_str)
except ConnStringFormatError as e:
self.show_error(str(e))
return
# use ConfirmTxDialog
# we need to know the fee before we broadcast, because the txid is required
make_tx = self.mktx_for_open_channel(funding_sat)
make_tx = self.mktx_for_open_channel(funding_sat, node_id)
d = ConfirmTxDialog(window=self, make_tx=make_tx, output_value=funding_sat, is_sweep=False)
# disable preview button because the user must not broadcast tx before establishment_flow
d.preview_button.setEnabled(False)
@ -2365,9 +2366,17 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
if d.exec_():
self.set_contact(line2.text(), line1.text())
def init_lightning_dialog(self):
if self.question(_(
"Warning: this wallet type does not support channel recovery from seed. "
"You will need to backup your wallet everytime you create a new wallet. "
"Create lightning keys?")):
self.wallet.init_lightning()
self.show_message("Lightning keys created. Please restart Electrum")
def show_wallet_info(self):
dialog = WindowModalDialog(self, _("Wallet Information"))
dialog.setMinimumSize(500, 100)
dialog.setMinimumSize(800, 100)
vbox = QVBoxLayout()
wallet_type = self.wallet.db.get('wallet_type', '')
if self.wallet.is_watching_only():
@ -2390,15 +2399,42 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
grid.addWidget(QLabel(ks_type), 4, 1)
# lightning
grid.addWidget(QLabel(_('Lightning') + ':'), 5, 0)
if self.wallet.can_have_lightning():
grid.addWidget(QLabel(_('Enabled')), 5, 1)
local_nodeid = QLabel(bh2u(self.wallet.lnworker.node_keypair.pubkey))
local_nodeid.setTextInteractionFlags(Qt.TextSelectableByMouse)
grid.addWidget(QLabel(_('Lightning Node ID:')), 6, 0)
grid.addWidget(local_nodeid, 6, 1, 1, 3)
from .util import IconLabel
if self.wallet.has_lightning():
if self.wallet.lnworker.has_deterministic_node_id():
grid.addWidget(QLabel(_('Enabled')), 5, 1)
else:
label = IconLabel(text='Enabled, non-recoverable channels')
label.setIcon(read_QIcon('warning.png'))
grid.addWidget(label, 5, 1)
if self.wallet.db.get('seed_type') == 'segwit':
msg = _("Your channels cannot be recovered from seed, because they were created with an old version of Electrum. "
"This means that you must save a backup of your wallet everytime you create a new channel.\n\n"
"If you want this wallet to have recoverable channels, you must close your existing channels and restore this wallet from seed")
else:
msg = _("Your channels cannot be recovered from seed. "
"This means that you must save a backup of your wallet everytime you create a new channel.\n\n"
"If you want to have recoverable channels, you must create a new wallet with an Electrum seed")
grid.addWidget(HelpButton(msg), 5, 3)
grid.addWidget(QLabel(_('Lightning Node ID:')), 7, 0)
# TODO: ButtonsLineEdit should have a addQrButton method
nodeid_text = self.wallet.lnworker.node_keypair.pubkey.hex()
nodeid_e = ButtonsLineEdit(nodeid_text)
qr_icon = "qrcode_white.png" if ColorScheme.dark_scheme else "qrcode.png"
nodeid_e.addButton(qr_icon, lambda: self.show_qrcode(nodeid_text, _("Node ID")), _("Show QR Code"))
nodeid_e.addCopyButton(self.app)
nodeid_e.setReadOnly(True)
nodeid_e.setFont(QFont(MONOSPACE_FONT))
grid.addWidget(nodeid_e, 8, 0, 1, 4)
else:
grid.addWidget(QLabel(_("Not available for this wallet.")), 5, 1)
grid.addWidget(HelpButton(_("Lightning is currently restricted to HD wallets with p2wpkh addresses.")), 5, 2)
if self.wallet.can_have_lightning():
grid.addWidget(QLabel('Not enabled'), 5, 1)
button = QPushButton(_("Enable"))
button.pressed.connect(self.init_lightning_dialog)
grid.addWidget(button, 5, 3)
else:
grid.addWidget(QLabel(_("Not available for this wallet.")), 5, 1)
grid.addWidget(HelpButton(_("Lightning is currently restricted to HD wallets with p2wpkh addresses.")), 5, 2)
vbox.addLayout(grid)
labels_clayout = None

12
electrum/gui/qt/settings_dialog.py

@ -41,6 +41,7 @@ from .util import (ColorScheme, WindowModalDialog, HelpLabel, Buttons,
from electrum.i18n import languages
from electrum import qrscanner
from electrum.gui import messages
if TYPE_CHECKING:
from electrum.simple_config import SimpleConfig
@ -130,6 +131,17 @@ class SettingsDialog(WindowModalDialog):
# lightning
lightning_widgets = []
if self.wallet.lnworker and self.wallet.lnworker.has_deterministic_node_id():
help_recov = _(messages.MSG_RECOVERABLE_CHANNELS)
recov_cb = QCheckBox(_("Create recoverable channels"))
recov_cb.setToolTip(help_recov)
recov_cb.setChecked(bool(self.config.get('use_recoverable_channels', True)))
def on_recov_checked(x):
self.config.set_key('use_recoverable_channels', bool(x))
recov_cb.stateChanged.connect(on_recov_checked)
recov_cb.setEnabled(not bool(self.config.get('lightning_listen')))
lightning_widgets.append((recov_cb, None))
help_gossip = _("""If this option is enabled, Electrum will download the network
channels graph and compute payment path locally, instead of using trampoline payments. """)
gossip_cb = QCheckBox(_("Download network graph"))

5
electrum/keystore.py

@ -613,6 +613,11 @@ class BIP32_KeyStore(Xpub, Deterministic_KeyStore):
cK = ecc.ECPrivkey(k).get_public_key_bytes()
return cK, k
def get_lightning_xprv(self, password):
xprv = self.get_master_private_key(password)
rootnode = BIP32Node.from_xkey(xprv)
node = rootnode.subkey_at_private_derivation("m/67'/")
return node.to_xprv()
class Old_KeyStore(MasterPublicKeyMixin, Deterministic_KeyStore):

36
electrum/lnchannel.py

@ -59,7 +59,7 @@ from .lnhtlc import HTLCManager
from .lnmsg import encode_msg, decode_msg
from .address_synchronizer import TX_HEIGHT_LOCAL
from .lnutil import CHANNEL_OPENING_TIMEOUT
from .lnutil import ChannelBackupStorage
from .lnutil import ChannelBackupStorage, ImportedChannelBackupStorage
from .lnutil import format_short_channel_id
if TYPE_CHECKING:
@ -301,9 +301,15 @@ class AbstractChannel(Logger, ABC):
if conf > 0:
self.set_state(ChannelState.CLOSED)
else:
# we must not trust the server with unconfirmed transactions
# if the remote force closed, we remain OPEN until the closing tx is confirmed
pass
if not self.is_backup():
# we must not trust the server with unconfirmed transactions,
# because the state transition is irreversible. if the remote
# force closed, we remain OPEN until the closing tx is confirmed
pass
else:
# for a backup, that state change will only affect the GUI
self.set_state(ChannelState.FORCE_CLOSING)
if self.get_state() == ChannelState.CLOSED and not keep_watching:
self.set_state(ChannelState.REDEEMED)
@ -400,11 +406,21 @@ class ChannelBackup(AbstractChannel):
self.name = None
Logger.__init__(self)
self.cb = cb
self.is_imported = isinstance(self.cb, ImportedChannelBackupStorage)
self._sweep_info = {}
self._fallback_sweep_address = sweep_address
self.storage = {} # dummy storage
self._state = ChannelState.OPENING
self.node_id = cb.node_id if self.is_imported else cb.node_id_prefix
self.channel_id = cb.channel_id()
self.funding_outpoint = cb.funding_outpoint()
self.lnworker = lnworker
self.short_channel_id = None
self.config = {}
if self.is_imported:
self.init_config(cb)
def init_config(self, cb):
self.config[LOCAL] = LocalConfig.from_seed(
channel_seed=cb.channel_seed,
to_self_delay=cb.local_delay,
@ -440,11 +456,6 @@ class ChannelBackup(AbstractChannel):
next_per_commitment_point=None,
current_per_commitment_point=None,
upfront_shutdown_script='')
self.node_id = cb.node_id
self.channel_id = cb.channel_id()
self.funding_outpoint = cb.funding_outpoint()
self.lnworker = lnworker
self.short_channel_id = None
def get_capacity(self):
return self.lnworker.lnwatcher.get_tx_delta(self.funding_outpoint.txid, self.cb.funding_address)
@ -455,6 +466,13 @@ class ChannelBackup(AbstractChannel):
def create_sweeptxs_for_their_ctx(self, ctx):
return {}
def create_sweeptxs_for_our_ctx(self, ctx):
if self.is_imported:
return create_sweeptxs_for_our_ctx(chan=self, ctx=ctx, sweep_address=self.sweep_address)
else:
# backup from op_return
return {}
def get_funding_address(self):
return self.cb.funding_address

17
electrum/lnpeer.py

@ -24,6 +24,7 @@ from . import constants
from .util import (bh2u, bfh, log_exceptions, ignore_exceptions, chunks, SilentTaskGroup,
UnrelatedTransactionException)
from . import transaction
from .bitcoin import make_op_return
from .transaction import PartialTxOutput, match_script_against_template
from .logging import Logger
from .lnonion import (new_onion_packet, OnionFailureCode, calc_hops_data_for_payment,
@ -681,6 +682,19 @@ class Peer(Logger):
funding_tx._outputs.remove(dummy_output)
if dummy_output in funding_tx.outputs(): raise Exception("LN dummy output (err 2)")
funding_tx.add_outputs([funding_output])
# find and encrypt op_return data associated to funding_address
if self.lnworker and self.lnworker.has_recoverable_channels():
backup_data = self.lnworker.cb_data(self.pubkey)
dummy_scriptpubkey = make_op_return(backup_data)
for o in funding_tx.outputs():
if o.scriptpubkey == dummy_scriptpubkey:
encrypted_data = self.lnworker.encrypt_cb_data(backup_data, funding_address)
assert len(encrypted_data) == len(backup_data)
o.scriptpubkey = make_op_return(encrypted_data)
break
else:
raise Exception('op_return output not found in funding tx')
# must not be malleable
funding_tx.set_rbf(False)
if not funding_tx.is_segwit():
raise Exception('Funding transaction is not segwit')
@ -755,6 +769,9 @@ class Peer(Logger):
Channel configurations are initialized in this method.
"""
if self.lnworker.has_recoverable_channels():
# FIXME: we might want to keep the connection open
raise Exception('not accepting channels')
# <- open_channel
if payload['chain_hash'] != constants.net.rev_genesis_bytes():
raise Exception('wrong chain_hash')

29
electrum/lnutil.py

@ -169,21 +169,13 @@ class ChannelConstraints(StoredObject):
CHANNEL_BACKUP_VERSION = 0
@attr.s
class ChannelBackupStorage(StoredObject):
node_id = attr.ib(type=bytes, converter=hex_to_bytes)
privkey = attr.ib(type=bytes, converter=hex_to_bytes)
funding_txid = attr.ib(type=str)
funding_index = attr.ib(type=int, converter=int)
funding_address = attr.ib(type=str)
host = attr.ib(type=str)
port = attr.ib(type=int, converter=int)
is_initiator = attr.ib(type=bool)
channel_seed = attr.ib(type=bytes, converter=hex_to_bytes)
local_delay = attr.ib(type=int, converter=int)
remote_delay = attr.ib(type=int, converter=int)
remote_payment_pubkey = attr.ib(type=bytes, converter=hex_to_bytes)
remote_revocation_pubkey = attr.ib(type=bytes, converter=hex_to_bytes)
def funding_outpoint(self):
return Outpoint(self.funding_txid, self.funding_index)
@ -192,6 +184,22 @@ class ChannelBackupStorage(StoredObject):
chan_id, _ = channel_id_from_funding_tx(self.funding_txid, self.funding_index)
return chan_id
@attr.s
class OnchainChannelBackupStorage(ChannelBackupStorage):
node_id_prefix = attr.ib(type=bytes, converter=hex_to_bytes)
@attr.s
class ImportedChannelBackupStorage(ChannelBackupStorage):
node_id = attr.ib(type=bytes, converter=hex_to_bytes)
privkey = attr.ib(type=bytes, converter=hex_to_bytes)
host = attr.ib(type=str)
port = attr.ib(type=int, converter=int)
channel_seed = attr.ib(type=bytes, converter=hex_to_bytes)
local_delay = attr.ib(type=int, converter=int)
remote_delay = attr.ib(type=int, converter=int)
remote_payment_pubkey = attr.ib(type=bytes, converter=hex_to_bytes)
remote_revocation_pubkey = attr.ib(type=bytes, converter=hex_to_bytes)
def to_bytes(self) -> bytes:
vds = BCDataStream()
vds.write_int16(CHANNEL_BACKUP_VERSION)
@ -217,7 +225,7 @@ class ChannelBackupStorage(StoredObject):
version = vds.read_int16()
if version != CHANNEL_BACKUP_VERSION:
raise Exception(f"unknown version for channel backup: {version}")
return ChannelBackupStorage(
return ImportedChannelBackupStorage(
is_initiator = vds.read_boolean(),
privkey = vds.read_bytes(32).hex(),
channel_seed = vds.read_bytes(32).hex(),
@ -1245,6 +1253,7 @@ class LnKeyFamily(IntEnum):
DELAY_BASE = 4 | BIP32_PRIME
REVOCATION_ROOT = 5 | BIP32_PRIME
NODE_KEY = 6
BACKUP_CIPHER = 7 | BIP32_PRIME
def generate_keypair(node: BIP32Node, key_family: LnKeyFamily) -> Keypair:

129
electrum/lnworker.py

@ -32,10 +32,13 @@ from .util import NetworkRetryManager, JsonRPCClient
from .lnutil import LN_MAX_FUNDING_SAT
from .keystore import BIP32_KeyStore
from .bitcoin import COIN
from .bitcoin import opcodes, make_op_return, address_to_script
from .transaction import Transaction
from .transaction import get_script_type_from_output_script
from .crypto import sha256
from .bip32 import BIP32Node
from .util import bh2u, bfh, InvoiceError, resolve_dns_srv, is_ip_address, log_exceptions
from .crypto import chacha20_encrypt, chacha20_decrypt
from .util import ignore_exceptions, make_aiohttp_session, SilentTaskGroup
from .util import timestamp_to_datetime, random_shuffled_copy
from .util import MyEncoder, is_private_netaddress
@ -71,7 +74,7 @@ from .address_synchronizer import TX_HEIGHT_LOCAL
from . import lnsweep
from .lnwatcher import LNWalletWatcher
from .crypto import pw_encode_with_version_and_mac, pw_decode_with_version_and_mac
from .lnutil import ChannelBackupStorage
from .lnutil import ImportedChannelBackupStorage, OnchainChannelBackupStorage
from .lnchannel import ChannelBackup
from .channel_db import UpdateStatus
from .channel_db import get_mychannel_info, get_mychannel_policy
@ -92,6 +95,10 @@ SAVED_PR_STATUS = [PR_PAID, PR_UNPAID] # status that are persisted
NUM_PEERS_TARGET = 4
# onchain channel backup data
CB_VERSION = 0
CB_MAGIC_BYTES = bytes([0, 0, 0, CB_VERSION])
FALLBACK_NODE_LIST_TESTNET = (
LNPeerAddr(host='203.132.95.10', port=9735, pubkey=bfh('038863cf8ab91046230f561cd5b386cbff8309fa02e3f0c3ed161a3aeb64a643b9')),
@ -189,6 +196,7 @@ class LNWorker(Logger, NetworkRetryManager[LNPeerAddr]):
)
self.lock = threading.RLock()
self.node_keypair = generate_keypair(BIP32Node.from_xkey(xprv), LnKeyFamily.NODE_KEY)
self.backup_key = generate_keypair(BIP32Node.from_xkey(xprv), LnKeyFamily.BACKUP_CIPHER).privkey
self._peers = {} # type: Dict[bytes, Peer] # pubkey -> Peer # needs self.lock
self.taskgroup = SilentTaskGroup()
self.listen_server = None # type: Optional[asyncio.AbstractServer]
@ -612,9 +620,11 @@ class LNWallet(LNWorker):
self._channels[bfh(channel_id)] = Channel(c, sweep_address=self.sweep_address, lnworker=self)
self._channel_backups = {} # type: Dict[bytes, ChannelBackup]
channel_backups = self.db.get_dict("channel_backups")
for channel_id, cb in random_shuffled_copy(channel_backups.items()):
self._channel_backups[bfh(channel_id)] = ChannelBackup(cb, sweep_address=self.sweep_address, lnworker=self)
# order is important: imported should overwrite onchain
for name in ["onchain_channel_backups", "imported_channel_backups"]:
channel_backups = self.db.get_dict(name)
for channel_id, storage in channel_backups.items():
self._channel_backups[bfh(channel_id)] = ChannelBackup(storage, sweep_address=self.sweep_address, lnworker=self)
self.sent_htlcs = defaultdict(asyncio.Queue) # type: Dict[bytes, asyncio.Queue[HtlcLog]]
self.sent_htlcs_routes = dict() # (RHASH, scid, htlc_id) -> route, payment_secret, amount_msat, bucket_msat
@ -629,6 +639,15 @@ class LNWallet(LNWorker):
self.trampoline_forwarding_failures = {} # todo: should be persisted
def has_deterministic_node_id(self):
return bool(self.db.get('lightning_xprv'))
def has_recoverable_channels(self):
# TODO: expose use_recoverable_channels in preferences
return self.has_deterministic_node_id() \
and self.config.get('use_recoverable_channels', True) \
and not (self.config.get('lightning_listen'))
@property
def channels(self) -> Mapping[bytes, Channel]:
"""Returns a read-only copy of channels."""
@ -990,13 +1009,29 @@ class LNWallet(LNWorker):
self.remove_channel(chan.channel_id)
raise
def cb_data(self, node_id):
return CB_MAGIC_BYTES + node_id[0:16]
def decrypt_cb_data(self, encrypted_data, funding_address):
funding_scriptpubkey = bytes.fromhex(address_to_script(funding_address))
nonce = funding_scriptpubkey[0:12]
return chacha20_decrypt(key=self.backup_key, data=encrypted_data, nonce=nonce)
def encrypt_cb_data(self, data, funding_address):
funding_scriptpubkey = bytes.fromhex(address_to_script(funding_address))
nonce = funding_scriptpubkey[0:12]
return chacha20_encrypt(key=self.backup_key, data=data, nonce=nonce)
def mktx_for_open_channel(
self, *,
coins: Sequence[PartialTxInput],
funding_sat: int,
node_id: bytes,
fee_est=None) -> PartialTransaction:
dummy_address = ln_dummy_address()
outputs = [PartialTxOutput.from_address_and_value(dummy_address, funding_sat)]
outputs = [PartialTxOutput.from_address_and_value(ln_dummy_address(), funding_sat)]
if self.has_recoverable_channels():
dummy_scriptpubkey = make_op_return(self.cb_data(node_id))
outputs.append(PartialTxOutput(scriptpubkey=dummy_scriptpubkey, value=0))
tx = self.wallet.make_unsigned_transaction(
coins=coins,
outputs=outputs,
@ -1986,7 +2021,7 @@ class LNWallet(LNWorker):
assert chan.is_static_remotekey_enabled()
peer_addresses = list(chan.get_peer_addresses())
peer_addr = peer_addresses[0]
return ChannelBackupStorage(
return ImportedChannelBackupStorage(
node_id = chan.node_id,
privkey = self.node_keypair.privkey,
funding_txid = chan.funding_outpoint.txid,
@ -2004,7 +2039,7 @@ class LNWallet(LNWorker):
def export_channel_backup(self, channel_id):
xpub = self.wallet.get_fingerprint()
backup_bytes = self.create_channel_backup(channel_id).to_bytes()
assert backup_bytes == ChannelBackupStorage.from_bytes(backup_bytes).to_bytes(), "roundtrip failed"
assert backup_bytes == ImportedChannelBackupStorage.from_bytes(backup_bytes).to_bytes(), "roundtrip failed"
encrypted = pw_encode_with_version_and_mac(backup_bytes, xpub)
assert backup_bytes == pw_decode_with_version_and_mac(encrypted, xpub), "encrypt failed"
return 'channel_backup:' + encrypted
@ -2030,22 +2065,22 @@ class LNWallet(LNWorker):
encrypted = data[15:]
xpub = self.wallet.get_fingerprint()
decrypted = pw_decode_with_version_and_mac(encrypted, xpub)
cb_storage = ChannelBackupStorage.from_bytes(decrypted)
cb_storage = ImportedChannelBackupStorage.from_bytes(decrypted)
channel_id = cb_storage.channel_id()
if channel_id.hex() in self.db.get_dict("channels"):
raise Exception('Channel already in wallet')
self.logger.info(f'importing channel backup: {channel_id.hex()}')
cb = ChannelBackup(cb_storage, sweep_address=self.sweep_address, lnworker=self)
d = self.db.get_dict("channel_backups")
d = self.db.get_dict("imported_channel_backups")
d[channel_id.hex()] = cb_storage
with self.lock:
cb = ChannelBackup(cb_storage, sweep_address=self.sweep_address, lnworker=self)
self._channel_backups[channel_id] = cb
self.wallet.save_db()
util.trigger_callback('channels_updated', self.wallet)
self.lnwatcher.add_channel(cb.funding_outpoint.to_str(), cb.get_funding_address())
def remove_channel_backup(self, channel_id):
d = self.db.get_dict("channel_backups")
d = self.db.get_dict("imported_channel_backups")
if channel_id.hex() not in d:
raise Exception('Channel not found')
with self.lock:
@ -2061,11 +2096,65 @@ class LNWallet(LNWorker):
raise Exception(f'channel backup not found {self.channel_backups}')
cb = cb.cb # storage
self.logger.info(f'requesting channel force close: {channel_id.hex()}')
# TODO also try network addresses from gossip db (as it might have changed)
peer_addr = LNPeerAddr(cb.host, cb.port, cb.node_id)
transport = LNTransport(cb.privkey, peer_addr, proxy=self.network.proxy)
peer = Peer(self, cb.node_id, transport, is_channel_backup=True)
async with TaskGroup(wait=any) as group:
await group.spawn(peer._message_loop())
await group.spawn(peer.trigger_force_close(channel_id))
return True
if isinstance(cb, ImportedChannelBackupStorage):
node_id = cb.node_id
addresses = [(cb.host, cb.port, 0)]
# TODO also try network addresses from gossip db (as it might have changed)
else:
assert isinstance(cb, OnchainChannelBackupStorage)
if not self.channel_db:
raise Exception('Enable gossip first')
node_id = self.network.channel_db.get_node_by_prefix(cb.node_id_prefix)
addresses = self.network.channel_db.get_node_addresses(node_id)
if not addresses:
raise Exception('Peer not found in gossip database')
for host, port, timestamp in addresses:
peer_addr = LNPeerAddr(host, port, node_id)
transport = LNTransport(self.node_keypair.privkey, peer_addr, proxy=self.network.proxy)
peer = Peer(self, node_id, transport, is_channel_backup=True)
try:
async with TaskGroup(wait=any) as group:
await group.spawn(peer._message_loop())
await group.spawn(peer.trigger_force_close(channel_id))
return
except Exception as e:
self.logger.info(f'failed to connect {host} {e}')
continue
else:
raise Exception('failed to connect')
def maybe_add_backup_from_tx(self, tx):
funding_address = None
node_id_prefix = None
for i, o in enumerate(tx.outputs()):
script_type = get_script_type_from_output_script(o.scriptpubkey)
if script_type == 'p2wsh':
funding_index = i
funding_address = o.address
for o2 in tx.outputs():
if o2.scriptpubkey.startswith(bytes([opcodes.OP_RETURN])):
encrypted_data = o2.scriptpubkey[2:]
data = self.decrypt_cb_data(encrypted_data, funding_address)
if data.startswith(CB_MAGIC_BYTES):
node_id_prefix = data[4:]
if node_id_prefix is None:
return
funding_txid = tx.txid()
cb_storage = OnchainChannelBackupStorage(
node_id_prefix = node_id_prefix,
funding_txid = funding_txid,
funding_index = funding_index,
funding_address = funding_address,
is_initiator = True)
channel_id = cb_storage.channel_id().hex()
if channel_id in self.db.get_dict("channels"):
return
self.logger.info(f"adding backup from tx")
d = self.db.get_dict("onchain_channel_backups")
d[channel_id] = cb_storage
cb = ChannelBackup(cb_storage, sweep_address=self.sweep_address, lnworker=self)
self.wallet.save_db()
with self.lock:
self._channel_backups[bfh(channel_id)] = cb
util.trigger_callback('channels_updated', self.wallet)
self.lnwatcher.add_channel(cb.funding_outpoint.to_str(), cb.get_funding_address())

2
electrum/tests/regtest/regtest.sh

@ -141,7 +141,6 @@ if [[ $1 == "backup" ]]; then
echo "channel point: $channel"
new_blocks 3
wait_until_channel_open alice
backup=$($alice export_channel_backup $channel)
request=$($bob add_lightning_request 0.01 -m "blah" | jq -r ".invoice")
echo "alice pays"
$alice lnpay $request
@ -151,7 +150,6 @@ if [[ $1 == "backup" ]]; then
$alice -o restore "$seed"
$alice daemon -d
$alice load_wallet
$alice import_channel_backup $backup
$alice request_force_close $channel
wait_for_balance alice 0.989
fi

2
electrum/tests/test_wallet.py

@ -167,7 +167,7 @@ class TestCreateRestoreWallet(WalletTestCase):
wallet = d['wallet'] # type: Standard_Wallet
# lightning initialization
self.assertTrue(wallet.db.get('lightning_privkey2').startswith('xprv'))
self.assertTrue(wallet.db.get('lightning_xprv').startswith('zprv'))
wallet.check_password(password)
self.assertEqual(passphrase, wallet.keystore.get_passphrase(password))

16
electrum/wallet.py

@ -326,6 +326,7 @@ class Abstract_Wallet(AddressSynchronizer, ABC):
for chan_id, chan in self.lnworker.channels.items():
channel_backups[chan_id.hex()] = self.lnworker.create_channel_backup(chan_id)
new_db.put('channels', None)
new_db.put('lightning_xprv', None)
new_db.put('lightning_privkey2', None)
new_path = os.path.join(backup_dir, self.basename() + '.backup')
@ -345,6 +346,7 @@ class Abstract_Wallet(AddressSynchronizer, ABC):
def init_lightning(self):
assert self.can_have_lightning()
assert self.db.get('lightning_xprv') is None
if self.db.get('lightning_privkey2'):
return
# TODO derive this deterministically from wallet.keystore at keystore generation time
@ -887,9 +889,10 @@ class Abstract_Wallet(AddressSynchronizer, ABC):
def add_transaction(self, tx, *, allow_unrelated=False):
tx_was_added = super().add_transaction(tx, allow_unrelated=allow_unrelated)
if tx_was_added:
self._maybe_set_tx_label_based_on_invoices(tx)
if self.lnworker:
self.lnworker.maybe_add_backup_from_tx(tx)
return tx_was_added
@profiler
@ -2758,11 +2761,8 @@ class Deterministic_Wallet(Abstract_Wallet):
# generate addresses now. note that without libsecp this might block
# for a few seconds!
self.synchronize()
# create lightning keys
if self.can_have_lightning():
self.init_lightning()
ln_xprv = self.db.get('lightning_privkey2')
# lightning_privkey2 is not deterministic (legacy wallets, bip39)
ln_xprv = self.db.get('lightning_xprv') or self.db.get('lightning_privkey2')
# lnworker can only be initialized once receiving addresses are available
# therefore we instantiate lnworker in DeterministicWallet
self.lnworker = LNWallet(self, ln_xprv) if ln_xprv else None
@ -3143,6 +3143,8 @@ def create_new_wallet(*, path, config: SimpleConfig, passphrase=None, password=N
k = keystore.from_seed(seed, passphrase)
db.put('keystore', k.dump())
db.put('wallet_type', 'standard')
if keystore.seed_type(seed) == 'segwit':
db.put('lightning_xprv', k.get_lightning_xprv(None))
if gap_limit is not None:
db.put('gap_limit', gap_limit)
wallet = Wallet(db, storage, config=config)
@ -3185,6 +3187,8 @@ def restore_wallet_from_text(text, *, path, config: SimpleConfig,
k = keystore.from_master_key(text)
elif keystore.is_seed(text):
k = keystore.from_seed(text, passphrase)
if keystore.seed_type(text) == 'segwit':
db.put('lightning_xprv', k.get_lightning_xprv(None))
else:
raise Exception("Seed or key not recognized")
db.put('keystore', k.dump())

19
electrum/wallet_db.py

@ -37,7 +37,8 @@ from .invoices import PR_TYPE_ONCHAIN, Invoice
from .keystore import bip44_derivation
from .transaction import Transaction, TxOutpoint, tx_from_any, PartialTransaction, PartialTxOutput
from .logging import Logger
from .lnutil import LOCAL, REMOTE, FeeUpdate, UpdateAddHtlc, LocalConfig, RemoteConfig, Keypair, OnlyPubkeyKeypair, RevocationStore, ChannelBackupStorage
from .lnutil import LOCAL, REMOTE, FeeUpdate, UpdateAddHtlc, LocalConfig, RemoteConfig, Keypair, OnlyPubkeyKeypair, RevocationStore
from .lnutil import ImportedChannelBackupStorage, OnchainChannelBackupStorage
from .lnutil import ChannelConstraints, Outpoint, ShachainElement
from .json_db import StoredDict, JsonDB, locked, modifier
from .plugin import run_hook, plugin_loaders
@ -52,7 +53,7 @@ if TYPE_CHECKING:
OLD_SEED_VERSION = 4 # electrum versions < 2.0
NEW_SEED_VERSION = 11 # electrum versions >= 2.0
FINAL_SEED_VERSION = 38 # electrum >= 2.7 will set this to prevent
FINAL_SEED_VERSION = 39 # electrum >= 2.7 will set this to prevent
# old versions from overwriting new format
@ -186,6 +187,7 @@ class WalletDB(JsonDB):
self._convert_version_36()
self._convert_version_37()
self._convert_version_38()
self._convert_version_39()
self.put('seed_version', FINAL_SEED_VERSION) # just to be sure
self._after_upgrade_tasks()
@ -778,6 +780,13 @@ class WalletDB(JsonDB):
del d[key]
self.data['seed_version'] = 38
def _convert_version_39(self):
# this upgrade prevents initialization of lightning_privkey2 after lightning_xprv has been set
if not self._is_upgrade_method_needed(38, 38):
return
self.data['imported_channel_backups'] = self.data.pop('channel_backups', {})
self.data['seed_version'] = 39
def _convert_imported(self):
if not self._is_upgrade_method_needed(0, 13):
return
@ -1273,8 +1282,10 @@ class WalletDB(JsonDB):
v = dict((k, FeeUpdate(**x)) for k, x in v.items())
elif key == 'submarine_swaps':
v = dict((k, SwapData(**x)) for k, x in v.items())
elif key == 'channel_backups':
v = dict((k, ChannelBackupStorage(**x)) for k, x in v.items())
elif key == 'imported_channel_backups':
v = dict((k, ImportedChannelBackupStorage(**x)) for k, x in v.items())
elif key == 'onchain_channel_backups':
v = dict((k, OnchainChannelBackupStorage(**x)) for k, x in v.items())
elif key == 'tx_fees':
v = dict((k, TxFeesValue(*x)) for k, x in v.items())
elif key == 'prevouts_by_scripthash':

Loading…
Cancel
Save