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