(this somehow escaped attention before, as most objects usually don't have multiple instances, unless multiple wallets are open at the same time.) Also, move all signal declarations, class constants and variables to the top of class definitions.
358 lines
12 KiB
Python
358 lines
12 KiB
Python
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__)
|
|
|
|
error = pyqtSignal([str], arguments=['message'])
|
|
confirm = pyqtSignal([str], arguments=['message'])
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
|
|
self._wallet = None
|
|
self._sliderPos = 0
|
|
self._rangeMin = 0
|
|
self._rangeMax = 0
|
|
self._tx = None
|
|
self._valid = False
|
|
self._userinfo = ''
|
|
self._tosend = QEAmount()
|
|
self._toreceive = QEAmount()
|
|
self._serverfeeperc = ''
|
|
self._serverfee = QEAmount()
|
|
self._miningfee = QEAmount()
|
|
self._isReverse = False
|
|
|
|
self._service_available = False
|
|
self._send_amount = 0
|
|
self._receive_amount = 0
|
|
|
|
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)
|