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.
 
 
 
 

424 lines
15 KiB

import asyncio
import threading
import math
from typing import Union
from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QTimer
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
from .util import QtEventListener, qt_event_listener
class QESwapHelper(AuthMixin, QObject, QtEventListener):
_logger = get_logger(__name__)
confirm = pyqtSignal([str], arguments=['message'])
error = pyqtSignal([str], arguments=['message'])
swapStarted = pyqtSignal()
swapSuccess = pyqtSignal()
swapFailed = 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 = ' '.join([
_('Move the slider to set the amount and direction of the swap.'),
_('Swapping lightning funds for onchain funds will increase your capacity to receive lightning payments.'),
])
self._tosend = QEAmount()
self._toreceive = QEAmount()
self._serverfeeperc = ''
self._server_miningfee = QEAmount()
self._miningfee = QEAmount()
self._isReverse = False
self._service_available = False
self._send_amount = 0
self._receive_amount = 0
self._leftVoid = 0
self._rightVoid = 0
self.register_callbacks()
self.destroyed.connect(lambda: self.on_destroy())
self._fwd_swap_updatetx_timer = QTimer(self)
self._fwd_swap_updatetx_timer.setSingleShot(True)
# self._fwd_swap_updatetx_timer.setInterval(500)
self._fwd_swap_updatetx_timer.timeout.connect(self.fwd_swap_updatetx)
def on_destroy(self):
self.unregister_callbacks()
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()
leftVoidChanged = pyqtSignal()
@pyqtProperty(float, notify=leftVoidChanged)
def leftVoid(self):
return self._leftVoid
rightVoidChanged = pyqtSignal()
@pyqtProperty(float, notify=rightVoidChanged)
def rightVoid(self):
return self._rightVoid
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()
server_miningfeeChanged = pyqtSignal()
@pyqtProperty(QEAmount, notify=server_miningfeeChanged)
def server_miningfee(self):
return self._server_miningfee
@server_miningfee.setter
def server_miningfee(self, server_miningfee):
if self._server_miningfee != server_miningfee:
self._server_miningfee = server_miningfee
self.server_miningfeeChanged.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
if not lnworker:
return
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(lnworker.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
# percentage of void, right or left
if reverse < forward:
self._leftVoid = 0.5 * (forward - reverse) / forward
self._rightVoid = 0
elif reverse > forward:
self._leftVoid = 0
self._rightVoid = - 0.5 * (forward - reverse) / reverse
else:
self._leftVoid = 0
self._rightVoid = 0
self.leftVoidChanged.emit()
self.rightVoidChanged.emit()
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
@qt_event_listener
def on_event_fee_histogram(self, *args):
self.swap_slider_moved()
@qt_event_listener
def on_event_fee(self, *args):
self.swap_slider_moved()
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.isReverse = True
self._send_amount = abs(position)
self.tosend = QEAmount(amount_sat=self._send_amount)
self._receive_amount = swap_manager.get_recv_amount(
send_amount=self._send_amount, is_reverse=True)
self.toreceive = QEAmount(amount_sat=self._receive_amount)
# fee breakdown
self.serverfeeperc = f'{swap_manager.percentage:0.1f}%'
server_miningfee = swap_manager.lockup_fee
self.server_miningfee = QEAmount(amount_sat=server_miningfee)
self.miningfee = QEAmount(amount_sat=swap_manager.get_claim_fee())
self.check_valid(self._send_amount, self._receive_amount)
else: # forward (normal) swap
self.isReverse = False
self._send_amount = position
self.tosend = QEAmount(amount_sat=self._send_amount)
self._receive_amount = swap_manager.get_recv_amount(send_amount=position, is_reverse=False)
self.toreceive = QEAmount(amount_sat=self._receive_amount)
# fee breakdown
self.serverfeeperc = f'{swap_manager.percentage:0.1f}%'
server_miningfee = swap_manager.normal_fee
self.server_miningfee = QEAmount(amount_sat=server_miningfee)
# the slow stuff we delegate to a delay timer which triggers after slider
# doesn't update for a while
self.valid = False # wait for timer
self._fwd_swap_updatetx_timer.start(250)
def check_valid(self, send_amount, receive_amount):
if send_amount and receive_amount:
self.valid = True
else:
# add more nuanced error reporting?
self.valid = False
def fwd_swap_updatetx(self):
self.update_tx(self._send_amount)
# add lockup fees, but the swap amount is position
pay_amount = self._send_amount + self._tx.get_fee() if self._tx else 0
self.miningfee = QEAmount(amount_sat=self._tx.get_fee()) if self._tx else QEAmount()
self.check_valid(pay_amount, self._receive_amount)
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)
self.swapStarted.emit()
txid = fut.result()
self.swapSuccess.emit()
except Exception as e:
self._logger.error(str(e))
self.swapFailed.emit(str(e))
finally:
self.deleteLater()
threading.Thread(target=swap_task, daemon=True).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)
self.swapStarted.emit()
success = fut.result()
if success:
self.swapSuccess.emit()
else:
self.swapFailed.emit('')
except Exception as e:
self._logger.error(str(e))
self.swapFailed.emit(str(e))
finally:
self.deleteLater()
threading.Thread(target=swap_task, daemon=True).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)