diff --git a/contrib/requirements/requirements.txt b/contrib/requirements/requirements.txt index d3d9e3f8bd4..30949f68369 100644 --- a/contrib/requirements/requirements.txt +++ b/contrib/requirements/requirements.txt @@ -8,7 +8,7 @@ certifi attrs>=20.1.0 jsonpatch electrum_ecc -electrum_aionostr>=0.0.6 +electrum_aionostr>=0.0.7 # Note that we also need the dnspython[DNSSEC] extra which pulls in cryptography, # but as that is not pure-python it cannot be listed in this file! diff --git a/electrum/gui/qml/components/Constants.qml b/electrum/gui/qml/components/Constants.qml index ff88c600954..6fc4fc9713a 100644 --- a/electrum/gui/qml/components/Constants.qml +++ b/electrum/gui/qml/components/Constants.qml @@ -44,6 +44,7 @@ Item { property color colorValidBackground: '#ff008000' property color colorInvalidBackground: '#ff800000' property color colorAcceptable: '#ff8080ff' + property color colorOk: colorDone property color colorLightningLocal: "#6060ff" property color colorLightningLocalReserve: "#0000a0" diff --git a/electrum/gui/qml/components/NostrSwapServersDialog.qml b/electrum/gui/qml/components/NostrSwapServersDialog.qml new file mode 100644 index 00000000000..86d70848f59 --- /dev/null +++ b/electrum/gui/qml/components/NostrSwapServersDialog.qml @@ -0,0 +1,140 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import QtQuick.Controls.Material + +import org.electrum 1.0 + +import "controls" + +ElDialog { + id: dialog + title: qsTr("Select Swap Server") + + property QtObject swaphelper + + property string selectedPubkey + + anchors.centerIn: parent + + padding: 0 + + width: parent.width * 4/5 + height: parent.height * 4/5 + + ColumnLayout { + id: rootLayout + width: parent.width + height: parent.height + + Frame { + id: accountsFrame + Layout.fillWidth: true + Layout.fillHeight: true + Layout.topMargin: constants.paddingLarge + Layout.bottomMargin: constants.paddingLarge + Layout.leftMargin: constants.paddingMedium + Layout.rightMargin: constants.paddingMedium + + verticalPadding: 0 + horizontalPadding: 0 + background: PaneInsetBackground {} + + ColumnLayout { + spacing: 0 + anchors.fill: parent + + ListView { + id: listview + Layout.preferredWidth: parent.width + Layout.fillHeight: true + clip: true + model: swaphelper.availableSwapServers + + delegate: ItemDelegate { + width: ListView.view.width + height: itemLayout.height + + onClicked: { + dialog.selectedPubkey = model.npub + dialog.doAccept() + } + + GridLayout { + id: itemLayout + columns: 3 + rowSpacing: 0 + + anchors { + left: parent.left + right: parent.right + leftMargin: constants.paddingMedium + rightMargin: constants.paddingMedium + } + + Item { + Layout.columnSpan: 3 + Layout.preferredHeight: constants.paddingLarge + Layout.preferredWidth: 1 + } + Image { + Layout.rowSpan: 3 + source: Qt.resolvedUrl('../../icons/network.png') + } + Label { + text: qsTr('npub') + color: Material.accentColor + } + Label { + Layout.fillWidth: true + text: model.npub.substring(0,10) + wrapMode: Text.Wrap + } + Label { + text: qsTr('fee') + color: Material.accentColor + } + Label { + Layout.fillWidth: true + text: model.percentage_fee + '%' + } + Label { + text: qsTr('last seen') + color: Material.accentColor + } + Label { + Layout.fillWidth: true + text: model.timestamp + } + Item { + Layout.columnSpan: 3 + Layout.preferredHeight: constants.paddingLarge + Layout.preferredWidth: 1 + } + } + } + + ScrollIndicator.vertical: ScrollIndicator { } + + Label { + visible: swaphelper.availableSwapServers.count == 0 + anchors.centerIn: parent + width: listview.width * 4/5 + font.pixelSize: constants.fontSizeXXLarge + color: constants.mutedForeground + text: qsTr('No swap servers found') + wrapMode: Text.Wrap + horizontalAlignment: Text.AlignHCenter + } + + } + } + } + } + + Component.onCompleted: { + if (dialog.selectedPubkey) { + listview.currentIndex = swaphelper.availableSwapServers.indexFor(dialog.selectedPubkey) + } + } +} diff --git a/electrum/gui/qml/components/SwapDialog.qml b/electrum/gui/qml/components/SwapDialog.qml index 30a3fd46eb2..759b6890a31 100644 --- a/electrum/gui/qml/components/SwapDialog.qml +++ b/electrum/gui/qml/components/SwapDialog.qml @@ -33,13 +33,15 @@ ElDialog { Layout.alignment: Qt.AlignHCenter visible: swaphelper.userinfo != '' text: swaphelper.userinfo - iconStyle: swaphelper.state == SwapHelper.Started + iconStyle: swaphelper.state == SwapHelper.Started || swaphelper.state == SwapHelper.Initializing ? InfoTextArea.IconStyle.Spinner : swaphelper.state == SwapHelper.Failed || swaphelper.state == SwapHelper.Cancelled ? InfoTextArea.IconStyle.Error : swaphelper.state == SwapHelper.Success ? InfoTextArea.IconStyle.Done - : InfoTextArea.IconStyle.Info + : swaphelper.state == SwapHelper.NoService + ? InfoTextArea.IconStyle.Warn + : InfoTextArea.IconStyle.Info } GridLayout { @@ -170,7 +172,7 @@ ElDialog { Layout.leftMargin: constants.paddingXXLarge + (parent.width - 2 * constants.paddingXXLarge) * swaphelper.leftVoid Layout.rightMargin: constants.paddingXXLarge + (parent.width - 2 * constants.paddingXXLarge) * swaphelper.rightVoid - property real scenter: -swapslider.from/(swapslider.to-swapslider.from) + property real scenter: -swapslider.from / (swapslider.to - swapslider.from) enabled: swaphelper.state == SwapHelper.ServiceReady || swaphelper.state == SwapHelper.Failed @@ -193,21 +195,21 @@ ElDialog { x: swapslider.visualPosition > swapslider.scenter ? swapslider.scenter * parent.rangeWidth : swapslider.visualPosition * parent.rangeWidth + y: enabled ? -1 : 0 width: swapslider.visualPosition > swapslider.scenter ? (swapslider.visualPosition-swapslider.scenter) * parent.rangeWidth : (swapslider.scenter-swapslider.visualPosition) * parent.rangeWidth - height: parent.height + height: enabled ? parent.height + 2 : parent.height color: enabled - ? Material.accentColor + ? constants.colorOk : Material.sliderDisabledColor - radius: 2 } Rectangle { x: - (swapslider.parent.width - 2 * constants.paddingXXLarge) * swaphelper.leftVoid z: -1 // width makes rectangle go outside the control, into the Layout margins - width: parent.width + (swapslider.parent.width - 2 * constants.paddingXXLarge) * swaphelper.rightVoid + width: swapslider.parent.width - 2 * constants.paddingXXLarge - swapslider.leftPadding - swapslider.rightPadding height: parent.height color: Material.sliderDisabledColor } @@ -249,6 +251,34 @@ ElDialog { } } + + Pane { + Layout.alignment: Qt.AlignHCenter + visible: _swaphelper.isNostr() + background: Rectangle { color: constants.darkerDialogBackground } + padding: 0 + + FlatButton { + text: qsTr('Choose swap provider') + enabled: _swaphelper.state != SwapHelper.Initializing + && _swaphelper.state != SwapHelper.Success + && _swaphelper.availableSwapServers.count + onClicked: { + var dialog = app.nostrSwapServersDialog.createObject(app, { + swaphelper: _swaphelper, + selectedPubkey: Config.swapServerNPub + }) + dialog.accepted.connect(function() { + if (Config.swapServerNPub != dialog.selectedPubkey) { + Config.swapServerNPub = dialog.selectedPubkey + _swaphelper.init_swap_manager() + } + }) + dialog.open() + } + } + } + Item { Layout.fillHeight: true; Layout.preferredWidth: 1 } ButtonContainer { diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index e89afc2278e..317a869e19c 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -436,9 +436,18 @@ ApplicationWindow } } + property alias nostrSwapServersDialog: _nostrSwapServersDialog + Component { + id: _nostrSwapServersDialog + NostrSwapServersDialog { + onClosed: destroy() + } + } + Component { id: swapDialog SwapDialog { + id: _swapdialog onClosed: destroy() swaphelper: SwapHelper { id: _swaphelper @@ -454,6 +463,20 @@ ApplicationWindow }) dialog.open() } + onUndefinedNPub: { + var dialog = app.nostrSwapServersDialog.createObject(app, { + swaphelper: _swaphelper, + selectedPubkey: Config.swapServerNPub + }) + dialog.accepted.connect(function() { + Config.swapServerNPub = dialog.selectedPubkey + _swaphelper.init_swap_manager() + }) + dialog.rejected.connect(function() { + _swaphelper.npubSelectionCancelled() + }) + dialog.open() + } } } } diff --git a/electrum/gui/qml/qeconfig.py b/electrum/gui/qml/qeconfig.py index 7eb6c9715bc..ffeda787d72 100644 --- a/electrum/gui/qml/qeconfig.py +++ b/electrum/gui/qml/qeconfig.py @@ -276,6 +276,17 @@ def lightningPaymentFeeMaxMillionths(self, lightningPaymentFeeMaxMillionths): self.config.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS = lightningPaymentFeeMaxMillionths self.lightningPaymentFeeMaxMillionthsChanged.emit() + swapServerNPubChanged = pyqtSignal() + @pyqtProperty(str, notify=swapServerNPubChanged) + def swapServerNPub(self): + return self.config.SWAPSERVER_NPUB + + @swapServerNPub.setter + def swapServerNPub(self, swapserver_npub): + if swapserver_npub != self.config.SWAPSERVER_NPUB: + self.config.SWAPSERVER_NPUB = swapserver_npub + self.swapServerNPubChanged.emit() + @pyqtSlot('qint64', result=str) @pyqtSlot(QEAmount, result=str) def formatSatsForEditing(self, satoshis): diff --git a/electrum/gui/qml/qeswaphelper.py b/electrum/gui/qml/qeswaphelper.py index 9513e02ebaf..24dfb39be42 100644 --- a/electrum/gui/qml/qeswaphelper.py +++ b/electrum/gui/qml/qeswaphelper.py @@ -1,16 +1,19 @@ import asyncio import concurrent import threading +import time from enum import IntEnum from typing import Union, Optional -from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QTimer, pyqtEnum +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 +from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates, profiler, get_asyncio_loop, age +from electrum.submarine_swaps import NostrTransport from electrum.gui import messages @@ -23,35 +26,106 @@ class InvalidSwapParameters(Exception): pass +class QESwapServerNPubListModel(QAbstractListModel): + _logger = get_logger(__name__) + + # define listmodel rolemap + _ROLE_NAMES= ('npub', 'timestamp', 'percentage_fee', 'normal_mining_fee', 'reverse_mining_fee', 'claim_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'], + 'normal_mining_fee': x['normal_mining_fee'], + 'reverse_mining_fee': x['reverse_mining_fee'], + 'claim_mining_fee': x['claim_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): - Initialized = 0 - ServiceReady = 1 - Started = 2 - Failed = 3 - Success = 4 - Cancelled = 5 + 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 = 0 - self._rangeMax = 0 + self._rangeMin = -1 + self._rangeMax = 1 self._tx = None self._valid = False self._state = QESwapHelper.State.Initialized - 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._userinfo = QESwapHelper.MESSAGE_SWAP_HOWTO self._tosend = QEAmount() self._toreceive = QEAmount() self._serverfeeperc = '' @@ -69,13 +143,17 @@ def __init__(self, parent=None): 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.setInterval(500) 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() @@ -89,7 +167,7 @@ def wallet(self): def wallet(self, wallet: QEWallet): if self._wallet != wallet: self._wallet = wallet - self.init_swap_slider_range() + self.init_swap_manager() self.walletChanged.emit() sliderPosChanged = pyqtSignal() @@ -246,18 +324,108 @@ def canCancel(self, 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): + 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): + now = int(time.time()) + 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.offers.values() if now - x['timestamp'] < NostrTransport.NOSTR_EVENT_TIMEOUT] + 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.offers.values() if now - x['timestamp'] < NostrTransport.NOSTR_EVENT_TIMEOUT] + 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 - if not lnworker: - return swap_manager = lnworker.swap_manager - try: - asyncio.run(swap_manager.get_pairs()) - self.state = QESwapHelper.State.ServiceReady - 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.""" @@ -322,7 +490,7 @@ def on_event_fee(self, *args): self.swap_slider_moved() def swap_slider_moved(self): - if self._state == QESwapHelper.State.Initialized: + if self._state in [QESwapHelper.State.Initializing, QESwapHelper.State.Initialized, QESwapHelper.State.NoService]: return position = int(self._sliderPos) @@ -345,7 +513,11 @@ def swap_slider_moved(self): else: # update tx only if slider isn't moved for a while self.valid = False - self._fwd_swap_updatetx_timer.start(250) + # 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: @@ -366,55 +538,57 @@ def do_normal_swap(self, lightning_amount, onchain_amount): if lightning_amount is None or onchain_amount is None: return loop = get_asyncio_loop() - coro = self._wallet.wallet.lnworker.swap_manager.request_normal_swap( - lightning_amount_sat=lightning_amount, - expected_onchain_amount_sat=onchain_amount, - ) def swap_task(): - 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(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!') + 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 - 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 + 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() @@ -446,38 +620,42 @@ def _create_tx(self, onchain_amount: Union[int, str, None]) -> PartialTransactio 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 = get_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.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!') + swap_manager = self._wallet.wallet.lnworker.swap_manager + loop = get_asyncio_loop() + with self._wallet.wallet.lnworker.swap_manager.create_transport() as transport: + coro = swap_manager.reverse_swap( + transport, + lightning_amount_sat=lightning_amount, + expected_onchain_amount_sat=onchain_amount + swap_manager.get_claim_fee(), + ) + try: + time.sleep(1) # FIXME: this is needed because transport hasn't finished initializing yet. + 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 - except RuntimeError: - pass - 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 + 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() diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index aea6eaaa9f3..4f9614d6d16 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -1252,7 +1252,7 @@ def choose_swapserver_dialog(self, transport): sm = self.wallet.lnworker.swap_manager def descr(x): last_seen = util.age(x['timestamp']) - return f"pubkey={x['pubkey'][0:10]}, fee={x['percentage_fee']}% + {x['reverse_mining_fee']} sats" + return f"pubkey={x['pubkey'][0:10]}, fee={x['percentage_fee']}% + {x['reverse_mining_fee']} sats, {last_seen=}" server_keys = [(x['pubkey'], descr(x)) for x in recent_offers] msg = '\n'.join([ _("Please choose a server from this list."), diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py index 0d30b0bcffe..bcb40b6e550 100644 --- a/electrum/submarine_swaps.py +++ b/electrum/submarine_swaps.py @@ -1,6 +1,7 @@ import asyncio import json import os +import ssl from typing import TYPE_CHECKING, Optional, Dict, Union, Sequence, Tuple, Iterable from decimal import Decimal import math @@ -24,7 +25,7 @@ construct_witness) from .transaction import PartialTxInput, PartialTxOutput, PartialTransaction, Transaction, TxInput, TxOutpoint from .transaction import script_GetOp, match_script_against_template, OPPushDataGeneric, OPPushDataPubkey -from .util import log_exceptions, ignore_exceptions, BelowDustLimit, OldTaskGroup, age +from .util import log_exceptions, ignore_exceptions, BelowDustLimit, OldTaskGroup, age, ca_path from .lnutil import REDEEM_AFTER_DOUBLE_SPENT_DELAY from .bitcoin import dust_threshold, DummyAddress from .logging import Logger @@ -856,6 +857,7 @@ async def reverse_swap( "preimageHash": payment_hash.hex(), "claimPublicKey": our_pubkey.hex() } + self.logger.debug(f'rswap: sending request for {lightning_amount_sat}') data = await transport.send_request_to_server('createswap', request_data) invoice = data['invoice'] fee_invoice = data.get('minerFeeInvoice') @@ -864,6 +866,7 @@ async def reverse_swap( locktime = data['timeoutBlockHeight'] onchain_amount = data["onchainAmount"] response_id = data['id'] + self.logger.debug(f'rswap: {response_id=}') # verify redeem_script is built with our pubkey and preimage check_reverse_redeem_script( redeem_script=redeem_script, @@ -1291,7 +1294,6 @@ async def get_pairs(self) -> None: self.sm.update_pairs(pairs) - class NostrTransport(Logger): # uses nostr: # - to advertise servers @@ -1313,7 +1315,8 @@ def __init__(self, config, sm, keypair): self.nostr_private_key = to_nip19('nsec', keypair.privkey.hex()) self.nostr_pubkey = keypair.pubkey.hex()[2:] self.dm_replies = defaultdict(asyncio.Future) # type: Dict[bytes, asyncio.Future] - self.relay_manager = aionostr.Manager(self.relays, private_key=self.nostr_private_key) + ssl_context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH, cafile=ca_path) + self.relay_manager = aionostr.Manager(self.relays, private_key=self.nostr_private_key, log=self.logger, ssl_context=ssl_context) self.taskgroup = OldTaskGroup() self.is_connected = asyncio.Event() self.server_relays = None @@ -1360,6 +1363,7 @@ async def stop(self): self.sm.is_initialized.clear() await self.taskgroup.cancel_remaining() await self.relay_manager.close() + self.logger.info("nostr transport shut down") @property def relays(self): @@ -1432,6 +1436,9 @@ async def receive_offers(self): continue if content.get('network') != constants.net.NET_NAME: continue + if now() - event.created_at > self.NOSTR_EVENT_TIMEOUT: + self.logger.debug(f'offer for swap server too old (pub={event.pubkey})') + continue # check if this is the most recent event for this pubkey pubkey = event.pubkey ts = self.offers.get(pubkey, {}).get('timestamp', 0) @@ -1457,6 +1464,9 @@ async def get_pairs(self): continue if content.get('network') != constants.net.NET_NAME: continue + if now() - event.created_at > self.NOSTR_EVENT_TIMEOUT: + self.logger.debug(f'pair for selected swap server too old (pub={event.pubkey})') + continue # check if this is the most recent event for this pubkey pubkey = event.pubkey content['pubkey'] = pubkey @@ -1492,7 +1502,7 @@ async def handle_request(self, request): method = request.pop('method') event_id = request.pop('event_id') event_pubkey = request.pop('event_pubkey') - print(f'handle_request: id={event_id} {method} {request}') + self.logger.info(f'handle_request: id={event_id} {method} {request}') relays = request.pop('relays').split(',') if method == 'addswapinvoice': r = self.sm.server_add_swap_invoice(request)