From b2fafcb428f76b700ad46b7955a9d378b38ade56 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 30 Jun 2022 15:06:45 +0200 Subject: [PATCH] add initial submarine swap functionality --- electrum/gui/qml/components/Channels.qml | 23 +- electrum/gui/qml/components/Swap.qml | 205 ++++++++++++++ electrum/gui/qml/components/main.qml | 7 + electrum/gui/qml/qeapp.py | 3 +- electrum/gui/qml/qeswaphelper.py | 324 +++++++++++++++++++++++ electrum/gui/qml/qewallet.py | 7 +- 6 files changed, 565 insertions(+), 4 deletions(-) create mode 100644 electrum/gui/qml/components/Swap.qml create mode 100644 electrum/gui/qml/qeswaphelper.py diff --git a/electrum/gui/qml/components/Channels.qml b/electrum/gui/qml/components/Channels.qml index a401e0195..2d9e2f169 100644 --- a/electrum/gui/qml/components/Channels.qml +++ b/electrum/gui/qml/components/Channels.qml @@ -1,6 +1,6 @@ import QtQuick 2.6 import QtQuick.Layouts 1.0 -import QtQuick.Controls 2.0 +import QtQuick.Controls 2.3 import QtQuick.Controls.Material 2.0 import org.electrum 1.0 @@ -8,8 +8,25 @@ import org.electrum 1.0 import "controls" Pane { + id: root property string title: qsTr("Lightning Channels") + property QtObject menu: Menu { + id: menu + MenuItem { + icon.color: 'transparent' + action: Action { + text: qsTr('Swap'); + enabled: Daemon.currentWallet.lightningCanSend.satsInt > 0 || Daemon.currentWallet.lightningCanReceive.satInt > 0 + onTriggered: { + var dialog = swapDialog.createObject(root) + dialog.open() + } + icon.source: '../../icons/status_waiting.png' + } + } + } + ColumnLayout { id: layout width: parent.width @@ -129,4 +146,8 @@ Pane { } + Component { + id: swapDialog + Swap {} + } } diff --git a/electrum/gui/qml/components/Swap.qml b/electrum/gui/qml/components/Swap.qml new file mode 100644 index 000000000..9a6532256 --- /dev/null +++ b/electrum/gui/qml/components/Swap.qml @@ -0,0 +1,205 @@ +import QtQuick 2.6 +import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.0 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +import "controls" + +Dialog { + id: root + + width: parent.width + height: parent.height + + title: qsTr('Lightning Swap') + standardButtons: Dialog.Cancel + + modal: true + parent: Overlay.overlay + Overlay.modal: Rectangle { + color: "#aa000000" + } + + GridLayout { + id: layout + width: parent.width + height: parent.height + columns: 2 + + Rectangle { + height: 1 + Layout.fillWidth: true + Layout.columnSpan: 2 + color: Material.accentColor + } + + Label { + text: qsTr('You send') + color: Material.accentColor + } + + RowLayout { + Label { + id: tosend + text: Config.formatSats(swaphelper.tosend) + font.family: FixedFont + visible: swaphelper.valid + } + Label { + text: Config.baseUnit + color: Material.accentColor + visible: swaphelper.valid + } + Label { + text: swaphelper.isReverse ? qsTr('(offchain)') : qsTr('(onchain)') + visible: swaphelper.valid + } + } + + Label { + text: qsTr('You receive') + color: Material.accentColor + } + + RowLayout { + Layout.fillWidth: true + Label { + id: toreceive + text: Config.formatSats(swaphelper.toreceive) + font.family: FixedFont + visible: swaphelper.valid + } + Label { + text: Config.baseUnit + color: Material.accentColor + visible: swaphelper.valid + } + Label { + text: swaphelper.isReverse ? qsTr('(onchain)') : qsTr('(offchain)') + visible: swaphelper.valid + } + } + + Label { + text: qsTr('Server fee') + color: Material.accentColor + } + + RowLayout { + Label { + text: swaphelper.serverfeeperc + } + Label { + text: Config.formatSats(swaphelper.serverfee) + font.family: FixedFont + } + Label { + text: Config.baseUnit + color: Material.accentColor + } + } + + Label { + text: qsTr('Mining fee') + color: Material.accentColor + } + + RowLayout { + Label { + text: Config.formatSats(swaphelper.miningfee) + font.family: FixedFont + } + Label { + text: Config.baseUnit + color: Material.accentColor + } + } + + Slider { + id: swapslider + Layout.columnSpan: 2 + Layout.preferredWidth: 2/3 * layout.width + Layout.alignment: Qt.AlignHCenter + + from: swaphelper.rangeMin + to: swaphelper.rangeMax + + onValueChanged: { + if (activeFocus) + swaphelper.sliderPos = value + } + Component.onCompleted: { + value = swaphelper.sliderPos + } + Connections { + target: swaphelper + function onSliderPosChanged() { + swapslider.value = swaphelper.sliderPos + } + } + } + + InfoTextArea { + Layout.columnSpan: 2 + visible: swaphelper.userinfo != '' + text: swaphelper.userinfo + } + + Rectangle { + height: 1 + Layout.fillWidth: true + Layout.columnSpan: 2 + color: Material.accentColor + } + + Button { + Layout.alignment: Qt.AlignHCenter + Layout.columnSpan: 2 + text: qsTr('Ok') + enabled: swaphelper.valid + onClicked: swaphelper.executeSwap() + } + + Item { Layout.fillHeight: true; Layout.preferredWidth: 1; Layout.columnSpan: 2 } + } + + SwapHelper { + id: swaphelper + wallet: Daemon.currentWallet + onError: { + var dialog = app.messageDialog.createObject(root, {'text': message}) + dialog.open() + } + onConfirm: { + var dialog = app.messageDialog.createObject(app, {'text': message, 'yesno': true}) + dialog.yesClicked.connect(function() { + dialog.close() + swaphelper.executeSwap(true) + root.close() + }) + dialog.open() + } + onAuthRequired: { // TODO: don't replicate this code + if (swaphelper.wallet.verify_password('')) { + // wallet has no password + console.log('wallet has no password, proceeding') + swaphelper.authProceed() + } else { + var dialog = app.passwordDialog.createObject(app, {'title': qsTr('Enter current password')}) + dialog.accepted.connect(function() { + if (swaphelper.wallet.verify_password(dialog.password)) { + swaphelper.wallet.authProceed() + } else { + swaphelper.wallet.authCancel() + } + }) + dialog.rejected.connect(function() { + swaphelper.wallet.authCancel() + }) + dialog.open() + } + } + } +} diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index 9320857d4..b820e4f4a 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -251,6 +251,13 @@ ApplicationWindow dialog.open() } } + // TODO: add to notification queue instead of barging through + function onPaymentSucceeded(key) { + notificationPopup.show(qsTr('Payment Succeeded')) + } + function onPaymentFailed(key, reason) { + notificationPopup.show(qsTr('Payment Failed') + ': ' + reason) + } } Connections { diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index eba9be641..5dfc5fa71 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -26,6 +26,7 @@ from .qetxdetails import QETxDetails from .qechannelopener import QEChannelOpener from .qelnpaymentdetails import QELnPaymentDetails from .qechanneldetails import QEChannelDetails +from .qeswaphelper import QESwapHelper notification = None @@ -148,12 +149,12 @@ class ElectrumQmlApplication(QGuiApplication): qmlRegisterType(QEInvoice, 'org.electrum', 1, 0, 'Invoice') qmlRegisterType(QEInvoiceParser, 'org.electrum', 1, 0, 'InvoiceParser') qmlRegisterType(QEUserEnteredPayment, 'org.electrum', 1, 0, 'UserEnteredPayment') - qmlRegisterType(QEAddressDetails, 'org.electrum', 1, 0, 'AddressDetails') qmlRegisterType(QETxDetails, 'org.electrum', 1, 0, 'TxDetails') qmlRegisterType(QEChannelOpener, 'org.electrum', 1, 0, 'ChannelOpener') qmlRegisterType(QELnPaymentDetails, 'org.electrum', 1, 0, 'LnPaymentDetails') qmlRegisterType(QEChannelDetails, 'org.electrum', 1, 0, 'ChannelDetails') + qmlRegisterType(QESwapHelper, 'org.electrum', 1, 0, 'SwapHelper') qmlRegisterUncreatableType(QEAmount, 'org.electrum', 1, 0, 'Amount', 'Amount can only be used as property') diff --git a/electrum/gui/qml/qeswaphelper.py b/electrum/gui/qml/qeswaphelper.py new file mode 100644 index 000000000..e7cc22ae3 --- /dev/null +++ b/electrum/gui/qml/qeswaphelper.py @@ -0,0 +1,324 @@ +import asyncio +from typing import TYPE_CHECKING, Optional, Union + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject + +from electrum.i18n import _ +from electrum.logging import get_logger +from electrum.lnutil import ln_dummy_address +from electrum.transaction import PartialTxOutput +from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates, profiler + +from .qewallet import QEWallet +from .qetypes import QEAmount +from .auth import AuthMixin, auth_protect + +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 + + _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 + asyncio.run(swap_manager.get_pairs()) + """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 float('inf') + 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): + 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}%' + self.serverfee = QEAmount(amount_sat=swap_manager.lockup_fee) + 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}%' + self.serverfee = QEAmount(amount_sat=swap_manager.normal_fee) + self.miningfee = QEAmount(amount_sat=self._tx.get_fee()) + + 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, password): + 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=password, + tx=self._tx, + ) + asyncio.run_coroutine_threadsafe(coro, loop) + + def do_reverse_swap(self, lightning_amount, onchain_amount, password): + 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(), + ) + asyncio.run_coroutine_threadsafe(coro, loop) + + @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, None) + else: + lightning_amount = self._receive_amount + onchain_amount = self._send_amount + self.do_normal_swap(lightning_amount, onchain_amount, None) diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 69d621cf2..7de66c61e 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -120,8 +120,11 @@ class QEWallet(AuthMixin, QObject): self._logger.debug('invoice status update for key %s' % key) # FIXME event doesn't pass the new status, so we need to retrieve invoice = self.wallet.get_invoice(key) - status = self.wallet.get_invoice_status(invoice) - self.invoiceStatusChanged.emit(key, status) + if invoice: + status = self.wallet.get_invoice_status(invoice) + self.invoiceStatusChanged.emit(key, status) + else: + self._logger.debug(f'No invoice found for key {key}') elif event == 'new_transaction': wallet, tx = args if wallet == self.wallet: