Browse Source

LNWorker: Add suggest_rebalance methods for sending and receiving.

These methods return a list of channels that can be rebalanced,
in order to receive or send a given amount.

Also add 'channels' parameter to submarine swaps.
Previously, swaps were not considering which channel to use.

When we do not have liquidity to pay an invoice:
 - add 'rebalance' option in order to pay an invoice
 - use the suggested channel in the 'swap' option

When we do not have the liquidity to receive an invoice:
 - add 'Rebalance' and 'Swap' buttons to the receive tab
patch-4
ThomasV 3 years ago
parent
commit
53151244e2
  1. 70
      electrum/gui/qt/main_window.py
  2. 4
      electrum/gui/qt/swap_dialog.py
  3. 159
      electrum/lnworker.py
  4. 5
      electrum/submarine_swaps.py
  5. 2
      electrum/tests/test_lnpeer.py
  6. 20
      electrum/wallet.py

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

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

159
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,

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

2
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

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

Loading…
Cancel
Save