diff --git a/electrum/gui/qt/channels_list.py b/electrum/gui/qt/channels_list.py index 0d423f445..f02f15161 100644 --- a/electrum/gui/qt/channels_list.py +++ b/electrum/gui/qt/channels_list.py @@ -383,7 +383,7 @@ class ChannelsList(MyTreeView): vbox.addLayout(Buttons(OkButton(d))) d.exec_() - def new_channel_dialog(self): + def new_channel_dialog(self, *, amount_sat=None): lnworker = self.parent.wallet.lnworker d = WindowModalDialog(self.parent, _('Open Channel')) vbox = QVBoxLayout(d) @@ -413,6 +413,7 @@ class ChannelsList(MyTreeView): trampoline_combo.setCurrentIndex(1) amount_e = BTCAmountEdit(self.parent.get_decimal_point) + amount_e.setAmount(amount_sat) # max button def spend_max(): amount_e.setFrozen(max_button.isChecked()) @@ -481,6 +482,7 @@ class ChannelsList(MyTreeView): if not connect_str or not funding_sat: return self.parent.open_channel(connect_str, funding_sat, 0) + return True def swap_dialog(self): from .swap_dialog import SwapDialog diff --git a/electrum/gui/qt/invoice_list.py b/electrum/gui/qt/invoice_list.py index 0d66af220..76bcf5a64 100644 --- a/electrum/gui/qt/invoice_list.py +++ b/electrum/gui/qt/invoice_list.py @@ -33,7 +33,7 @@ from PyQt5.QtWidgets import QMenu, QVBoxLayout, QTreeWidget, QTreeWidgetItem, QH from electrum.i18n import _ from electrum.util import format_time -from electrum.invoices import Invoice, PR_UNPAID, PR_PAID, PR_INFLIGHT, PR_FAILED +from electrum.invoices import Invoice, PR_UNPAID, PR_PAID, PR_INFLIGHT, PR_FAILED, PR_SCHEDULED from electrum.lnutil import HtlcLog from .util import MyTreeView, read_QIcon, MySortModel, pr_icons @@ -161,6 +161,8 @@ class InvoiceList(MyTreeView): status = wallet.get_invoice_status(invoice) if status == PR_UNPAID: menu.addAction(_("Pay") + "...", lambda: self.parent.do_pay_invoice(invoice)) + if status == PR_SCHEDULED: + menu.addAction(_("Cancel") + "...", lambda: self.parent.cancel_scheduled_invoice(key)) if status == PR_FAILED: menu.addAction(_("Retry"), lambda: self.parent.do_pay_invoice(invoice)) if self.parent.wallet.lnworker: diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 408794caf..8a2492c3c 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -67,7 +67,7 @@ from electrum.util import (format_time, AddTransactionException, BITCOIN_BIP21_URI_SCHEME, InvoiceError, parse_max_spend) from electrum.invoices import PR_DEFAULT_EXPIRATION_WHEN_CREATING, Invoice -from electrum.invoices import PR_PAID, PR_FAILED, pr_expiration_values, Invoice +from electrum.invoices import PR_PAID, PR_UNPAID, PR_FAILED, PR_SCHEDULED, pr_expiration_values, Invoice from electrum.transaction import (Transaction, PartialTxInput, PartialTransaction, PartialTxOutput) from electrum.wallet import (Multisig_Wallet, CannotBumpFee, Abstract_Wallet, @@ -105,6 +105,7 @@ from .confirm_tx_dialog import ConfirmTxDialog from .transaction_dialog import PreviewTxDialog from .rbf_dialog import BumpFeeDialog, DSCancelDialog from .qrreader import scan_qrcode +from .swap_dialog import SwapDialog if TYPE_CHECKING: from . import ElectrumGui @@ -1654,16 +1655,65 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): return False # no errors - def pay_lightning_invoice(self, invoice: str, *, amount_msat: Optional[int]): - if amount_msat is None: + def pay_lightning_invoice(self, invoice: Invoice): + amount_sat = invoice.get_amount_sat() + key = self.wallet.get_key_for_outgoing_invoice(invoice) + if amount_sat is None: raise Exception("missing amount for LN invoice") - amount_sat = Decimal(amount_msat) / 1000 + num_sats_can_send = int(self.wallet.lnworker.num_sats_can_send()) + if amount_sat > num_sats_can_send: + lightning_needed = amount_sat - num_sats_can_send + lightning_needed += (lightning_needed // 20) # operational safety margin + coins = self.get_coins(nonlocal_only=True) + can_pay_onchain = invoice.get_address() and self.wallet.can_pay_onchain(invoice.get_outputs(), coins=coins) + can_pay_with_new_channel, channel_funding_sat = self.wallet.can_pay_with_new_channel(amount_sat, coins=coins) + can_pay_with_swap, swap_recv_amount_sat = self.wallet.can_pay_with_swap(amount_sat, coins=coins) + choices = {} + if can_pay_onchain: + msg = ''.join([ + _('Pay this invoice onchain'), '\n', + _('Funds will be sent to the invoice fallback address.') + ]) + choices[0] = msg + if can_pay_with_new_channel: + msg = ''.join([ + _('Open a new channel'), '\n', + _('Your payment will be scheduled for when the channel is open.') + ]) + choices[1] = msg + if can_pay_with_swap: + msg = ''.join([ + _('Rebalance your channels with a submarine swap'), '\n', + _('Your payment will be scheduled after the swap is confirmed.') + ]) + choices[2] = msg + if not choices: + raise NotEnoughFunds() + msg = _('You cannot pay that invoice using Lightning.') + if self.wallet.lnworker.channels: + msg += '\n' + _('Your channels can send {}.').format(self.format_amount(num_sats_can_send) + self.base_unit()) + + r = self.query_choice(msg, choices) + if r is not None: + self.save_pending_invoice() + if r == 0: + self.pay_onchain_dialog(coins, invoice.get_outputs()) + elif r == 1: + if self.channels_list.new_channel_dialog(amount_sat=channel_funding_sat): + self.wallet.lnworker.set_invoice_status(key, PR_SCHEDULED) + elif r == 2: + d = SwapDialog(self, is_reverse=False, recv_amount_sat=swap_recv_amount_sat) + if d.run(): + self.wallet.lnworker.set_invoice_status(key, PR_SCHEDULED) + return + # FIXME this is currently lying to user as we truncate to satoshis - msg = _("Pay lightning invoice?") + '\n\n' + _("This will send {}?").format(self.format_amount_and_units(amount_sat)) + amount_msat = invoice.get_amount_msat() + msg = _("Pay lightning invoice?") + '\n\n' + _("This will send {}?").format(self.format_amount_and_units(Decimal(amount_msat)/1000)) if not self.question(msg): return self.save_pending_invoice() - coro = self.wallet.lnworker.pay_invoice(invoice, amount_msat=amount_msat, attempts=LN_NUM_PAYMENT_ATTEMPTS) + coro = self.wallet.lnworker.pay_invoice(invoice.lightning_invoice, amount_msat=amount_msat, attempts=LN_NUM_PAYMENT_ATTEMPTS) self.run_coroutine_from_thread(coro) def on_request_status(self, wallet, key, status): @@ -1765,10 +1815,13 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): def do_pay_invoice(self, invoice: 'Invoice'): if invoice.is_lightning(): - self.pay_lightning_invoice(invoice.lightning_invoice, amount_msat=invoice.get_amount_msat()) + self.pay_lightning_invoice(invoice) else: self.pay_onchain_dialog(self.get_coins(), invoice.outputs) + def cancel_scheduled_invoice(self, key): + self.wallet.lnworker.set_invoice_status(key, PR_UNPAID) + def get_coins(self, *, nonlocal_only=False) -> Sequence[PartialTxInput]: coins = self.get_manually_selected_coins() if coins is not None: @@ -2002,7 +2055,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): clayout = ChoicesLayout(msg, choices) vbox = QVBoxLayout(dialog) vbox.addLayout(clayout.layout()) - vbox.addLayout(Buttons(OkButton(dialog))) + vbox.addLayout(Buttons(CancelButton(dialog), OkButton(dialog))) if not dialog.exec_(): return None return clayout.selected_index() diff --git a/electrum/gui/qt/swap_dialog.py b/electrum/gui/qt/swap_dialog.py index fbd2bef7b..e97049ea7 100644 --- a/electrum/gui/qt/swap_dialog.py +++ b/electrum/gui/qt/swap_dialog.py @@ -29,7 +29,7 @@ class SwapDialog(WindowModalDialog): tx: Optional[PartialTransaction] update_signal = pyqtSignal() - def __init__(self, window: 'ElectrumWindow'): + def __init__(self, window: 'ElectrumWindow', is_reverse=True, recv_amount_sat=None): WindowModalDialog.__init__(self, window, _('Submarine Swap')) self.window = window self.config = window.config @@ -37,7 +37,7 @@ class SwapDialog(WindowModalDialog): self.swap_manager = self.lnworker.swap_manager self.network = window.network self.tx = None # for the forward-swap only - self.is_reverse = True + self.is_reverse = is_reverse vbox = QVBoxLayout(self) self.description_label = WWLabel(self.get_description()) self.send_amount_e = BTCAmountEdit(self.window.get_decimal_point) @@ -87,6 +87,8 @@ class SwapDialog(WindowModalDialog): vbox.addLayout(Buttons(CancelButton(self), self.ok_button)) self.update_signal.connect(self.update) self.update() + if recv_amount_sat: + self.recv_amount_e.setAmount(recv_amount_sat) def fee_slider_callback(self, dyn, pos, fee_rate): if dyn: diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py index 9376eb421..0ad39346d 100644 --- a/electrum/gui/qt/util.py +++ b/electrum/gui/qt/util.py @@ -28,7 +28,7 @@ from PyQt5.QtWidgets import (QPushButton, QLabel, QMessageBox, QHBoxLayout, from electrum.i18n import _, languages from electrum.util import FileImportFailed, FileExportFailed, make_aiohttp_session, resource_path -from electrum.invoices import PR_UNPAID, PR_PAID, PR_EXPIRED, PR_INFLIGHT, PR_UNKNOWN, PR_FAILED, PR_ROUTING, PR_UNCONFIRMED +from electrum.invoices import PR_UNPAID, PR_PAID, PR_EXPIRED, PR_INFLIGHT, PR_UNKNOWN, PR_FAILED, PR_ROUTING, PR_UNCONFIRMED, PR_SCHEDULED from electrum.logging import Logger if TYPE_CHECKING: @@ -56,6 +56,7 @@ pr_icons = { PR_FAILED:"warning.png", PR_ROUTING:"unconfirmed.png", PR_UNCONFIRMED:"unconfirmed.png", + PR_SCHEDULED:"unconfirmed.png", } @@ -396,23 +397,23 @@ class ChoicesLayout(object): msg = "" gb2 = QGroupBox(msg) vbox.addWidget(gb2) - vbox2 = QVBoxLayout() gb2.setLayout(vbox2) - self.group = group = QButtonGroup() - for i,c in enumerate(choices): + if isinstance(choices, list): + iterator = enumerate(choices) + else: + iterator = choices.items() + for i, c in iterator: button = QRadioButton(gb2) button.setText(c) vbox2.addWidget(button) group.addButton(button) group.setId(button, i) - if i==checked_index: + if i == checked_index: button.setChecked(True) - if on_clicked: group.buttonClicked.connect(partial(on_clicked, self)) - self.vbox = vbox def layout(self): diff --git a/electrum/invoices.py b/electrum/invoices.py index a745d75a1..4b769e88e 100644 --- a/electrum/invoices.py +++ b/electrum/invoices.py @@ -27,6 +27,8 @@ PR_INFLIGHT = 4 # only for LN. payment attempt in progress PR_FAILED = 5 # only for LN. we attempted to pay it, but all attempts failed PR_ROUTING = 6 # only for LN. *unused* atm. PR_UNCONFIRMED = 7 # only onchain. invoice is satisfied but tx is not mined yet. +PR_SCHEDULED = 8 # lightning invoice will be paid once channel liquidity is available + pr_color = { PR_UNPAID: (.7, .7, .7, 1), @@ -37,6 +39,7 @@ pr_color = { PR_FAILED: (.9, .2, .2, 1), PR_ROUTING: (.9, .6, .3, 1), PR_UNCONFIRMED: (.9, .6, .3, 1), + PR_SCHEDULED: (.9, .6, .3, 1), } pr_tooltips = { @@ -48,6 +51,7 @@ pr_tooltips = { PR_FAILED:_('Failed'), PR_ROUTING: _('Computing route...'), PR_UNCONFIRMED: _('Unconfirmed'), + PR_SCHEDULED: _('Scheduled'), } PR_DEFAULT_EXPIRATION_WHEN_CREATING = 24*60*60 # 1 day diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index ae0e56645..bbd6152c8 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -1334,6 +1334,8 @@ class Peer(Logger): # only allow state transition from "FUNDED" to "OPEN" old_state = chan.get_state() if old_state == ChannelState.OPEN: + if self.lnworker: + self.lnworker.pay_scheduled_invoices() return if old_state != ChannelState.FUNDED: self.logger.info(f"cannot mark open ({chan.get_id_for_log()}), current state: {repr(old_state)}") @@ -1353,6 +1355,8 @@ class Peer(Logger): self.logger.info(f"sending channel update for outgoing edge ({chan.get_id_for_log()})") chan_upd = chan.get_outgoing_gossip_channel_update() self.transport.send_bytes(chan_upd) + if self.lnworker: + self.lnworker.pay_scheduled_invoices() def send_announcement_signatures(self, chan: Channel): chan_ann = chan.construct_channel_announcement_without_sigs() diff --git a/electrum/lnworker.py b/electrum/lnworker.py index f59e32fef..5ffb16ea7 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -27,7 +27,7 @@ from aiorpcx import run_in_thread, NetAddress, ignore_after from . import constants, util from . import keystore from .util import profiler, chunks, OldTaskGroup -from .invoices import Invoice, PR_UNPAID, PR_EXPIRED, PR_PAID, PR_INFLIGHT, PR_FAILED, PR_ROUTING, LN_EXPIRY_NEVER +from .invoices import Invoice, PR_UNPAID, PR_EXPIRED, PR_PAID, PR_INFLIGHT, PR_FAILED, PR_ROUTING, PR_SCHEDULED, LN_EXPIRY_NEVER from .util import NetworkRetryManager, JsonRPCClient from .lnutil import LN_MAX_FUNDING_SAT from .keystore import BIP32_KeyStore @@ -89,7 +89,7 @@ if TYPE_CHECKING: from .simple_config import SimpleConfig -SAVED_PR_STATUS = [PR_PAID, PR_UNPAID] # status that are persisted +SAVED_PR_STATUS = [PR_PAID, PR_UNPAID, PR_SCHEDULED] # status that are persisted NUM_PEERS_TARGET = 4 @@ -1082,6 +1082,14 @@ class LNWallet(LNWorker): if chan.short_channel_id == short_channel_id: return chan + def pay_scheduled_invoices(self): + asyncio.ensure_future(self._pay_scheduled_invoices()) + + async def _pay_scheduled_invoices(self): + for invoice in self.wallet.get_scheduled_invoices(): + if invoice.is_lightning() and self.can_pay_invoice(invoice): + await self.pay_invoice(invoice.lightning_invoice, attempts=10) + @log_exceptions async def pay_invoice( self, invoice: str, *, @@ -1884,9 +1892,12 @@ class LNWallet(LNWorker): def set_payment_status(self, payment_hash: bytes, status: int) -> None: info = self.get_payment_info(payment_hash) - if info is None: + if info is None and status != PR_SCHEDULED: # if we are forwarding return + if info is None and status == PR_SCHEDULED: + # we should add a htlc to our ctx, so that the funds are 'reserved' + info = PaymentInfo(payment_hash, 0, SENT, PR_SCHEDULED) info = info._replace(status=status) self.save_payment_info(info) diff --git a/electrum/tests/test_lnpeer.py b/electrum/tests/test_lnpeer.py index b21727445..b1a1db88b 100644 --- a/electrum/tests/test_lnpeer.py +++ b/electrum/tests/test_lnpeer.py @@ -158,6 +158,9 @@ class MockLNWallet(Logger, NetworkRetryManager[LNPeerAddr]): self.logger.info(f"created LNWallet[{name}] with nodeID={local_keypair.pubkey.hex()}") + def pay_scheduled_invoices(self): + pass + def get_invoice_status(self, key): pass diff --git a/electrum/wallet.py b/electrum/wallet.py index b4566f691..b402bd1d7 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -74,7 +74,7 @@ from .plugin import run_hook from .address_synchronizer import (AddressSynchronizer, TX_HEIGHT_LOCAL, TX_HEIGHT_UNCONF_PARENT, TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_FUTURE) from .invoices import Invoice -from .invoices import PR_PAID, PR_UNPAID, PR_UNKNOWN, PR_EXPIRED, PR_UNCONFIRMED +from .invoices import PR_PAID, PR_UNPAID, PR_UNKNOWN, PR_EXPIRED, PR_UNCONFIRMED, PR_SCHEDULED from .contacts import Contacts from .interface import NetworkException from .mnemonic import Mnemonic @@ -82,6 +82,7 @@ from .logging import get_logger from .lnworker import LNWallet from .paymentrequest import PaymentRequest from .util import read_json_file, write_json_file, UserFacingException +from .lnutil import ln_dummy_address if TYPE_CHECKING: from .network import Network @@ -811,6 +812,10 @@ class Abstract_Wallet(AddressSynchronizer, ABC): invoices = self.get_invoices() return [x for x in invoices if self.get_invoice_status(x) != PR_PAID] + def get_scheduled_invoices(self): + invoices = self.get_invoices() + return [x for x in invoices if self.get_invoice_status(x) == PR_SCHEDULED] + def get_invoice(self, key): return self.invoices.get(key) @@ -1318,6 +1323,51 @@ class Abstract_Wallet(AddressSynchronizer, ABC): assert is_address(selected_addr), f"not valid bitcoin address: {selected_addr}" return selected_addr + def can_pay_onchain(self, outputs, coins=None): + try: + self.make_unsigned_transaction( + coins=coins, + outputs=outputs, + fee=None) + except NotEnoughFunds: + return False + return True + + def can_pay_with_new_channel(self, amount_sat, coins=None): + """ wether we can pay amount_sat after opening a new channel""" + if self.lnworker is None: + return False, None + num_sats_can_send = int(self.lnworker.num_sats_can_send()) + if amount_sat <= num_sats_can_send: + return True, None + lightning_needed = amount_sat - num_sats_can_send + lightning_needed += (lightning_needed // 20) # operational safety margin + channel_funding_sat = lightning_needed + 1000 # channel reserves safety margin + try: + self.lnworker.mktx_for_open_channel(coins=coins, funding_sat=channel_funding_sat, node_id=bytes(32), fee_est=None) + except NotEnoughFunds: + return False, None + return True, channel_funding_sat + + def can_pay_with_swap(self, amount_sat, coins=None): + # fixme: if swap_amount_sat is lower than the minimum swap amount, we need to propose a higher value + if self.lnworker is None: + return False, None + num_sats_can_send = int(self.lnworker.num_sats_can_send()) + if amount_sat <= num_sats_can_send: + return True, None + lightning_needed = amount_sat - num_sats_can_send + lightning_needed += (lightning_needed // 20) # operational safety margin + swap_recv_amount = lightning_needed # the server's percentage fee is presumably within the above margin + swap_server_mining_fee = 10000 # guessing, because we have not called get_pairs yet + swap_funding_sat = swap_recv_amount + swap_server_mining_fee + swap_output = PartialTxOutput.from_address_and_value(ln_dummy_address(), swap_funding_sat) + can_do_swap_onchain = self.can_pay_onchain([swap_output], coins=coins) + can_do_swap_lightning = self.lnworker.num_sats_can_receive() >= swap_recv_amount + if can_do_swap_onchain and can_do_swap_lightning: + return True, swap_recv_amount + return False, None + def make_unsigned_transaction( self, *, coins: Sequence[PartialTxInput],