Browse Source

add initial submarine swap functionality

master
Sander van Grieken 4 years ago
parent
commit
b2fafcb428
  1. 23
      electrum/gui/qml/components/Channels.qml
  2. 205
      electrum/gui/qml/components/Swap.qml
  3. 7
      electrum/gui/qml/components/main.qml
  4. 3
      electrum/gui/qml/qeapp.py
  5. 324
      electrum/gui/qml/qeswaphelper.py
  6. 3
      electrum/gui/qml/qewallet.py

23
electrum/gui/qml/components/Channels.qml

@ -1,6 +1,6 @@
import QtQuick 2.6 import QtQuick 2.6
import QtQuick.Layouts 1.0 import QtQuick.Layouts 1.0
import QtQuick.Controls 2.0 import QtQuick.Controls 2.3
import QtQuick.Controls.Material 2.0 import QtQuick.Controls.Material 2.0
import org.electrum 1.0 import org.electrum 1.0
@ -8,8 +8,25 @@ import org.electrum 1.0
import "controls" import "controls"
Pane { Pane {
id: root
property string title: qsTr("Lightning Channels") 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 { ColumnLayout {
id: layout id: layout
width: parent.width width: parent.width
@ -129,4 +146,8 @@ Pane {
} }
Component {
id: swapDialog
Swap {}
}
} }

205
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()
}
}
}
}

7
electrum/gui/qml/components/main.qml

@ -251,6 +251,13 @@ ApplicationWindow
dialog.open() 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 { Connections {

3
electrum/gui/qml/qeapp.py

@ -26,6 +26,7 @@ from .qetxdetails import QETxDetails
from .qechannelopener import QEChannelOpener from .qechannelopener import QEChannelOpener
from .qelnpaymentdetails import QELnPaymentDetails from .qelnpaymentdetails import QELnPaymentDetails
from .qechanneldetails import QEChannelDetails from .qechanneldetails import QEChannelDetails
from .qeswaphelper import QESwapHelper
notification = None notification = None
@ -148,12 +149,12 @@ class ElectrumQmlApplication(QGuiApplication):
qmlRegisterType(QEInvoice, 'org.electrum', 1, 0, 'Invoice') qmlRegisterType(QEInvoice, 'org.electrum', 1, 0, 'Invoice')
qmlRegisterType(QEInvoiceParser, 'org.electrum', 1, 0, 'InvoiceParser') qmlRegisterType(QEInvoiceParser, 'org.electrum', 1, 0, 'InvoiceParser')
qmlRegisterType(QEUserEnteredPayment, 'org.electrum', 1, 0, 'UserEnteredPayment') qmlRegisterType(QEUserEnteredPayment, 'org.electrum', 1, 0, 'UserEnteredPayment')
qmlRegisterType(QEAddressDetails, 'org.electrum', 1, 0, 'AddressDetails') qmlRegisterType(QEAddressDetails, 'org.electrum', 1, 0, 'AddressDetails')
qmlRegisterType(QETxDetails, 'org.electrum', 1, 0, 'TxDetails') qmlRegisterType(QETxDetails, 'org.electrum', 1, 0, 'TxDetails')
qmlRegisterType(QEChannelOpener, 'org.electrum', 1, 0, 'ChannelOpener') qmlRegisterType(QEChannelOpener, 'org.electrum', 1, 0, 'ChannelOpener')
qmlRegisterType(QELnPaymentDetails, 'org.electrum', 1, 0, 'LnPaymentDetails') qmlRegisterType(QELnPaymentDetails, 'org.electrum', 1, 0, 'LnPaymentDetails')
qmlRegisterType(QEChannelDetails, 'org.electrum', 1, 0, 'ChannelDetails') 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') qmlRegisterUncreatableType(QEAmount, 'org.electrum', 1, 0, 'Amount', 'Amount can only be used as property')

324
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)

3
electrum/gui/qml/qewallet.py

@ -120,8 +120,11 @@ class QEWallet(AuthMixin, QObject):
self._logger.debug('invoice status update for key %s' % key) self._logger.debug('invoice status update for key %s' % key)
# FIXME event doesn't pass the new status, so we need to retrieve # FIXME event doesn't pass the new status, so we need to retrieve
invoice = self.wallet.get_invoice(key) invoice = self.wallet.get_invoice(key)
if invoice:
status = self.wallet.get_invoice_status(invoice) status = self.wallet.get_invoice_status(invoice)
self.invoiceStatusChanged.emit(key, status) self.invoiceStatusChanged.emit(key, status)
else:
self._logger.debug(f'No invoice found for key {key}')
elif event == 'new_transaction': elif event == 'new_transaction':
wallet, tx = args wallet, tx = args
if wallet == self.wallet: if wallet == self.wallet:

Loading…
Cancel
Save