swaps: add swaps to android
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user