6 changed files with 565 additions and 4 deletions
@ -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() |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -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) |
||||
Loading…
Reference in new issue