electrum

Electrum Bitcoin wallet
git clone https://git.parazyd.org/electrum
Log | Files | Refs | Submodules

commit fe78ed2a8e0d09087e22d2b5803e86e013e8fdfc
parent 6522a1e1a3c5893a4f55fc89336f36bdecb65c29
Author: bitromortac <bitromortac@protonmail.com>
Date:   Thu,  7 Jan 2021 10:43:10 +0100

swaps: add swaps to android

Diffstat:
Melectrum/gui/kivy/main_window.py | 6+++++-
Melectrum/gui/kivy/uix/dialogs/lightning_channels.py | 328+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
2 files changed, 327 insertions(+), 7 deletions(-)

diff --git a/electrum/gui/kivy/main_window.py b/electrum/gui/kivy/main_window.py @@ -84,7 +84,7 @@ from electrum.util import (NoDynamicFeeEstimates, NotEnoughFunds, BITCOIN_BIP21_URI_SCHEME, LIGHTNING_URI_SCHEME) from .uix.dialogs.lightning_open_channel import LightningOpenChannelDialog -from .uix.dialogs.lightning_channels import LightningChannelsDialog +from .uix.dialogs.lightning_channels import LightningChannelsDialog, SwapDialog if TYPE_CHECKING: from . import ElectrumGui @@ -718,6 +718,10 @@ class ElectrumWindow(App, Logger): d = LightningOpenChannelDialog(self) d.open() + def swap_dialog(self): + d = SwapDialog(self, self.electrum_config) + d.open() + def open_channel_dialog_with_warning(self, b): if b: d = LightningOpenChannelDialog(self) diff --git a/electrum/gui/kivy/uix/dialogs/lightning_channels.py b/electrum/gui/kivy/uix/dialogs/lightning_channels.py @@ -1,11 +1,11 @@ import asyncio -import binascii -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional, Tuple from kivy.lang import Builder from kivy.factory import Factory from kivy.uix.popup import Popup from kivy.clock import Clock +from .fee_dialog import FeeDialog from electrum.util import bh2u from electrum.logging import Logger @@ -13,12 +13,135 @@ from electrum.lnutil import LOCAL, REMOTE, format_short_channel_id from electrum.lnchannel import AbstractChannel, Channel from electrum.gui.kivy.i18n import _ from .question import Question +from electrum.transaction import PartialTxOutput, PartialTransaction +from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates, format_fee_satoshis +from electrum.lnutil import ln_dummy_address if TYPE_CHECKING: from ...main_window import ElectrumWindow + from electrum import SimpleConfig Builder.load_string(r''' +<SwapDialog@Popup> + id: popup + title: _('Lightning Swap') + size_hint: 0.8, 0.8 + pos_hint: {'top':0.9} + method: 0 + BoxLayout: + orientation: 'vertical' + BoxLayout: + orientation: 'horizontal' + size_hint: 1, 0.5 + Label: + text: _('Swap Settings') + background_color: (0,0,0,0) + BoxLayout: + orientation: 'horizontal' + size_hint: 1, 0.5 + Label: + text: _('You Send') + ':' + size_hint: 0.4, 1 + Label: + id: send_amount_label + size_hint: 0.6, 1 + text: _('0') + background_color: (0,0,0,0) + BoxLayout: + orientation: 'horizontal' + size_hint: 1, 0.5 + Label: + text: _('You Receive') + ':' + size_hint: 0.4, 1 + Label: + id: receive_amount_label + text: _('0') + background_color: (0,0,0,0) + size_hint: 0.6, 1 + BoxLayout: + orientation: 'horizontal' + size_hint: 1, 0.5 + Label: + text: _('Server Fee') + ':' + size_hint: 0.4, 1 + Label: + id: server_fee_label + text: _('0') + background_color: (0,0,0,0) + size_hint: 0.6, 1 + BoxLayout: + orientation: 'horizontal' + size_hint: 1, 0.5 + Label: + text: _('Mining Fee') + ':' + size_hint: 0.4, 1 + Label: + id: mining_fee_label + text: _('0') + background_color: (0,0,0,0) + size_hint: 0.6, 1 + BoxLayout: + orientation: 'horizontal' + size_hint: 1, 0.5 + Label: + id: swap_action_label + text: _('Adds receiving capacity') + background_color: (0,0,0,0) + font_size: '14dp' + Slider: + id: swap_slider + range: 0, 4 + step: 1 + on_value: root.swap_slider_moved(self.value) + Widget: + size_hint: 1, 0.5 + BoxLayout: + orientation: 'horizontal' + size_hint: 1, 0.5 + Label: + text: _('Onchain Fees') + background_color: (0,0,0,0) + BoxLayout: + orientation: 'horizontal' + size_hint: 1, 0.5 + Label: + text: _('Fee rate:') + Button: + id: fee_rate + text: '? sat/B' + background_color: (0,0,0,0) + bold: True + on_release: + root.on_fee_button() + Widget: + size_hint: 1, 0.5 + BoxLayout: + orientation: 'horizontal' + size_hint: 1, 0.5 + TopLabel: + id: fee_estimate + text: '' + font_size: '14dp' + Widget: + size_hint: 1, 0.5 + BoxLayout: + orientation: 'horizontal' + size_hint: 1, 0.5 + Button: + text: 'Cancel' + size_hint: 0.5, None + height: '48dp' + on_release: root.dismiss() + Button: + id: ok_button + text: 'OK' + size_hint: 0.5, None + height: '48dp' + on_release: + root.on_ok() + root.dismiss() + <LightningChannelItem@CardItem> details: {} active: False @@ -95,14 +218,20 @@ Builder.load_string(r''' Button: size_hint: 0.3, None height: '48dp' - text: _('Show Gossip') - on_release: popup.app.popup_dialog('lightning') + text: _('Open') + disabled: not root.has_lightning + on_release: popup.app.popup_dialog('lightning_open_channel_dialog') Button: size_hint: 0.3, None height: '48dp' - text: _('New...') + text: _('Swap') disabled: not root.has_lightning - on_release: popup.app.popup_dialog('lightning_open_channel_dialog') + on_release: popup.app.popup_dialog('swap_dialog') + Button: + size_hint: 0.3, None + height: '48dp' + text: _('Gossip') + on_release: popup.app.popup_dialog('lightning') <ChannelDetailsPopup@Popup>: @@ -332,6 +461,7 @@ class ChannelBackupPopup(Popup, Logger): self.app.wallet.lnbackups.remove_channel_backup(self.chan.channel_id) self.dismiss() + class ChannelDetailsPopup(Popup, Logger): def __init__(self, chan: Channel, app: 'ElectrumWindow', **kwargs): @@ -486,3 +616,189 @@ class LightningChannelsDialog(Factory.Popup): 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()) + + +# Swaps should be done in due time which is why we recommend a certain fee. +RECOMMEND_BLOCKS_SWAP = 25 + + +class SwapDialog(Factory.Popup): + def __init__(self, app: 'ElectrumWindow', config: 'SimpleConfig'): + super(SwapDialog, self).__init__() + self.app = app + self.config = config + self.fmt_amt = self.app.format_amount_and_units + self.lnworker = self.app.wallet.lnworker + + # swap related + self.swap_manager = self.lnworker.swap_manager + self.send_amount: Optional[int] = None + self.receive_amount: Optional[int] = None + self.tx = None # only for forward swap + + # init swaps and sliders + asyncio.run(self.swap_manager.get_pairs()) + self.update_and_init() + + def update_and_init(self): + self.update_fee_text() + self.update_swap_slider() + self.swap_slider_moved(0) + + def on_fee_button(self): + fee_dialog = FeeDialog(self, self.config, self.after_fee_changed) + fee_dialog.open() + + def after_fee_changed(self): + self.update_fee_text() + self.update_swap_slider() + self.swap_slider_moved(self.ids.swap_slider.value) + + def update_fee_text(self): + fee_per_kb = self.config.fee_per_kb() + # eta is -1 when block inclusion cannot be estimated for low fees + eta = self.config.fee_to_eta(fee_per_kb) + + fee_per_b = format_fee_satoshis(fee_per_kb / 1000) + suggest_fee = self.config.eta_target_to_fee(RECOMMEND_BLOCKS_SWAP) + suggest_fee_per_b = format_fee_satoshis(suggest_fee / 1000) + + s = 's' if eta > 1 else '' + if eta > RECOMMEND_BLOCKS_SWAP or eta == -1: + msg = f'Warning: Your fee rate of {fee_per_b} sat/B may be too ' \ + f'low for the swap to succeed before its timeout. ' \ + f'The recommended fee rate is at least {suggest_fee_per_b} ' \ + f'sat/B.' + else: + msg = f'Info: Your swap is estimated to be processed in {eta} ' \ + f'block{s} with an onchain fee rate of {fee_per_b} sat/B.' + + self.ids.fee_rate.text = f'{fee_per_b} sat/B' + self.ids.fee_estimate.text = msg + + def update_tx(self, onchain_amount: int): + """Updates the transaction associated with a forward swap.""" + if onchain_amount is None: + self.tx = None + self.ids.ok_button.disabled = True + return + outputs = [PartialTxOutput.from_address_and_value(ln_dummy_address(), onchain_amount)] + coins = self.app.wallet.get_spendable_coins(None) + try: + self.tx = self.app.wallet.make_unsigned_transaction( + coins=coins, + outputs=outputs) + except (NotEnoughFunds, NoDynamicFeeEstimates) as e: + self.tx = None + self.ids.ok_button.disabled = True + + def update_swap_slider(self): + """Sets the minimal and maximal amount that can be swapped for the swap + slider.""" + # tx is updated again afterwards with send_amount in case of normal swap + # this is just to estimate the maximal spendable onchain amount for HTLC + self.update_tx('!') + try: + max_onchain_spend = self.tx.output_value_for_address(ln_dummy_address()) + except AttributeError: # happens if there are no utxos + max_onchain_spend = 0 + reverse = int(min(self.lnworker.num_sats_can_send(), + self.swap_manager.get_max_amount())) + forward = int(min(self.lnworker.num_sats_can_receive(), + # maximally supported swap amount by provider + self.swap_manager.get_max_amount(), + max_onchain_spend)) + # we expect range to adjust the value of the swap slider to be in the + # correct range, i.e., to correct an overflow when reducing the limits + self.ids.swap_slider.range = (-reverse, forward) + + def swap_slider_moved(self, position: float): + position = int(position) + # pay_amount and receive_amounts are always with fees already included + # so they reflect the net balance change after the swap + if position < 0: # reverse swap + self.ids.swap_action_label.text = "Adds Lightning receiving capacity." + self.is_reverse = True + + pay_amount = abs(position) + self.send_amount = pay_amount + self.ids.send_amount_label.text = \ + f"{self.fmt_amt(pay_amount)} (offchain)" if pay_amount else "" + + receive_amount = self.swap_manager.get_recv_amount( + send_amount=pay_amount, is_reverse=True) + self.receive_amount = receive_amount + self.ids.receive_amount_label.text = \ + f"{self.fmt_amt(receive_amount)} (onchain)" if receive_amount else "" + + # fee breakdown + self.ids.server_fee_label.text = \ + f"{self.swap_manager.percentage:0.1f}% + {self.fmt_amt(self.swap_manager.lockup_fee)}" + self.ids.mining_fee_label.text = \ + f"{self.fmt_amt(self.swap_manager.get_claim_fee())}" + + else: # forward (normal) swap + self.ids.swap_action_label.text = f"Adds Lightning sending capacity." + self.is_reverse = False + self.send_amount = position + + self.update_tx(self.send_amount) + # add lockup fees, but the swap amount is position + pay_amount = position + self.tx.get_fee() if self.tx else 0 + self.ids.send_amount_label.text = \ + f"{self.fmt_amt(pay_amount)} (onchain)" if self.fmt_amt(pay_amount) else "" + + receive_amount = self.swap_manager.get_recv_amount( + send_amount=position, is_reverse=False) + self.receive_amount = receive_amount + self.ids.receive_amount_label.text = \ + f"{self.fmt_amt(receive_amount)} (offchain)" if receive_amount else "" + + # fee breakdown + self.ids.server_fee_label.text = \ + f"{self.swap_manager.percentage:0.1f}% + {self.fmt_amt(self.swap_manager.normal_fee)}" + self.ids.mining_fee_label.text = \ + f"{self.fmt_amt(self.tx.get_fee())}" if self.tx else "" + + if pay_amount and receive_amount: + self.ids.ok_button.disabled = False + else: + # add more nuanced error reporting? + self.ids.swap_action_label.text = "Swap below minimal swap size, change the slider." + self.ids.ok_button.disabled = True + + def do_normal_swap(self, lightning_amount, onchain_amount, password): + tx = self.tx + assert tx + if lightning_amount is None or onchain_amount is None: + return + loop = self.app.network.asyncio_loop + coro = self.swap_manager.normal_swap( + lightning_amount, onchain_amount, password, tx=tx) + asyncio.run_coroutine_threadsafe(coro, loop) + + def do_reverse_swap(self, lightning_amount, onchain_amount, password): + if lightning_amount is None or onchain_amount is None: + return + loop = self.app.network.asyncio_loop + coro = self.swap_manager.reverse_swap( + lightning_amount, onchain_amount + self.swap_manager.get_claim_fee()) + asyncio.run_coroutine_threadsafe(coro, loop) + + def on_ok(self): + if not self.app.network: + self.window.show_error(_("You are offline.")) + return + if self.is_reverse: + lightning_amount = self.send_amount + onchain_amount = self.receive_amount + self.app.protected( + 'Do you want to do a reverse submarine swap?', + self.do_reverse_swap, (lightning_amount, onchain_amount)) + else: + lightning_amount = self.receive_amount + onchain_amount = self.send_amount + self.app.protected( + 'Do you want to do a submarine swap? ' + 'You will need to wait for the swap transaction to confirm.', + self.do_normal_swap, (lightning_amount, onchain_amount))