You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

357 lines
12 KiB

import asyncio
import threading
import math
from typing import Union
from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject
from electrum.i18n import _
from electrum.lnutil import ln_dummy_address
from electrum.logging import get_logger
from electrum.transaction import PartialTxOutput
from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates, profiler
from .auth import AuthMixin, auth_protect
from .qetypes import QEAmount
from .qewallet import QEWallet
class QESwapHelper(AuthMixin, QObject):
_logger = get_logger(__name__)
_wallet = None
_sliderPos = 0
_rangeMin = 0
_rangeMax = 0
_tx = None
_valid = False
_userinfo = ''
_tosend = QEAmount()
_toreceive = QEAmount()
_serverfeeperc = ''
_serverfee = QEAmount()
_miningfee = QEAmount()
_isReverse = False
_service_available = False
_send_amount = 0
_receive_amount = 0
error = pyqtSignal([str], arguments=['message'])
confirm = pyqtSignal([str], arguments=['message'])
def __init__(self, parent=None):
super().__init__(parent)
walletChanged = pyqtSignal()
@pyqtProperty(QEWallet, notify=walletChanged)
def wallet(self):
return self._wallet
@wallet.setter
def wallet(self, wallet: QEWallet):
if self._wallet != wallet:
self._wallet = wallet
self.init_swap_slider_range()
self.walletChanged.emit()
sliderPosChanged = pyqtSignal()
@pyqtProperty(float, notify=sliderPosChanged)
def sliderPos(self):
return self._sliderPos
@sliderPos.setter
def sliderPos(self, sliderPos):
if self._sliderPos != sliderPos:
self._sliderPos = sliderPos
self.swap_slider_moved()
self.sliderPosChanged.emit()
rangeMinChanged = pyqtSignal()
@pyqtProperty(float, notify=rangeMinChanged)
def rangeMin(self):
return self._rangeMin
@rangeMin.setter
def rangeMin(self, rangeMin):
if self._rangeMin != rangeMin:
self._rangeMin = rangeMin
self.rangeMinChanged.emit()
rangeMaxChanged = pyqtSignal()
@pyqtProperty(float, notify=rangeMaxChanged)
def rangeMax(self):
return self._rangeMax
@rangeMax.setter
def rangeMax(self, rangeMax):
if self._rangeMax != rangeMax:
self._rangeMax = rangeMax
self.rangeMaxChanged.emit()
validChanged = pyqtSignal()
@pyqtProperty(bool, notify=validChanged)
def valid(self):
return self._valid
@valid.setter
def valid(self, valid):
if self._valid != valid:
self._valid = valid
self.validChanged.emit()
userinfoChanged = pyqtSignal()
@pyqtProperty(str, notify=userinfoChanged)
def userinfo(self):
return self._userinfo
@userinfo.setter
def userinfo(self, userinfo):
if self._userinfo != userinfo:
self._userinfo = userinfo
self.userinfoChanged.emit()
tosendChanged = pyqtSignal()
@pyqtProperty(QEAmount, notify=tosendChanged)
def tosend(self):
return self._tosend
@tosend.setter
def tosend(self, tosend):
if self._tosend != tosend:
self._tosend = tosend
self.tosendChanged.emit()
toreceiveChanged = pyqtSignal()
@pyqtProperty(QEAmount, notify=toreceiveChanged)
def toreceive(self):
return self._toreceive
@toreceive.setter
def toreceive(self, toreceive):
if self._toreceive != toreceive:
self._toreceive = toreceive
self.toreceiveChanged.emit()
serverfeeChanged = pyqtSignal()
@pyqtProperty(QEAmount, notify=serverfeeChanged)
def serverfee(self):
return self._serverfee
@serverfee.setter
def serverfee(self, serverfee):
if self._serverfee != serverfee:
self._serverfee = serverfee
self.serverfeeChanged.emit()
serverfeepercChanged = pyqtSignal()
@pyqtProperty(str, notify=serverfeepercChanged)
def serverfeeperc(self):
return self._serverfeeperc
@serverfeeperc.setter
def serverfeeperc(self, serverfeeperc):
if self._serverfeeperc != serverfeeperc:
self._serverfeeperc = serverfeeperc
self.serverfeepercChanged.emit()
miningfeeChanged = pyqtSignal()
@pyqtProperty(QEAmount, notify=miningfeeChanged)
def miningfee(self):
return self._miningfee
@miningfee.setter
def miningfee(self, miningfee):
if self._miningfee != miningfee:
self._miningfee = miningfee
self.miningfeeChanged.emit()
isReverseChanged = pyqtSignal()
@pyqtProperty(bool, notify=isReverseChanged)
def isReverse(self):
return self._isReverse
@isReverse.setter
def isReverse(self, isReverse):
if self._isReverse != isReverse:
self._isReverse = isReverse
self.isReverseChanged.emit()
def init_swap_slider_range(self):
lnworker = self._wallet.wallet.lnworker
swap_manager = lnworker.swap_manager
try:
asyncio.run(swap_manager.get_pairs())
self._service_available = True
except Exception as e:
self.error.emit(_('Swap service unavailable'))
self._logger.error(f'could not get pairs for swap: {repr(e)}')
return
"""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(lnworker.num_sats_can_send(),
swap_manager.get_max_amount()))
max_recv_amt_ln = int(swap_manager.num_sats_can_receive())
max_recv_amt_oc = swap_manager.get_send_amount(max_recv_amt_ln, is_reverse=False) or 0
forward = int(min(max_recv_amt_oc,
# maximally supported swap amount by provider
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._logger.debug(f'Slider range {-reverse} - {forward}')
self.rangeMin = -reverse
self.rangeMax = forward
self.swap_slider_moved()
@profiler
def update_tx(self, onchain_amount: Union[int, str]):
"""Updates the transaction associated with a forward swap."""
if onchain_amount is None:
self._tx = None
self.valid = False
return
outputs = [PartialTxOutput.from_address_and_value(ln_dummy_address(), onchain_amount)]
coins = self._wallet.wallet.get_spendable_coins(None)
try:
self._tx = self._wallet.wallet.make_unsigned_transaction(
coins=coins,
outputs=outputs)
except (NotEnoughFunds, NoDynamicFeeEstimates):
self._tx = None
self.valid = False
def swap_slider_moved(self):
if not self._service_available:
return
position = int(self._sliderPos)
swap_manager = self._wallet.wallet.lnworker.swap_manager
# 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.userinfo = _('Adds Lightning receiving capacity.')
self.isReverse = True
pay_amount = abs(position)
self._send_amount = pay_amount
self.tosend = QEAmount(amount_sat=pay_amount)
receive_amount = swap_manager.get_recv_amount(
send_amount=pay_amount, is_reverse=True)
self._receive_amount = receive_amount
self.toreceive = QEAmount(amount_sat=receive_amount)
# fee breakdown
self.serverfeeperc = f'{swap_manager.percentage:0.1f}%'
serverfee = math.ceil(swap_manager.percentage * pay_amount / 100) + swap_manager.lockup_fee
self.serverfee = QEAmount(amount_sat=serverfee)
self.miningfee = QEAmount(amount_sat=swap_manager.get_claim_fee())
else: # forward (normal) swap
self.userinfo = _('Adds Lightning sending capacity.')
self.isReverse = 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.tosend = QEAmount(amount_sat=pay_amount)
receive_amount = swap_manager.get_recv_amount(send_amount=position, is_reverse=False)
self._receive_amount = receive_amount
self.toreceive = QEAmount(amount_sat=receive_amount)
# fee breakdown
self.serverfeeperc = f'{swap_manager.percentage:0.1f}%'
serverfee = math.ceil(swap_manager.percentage * pay_amount / 100) + swap_manager.normal_fee
self.serverfee = QEAmount(amount_sat=serverfee)
self.miningfee = QEAmount(amount_sat=self._tx.get_fee()) if self._tx else QEAmount()
if pay_amount and receive_amount:
self.valid = True
else:
# add more nuanced error reporting?
self.userinfo = _('Swap below minimal swap size, change the slider.')
self.valid = False
def do_normal_swap(self, lightning_amount, onchain_amount):
assert self._tx
if lightning_amount is None or onchain_amount is None:
return
loop = self._wallet.wallet.network.asyncio_loop
coro = self._wallet.wallet.lnworker.swap_manager.normal_swap(
lightning_amount_sat=lightning_amount,
expected_onchain_amount_sat=onchain_amount,
password=self._wallet.password,
tx=self._tx,
)
def swap_task():
try:
fut = asyncio.run_coroutine_threadsafe(coro, loop)
result = fut.result()
except Exception as e:
self._logger.error(str(e))
self.error.emit(str(e))
threading.Thread(target=swap_task).start()
def do_reverse_swap(self, lightning_amount, onchain_amount):
if lightning_amount is None or onchain_amount is None:
return
swap_manager = self._wallet.wallet.lnworker.swap_manager
loop = self._wallet.wallet.network.asyncio_loop
coro = swap_manager.reverse_swap(
lightning_amount_sat=lightning_amount,
expected_onchain_amount_sat=onchain_amount + swap_manager.get_claim_fee(),
)
def swap_task():
try:
fut = asyncio.run_coroutine_threadsafe(coro, loop)
result = fut.result()
except Exception as e:
self._logger.error(str(e))
self.error.emit(str(e))
threading.Thread(target=swap_task).start()
@pyqtSlot()
@pyqtSlot(bool)
def executeSwap(self, confirm=False):
if not self._wallet.wallet.network:
self.error.emit(_("You are offline."))
return
if confirm:
self._do_execute_swap()
return
if self.isReverse:
self.confirm.emit(_('Do you want to do a reverse submarine swap?'))
else:
self.confirm.emit(_('Do you want to do a submarine swap? '
'You will need to wait for the swap transaction to confirm.'
))
@auth_protect
def _do_execute_swap(self):
if self.isReverse:
lightning_amount = self._send_amount
onchain_amount = self._receive_amount
self.do_reverse_swap(lightning_amount, onchain_amount)
else:
lightning_amount = self._receive_amount
onchain_amount = self._send_amount
self.do_normal_swap(lightning_amount, onchain_amount)