Browse Source

Show options if we do not have the liquidity to pay a lightning invoice:

pay onchain, open channel, rebalance.

If we do a swap or open a channel, the payment will be scheduled.
patch-4
ThomasV 4 years ago
parent
commit
60865f3902
  1. 4
      electrum/gui/qt/channels_list.py
  2. 4
      electrum/gui/qt/invoice_list.py
  3. 69
      electrum/gui/qt/main_window.py
  4. 6
      electrum/gui/qt/swap_dialog.py
  5. 15
      electrum/gui/qt/util.py
  6. 4
      electrum/invoices.py
  7. 4
      electrum/lnpeer.py
  8. 17
      electrum/lnworker.py
  9. 3
      electrum/tests/test_lnpeer.py
  10. 52
      electrum/wallet.py

4
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

4
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:

69
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()

6
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:

15
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):

4
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

4
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()

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

3
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

52
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],

Loading…
Cancel
Save