diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index fc9620f78..4147dfb67 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -1185,9 +1185,31 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.receive_address_help.setVisible(False) self.receive_URI_e = ButtonsTextEdit() self.receive_lightning_e = ButtonsTextEdit() - self.receive_lightning_help = WWLabel('') + + self.receive_lightning_help_text = WWLabel('') + self.receive_rebalance_button = QPushButton('Rebalance') + self.receive_rebalance_button.suggestion = None + def on_receive_rebalance(): + if self.receive_rebalance_button.suggestion: + chan1, chan2, delta = self.receive_rebalance_button.suggestion + self.rebalance_dialog(chan1, chan2, amount_sat=delta) + self.receive_rebalance_button.clicked.connect(on_receive_rebalance) + self.receive_swap_button = QPushButton('Swap') + self.receive_swap_button.suggestion = None + def on_receive_swap(): + if self.receive_swap_button.suggestion: + chan, swap_recv_amount_sat = self.receive_swap_button.suggestion + self.run_swap_dialog(is_reverse=True, recv_amount_sat=swap_recv_amount_sat, channels=[chan]) + self.receive_swap_button.clicked.connect(on_receive_swap) + buttons = QHBoxLayout() + buttons.addWidget(self.receive_rebalance_button) + buttons.addWidget(self.receive_swap_button) + vbox = QVBoxLayout() + vbox.addWidget(self.receive_lightning_help_text) + vbox.addLayout(buttons) + self.receive_lightning_help = QWidget() self.receive_lightning_help.setVisible(False) - #self.receive_URI_e.setFocusPolicy(Qt.ClickFocus) + self.receive_lightning_help.setLayout(vbox) fixedSize = 200 for e in [self.receive_address_e, self.receive_URI_e, self.receive_lightning_e]: @@ -1273,12 +1295,13 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): def show_receive_request(self, req): addr = req.get_address() or '' + amount_sat = req.get_amount_sat() or 0 address_help = '' if addr else _('Amount too small to be received onchain') lnaddr = req.lightning_invoice bip21_lightning = lnaddr if self.config.get('bip21_lightning', False) else None URI = req.get_bip21_URI(lightning=bip21_lightning) lightning_online = self.wallet.lnworker and self.wallet.lnworker.num_peers() > 0 - can_receive_lightning = self.wallet.lnworker and (req.get_amount_sat() or 0) <= self.wallet.lnworker.num_sats_can_receive() + can_receive_lightning = self.wallet.lnworker and amount_sat <= self.wallet.lnworker.num_sats_can_receive() if lnaddr is None: ln_help = _('This request does not have a Lightning invoice.') lnaddr = '' @@ -1286,7 +1309,15 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): ln_help = _('You must be online to receive Lightning payments.') lnaddr = '' elif not can_receive_lightning: + self.receive_rebalance_button.suggestion = self.wallet.lnworker.suggest_rebalance_to_receive(amount_sat) + self.receive_swap_button.suggestion = self.wallet.lnworker.suggest_swap_to_receive(amount_sat) ln_help = _('Your Lightning channels do not have the capacity to receive this amount.') + can_rebalance = bool(self.receive_rebalance_button.suggestion) + can_swap = bool(self.receive_swap_button.suggestion) + self.receive_rebalance_button.setEnabled(can_rebalance) + self.receive_rebalance_button.setVisible(can_rebalance) + self.receive_swap_button.setEnabled(can_swap) + self.receive_swap_button.setVisible(can_swap) lnaddr = '' else: ln_help = '' @@ -1303,7 +1334,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): self.receive_URI_e.setText(URI) self.receive_URI_qr.setData(URI) self.receive_lightning_e.setText(lnaddr) # TODO maybe prepend "lightning:" ?? - self.receive_lightning_help.setText(ln_help) + self.receive_lightning_help_text.setText(ln_help) self.receive_lightning_qr.setData(lnaddr_qr) # macOS hack (similar to #4777) self.receive_lightning_e.repaint() @@ -1691,26 +1722,33 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): 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) + can_pay_with_swap = self.wallet.lnworker.suggest_swap_to_send(amount_sat, coins=coins) + can_rebalance = self.wallet.lnworker.suggest_rebalance_to_send(amount_sat) choices = {} + if can_rebalance: + msg = ''.join([ + _('Rebalance channels'), '\n', + _('Funds will be sent between your channels in order to raise your sending capacity.') + ]) + choices[0] = msg if can_pay_onchain: msg = ''.join([ _('Pay onchain'), '\n', _('Funds will be sent to the invoice fallback address.') ]) - choices[0] = msg + choices[1] = msg if can_pay_with_new_channel: msg = ''.join([ _('Open a new channel'), '\n', _('You will be able to pay once the channel is open.') ]) - choices[1] = msg + choices[2] = msg if can_pay_with_swap: msg = ''.join([ _('Rebalance your channels with a submarine swap'), '\n', _('You will be able to pay once the swap is confirmed.') ]) - choices[2] = msg + choices[3] = msg if not choices: raise NotEnoughFunds() msg = _('You cannot pay that invoice using Lightning.') @@ -1720,11 +1758,15 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): if r is not None: self.save_pending_invoice() if r == 0: - self.pay_onchain_dialog(coins, invoice.get_outputs()) + chan1, chan2, delta = can_rebalance + self.rebalance_dialog(chan1, chan2, amount_sat=delta) elif r == 1: - self.channels_list.new_channel_dialog(amount_sat=channel_funding_sat) + self.pay_onchain_dialog(coins, invoice.get_outputs()) elif r == 2: - self.run_swap_dialog(is_reverse=False, recv_amount_sat=swap_recv_amount_sat) + self.channels_list.new_channel_dialog(amount_sat=channel_funding_sat) + elif r == 3: + chan, swap_recv_amount_sat = can_pay_with_swap + self.run_swap_dialog(is_reverse=False, recv_amount_sat=swap_recv_amount_sat, channels=[chan]) return # FIXME this is currently lying to user as we truncate to satoshis @@ -1736,14 +1778,14 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): coro = self.wallet.lnworker.pay_invoice(invoice.lightning_invoice, amount_msat=amount_msat) self.run_coroutine_from_thread(coro) - def run_swap_dialog(self, is_reverse=None, recv_amount_sat=None): + def run_swap_dialog(self, is_reverse=None, recv_amount_sat=None, channels=None): if not self.network: self.window.show_error(_("You are offline.")) return def get_pairs_thread(): self.network.run_from_another_thread(self.wallet.lnworker.swap_manager.get_pairs()) BlockingWaitingDialog(self, _('Please wait...'), get_pairs_thread) - d = SwapDialog(self, is_reverse=is_reverse, recv_amount_sat=recv_amount_sat) + d = SwapDialog(self, is_reverse=is_reverse, recv_amount_sat=recv_amount_sat, channels=channels) return d.run() def on_request_status(self, wallet, key, status): @@ -2079,7 +2121,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger): def query_choice(self, msg, choices): # Needed by QtHandler for hardware wallets - dialog = WindowModalDialog(self.top_level_window()) + dialog = WindowModalDialog(self.top_level_window(), title='Question') clayout = ChoicesLayout(msg, choices) vbox = QVBoxLayout(dialog) vbox.addLayout(clayout.layout()) diff --git a/electrum/gui/qt/swap_dialog.py b/electrum/gui/qt/swap_dialog.py index 5d680b68e..f59beef76 100644 --- a/electrum/gui/qt/swap_dialog.py +++ b/electrum/gui/qt/swap_dialog.py @@ -28,7 +28,7 @@ class SwapDialog(WindowModalDialog): tx: Optional[PartialTransaction] - def __init__(self, window: 'ElectrumWindow', is_reverse=None, recv_amount_sat=None): + def __init__(self, window: 'ElectrumWindow', is_reverse=None, recv_amount_sat=None, channels=None): WindowModalDialog.__init__(self, window, _('Submarine Swap')) self.window = window self.config = window.config @@ -36,6 +36,7 @@ class SwapDialog(WindowModalDialog): self.swap_manager = self.lnworker.swap_manager self.network = window.network self.tx = None # for the forward-swap only + self.channels = channels self.is_reverse = is_reverse if is_reverse is not None else True vbox = QVBoxLayout(self) self.description_label = WWLabel(self.get_description()) @@ -287,6 +288,7 @@ class SwapDialog(WindowModalDialog): expected_onchain_amount_sat=onchain_amount, password=password, tx=tx, + channels=self.channels, ) self.window.run_coroutine_from_thread(coro) diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 60b3c3523..15f130c6b 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -29,7 +29,7 @@ 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 .util import NetworkRetryManager, JsonRPCClient +from .util import NetworkRetryManager, JsonRPCClient, NotEnoughFunds from .lnutil import LN_MAX_FUNDING_SAT from .keystore import BIP32_KeyStore from .bitcoin import COIN @@ -1998,7 +1998,7 @@ class LNWallet(LNWorker): """calculate routing hints (BOLT-11 'r' field)""" routing_hints = [] if channels is None: - channels = list(self.get_channels_to_include_in_invoice(amount_msat)) + channels = list(self.get_channels_for_receiving(amount_msat)) random.shuffle(channels) # let's not leak channel order scid_to_my_channels = {chan.short_channel_id: chan for chan in channels if chan.short_channel_id is not None} @@ -2052,29 +2052,50 @@ class LNWallet(LNWorker): chan.balance(LOCAL) if not chan.is_closed() and (chan.is_frozen_for_sending() if frozen else True) else 0 for chan in self.channels.values())) / 1000 - def num_sats_can_send(self) -> Decimal: - can_send_dict = defaultdict(int) - with self.lock: - if self.channels: - for c in self.channels.values(): - if c.is_active() and not c.is_frozen_for_sending(): - if not self.channel_db and not self.is_trampoline_peer(c.node_id): - continue - if self.channel_db: - can_send_dict[0] += c.available_to_spend(LOCAL) - else: - can_send_dict[c.node_id] += c.available_to_spend(LOCAL) - can_send = max(can_send_dict.values()) if can_send_dict else 0 + def get_channels_for_sending(self): + for c in self.channels.values(): + if c.is_active() and not c.is_frozen_for_sending(): + if self.channel_db or self.is_trampoline_peer(c.node_id): + yield c + + def fee_estimate(self, amount_sat): # Here we have to guess a fee, because some callers (submarine swaps) # use this method to initiate a payment, which would otherwise fail. fee_base_msat = TRAMPOLINE_FEES[3]['fee_base_msat'] fee_proportional_millionths = TRAMPOLINE_FEES[3]['fee_proportional_millionths'] # inverse of fee_for_edge_msat - can_send_minus_fees = (can_send - fee_base_msat) * 1_000_000 // ( 1_000_000 + fee_proportional_millionths) - can_send_minus_fees = max(0, can_send_minus_fees) - return Decimal(can_send_minus_fees) / 1000 + amount_msat = amount_sat * 1000 + amount_minus_fees = (amount_msat - fee_base_msat) * 1_000_000 // ( 1_000_000 + fee_proportional_millionths) + return Decimal(amount_msat - amount_minus_fees) / 1000 + + def num_sats_can_send(self, deltas=None) -> Decimal: + """ + without trampoline, sum of all channel capacity + with trampoline, MPP must use a single trampoline + """ + if deltas is None: + deltas = {} + def send_capacity(chan): + if chan in deltas: + delta_msat = deltas[chan] * 1000 + if delta_msat > chan.available_to_spend(REMOTE): + delta_msat = 0 + else: + delta_msat = 0 + return chan.available_to_spend(LOCAL) + delta_msat + can_send_dict = defaultdict(int) + with self.lock: + for c in self.get_channels_for_sending(): + if self.channel_db: + can_send_dict[0] += send_capacity(c) + else: + can_send_dict[c.node_id] += send_capacity(c) + can_send = max(can_send_dict.values()) if can_send_dict else 0 + can_send_sat = Decimal(can_send)/1000 + can_send_sat -= self.fee_estimate(can_send_sat) + return max(can_send_sat, 0) - def get_channels_to_include_in_invoice(self, amount_msat=None) -> Sequence[Channel]: + def get_channels_for_receiving(self, amount_msat=None) -> Sequence[Channel]: if not amount_msat: # assume we want to recv a large amt, e.g. finding max. amount_msat = float('inf') with self.lock: @@ -2103,16 +2124,26 @@ class LNWallet(LNWorker): channels = channels[:10] return channels - def num_sats_can_receive(self) -> Decimal: + def num_sats_can_receive(self, deltas=None) -> Decimal: """Return a conservative estimate of max sat value we can realistically receive in a single payment. (MPP is allowed) The theoretical max would be `sum(chan.available_to_spend(REMOTE) for chan in self.channels)`, but that would require a sender using MPP to magically guess all our channel liquidities. """ + if deltas is None: + deltas = {} + def recv_capacity(chan): + if chan in deltas: + delta_msat = deltas[chan] * 1000 + if delta_msat > chan.available_to_spend(LOCAL): + delta_msat = 0 + else: + delta_msat = 0 + return chan.available_to_spend(REMOTE) + delta_msat with self.lock: - recv_channels = self.get_channels_to_include_in_invoice() - recv_chan_msats = [chan.available_to_spend(REMOTE) for chan in recv_channels] + recv_channels = self.get_channels_for_receiving() + recv_chan_msats = [recv_capacity(chan) for chan in recv_channels] if not recv_chan_msats: return Decimal(0) can_receive_msat = max( @@ -2121,6 +2152,90 @@ class LNWallet(LNWorker): ) return Decimal(can_receive_msat) / 1000 + def _suggest_channels_for_rebalance(self, direction, amount_sat) -> Sequence[Tuple[Channel, int]]: + """ + Suggest a channel and amount to send/receive with that channel, so that we will be able to receive/send amount_sat + This is used when suggesting a swap or rebalance in order to receive a payment + """ + with self.lock: + func = self.num_sats_can_send if direction == SENT else self.num_sats_can_receive + delta = amount_sat - func() + assert delta > 0 + delta += self.fee_estimate(amount_sat) + # add safety margin, for example if channel reserves is not met + # also covers swap server percentage fee + delta += delta // 20 + suggestions = [] + channels = self.get_channels_for_sending() if direction == SENT else self.get_channels_for_receiving() + for chan in channels: + if func(deltas={chan:delta}) >= amount_sat: + suggestions.append((chan, delta)) + if not suggestions: + raise NotEnoughFunds + return suggestions + + def _suggest_rebalance(self, direction, amount_sat): + """ + Suggest a rebalance in order to be able to send or receive amount_sat. + Returns (from_channel, to_channel, amount to shuffle) + """ + try: + suggestions = self._suggest_channels_for_rebalance(direction, amount_sat) + except NotEnoughFunds: + return False + for chan2, delta in suggestions: + # margin for fee caused by rebalancing + delta += self.fee_estimate(amount_sat) + # find other channel or trampoline that can send delta + for chan1 in self.channels.values(): + if chan1.is_frozen_for_sending() or not chan1.is_active(): + continue + if chan1 == chan2: + continue + if not self.channel_db and chan1.node_id == chan2.node_id: + continue + if direction == SENT: + if chan1.can_pay(delta*1000): + return (chan1, chan2, delta) + else: + if chan1.can_receive(delta*1000): + return (chan2, chan1, delta) + else: + continue + else: + return False + + def suggest_rebalance_to_send(self, amount_sat): + return self._suggest_rebalance(SENT, amount_sat) + + def suggest_rebalance_to_receive(self, amount_sat): + return self._suggest_rebalance(RECEIVED, amount_sat) + + def suggest_swap_to_send(self, amount_sat, coins): + # fixme: if swap_amount_sat is lower than the minimum swap amount, we need to propose a higher value + assert amount_sat > self.num_sats_can_send() + try: + suggestions = self._suggest_channels_for_rebalance(SENT, amount_sat) + except NotEnoughFunds: + return + for chan, swap_recv_amount in suggestions: + # check that we can send onchain + 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(), int(swap_funding_sat)) + if not self.wallet.can_pay_onchain([swap_output], coins=coins): + continue + return (chan, swap_recv_amount) + + def suggest_swap_to_receive(self, amount_sat): + assert amount_sat > self.num_sats_can_receive() + try: + suggestions = self._suggest_channels_for_rebalance(RECEIVED, amount_sat) + except NotEnoughFunds: + return + for chan, swap_recv_amount in suggestions: + return (chan, swap_recv_amount) + async def rebalance_channels(self, chan1, chan2, amount_msat): lnaddr, invoice = self.create_invoice( amount_msat=amount_msat, diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py index 75f1e57a2..dcbbda273 100644 --- a/electrum/submarine_swaps.py +++ b/electrum/submarine_swaps.py @@ -262,6 +262,7 @@ class SwapManager(Logger): expected_onchain_amount_sat: int, password, tx: PartialTransaction = None, + channels = None, ) -> str: """send on-chain BTC, receive on Lightning @@ -279,6 +280,7 @@ class SwapManager(Logger): message='swap', expiry=3600 * 24, fallback_address=None, + channels=channels, ) payment_hash = lnaddr.paymenthash preimage = self.lnworker.get_preimage(payment_hash) @@ -358,6 +360,7 @@ class SwapManager(Logger): *, lightning_amount_sat: int, expected_onchain_amount_sat: int, + channels = None, ) -> bool: """send on Lightning, receive on-chain @@ -457,7 +460,7 @@ class SwapManager(Logger): self.prepayments[prepay_hash] = preimage_hash asyncio.ensure_future(self.lnworker.pay_invoice(fee_invoice, attempts=10)) # initiate payment. - success, log = await self.lnworker.pay_invoice(invoice, attempts=10) + success, log = await self.lnworker.pay_invoice(invoice, attempts=10, channels=channels) return success def _add_or_reindex_swap(self, swap: SwapData) -> None: diff --git a/electrum/tests/test_lnpeer.py b/electrum/tests/test_lnpeer.py index b6bef11d7..2aceae9ff 100644 --- a/electrum/tests/test_lnpeer.py +++ b/electrum/tests/test_lnpeer.py @@ -243,7 +243,7 @@ class MockLNWallet(Logger, NetworkRetryManager[LNPeerAddr]): get_channel_by_id = LNWallet.get_channel_by_id channels_for_peer = LNWallet.channels_for_peer calc_routing_hints_for_invoice = LNWallet.calc_routing_hints_for_invoice - get_channels_to_include_in_invoice = LNWallet.get_channels_to_include_in_invoice + get_channels_for_receiving = LNWallet.get_channels_for_receiving handle_error_code_from_failed_htlc = LNWallet.handle_error_code_from_failed_htlc is_trampoline_peer = LNWallet.is_trampoline_peer wait_for_received_pending_htlcs_to_get_removed = LNWallet.wait_for_received_pending_htlcs_to_get_removed diff --git a/electrum/wallet.py b/electrum/wallet.py index a8ab8e2c3..6bcccfc9c 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -82,7 +82,6 @@ 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 @@ -1359,25 +1358,6 @@ class Abstract_Wallet(AddressSynchronizer, ABC): 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],