1
0
Files
electrum/electrum/gui/qml/qeswaphelper.py
2025-03-13 17:31:42 +01:00

683 lines
26 KiB
Python

import asyncio
import concurrent
import threading
from enum import IntEnum
from typing import Union, Optional
from PyQt6.QtCore import (pyqtProperty, pyqtSignal, pyqtSlot, QObject, QTimer, pyqtEnum, QAbstractListModel, Qt,
QModelIndex)
from electrum.i18n import _
from electrum.bitcoin import DummyAddress
from electrum.logging import get_logger
from electrum.transaction import PartialTxOutput, PartialTransaction
from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates, profiler, get_asyncio_loop, age
from electrum.submarine_swaps import NostrTransport, SwapServerTransport
from electrum.fee_policy import FeePolicy
from electrum.gui import messages
from .auth import AuthMixin, auth_protect
from .qetypes import QEAmount
from .qewallet import QEWallet
from .util import QtEventListener, qt_event_listener
class InvalidSwapParameters(Exception): pass
class QESwapServerNPubListModel(QAbstractListModel):
_logger = get_logger(__name__)
# define listmodel rolemap
_ROLE_NAMES= ('npub', 'timestamp', 'percentage_fee', 'mining_fee', 'min_amount', 'max_amount')
_ROLE_KEYS = range(Qt.ItemDataRole.UserRole, Qt.ItemDataRole.UserRole + len(_ROLE_NAMES))
_ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES]))
def __init__(self, config, parent=None):
super().__init__(parent)
self.config = config
self._services = []
def rowCount(self, index):
return len(self._services)
# also expose rowCount as a property
countChanged = pyqtSignal()
@pyqtProperty(int, notify=countChanged)
def count(self):
return len(self._services)
def roleNames(self):
return self._ROLE_MAP
def data(self, index, role):
service = self._services[index.row()]
role_index = role - Qt.ItemDataRole.UserRole
value = service[self._ROLE_NAMES[role_index]]
if isinstance(value, (bool, list, int, str)) or value is None:
return value
return str(value)
def clear(self):
self.beginResetModel()
self._services = []
self.endResetModel()
def initModel(self, items):
self.beginInsertRows(QModelIndex(), len(items), len(items))
self._services = [{
'npub': x['pubkey'],
'percentage_fee': x['percentage_fee'],
'mining_fee': x['mining_fee'],
'min_amount': x['min_amount'],
'max_amount': x['max_amount'],
'timestamp': age(x['timestamp']),
} for x in items]
self.endInsertRows()
self.countChanged.emit()
@pyqtSlot(str, result=int)
def indexFor(self, npub: str):
for i, item in enumerate(self._services):
if npub == item['npub']:
return i
return -1
class QESwapHelper(AuthMixin, QObject, QtEventListener):
_logger = get_logger(__name__)
MESSAGE_SWAP_HOWTO = ' '.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.'),
])
@pyqtEnum
class State(IntEnum):
Initializing = 0
Initialized = 1
NoService = 2
ServiceReady = 3
Started = 4
Failed = 5
Success = 6
Cancelled = 7
confirm = pyqtSignal([str], arguments=['message'])
error = pyqtSignal([str], arguments=['message'])
undefinedNPub = pyqtSignal()
offersUpdated = pyqtSignal()
requestTxUpdate = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self._wallet = None # type: Optional[QEWallet]
self._sliderPos = 0
self._rangeMin = -1
self._rangeMax = 1
self._tx = None
self._valid = False
self._state = QESwapHelper.State.Initialized
self._userinfo = QESwapHelper.MESSAGE_SWAP_HOWTO
self._tosend = QEAmount()
self._toreceive = QEAmount()
self._serverfeeperc = ''
self._server_miningfee = QEAmount()
self._miningfee = QEAmount()
self._isReverse = False
self._canCancel = False
self._swap = None
self._fut_htlc_wait = None
self._service_available = False
self._send_amount = 0
self._receive_amount = 0
self._leftVoid = 0
self._rightVoid = 0
self._available_swapservers = None
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.timeout.connect(self.fwd_swap_updatetx)
self.requestTxUpdate.connect(self.tx_update_pushback_timer)
self.offersUpdated.connect(self.on_offers_updated)
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_manager()
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()
stateChanged = pyqtSignal()
@pyqtProperty(int, notify=stateChanged)
def state(self):
return self._state
@state.setter
def state(self, state):
if self._state != state:
self._state = state
self.stateChanged.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()
serverMiningfeeChanged = pyqtSignal()
@pyqtProperty(QEAmount, notify=serverMiningfeeChanged)
def serverMiningfee(self):
return self._server_miningfee
@serverMiningfee.setter
def serverMiningfee(self, server_miningfee):
if self._server_miningfee != server_miningfee:
self._server_miningfee = server_miningfee
self.serverMiningfeeChanged.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()
canCancelChanged = pyqtSignal()
@pyqtProperty(bool, notify=canCancelChanged)
def canCancel(self):
return self._canCancel
@canCancel.setter
def canCancel(self, canCancel):
if self._canCancel != canCancel:
self._canCancel = canCancel
self.canCancelChanged.emit()
availableSwapServersChanged = pyqtSignal()
@pyqtProperty(QESwapServerNPubListModel, notify=availableSwapServersChanged)
def availableSwapServers(self):
if not self._available_swapservers:
self._available_swapservers = QESwapServerNPubListModel(self._wallet.wallet.config)
return self._available_swapservers
def on_offers_updated(self):
self.availableSwapServers.initModel(self.recent_offers)
@pyqtSlot(result=bool)
def isNostr(self):
return True # TODO
@pyqtSlot()
def init_swap_manager(self):
self._logger.debug('init_swap_manager')
if (lnworker := self._wallet.wallet.lnworker) is None:
return
swap_manager = lnworker.swap_manager
assert not swap_manager.is_server, 'running as swap server not supported'
# if not self._wallet.wallet.config.SWAPSERVER_URL and not self._wallet.wallet.config.SWAPSERVER_NPUB: # TODO enable nostr
# self._logger.debug('nostr is preferred but swapserver npub still undefined')
# FIXME: clearing is_initialized, we might be called because the npub was changed
swap_manager.is_initialized.clear()
self.state = QESwapHelper.State.Initialized if swap_manager.is_initialized.is_set() else QESwapHelper.State.Initializing
swap_transport = swap_manager.create_transport()
def query_task(transport: SwapServerTransport):
with transport:
try:
async def wait_initialized():
try:
await asyncio.wait_for(swap_manager.is_initialized.wait(), timeout=15)
self._logger.debug('swapmanager initialized')
self.state = QESwapHelper.State.Initialized
except asyncio.TimeoutError:
self._logger.debug('swapmanager init timeout')
self.state = QESwapHelper.State.NoService
return
if not swap_manager.is_initialized.is_set():
self.userinfo = _('Initializing...')
fut = asyncio.run_coroutine_threadsafe(wait_initialized(), get_asyncio_loop())
fut.result()
except Exception as e:
try: # swaphelper might be destroyed at this point
self.userinfo = _('Error') + ': ' + str(e)
self.state = QESwapHelper.State.NoService
self._logger.error(str(e))
except RuntimeError:
pass
if isinstance(transport, NostrTransport):
if not swap_manager.is_initialized.is_set():
if not transport.is_connected.is_set():
self.userinfo = _('Error') + ': ' + '\n'.join([
_('Could not connect to a Nostr relay.'),
_('Please check your relays and network connection')
])
self.state = QESwapHelper.State.NoService
return
self.recent_offers = [x for x in transport.get_recent_offers()]
if not self.recent_offers:
self.userinfo = _('Could not find a swap provider.')
self.state = QESwapHelper.State.NoService
return
self.offersUpdated.emit()
self.undefinedNPub.emit()
return
else:
self.recent_offers = [x for x in transport.get_recent_offers()]
if not self.recent_offers:
self.userinfo = _('Could not find a swap provider.')
self.state = QESwapHelper.State.NoService
return
self.offersUpdated.emit()
self.state = QESwapHelper.State.ServiceReady
self.userinfo = QESwapHelper.MESSAGE_SWAP_HOWTO
self.init_swap_slider_range()
threading.Thread(target=query_task, args=(swap_transport,), daemon=True).start()
@pyqtSlot()
def npubSelectionCancelled(self):
if not self._wallet.wallet.config.SWAPSERVER_NPUB:
self._logger.debug('nostr is preferred but swapserver npub still undefined')
self.userinfo = _('No swap provider selected.')
self.state = QESwapHelper.State.NoService
def init_swap_slider_range(self):
lnworker = self._wallet.wallet.lnworker
swap_manager = lnworker.swap_manager
"""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(DummyAddress.SWAP)
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 = min(swap_manager.get_max_amount(), 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(DummyAddress.SWAP, onchain_amount)]
coins = self._wallet.wallet.get_spendable_coins(None)
fee_policy = FeePolicy(self._wallet.wallet.config.FEE_POLICY)
try:
self._tx = self._wallet.wallet.make_unsigned_transaction(
coins=coins,
outputs=outputs,
fee_policy=fee_policy)
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 self._state in [QESwapHelper.State.Initializing, QESwapHelper.State.Initialized, QESwapHelper.State.NoService]:
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
self.isReverse = (position < 0)
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=self.isReverse)
self.toreceive = QEAmount(amount_sat=self._receive_amount)
# fee breakdown
self.serverfeeperc = f'{swap_manager.percentage:0.1f}%'
server_miningfee = swap_manager.mining_fee
self.serverMiningfee = QEAmount(amount_sat=server_miningfee)
if self.isReverse:
self.check_valid(self._send_amount, self._receive_amount)
else:
# update tx only if slider isn't moved for a while
self.valid = False
# trigger tx_update_pushback_timer through signal, as this might be called from other thread
self.requestTxUpdate.emit()
def tx_update_pushback_timer(self):
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 = get_asyncio_loop()
def swap_task():
with self._wallet.wallet.lnworker.swap_manager.create_transport() as transport:
coro = self._wallet.wallet.lnworker.swap_manager.request_normal_swap(
transport,
lightning_amount_sat=lightning_amount,
expected_onchain_amount_sat=onchain_amount,
)
try:
dummy_tx = self._create_tx(onchain_amount)
fut = asyncio.run_coroutine_threadsafe(coro, loop)
self.userinfo = _('Performing swap...')
self.state = QESwapHelper.State.Started
self._swap, invoice = fut.result()
tx = self._wallet.wallet.lnworker.swap_manager.create_funding_tx(self._swap, dummy_tx, password=self._wallet.password)
coro2 = self._wallet.wallet.lnworker.swap_manager.wait_for_htlcs_and_broadcast(transport, swap=self._swap, invoice=invoice, tx=tx)
self._fut_htlc_wait = fut = asyncio.run_coroutine_threadsafe(coro2, loop)
self.canCancel = True
txid = fut.result()
try: # swaphelper might be destroyed at this point
if txid:
self.userinfo = ' '.join([
_('Success!'),
messages.MSG_FORWARD_SWAP_FUNDING_MEMPOOL,
])
self.state = QESwapHelper.State.Success
else:
self.userinfo = _('Swap failed!')
self.state = QESwapHelper.State.Failed
except RuntimeError:
pass
except concurrent.futures.CancelledError:
self._wallet.wallet.lnworker.swap_manager.cancel_normal_swap(self._swap)
self.userinfo = _('Swap cancelled')
self.state = QESwapHelper.State.Cancelled
except Exception as e:
try: # swaphelper might be destroyed at this point
self.state = QESwapHelper.State.Failed
self.userinfo = _('Error') + ': ' + str(e)
self._logger.error(str(e))
except RuntimeError:
pass
finally:
try: # swaphelper might be destroyed at this point
self.canCancel = False
self._swap = None
self._fut_htlc_wait = None
except RuntimeError:
pass
threading.Thread(target=swap_task, daemon=True).start()
def _create_tx(self, onchain_amount: Union[int, str, None]) -> PartialTransaction:
# TODO: func taken from qt GUI, this should be common code
assert not self.isReverse
if onchain_amount is None:
raise InvalidSwapParameters("onchain_amount is None")
# coins = self.window.get_coins()
coins = self._wallet.wallet.get_spendable_coins()
if onchain_amount == '!':
max_amount = sum(c.value_sats() for c in coins)
max_swap_amount = self._wallet.wallet.lnworker.swap_manager.max_amount_forward_swap()
if max_swap_amount is None:
raise InvalidSwapParameters("swap_manager.max_amount_forward_swap() is None")
if max_amount > max_swap_amount:
onchain_amount = max_swap_amount
outputs = [PartialTxOutput.from_address_and_value(DummyAddress.SWAP, onchain_amount)]
fee_policy = FeePolicy(self._wallet.wallet.config.FEE_POLICY)
try:
tx = self._wallet.wallet.make_unsigned_transaction(
coins=coins,
outputs=outputs,
send_change_to_lightning=False,
fee_policy=fee_policy
)
except (NotEnoughFunds, NoDynamicFeeEstimates) as e:
raise InvalidSwapParameters(str(e)) from e
return tx
def do_reverse_swap(self, lightning_amount, onchain_amount):
if lightning_amount is None or onchain_amount is None:
return
def swap_task():
swap_manager = self._wallet.wallet.lnworker.swap_manager
loop = get_asyncio_loop()
with self._wallet.wallet.lnworker.swap_manager.create_transport() as transport:
async def coro():
await swap_manager.is_initialized.wait()
return await swap_manager.reverse_swap(
transport,
lightning_amount_sat=lightning_amount,
expected_onchain_amount_sat=onchain_amount + swap_manager.get_swap_tx_fee(),
)
try:
fut = asyncio.run_coroutine_threadsafe(coro(), loop)
self.userinfo = _('Performing swap...')
self.state = QESwapHelper.State.Started
txid = fut.result()
try: # swaphelper might be destroyed at this point
if txid:
self.userinfo = ' '.join([
_('Success!'),
messages.MSG_REVERSE_SWAP_FUNDING_MEMPOOL,
])
self.state = QESwapHelper.State.Success
else:
self.userinfo = _('Swap failed!')
self.state = QESwapHelper.State.Failed
except RuntimeError:
pass
except Exception as e:
try: # swaphelper might be destroyed at this point
self.state = QESwapHelper.State.Failed
msg = _('Timeout') if isinstance(e, TimeoutError) else str(e)
self.userinfo = _('Error') + ': ' + msg
self._logger.error(str(e))
except RuntimeError:
pass
threading.Thread(target=swap_task, daemon=True).start()
@pyqtSlot()
def executeSwap(self):
if not self._wallet.wallet.network:
self.error.emit(_("You are offline."))
return
self._do_execute_swap()
@auth_protect(message=_('Confirm Lightning swap?'))
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)
@pyqtSlot()
def cancelNormalSwap(self):
assert self._swap
self.canCancel = False
self._fut_htlc_wait.cancel()