Browse Source

Replace wallet backup with channel backups

- channels can be backed up individually
 - backups are added to lnwatcher
 - AbstractChannel ancestor class
hard-fail-on-bad-server-string
ThomasV 5 years ago
parent
commit
8f41aeb783
  1. 10
      electrum/commands.py
  2. 20
      electrum/gui/kivy/main_window.py
  3. 126
      electrum/gui/kivy/uix/dialogs/lightning_channels.py
  4. 1
      electrum/gui/qt/__init__.py
  5. 51
      electrum/gui/qt/channels_list.py
  6. 59
      electrum/gui/qt/main_window.py
  7. 14
      electrum/gui/qt/settings_dialog.py
  8. 402
      electrum/lnchannel.py
  9. 26
      electrum/lnpeer.py
  10. 2
      electrum/lnsweep.py
  11. 70
      electrum/lnutil.py
  12. 1
      electrum/lnwatcher.py
  13. 108
      electrum/lnworker.py
  14. 4
      electrum/transaction.py
  15. 23
      electrum/wallet.py
  16. 4
      electrum/wallet_db.py

10
electrum/commands.py

@ -1050,6 +1050,16 @@ class Commands:
coro = wallet.lnworker.force_close_channel(chan_id) if force else wallet.lnworker.close_channel(chan_id)
return await coro
@command('w')
async def export_channel_backup(self, channel_point, wallet: Abstract_Wallet = None):
txid, index = channel_point.split(':')
chan_id, _ = channel_id_from_funding_tx(txid, int(index))
return wallet.lnworker.export_channel_backup(chan_id)
@command('w')
async def import_channel_backup(self, encrypted, wallet: Abstract_Wallet = None):
return wallet.lnworker.import_channel_backup(encrypted)
@command('wn')
async def get_channel_ctx(self, channel_point, iknowwhatimdoing=False, wallet: Abstract_Wallet = None):
""" return the current commitment transaction of a channel """

20
electrum/gui/kivy/main_window.py

@ -408,6 +408,9 @@ class ElectrumWindow(App):
if data.startswith('bitcoin:'):
self.set_URI(data)
return
if data.startswith('channel_backup:'):
self.import_channel_backup(data[15:])
return
bolt11_invoice = maybe_extract_bolt11_invoice(data)
if bolt11_invoice is not None:
self.set_ln_invoice(bolt11_invoice)
@ -727,9 +730,6 @@ class ElectrumWindow(App):
d.open()
def lightning_channels_dialog(self):
if not self.wallet.has_lightning():
self.show_error('Lightning not enabled on this wallet')
return
if self._channels_dialog is None:
self._channels_dialog = LightningChannelsDialog(self)
self._channels_dialog.open()
@ -1303,3 +1303,17 @@ class ElectrumWindow(App):
self.show_error("Invalid PIN")
return
self.protected(_("Enter your PIN code in order to decrypt your private key"), show_private_key, (addr, pk_label))
def import_channel_backup(self, encrypted):
d = Question(_('Import Channel Backup?'), lambda b: self._import_channel_backup(b, encrypted))
d.open()
def _import_channel_backup(self, b, encrypted):
if not b:
return
try:
self.wallet.lnbackups.import_channel_backup(encrypted)
except Exception as e:
self.show_error("failed to import backup" + '\n' + str(e))
return
self.lightning_channels_dialog()

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

@ -198,9 +198,118 @@ Builder.load_string(r'''
text: _('Delete')
on_release: root.remove_channel()
disabled: not root.is_redeemed
<ChannelBackupPopup@Popup>:
id: popuproot
data: []
is_closed: False
is_redeemed: False
node_id:''
short_id:''
initiator:''
capacity:''
funding_txid:''
closing_txid:''
state:''
is_open:False
BoxLayout:
padding: '12dp', '12dp', '12dp', '12dp'
spacing: '12dp'
orientation: 'vertical'
ScrollView:
scroll_type: ['bars', 'content']
scroll_wheel_distance: dp(114)
BoxLayout:
orientation: 'vertical'
height: self.minimum_height
size_hint_y: None
spacing: '5dp'
BoxLabel:
text: _('Channel ID')
value: root.short_id
BoxLabel:
text: _('State')
value: root.state
BoxLabel:
text: _('Initiator')
value: root.initiator
BoxLabel:
text: _('Capacity')
value: root.capacity
Widget:
size_hint: 1, 0.1
TopLabel:
text: _('Remote Node ID')
TxHashLabel:
data: root.node_id
name: _('Remote Node ID')
TopLabel:
text: _('Funding Transaction')
TxHashLabel:
data: root.funding_txid
name: _('Funding Transaction')
touch_callback: lambda: app.show_transaction(root.funding_txid)
TopLabel:
text: _('Closing Transaction')
opacity: int(bool(root.closing_txid))
TxHashLabel:
opacity: int(bool(root.closing_txid))
data: root.closing_txid
name: _('Closing Transaction')
touch_callback: lambda: app.show_transaction(root.closing_txid)
Widget:
size_hint: 1, 0.1
Widget:
size_hint: 1, 0.05
BoxLayout:
size_hint: 1, None
height: '48dp'
Button:
size_hint: 0.5, None
height: '48dp'
text: _('Request force-close')
on_release: root.request_force_close()
disabled: root.is_closed
Button:
size_hint: 0.5, None
height: '48dp'
text: _('Delete')
on_release: root.remove_backup()
''')
class ChannelBackupPopup(Popup):
def __init__(self, chan, app, **kwargs):
super(ChannelBackupPopup,self).__init__(**kwargs)
self.chan = chan
self.app = app
def request_force_close(self):
msg = _('Request force close?')
Question(msg, self._request_force_close).open()
def _request_force_close(self, b):
if not b:
return
loop = self.app.wallet.network.asyncio_loop
coro = asyncio.run_coroutine_threadsafe(self.app.wallet.lnbackups.request_force_close(self.chan.channel_id), loop)
try:
coro.result(5)
self.app.show_info(_('Channel closed'))
except Exception as e:
self.app.show_info(_('Could not close channel: ') + repr(e)) # repr because str(Exception()) == ''
def remove_backup(self):
msg = _('Delete backup?')
Question(msg, self._remove_backup).open()
def _remove_backup(self, b):
if not b:
return
self.app.wallet.lnbackups.remove_channel_backup(self.chan.channel_id)
self.dismiss()
class ChannelDetailsPopup(Popup):
def __init__(self, chan, app, **kwargs):
@ -282,7 +391,11 @@ class LightningChannelsDialog(Factory.Popup):
self.update()
def show_item(self, obj):
p = ChannelDetailsPopup(obj._chan, self.app)
chan = obj._chan
if chan.is_backup():
p = ChannelBackupPopup(chan, self.app)
else:
p = ChannelDetailsPopup(chan, self.app)
p.open()
def format_fields(self, chan):
@ -305,7 +418,7 @@ class LightningChannelsDialog(Factory.Popup):
def update_item(self, item):
chan = item._chan
item.status = chan.get_state_for_GUI()
item.short_channel_id = format_short_channel_id(chan.short_channel_id)
item.short_channel_id = chan.short_id_for_GUI()
l, r = self.format_fields(chan)
item.local_balance = _('Local') + ':' + l
item.remote_balance = _('Remote') + ': ' + r
@ -317,10 +430,13 @@ class LightningChannelsDialog(Factory.Popup):
if not self.app.wallet:
return
lnworker = self.app.wallet.lnworker
for i in lnworker.channels.values():
channels = list(lnworker.channels.values()) if lnworker else []
lnbackups = self.app.wallet.lnbackups
backups = list(lnbackups.channel_backups.values())
for i in channels + backups:
item = Factory.LightningChannelItem()
item.screen = self
item.active = i.node_id in lnworker.peers
item.active = i.node_id in (lnworker.peers if lnworker else [])
item._chan = i
self.update_item(item)
channel_cards.add_widget(item)
@ -328,5 +444,7 @@ class LightningChannelsDialog(Factory.Popup):
def update_can_send(self):
lnworker = self.app.wallet.lnworker
if not lnworker:
return
self.can_send = self.app.format_amount_and_units(lnworker.num_sats_can_send())
self.can_receive = self.app.format_amount_and_units(lnworker.num_sats_can_receive())

1
electrum/gui/qt/__init__.py

@ -235,7 +235,6 @@ class ElectrumGui(Logger):
run_hook('on_new_window', w)
w.warn_if_testnet()
w.warn_if_watching_only()
w.warn_if_lightning_backup()
return w
def count_wizards_in_progress(func):

51
electrum/gui/qt/channels_list.py

@ -57,6 +57,7 @@ class ChannelsList(MyTreeView):
self.update_single_row.connect(self.do_update_single_row)
self.network = self.parent.network
self.lnworker = self.parent.wallet.lnworker
self.lnbackups = self.parent.wallet.lnbackups
self.setSortingEnabled(True)
def format_fields(self, chan):
@ -78,7 +79,7 @@ class ChannelsList(MyTreeView):
else:
node_alias = ''
return [
format_short_channel_id(chan.short_channel_id),
chan.short_id_for_GUI(),
bh2u(chan.node_id),
node_alias,
'' if closed else labels[LOCAL],
@ -106,14 +107,11 @@ class ChannelsList(MyTreeView):
def force_close(self, channel_id):
chan = self.lnworker.channels[channel_id]
to_self_delay = chan.config[REMOTE].to_self_delay
if self.lnworker.wallet.is_lightning_backup():
msg = _('WARNING: force-closing from an old state might result in fund loss.\nAre you sure?')
else:
msg = _('Force-close channel?') + '\n\n'\
+ _(f'Funds retrieved from this channel will not be available before {to_self_delay} blocks after forced closure.') + ' '\
+ _('After that delay, funds will be sent to an address derived from your wallet seed.') + '\n\n'\
+ _('In the meantime, channel funds will not be recoverable from your seed, and will be lost if you lose your wallet.') + ' '\
+ _('To prevent that, you should backup your wallet if you have not already done so.')
msg = _('Force-close channel?') + '\n\n'\
+ _(f'Funds retrieved from this channel will not be available before {to_self_delay} blocks after forced closure.') + ' '\
+ _('After that delay, funds will be sent to an address derived from your wallet seed.') + '\n\n'\
+ _('In the meantime, channel funds will not be recoverable from your seed, and might be lost if you lose your wallet.') + ' '\
+ _('To prevent that, you should have a backup of this channel on another device.')
if self.parent.question(msg):
def task():
coro = self.lnworker.force_close_channel(channel_id)
@ -124,6 +122,22 @@ class ChannelsList(MyTreeView):
if self.main_window.question(_('Are you sure you want to delete this channel? This will purge associated transactions from your wallet history.')):
self.lnworker.remove_channel(channel_id)
def remove_channel_backup(self, channel_id):
if self.main_window.question(_('Remove channel backup?')):
self.lnbackups.remove_channel_backup(channel_id)
def export_channel_backup(self, channel_id):
data = self.lnworker.export_channel_backup(channel_id)
self.main_window.show_qrcode('channel_backup:' + data, 'channel backup')
def request_force_close(self, channel_id):
def task():
coro = self.lnbackups.request_force_close(channel_id)
return self.network.run_from_another_thread(coro)
def on_success(b):
self.main_window.show_message('success')
WaitingDialog(self, 'please wait..', task, on_success, self.on_failure)
def create_menu(self, position):
menu = QMenu()
menu.setSeparatorsCollapsible(True) # consecutive separators are merged together
@ -140,6 +154,11 @@ class ChannelsList(MyTreeView):
if not item:
return
channel_id = idx.sibling(idx.row(), self.Columns.NODE_ID).data(ROLE_CHANNEL_ID)
if channel_id in self.lnbackups.channel_backups:
menu.addAction(_("Request force-close"), lambda: self.request_force_close(channel_id))
menu.addAction(_("Delete"), lambda: self.remove_channel_backup(channel_id))
menu.exec_(self.viewport().mapToGlobal(position))
return
chan = self.lnworker.channels[channel_id]
menu.addAction(_("Details..."), lambda: self.parent.show_channel(channel_id))
cc = self.add_copy_menu(menu, idx)
@ -163,7 +182,6 @@ class ChannelsList(MyTreeView):
if chan.peer_state == peer_states.GOOD:
menu.addAction(_("Close channel"), lambda: self.close_channel(channel_id))
menu.addAction(_("Force-close channel"), lambda: self.force_close(channel_id))
menu.addSeparator()
else:
item = chan.get_closing_height()
if item:
@ -171,6 +189,8 @@ class ChannelsList(MyTreeView):
closing_tx = self.lnworker.lnwatcher.db.get_transaction(txid)
if closing_tx:
menu.addAction(_("View closing transaction"), lambda: self.parent.show_transaction(closing_tx))
menu.addSeparator()
menu.addAction(_("Export backup"), lambda: self.export_channel_backup(channel_id))
if chan.is_redeemed():
menu.addSeparator()
menu.addAction(_("Delete"), lambda: self.remove_channel(channel_id))
@ -195,13 +215,13 @@ class ChannelsList(MyTreeView):
def do_update_rows(self, wallet):
if wallet != self.parent.wallet:
return
lnworker = self.parent.wallet.lnworker
if not lnworker:
return
self.update_can_send(lnworker)
channels = list(wallet.lnworker.channels.values()) if wallet.lnworker else []
backups = list(wallet.lnbackups.channel_backups.values())
if wallet.lnworker:
self.update_can_send(wallet.lnworker)
self.model().clear()
self.update_headers(self.headers)
for chan in lnworker.channels.values():
for chan in channels + backups:
items = [QtGui.QStandardItem(x) for x in self.format_fields(chan)]
self.set_editability(items)
if self._default_item_bg_brush is None:
@ -212,6 +232,7 @@ class ChannelsList(MyTreeView):
items[self.Columns.REMOTE_BALANCE].setFont(QFont(MONOSPACE_FONT))
self._update_chan_frozen_bg(chan=chan, items=items)
self.model().insertRow(0, items)
self.sortByColumn(self.Columns.SHORT_CHANID, Qt.DescendingOrder)
def _update_chan_frozen_bg(self, *, chan: Channel, items: Sequence[QStandardItem]):

59
electrum/gui/qt/main_window.py

@ -221,8 +221,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
tabs.addTab(tab, icon, description.replace("&", ""))
add_optional_tab(tabs, self.addresses_tab, read_QIcon("tab_addresses.png"), _("&Addresses"), "addresses")
if self.wallet.has_lightning():
add_optional_tab(tabs, self.channels_tab, read_QIcon("lightning.png"), _("Channels"), "channels")
add_optional_tab(tabs, self.channels_tab, read_QIcon("lightning.png"), _("Channels"), "channels")
add_optional_tab(tabs, self.utxo_tab, read_QIcon("tab_coins.png"), _("Co&ins"), "utxo")
add_optional_tab(tabs, self.contacts_tab, read_QIcon("tab_contacts.png"), _("Con&tacts"), "contacts")
add_optional_tab(tabs, self.console_tab, read_QIcon("tab_console.png"), _("Con&sole"), "console")
@ -524,18 +523,6 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
])
self.show_warning(msg, title=_('Watch-only wallet'))
def warn_if_lightning_backup(self):
if self.wallet.is_lightning_backup():
msg = '\n\n'.join([
_("This file is a backup of a lightning wallet."),
_("You will not be able to perform lightning payments using this file, and the lightning balance displayed in this wallet might be outdated.") + ' ' + \
_("If you have lost the original wallet file, you can use this file to trigger a forced closure of your channels."),
_("Do you want to have your channels force-closed?")
])
if self.question(msg, title=_('Lightning Backup')):
self.network.maybe_init_lightning()
self.wallet.lnworker.start_network(self.network)
def warn_if_testnet(self):
if not constants.net.TESTNET:
return
@ -572,14 +559,44 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
return
self.gui_object.new_window(filename)
def select_backup_dir(self, b):
name = self.config.get('backup_dir', '')
dirname = QFileDialog.getExistingDirectory(self, "Select your SSL certificate file", name)
if dirname:
self.config.set_key('backup_dir', dirname)
self.backup_dir_e.setText(dirname)
def backup_wallet(self):
d = WindowModalDialog(self, _("File Backup"))
vbox = QVBoxLayout(d)
grid = QGridLayout()
backup_help = ""
backup_dir = self.config.get('backup_dir')
backup_dir_label = HelpLabel(_('Backup directory') + ':', backup_help)
msg = _('Please select a backup directory')
if self.wallet.lnworker and self.wallet.lnworker.channels:
msg += '\n\n' + ' '.join([
_("Note that lightning channels will be converted to channel backups."),
_("You cannot use channel backups to perform lightning payments."),
_("Channel backups can only be used to request your channels to be closed.")
])
self.backup_dir_e = QPushButton(backup_dir)
self.backup_dir_e.clicked.connect(self.select_backup_dir)
grid.addWidget(backup_dir_label, 1, 0)
grid.addWidget(self.backup_dir_e, 1, 1)
vbox.addLayout(grid)
vbox.addWidget(WWLabel(msg))
vbox.addLayout(Buttons(CancelButton(d), OkButton(d)))
if not d.exec_():
return
try:
new_path = self.wallet.save_backup()
except BaseException as reason:
self.show_critical(_("Electrum was unable to copy your wallet file to the specified location.") + "\n" + str(reason), title=_("Unable to create backup"))
return
if new_path:
self.show_message(_("A copy of your wallet file was created in")+" '%s'" % str(new_path), title=_("Wallet backup created"))
msg = _("A copy of your wallet file was created in")+" '%s'" % str(new_path)
self.show_message(msg, title=_("Wallet backup created"))
else:
self.show_message(_("You need to configure a backup directory in your preferences"), title=_("Backup not created"))
@ -2524,6 +2541,15 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
self.show_critical(_("Electrum was unable to parse your transaction") + ":\n" + repr(e))
return
def import_channel_backup(self, encrypted):
if not self.question('Import channel backup?'):
return
try:
self.wallet.lnbackups.import_channel_backup(encrypted)
except Exception as e:
self.show_error("failed to import backup" + '\n' + str(e))
return
def read_tx_from_qrcode(self):
from electrum import qrscanner
try:
@ -2537,6 +2563,9 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
if str(data).startswith("bitcoin:"):
self.pay_to_URI(data)
return
if data.startswith('channel_backup:'):
self.import_channel_backup(data[15:])
return
# else if the user scanned an offline signed tx
tx = self.tx_from_text(data)
if not tx:

14
electrum/gui/qt/settings_dialog.py

@ -146,13 +146,6 @@ class SettingsDialog(WindowModalDialog):
# lightning
lightning_widgets = []
backup_help = _("""If you configure a backup directory, a backup of your wallet file will be saved everytime you create a new channel.\n\nA backup file cannot be used as a wallet; it can only be used to retrieve the funds locked in your channels, by requesting your channels to be force closed (using data loss protection).\n\nIf the remote node is online, they will force-close your channels when you open the backup file. Note that a backup is not strictly necessary for that; if the remote party is online but they cannot reach you because you lost your wallet file, they should eventually close your channels, and your funds should be sent to an address recoverable from your seed (using static_remotekey).\n\nIf the remote node is not online, you can use the backup file to force close your channels, but only at the risk of losing all your funds in the channel, because you will be broadcasting an old state.""")
backup_dir = self.config.get('backup_dir')
backup_dir_label = HelpLabel(_('Backup directory') + ':', backup_help)
self.backup_dir_e = QPushButton(backup_dir)
self.backup_dir_e.clicked.connect(self.select_backup_dir)
lightning_widgets.append((backup_dir_label, self.backup_dir_e))
help_persist = _("""If this option is checked, Electrum will persist as a daemon after
you close all your wallet windows. Your local watchtower will keep
running, and it will protect your channels even if your wallet is not
@ -554,13 +547,6 @@ that is always connected to the internet. Configure a port if you want it to be
if alias:
self.window.fetch_alias()
def select_backup_dir(self, b):
name = self.config.get('backup_dir', '')
dirname = QFileDialog.getExistingDirectory(self, "Select your SSL certificate file", name)
if dirname:
self.config.set_key('backup_dir', dirname)
self.backup_dir_e.setText(dirname)
def select_ssl_certfile(self, b):
name = self.config.get('ssl_certfile', '')
filename, __ = QFileDialog.getOpenFileName(self, "Select your SSL certificate file", name)

402
electrum/lnchannel.py

@ -56,6 +56,8 @@ 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 format_short_channel_id
if TYPE_CHECKING:
from .lnworker import LNWallet
@ -121,19 +123,256 @@ def htlcsum(htlcs):
return sum([x.amount_msat for x in htlcs])
class Channel(Logger):
class AbstractChannel(Logger):
def set_short_channel_id(self, short_id: ShortChannelID) -> None:
self.short_channel_id = short_id
self.storage["short_channel_id"] = short_id
def get_id_for_log(self) -> str:
scid = self.short_channel_id
if scid:
return str(scid)
return self.channel_id.hex()
def set_state(self, state: channel_states) -> None:
""" set on-chain state """
old_state = self._state
if (old_state, state) not in state_transitions:
raise Exception(f"Transition not allowed: {old_state.name} -> {state.name}")
self.logger.debug(f'Setting channel state: {old_state.name} -> {state.name}')
self._state = state
self.storage['state'] = self._state.name
def get_state(self) -> channel_states:
return self._state
def is_open(self):
return self.get_state() == channel_states.OPEN
def is_closing(self):
return self.get_state() in [channel_states.CLOSING, channel_states.FORCE_CLOSING]
def is_closed(self):
# the closing txid has been saved
return self.get_state() >= channel_states.CLOSED
def is_redeemed(self):
return self.get_state() == channel_states.REDEEMED
def save_funding_height(self, txid, height, timestamp):
self.storage['funding_height'] = txid, height, timestamp
def get_funding_height(self):
return self.storage.get('funding_height')
def delete_funding_height(self):
self.storage.pop('funding_height', None)
def save_closing_height(self, txid, height, timestamp):
self.storage['closing_height'] = txid, height, timestamp
def get_closing_height(self):
return self.storage.get('closing_height')
def delete_closing_height(self):
self.storage.pop('closing_height', None)
def create_sweeptxs_for_our_ctx(self, ctx):
return create_sweeptxs_for_our_ctx(chan=self, ctx=ctx, sweep_address=self.sweep_address)
def create_sweeptxs_for_their_ctx(self, ctx):
return create_sweeptxs_for_their_ctx(chan=self, ctx=ctx, sweep_address=self.sweep_address)
def is_backup(self):
return False
def sweep_ctx(self, ctx: Transaction) -> Dict[str, SweepInfo]:
txid = ctx.txid()
if self.sweep_info.get(txid) is None:
our_sweep_info = self.create_sweeptxs_for_our_ctx(ctx)
their_sweep_info = self.create_sweeptxs_for_their_ctx(ctx)
if our_sweep_info is not None:
self.sweep_info[txid] = our_sweep_info
self.logger.info(f'we force closed')
elif their_sweep_info is not None:
self.sweep_info[txid] = their_sweep_info
self.logger.info(f'they force closed.')
else:
self.sweep_info[txid] = {}
self.logger.info(f'not sure who closed.')
return self.sweep_info[txid]
# ancestor for Channel and ChannelBackup
def update_onchain_state(self, funding_txid, funding_height, closing_txid, closing_height, keep_watching):
# note: state transitions are irreversible, but
# save_funding_height, save_closing_height are reversible
if funding_height.height == TX_HEIGHT_LOCAL:
self.update_unfunded_state()
elif closing_height.height == TX_HEIGHT_LOCAL:
self.update_funded_state(funding_txid, funding_height)
else:
self.update_closed_state(funding_txid, funding_height, closing_txid, closing_height, keep_watching)
def update_unfunded_state(self):
self.delete_funding_height()
self.delete_closing_height()
if self.get_state() in [channel_states.PREOPENING, channel_states.OPENING, channel_states.FORCE_CLOSING] and self.lnworker:
if self.is_initiator():
# set channel state to REDEEMED so that it can be removed manually
# to protect ourselves against a server lying by omission,
# we check that funding_inputs have been double spent and deeply mined
inputs = self.storage.get('funding_inputs', [])
if not inputs:
self.logger.info(f'channel funding inputs are not provided')
self.set_state(channel_states.REDEEMED)
for i in inputs:
spender_txid = self.lnworker.wallet.db.get_spent_outpoint(*i)
if spender_txid is None:
continue
if spender_txid != self.funding_outpoint.txid:
tx_mined_height = self.lnworker.wallet.get_tx_height(spender_txid)
if tx_mined_height.conf > lnutil.REDEEM_AFTER_DOUBLE_SPENT_DELAY:
self.logger.info(f'channel is double spent {inputs}')
self.set_state(channel_states.REDEEMED)
break
else:
now = int(time.time())
if now - self.storage.get('init_timestamp', 0) > CHANNEL_OPENING_TIMEOUT:
self.lnworker.remove_channel(self.channel_id)
def update_funded_state(self, funding_txid, funding_height):
self.save_funding_height(funding_txid, funding_height.height, funding_height.timestamp)
self.delete_closing_height()
if self.get_state() == channel_states.OPENING:
if self.is_funding_tx_mined(funding_height):
self.set_state(channel_states.FUNDED)
self.set_short_channel_id(ShortChannelID.from_components(
funding_height.height, funding_height.txpos, self.funding_outpoint.output_index))
self.logger.info(f"save_short_channel_id: {self.short_channel_id}")
def update_closed_state(self, funding_txid, funding_height, closing_txid, closing_height, keep_watching):
self.save_funding_height(funding_txid, funding_height.height, funding_height.timestamp)
self.save_closing_height(closing_txid, closing_height.height, closing_height.timestamp)
if self.get_state() < channel_states.CLOSED:
conf = closing_height.conf
if conf > 0:
self.set_state(channel_states.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 self.get_state() == channel_states.CLOSED and not keep_watching:
self.set_state(channel_states.REDEEMED)
class ChannelBackup(AbstractChannel):
"""
current capabilities:
- detect force close
- request force close
- sweep my ctx to_local
future:
- will need to sweep their ctx to_remote
"""
def __init__(self, cb: ChannelBackupStorage, *, sweep_address=None, lnworker=None):
self.name = None
Logger.__init__(self)
self.cb = cb
self.sweep_info = {} # type: Dict[str, Dict[str, SweepInfo]]
self.sweep_address = sweep_address
self.storage = {} # dummy storage
self._state = channel_states.OPENING
self.config = {}
self.config[LOCAL] = LocalConfig.from_seed(
channel_seed=cb.channel_seed,
to_self_delay=cb.local_delay,
# dummy values
static_remotekey=None,
dust_limit_sat=None,
max_htlc_value_in_flight_msat=None,
max_accepted_htlcs=None,
initial_msat=None,
reserve_sat=None,
funding_locked_received=False,
was_announced=False,
current_commitment_signature=None,
current_htlc_signatures=b'',
htlc_minimum_msat=1,
)
self.config[REMOTE] = RemoteConfig(
payment_basepoint=OnlyPubkeyKeypair(cb.remote_payment_pubkey),
revocation_basepoint=OnlyPubkeyKeypair(cb.remote_revocation_pubkey),
to_self_delay=cb.remote_delay,
# dummy values
multisig_key=OnlyPubkeyKeypair(None),
htlc_basepoint=OnlyPubkeyKeypair(None),
delayed_basepoint=OnlyPubkeyKeypair(None),
dust_limit_sat=None,
max_htlc_value_in_flight_msat=None,
max_accepted_htlcs=None,
initial_msat = None,
reserve_sat = None,
htlc_minimum_msat=None,
next_per_commitment_point=None,
current_per_commitment_point=None)
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 is_backup(self):
return True
def create_sweeptxs_for_their_ctx(self, ctx):
return {}
def get_funding_address(self):
return self.cb.funding_address
def short_id_for_GUI(self) -> str:
return 'BACKUP'
def is_initiator(self):
return self.cb.is_initiator
def get_state_for_GUI(self):
cs = self.get_state()
return cs.name
def get_oldest_unrevoked_ctn(self, who):
return -1
def included_htlcs(self, subject, direction, ctn):
return []
def funding_txn_minimum_depth(self):
return 1
def is_funding_tx_mined(self, funding_height):
return funding_height.conf > 1
def balance_minus_outgoing_htlcs(self, whose: HTLCOwner, *, ctx_owner: HTLCOwner = HTLCOwner.LOCAL, ctn: int = None):
return 0
def balance(self, whose: HTLCOwner, *, ctx_owner=HTLCOwner.LOCAL, ctn: int = None) -> int:
return 0
def is_frozen_for_sending(self) -> bool:
return False
def is_frozen_for_receiving(self) -> bool:
return False
class Channel(AbstractChannel):
# note: try to avoid naming ctns/ctxs/etc as "current" and "pending".
# they are ambiguous. Use "oldest_unrevoked" or "latest" or "next".
# TODO enforce this ^
def diagnostic_name(self):
if self.name:
return str(self.name)
try:
return f"lnchannel_{bh2u(self.channel_id[-4:])}"
except:
return super().diagnostic_name()
def __init__(self, state: 'StoredDict', *, sweep_address=None, name=None, lnworker=None, initial_feerate=None):
self.name = name
Logger.__init__(self)
@ -162,11 +401,22 @@ class Channel(Logger):
self._receive_fail_reasons = {} # type: Dict[int, BarePaymentAttemptLog]
self._ignore_max_htlc_value = False # used in tests
def get_id_for_log(self) -> str:
scid = self.short_channel_id
if scid:
return str(scid)
return self.channel_id.hex()
def short_id_for_GUI(self) -> str:
return format_short_channel_id(self.short_channel_id)
def is_initiator(self):
return self.constraints.is_initiator
def funding_txn_minimum_depth(self):
return self.constraints.funding_txn_minimum_depth
def diagnostic_name(self):
if self.name:
return str(self.name)
try:
return f"lnchannel_{bh2u(self.channel_id[-4:])}"
except:
return super().diagnostic_name()
def set_onion_key(self, key: int, value: bytes):
self.onion_keys[key] = value
@ -269,10 +519,6 @@ class Channel(Logger):
def is_static_remotekey_enabled(self) -> bool:
return bool(self.storage.get('static_remotekey_enabled'))
def set_short_channel_id(self, short_id: ShortChannelID) -> None:
self.short_channel_id = short_id
self.storage["short_channel_id"] = short_id
def get_feerate(self, subject: HTLCOwner, *, ctn: int) -> int:
# returns feerate in sat/kw
return self.hm.get_feerate(subject, ctn)
@ -322,21 +568,11 @@ class Channel(Logger):
self.peer_state = peer_states.GOOD
def set_state(self, state: channel_states) -> None:
""" set on-chain state """
old_state = self._state
if (old_state, state) not in state_transitions:
raise Exception(f"Transition not allowed: {old_state.name} -> {state.name}")
self.logger.debug(f'Setting channel state: {old_state.name} -> {state.name}')
self._state = state
self.storage['state'] = self._state.name
super().set_state(state)
if self.lnworker:
self.lnworker.save_channel(self)
self.lnworker.network.trigger_callback('channel', self)
def get_state(self) -> channel_states:
return self._state
def get_state_for_GUI(self):
# status displayed in the GUI
cs = self.get_state()
@ -347,16 +583,6 @@ class Channel(Logger):
return ps.name
return cs.name
def is_open(self):
return self.get_state() == channel_states.OPEN
def is_closing(self):
return self.get_state() in [channel_states.CLOSING, channel_states.FORCE_CLOSING]
def is_closed(self):
# the closing txid has been saved
return self.get_state() >= channel_states.CLOSED
def set_can_send_ctx_updates(self, b: bool) -> None:
self._can_send_ctx_updates = b
@ -373,27 +599,6 @@ class Channel(Logger):
def can_send_update_add_htlc(self) -> bool:
return self.can_send_ctx_updates() and not self.is_closing()
def save_funding_height(self, txid, height, timestamp):
self.storage['funding_height'] = txid, height, timestamp
def get_funding_height(self):
return self.storage.get('funding_height')
def delete_funding_height(self):
self.storage.pop('funding_height', None)
def save_closing_height(self, txid, height, timestamp):
self.storage['closing_height'] = txid, height, timestamp
def get_closing_height(self):
return self.storage.get('closing_height')
def delete_closing_height(self):
self.storage.pop('closing_height', None)
def is_redeemed(self):
return self.get_state() == channel_states.REDEEMED
def is_frozen_for_sending(self) -> bool:
"""Whether the user has marked this channel as frozen for sending.
Frozen channels are not supposed to be used for new outgoing payments.
@ -1039,21 +1244,6 @@ class Channel(Logger):
assert tx.is_complete()
return tx
def sweep_ctx(self, ctx: Transaction) -> Dict[str, SweepInfo]:
txid = ctx.txid()
if self.sweep_info.get(txid) is None:
our_sweep_info = create_sweeptxs_for_our_ctx(chan=self, ctx=ctx, sweep_address=self.sweep_address)
their_sweep_info = create_sweeptxs_for_their_ctx(chan=self, ctx=ctx, sweep_address=self.sweep_address)
if our_sweep_info is not None:
self.sweep_info[txid] = our_sweep_info
self.logger.info(f'we force closed.')
elif their_sweep_info is not None:
self.sweep_info[txid] = their_sweep_info
self.logger.info(f'they force closed.')
else:
self.sweep_info[txid] = {}
return self.sweep_info[txid]
def sweep_htlc(self, ctx: Transaction, htlc_tx: Transaction) -> Optional[SweepInfo]:
# look at the output address, check if it matches
return create_sweeptx_for_their_revoked_htlc(self, ctx, htlc_tx, self.sweep_address)
@ -1095,16 +1285,6 @@ class Channel(Logger):
500_000)
return total_value_sat > min_value_worth_closing_channel_over_sat
def update_onchain_state(self, funding_txid, funding_height, closing_txid, closing_height, keep_watching):
# note: state transitions are irreversible, but
# save_funding_height, save_closing_height are reversible
if funding_height.height == TX_HEIGHT_LOCAL:
self.update_unfunded_state()
elif closing_height.height == TX_HEIGHT_LOCAL:
self.update_funded_state(funding_txid, funding_height)
else:
self.update_closed_state(funding_txid, funding_height, closing_txid, closing_height, keep_watching)
def is_funding_tx_mined(self, funding_height):
"""
Checks if Funding TX has been mined. If it has, save the short channel ID in chan;
@ -1114,7 +1294,7 @@ class Channel(Logger):
funding_txid = self.funding_outpoint.txid
funding_idx = self.funding_outpoint.output_index
conf = funding_height.conf
if conf < self.constraints.funding_txn_minimum_depth:
if conf < self.funding_txn_minimum_depth():
self.logger.info(f"funding tx is still not at sufficient depth. actual depth: {conf}")
return False
assert conf > 0
@ -1132,53 +1312,3 @@ class Channel(Logger):
return False
return True
def update_unfunded_state(self):
self.delete_funding_height()
self.delete_closing_height()
if self.get_state() in [channel_states.PREOPENING, channel_states.OPENING, channel_states.FORCE_CLOSING] and self.lnworker:
if self.constraints.is_initiator:
# set channel state to REDEEMED so that it can be removed manually
# to protect ourselves against a server lying by omission,
# we check that funding_inputs have been double spent and deeply mined
inputs = self.storage.get('funding_inputs', [])
if not inputs:
self.logger.info(f'channel funding inputs are not provided')
self.set_state(channel_states.REDEEMED)
for i in inputs:
spender_txid = self.lnworker.wallet.db.get_spent_outpoint(*i)
if spender_txid is None:
continue
if spender_txid != self.funding_outpoint.txid:
tx_mined_height = self.lnworker.wallet.get_tx_height(spender_txid)
if tx_mined_height.conf > lnutil.REDEEM_AFTER_DOUBLE_SPENT_DELAY:
self.logger.info(f'channel is double spent {inputs}')
self.set_state(channel_states.REDEEMED)
break
else:
now = int(time.time())
if now - self.storage.get('init_timestamp', 0) > CHANNEL_OPENING_TIMEOUT:
self.lnworker.remove_channel(self.channel_id)
def update_funded_state(self, funding_txid, funding_height):
self.save_funding_height(funding_txid, funding_height.height, funding_height.timestamp)
self.delete_closing_height()
if self.get_state() == channel_states.OPENING:
if self.is_funding_tx_mined(funding_height):
self.set_state(channel_states.FUNDED)
self.set_short_channel_id(ShortChannelID.from_components(
funding_height.height, funding_height.txpos, self.funding_outpoint.output_index))
self.logger.info(f"save_short_channel_id: {self.short_channel_id}")
def update_closed_state(self, funding_txid, funding_height, closing_txid, closing_height, keep_watching):
self.save_funding_height(funding_txid, funding_height.height, funding_height.timestamp)
self.save_closing_height(closing_txid, closing_height.height, closing_height.timestamp)
if self.get_state() < channel_states.CLOSED:
conf = closing_height.conf
if conf > 0:
self.set_state(channel_states.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 self.get_state() == channel_states.CLOSED and not keep_watching:
self.set_state(channel_states.REDEEMED)

26
electrum/lnpeer.py

@ -44,7 +44,7 @@ from .lnutil import (Outpoint, LocalConfig, RECEIVED, UpdateAddHtlc,
MAXIMUM_REMOTE_TO_SELF_DELAY_ACCEPTED, RemoteMisbehaving, DEFAULT_TO_SELF_DELAY,
NBLOCK_OUR_CLTV_EXPIRY_DELTA, format_short_channel_id, ShortChannelID,
IncompatibleLightningFeatures, derive_payment_secret_from_payment_preimage)
from .lnutil import FeeUpdate
from .lnutil import FeeUpdate, channel_id_from_funding_tx
from .lntransport import LNTransport, LNTransportBase
from .lnmsg import encode_msg, decode_msg
from .interface import GracefulDisconnect, NetworkException
@ -60,10 +60,6 @@ if TYPE_CHECKING:
LN_P2P_NETWORK_TIMEOUT = 20
def channel_id_from_funding_tx(funding_txid: str, funding_index: int) -> Tuple[bytes, bytes]:
funding_txid_bytes = bytes.fromhex(funding_txid)[::-1]
i = int.from_bytes(funding_txid_bytes, 'big') ^ funding_index
return i.to_bytes(32, 'big'), funding_txid_bytes
class Peer(Logger):
@ -222,7 +218,7 @@ class Peer(Logger):
if constants.net.rev_genesis_bytes() not in their_chains:
raise GracefulDisconnect(f"no common chain found with remote. (they sent: {their_chains})")
# all checks passed
if isinstance(self.transport, LNTransport):
if self.channel_db and isinstance(self.transport, LNTransport):
self.channel_db.add_recent_peer(self.transport.peer_addr)
for chan in self.channels.values():
chan.add_or_update_peer_addr(self.transport.peer_addr)
@ -728,6 +724,17 @@ class Peer(Logger):
raise Exception(f'reserve too high: {remote_reserve_sat}, funding_sat: {funding_sat}')
return remote_reserve_sat
async def trigger_force_close(self, channel_id):
await self.initialized
latest_point = 0
self.send_message(
"channel_reestablish",
channel_id=channel_id,
next_local_commitment_number=0,
next_remote_revocation_number=0,
your_last_per_commitment_secret=0,
my_current_per_commitment_point=latest_point)
async def reestablish_channel(self, chan: Channel):
await self.initialized
chan_id = chan.channel_id
@ -749,8 +756,7 @@ class Peer(Logger):
next_remote_ctn = chan.get_next_ctn(REMOTE)
assert self.features & LnFeatures.OPTION_DATA_LOSS_PROTECT_OPT
# send message
srk_enabled = chan.is_static_remotekey_enabled()
if srk_enabled:
if chan.is_static_remotekey_enabled():
latest_secret, latest_point = chan.get_secret_and_point(LOCAL, 0)
else:
latest_secret, latest_point = chan.get_secret_and_point(LOCAL, latest_local_ctn)
@ -878,10 +884,6 @@ class Peer(Logger):
self.logger.warning(f"channel_reestablish ({chan.get_id_for_log()}): we are ahead of remote! trying to force-close.")
await self.lnworker.try_force_closing(chan_id)
return
elif self.lnworker.wallet.is_lightning_backup():
self.logger.warning(f"channel_reestablish ({chan.get_id_for_log()}): force-closing because we are a recent backup")
await self.lnworker.try_force_closing(chan_id)
return
chan.peer_state = peer_states.GOOD
# note: chan.short_channel_id being set implies the funding txn is already at sufficient depth

2
electrum/lnsweep.py

@ -18,7 +18,7 @@ from .lnutil import (make_commitment_output_to_remote_address, make_commitment_o
from .transaction import (Transaction, TxOutput, construct_witness, PartialTransaction, PartialTxInput,
PartialTxOutput, TxOutpoint)
from .simple_config import SimpleConfig
from .logging import get_logger
from .logging import get_logger, Logger
if TYPE_CHECKING:
from .lnchannel import Channel

70
electrum/lnutil.py

@ -24,6 +24,7 @@ from . import segwit_addr
from .i18n import _
from .lnaddr import lndecode
from .bip32 import BIP32Node, BIP32_PRIME
from .transaction import BCDataStream
if TYPE_CHECKING:
from .lnchannel import Channel
@ -47,6 +48,11 @@ def ln_dummy_address():
from .json_db import StoredObject
def channel_id_from_funding_tx(funding_txid: str, funding_index: int) -> Tuple[bytes, bytes]:
funding_txid_bytes = bytes.fromhex(funding_txid)[::-1]
i = int.from_bytes(funding_txid_bytes, 'big') ^ funding_index
return i.to_bytes(32, 'big'), funding_txid_bytes
hex_to_bytes = lambda v: v if isinstance(v, bytes) else bytes.fromhex(v) if v is not None else None
json_to_keypair = lambda v: v if isinstance(v, OnlyPubkeyKeypair) else Keypair(**v) if len(v)==2 else OnlyPubkeyKeypair(**v)
@ -116,6 +122,66 @@ class ChannelConstraints(StoredObject):
is_initiator = attr.ib(type=bool) # note: sometimes also called "funder"
funding_txn_minimum_depth = attr.ib(type=int)
@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)
def channel_id(self):
chan_id, _ = channel_id_from_funding_tx(self.funding_txid, self.funding_index)
return chan_id
def to_bytes(self):
vds = BCDataStream()
vds.write_boolean(self.is_initiator)
vds.write_bytes(self.privkey, 32)
vds.write_bytes(self.channel_seed, 32)
vds.write_bytes(self.node_id, 33)
vds.write_bytes(bfh(self.funding_txid), 32)
vds.write_int16(self.funding_index)
vds.write_string(self.funding_address)
vds.write_bytes(self.remote_payment_pubkey, 33)
vds.write_bytes(self.remote_revocation_pubkey, 33)
vds.write_int16(self.local_delay)
vds.write_int16(self.remote_delay)
vds.write_string(self.host)
vds.write_int16(self.port)
return vds.input
@staticmethod
def from_bytes(s):
vds = BCDataStream()
vds.write(s)
return ChannelBackupStorage(
is_initiator = bool(vds.read_bytes(1)),
privkey = vds.read_bytes(32).hex(),
channel_seed = vds.read_bytes(32).hex(),
node_id = vds.read_bytes(33).hex(),
funding_txid = vds.read_bytes(32).hex(),
funding_index = vds.read_int16(),
funding_address = vds.read_string(),
remote_payment_pubkey = vds.read_bytes(33).hex(),
remote_revocation_pubkey = vds.read_bytes(33).hex(),
local_delay = vds.read_int16(),
remote_delay = vds.read_int16(),
host = vds.read_string(),
port = vds.read_int16())
class ScriptHtlc(NamedTuple):
redeem_script: bytes
@ -716,8 +782,8 @@ def extract_ctn_from_tx(tx: Transaction, txin_index: int, funder_payment_basepoi
return get_obscured_ctn(obs, funder_payment_basepoint, fundee_payment_basepoint)
def extract_ctn_from_tx_and_chan(tx: Transaction, chan: 'Channel') -> int:
funder_conf = chan.config[LOCAL] if chan.constraints.is_initiator else chan.config[REMOTE]
fundee_conf = chan.config[LOCAL] if not chan.constraints.is_initiator else chan.config[REMOTE]
funder_conf = chan.config[LOCAL] if chan.is_initiator() else chan.config[REMOTE]
fundee_conf = chan.config[LOCAL] if not chan.is_initiator() else chan.config[REMOTE]
return extract_ctn_from_tx(tx, txin_index=0,
funder_payment_basepoint=funder_conf.payment_basepoint.pubkey,
fundee_payment_basepoint=fundee_conf.payment_basepoint.pubkey)

1
electrum/lnwatcher.py

@ -15,6 +15,7 @@ from typing import NamedTuple, Dict
from .sql_db import SqlDB, sql
from .wallet_db import WalletDB
from .util import bh2u, bfh, log_exceptions, ignore_exceptions
from .lnutil import Outpoint
from . import wallet
from .storage import WalletStorage
from .address_synchronizer import AddressSynchronizer, TX_HEIGHT_LOCAL, TX_HEIGHT_UNCONF_PARENT, TX_HEIGHT_UNCONFIRMED

108
electrum/lnworker.py

@ -64,6 +64,9 @@ from .lnrouter import RouteEdge, LNPaymentRoute, is_route_sane_to_use
from .address_synchronizer import TX_HEIGHT_LOCAL
from . import lnsweep
from .lnwatcher import LNWalletWatcher
from .crypto import pw_encode_bytes, pw_decode_bytes, PW_HASH_VERSION_LATEST
from .lnutil import ChannelBackupStorage
from .lnchannel import ChannelBackup
if TYPE_CHECKING:
from .network import Network
@ -219,7 +222,8 @@ class LNWorker(Logger):
return peer
def peer_closed(self, peer: Peer) -> None:
self.peers.pop(peer.pubkey)
if peer.pubkey in self.peers:
self.peers.pop(peer.pubkey)
def num_peers(self) -> int:
return sum([p.is_initialized() for p in self.peers.values()])
@ -492,7 +496,8 @@ class LNWallet(LNWorker):
self.lnwatcher = LNWalletWatcher(self, network)
self.lnwatcher.start_network(network)
self.network = network
for chan_id, chan in self.channels.items():
for chan in self.channels.values():
self.lnwatcher.add_channel(chan.funding_outpoint.to_str(), chan.get_funding_address())
super().start_network(network)
@ -763,8 +768,6 @@ class LNWallet(LNWorker):
def open_channel(self, *, connect_str: str, funding_tx: PartialTransaction,
funding_sat: int, push_amt_sat: int, password: str = None,
timeout: Optional[int] = 20) -> Tuple[Channel, PartialTransaction]:
if self.wallet.is_lightning_backup():
raise Exception(_('Cannot create channel: this is a backup file'))
if funding_sat > LN_MAX_FUNDING_SAT:
raise Exception(_("Requested channel capacity is over protocol allowed maximum."))
coro = self._open_channel_coroutine(connect_str=connect_str, funding_tx=funding_tx, funding_sat=funding_sat,
@ -1319,3 +1322,100 @@ class LNWallet(LNWorker):
if feerate_per_kvbyte is None:
feerate_per_kvbyte = FEERATE_FALLBACK_STATIC_FEE
return max(253, feerate_per_kvbyte // 4)
def create_channel_backup(self, channel_id):
chan = self.channels[channel_id]
peer_addresses = list(chan.get_peer_addresses())
peer_addr = peer_addresses[0]
return ChannelBackupStorage(
node_id = chan.node_id,
privkey = self.node_keypair.privkey,
funding_txid = chan.funding_outpoint.txid,
funding_index = chan.funding_outpoint.output_index,
funding_address = chan.get_funding_address(),
host = peer_addr.host,
port = peer_addr.port,
is_initiator = chan.constraints.is_initiator,
channel_seed = chan.config[LOCAL].channel_seed,
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)
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"
encrypted = pw_encode_bytes(backup_bytes, xpub, version=PW_HASH_VERSION_LATEST)
assert backup_bytes == pw_decode_bytes(encrypted, xpub, version=PW_HASH_VERSION_LATEST), "encrypt failed"
return encrypted
class LNBackups(Logger):
def __init__(self, wallet: 'Abstract_Wallet'):
Logger.__init__(self)
self.features = LnFeatures(0)
self.features |= LnFeatures.OPTION_DATA_LOSS_PROTECT_OPT
self.features |= LnFeatures.OPTION_STATIC_REMOTEKEY_OPT
self.taskgroup = SilentTaskGroup()
self.lock = threading.RLock()
self.wallet = wallet
self.db = wallet.db
self.sweep_address = wallet.get_receiving_address()
self.channel_backups = {}
for channel_id, cb in self.db.get_dict("channel_backups").items():
self.channel_backups[bfh(channel_id)] = ChannelBackup(cb, sweep_address=self.sweep_address, lnworker=self)
def peer_closed(self, chan):
pass
async def on_channel_update(self, chan):
pass
def channel_by_txo(self, txo):
with self.lock:
channel_backups = list(self.channel_backups.values())
for chan in channel_backups:
if chan.funding_outpoint.to_str() == txo:
return chan
def start_network(self, network: 'Network'):
assert network
self.lnwatcher = LNWalletWatcher(self, network)
self.lnwatcher.start_network(network)
self.network = network
for cb in self.channel_backups.values():
self.lnwatcher.add_channel(cb.funding_outpoint.to_str(), cb.get_funding_address())
def import_channel_backup(self, encrypted):
xpub = self.wallet.get_fingerprint()
x = pw_decode_bytes(encrypted, xpub, version=PW_HASH_VERSION_LATEST)
cb = ChannelBackupStorage.from_bytes(x)
channel_id = cb.channel_id().hex()
d = self.db.get_dict("channel_backups")
if channel_id in d:
raise Exception('Channel already in wallet')
d[channel_id] = cb
self.channel_backups[bfh(channel_id)] = ChannelBackup(cb, sweep_address=self.sweep_address, lnworker=self)
self.wallet.save_db()
self.network.trigger_callback('channels_updated', self.wallet)
def remove_channel_backup(self, channel_id):
d = self.db.get_dict("channel_backups")
if channel_id.hex() not in d:
raise Exception('Channel not found')
d.pop(channel_id.hex())
self.channel_backups.pop(channel_id)
self.wallet.save_db()
self.network.trigger_callback('channels_updated', self.wallet)
@log_exceptions
async def request_force_close(self, channel_id):
cb = self.channel_backups[channel_id].cb
peer_addr = LNPeerAddr(cb.host, cb.port, cb.node_id)
transport = LNTransport(cb.privkey, peer_addr)
peer = Peer(self, cb.node_id, transport)
await self.taskgroup.spawn(peer._message_loop())
await peer.initialized
await self.taskgroup.spawn(peer.trigger_force_close(channel_id))

4
electrum/transaction.py

@ -289,6 +289,10 @@ class BCDataStream(object):
else:
raise SerializationError('attempt to read past end of buffer')
def write_bytes(self, _bytes: Union[bytes, bytearray], length: int):
assert len(_bytes) == length, len(_bytes)
self.write(_bytes)
def can_read_more(self) -> bool:
if not self.input:
return False

23
electrum/wallet.py

@ -72,7 +72,7 @@ from .contacts import Contacts
from .interface import NetworkException
from .mnemonic import Mnemonic
from .logging import get_logger
from .lnworker import LNWallet
from .lnworker import LNWallet, LNBackups
from .paymentrequest import PaymentRequest
if TYPE_CHECKING:
@ -259,6 +259,7 @@ class Abstract_Wallet(AddressSynchronizer, ABC):
# lightning
ln_xprv = self.db.get('lightning_privkey2')
self.lnworker = LNWallet(self, ln_xprv) if ln_xprv else None
self.lnbackups = LNBackups(self)
def save_db(self):
if self.storage:
@ -269,7 +270,14 @@ class Abstract_Wallet(AddressSynchronizer, ABC):
if backup_dir is None:
return
new_db = WalletDB(self.db.dump(), manual_upgrades=False)
new_db.put('is_backup', True)
if self.lnworker:
channel_backups = new_db.get_dict('channel_backups')
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_privkey2', None)
new_path = os.path.join(backup_dir, self.basename() + '.backup')
new_storage = WalletStorage(new_path)
new_storage._encryption_version = self.storage._encryption_version
@ -305,9 +313,6 @@ class Abstract_Wallet(AddressSynchronizer, ABC):
self.db.put('lightning_privkey2', None)
self.save_db()
def is_lightning_backup(self):
return self.has_lightning() and self.db.get('is_backup')
def stop_threads(self):
super().stop_threads()
if any([ks.is_requesting_to_be_rewritten_to_wallet_file for ks in self.get_keystores()]):
@ -324,9 +329,11 @@ class Abstract_Wallet(AddressSynchronizer, ABC):
def start_network(self, network):
AddressSynchronizer.start_network(self, network)
if self.lnworker and network and not self.is_lightning_backup():
network.maybe_init_lightning()
self.lnworker.start_network(network)
if network:
if self.lnworker:
network.maybe_init_lightning()
self.lnworker.start_network(network)
self.lnbackups.start_network(network)
def load_and_cleanup(self):
self.load_keystore()

4
electrum/wallet_db.py

@ -36,7 +36,7 @@ from .util import profiler, WalletFileException, multisig_type, TxMinedInfo, bfh
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
from .lnutil import LOCAL, REMOTE, FeeUpdate, UpdateAddHtlc, LocalConfig, RemoteConfig, Keypair, OnlyPubkeyKeypair, RevocationStore, ChannelBackupStorage
from .lnutil import ChannelConstraints, Outpoint, ShachainElement
from .json_db import StoredDict, JsonDB, locked, modifier
from .plugin import run_hook, plugin_loaders
@ -1101,6 +1101,8 @@ class WalletDB(JsonDB):
v = dict((k, UpdateAddHtlc.from_tuple(*x)) for k, x in v.items())
elif key == 'fee_updates':
v = dict((k, FeeUpdate(**x)) for k, x in v.items())
elif key == 'channel_backups':
v = dict((k, ChannelBackupStorage(**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