Browse Source

Merge pull request #9041 from SomberNight/202405_tramp1

trampoline: use exponential search for fees, capped by configurable budget
master
ThomasV 2 years ago committed by GitHub
parent
commit
dfa247773f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 34
      electrum/gui/qt/settings_dialog.py
  2. 22
      electrum/lnrouter.py
  3. 21
      electrum/lnutil.py
  4. 31
      electrum/lnworker.py
  5. 13
      electrum/simple_config.py
  6. 105
      electrum/trampoline.py
  7. 2
      tests/test_lnpeer.py

34
electrum/gui/qt/settings_dialog.py

@ -30,7 +30,7 @@ from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (QComboBox, QTabWidget, QDialog, from PyQt5.QtWidgets import (QComboBox, QTabWidget, QDialog,
QSpinBox, QFileDialog, QCheckBox, QLabel, QSpinBox, QFileDialog, QCheckBox, QLabel,
QVBoxLayout, QGridLayout, QLineEdit, QVBoxLayout, QGridLayout, QLineEdit,
QPushButton, QWidget, QHBoxLayout) QPushButton, QWidget, QHBoxLayout, QSlider)
from electrum.i18n import _, languages from electrum.i18n import _, languages
from electrum import util, paymentrequest from electrum import util, paymentrequest
@ -155,6 +155,37 @@ class SettingsDialog(QDialog, QtEventListener):
self.config.WATCHTOWER_CLIENT_URL = url self.config.WATCHTOWER_CLIENT_URL = url
self.watchtower_url_e.editingFinished.connect(on_wt_url) self.watchtower_url_e.editingFinished.connect(on_wt_url)
lnfee_hlabel = HelpLabel.from_configvar(self.config.cv.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS)
lnfee_map = [500, 1_000, 3_000, 5_000, 10_000, 20_000, 30_000, 50_000]
def lnfee_update_vlabel(fee_val: int):
lnfee_vlabel.setText(_("{}% of payment").format(f"{fee_val / 10 ** 4:.2f}"))
def lnfee_slider_moved():
pos = lnfee_slider.sliderPosition()
fee_val = lnfee_map[pos]
lnfee_update_vlabel(fee_val)
def lnfee_slider_released():
pos = lnfee_slider.sliderPosition()
fee_val = lnfee_map[pos]
self.config.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS = fee_val
lnfee_slider = QSlider(Qt.Horizontal)
lnfee_slider.setRange(0, len(lnfee_map)-1)
lnfee_slider.setTracking(True)
try:
lnfee_spos = lnfee_map.index(self.config.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS)
except ValueError:
lnfee_spos = 0
lnfee_slider.setSliderPosition(lnfee_spos)
lnfee_vlabel = QLabel("")
lnfee_update_vlabel(self.config.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS)
lnfee_slider.valueChanged.connect(lnfee_slider_moved)
lnfee_slider.sliderReleased.connect(lnfee_slider_released)
lnfee_hbox = QHBoxLayout()
lnfee_hbox.setContentsMargins(0, 0, 0, 0)
lnfee_hbox.addWidget(lnfee_vlabel)
lnfee_hbox.addWidget(lnfee_slider)
lnfee_hbox_w = QWidget()
lnfee_hbox_w.setLayout(lnfee_hbox)
alias_label = HelpLabel.from_configvar(self.config.cv.OPENALIAS_ID) alias_label = HelpLabel.from_configvar(self.config.cv.OPENALIAS_ID)
alias = self.config.OPENALIAS_ID alias = self.config.OPENALIAS_ID
self.alias_e = QLineEdit(alias) self.alias_e = QLineEdit(alias)
@ -351,6 +382,7 @@ class SettingsDialog(QDialog, QtEventListener):
lightning_widgets.append((trampoline_cb, None)) lightning_widgets.append((trampoline_cb, None))
lightning_widgets.append((legacy_add_trampoline_cb, None)) lightning_widgets.append((legacy_add_trampoline_cb, None))
lightning_widgets.append((remote_wt_cb, self.watchtower_url_e)) lightning_widgets.append((remote_wt_cb, self.watchtower_url_e))
lightning_widgets.append((lnfee_hlabel, lnfee_hbox_w))
fiat_widgets = [] fiat_widgets = []
fiat_widgets.append((QLabel(_('Fiat currency')), ccy_combo)) fiat_widgets.append((QLabel(_('Fiat currency')), ccy_combo))
fiat_widgets.append((QLabel(_('Source')), ex_combo)) fiat_widgets.append((QLabel(_('Source')), ex_combo))

22
electrum/lnrouter.py

@ -105,16 +105,6 @@ class RouteEdge(PathEdge):
cltv_delta=channel_policy.cltv_delta, cltv_delta=channel_policy.cltv_delta,
node_features=node_info.features if node_info else 0) node_features=node_info.features if node_info else 0)
def is_sane_to_use(self, amount_msat: int) -> bool:
# TODO revise ad-hoc heuristics
# cltv cannot be more than 2 weeks
if self.cltv_delta > 14 * 144:
return False
total_fee = self.fee_for_edge(amount_msat)
if total_fee > get_default_fee_budget_msat(invoice_amount_msat=amount_msat):
return False
return True
def has_feature_varonion(self) -> bool: def has_feature_varonion(self) -> bool:
features = LnFeatures(self.node_features) features = LnFeatures(self.node_features)
return features.supports(LnFeatures.VAR_ONION_OPT) return features.supports(LnFeatures.VAR_ONION_OPT)
@ -153,7 +143,6 @@ def is_route_within_budget(
amt = amount_msat_for_dest amt = amount_msat_for_dest
cltv_cost_of_route = 0 # excluding cltv_delta_for_dest cltv_cost_of_route = 0 # excluding cltv_delta_for_dest
for route_edge in reversed(route[1:]): for route_edge in reversed(route[1:]):
if not route_edge.is_sane_to_use(amt): return False
amt += route_edge.fee_for_edge(amt) amt += route_edge.fee_for_edge(amt)
cltv_cost_of_route += route_edge.cltv_delta cltv_cost_of_route += route_edge.cltv_delta
fee_cost = amt - amount_msat_for_dest fee_cost = amt - amount_msat_for_dest
@ -169,12 +158,6 @@ def is_route_within_budget(
return True return True
def get_default_fee_budget_msat(*, invoice_amount_msat: int) -> int:
# fees <= 1 % of payment are fine
# fees <= 5 sat are fine
return max(5_000, invoice_amount_msat // 100)
class LiquidityHint: class LiquidityHint:
"""Encodes the amounts that can and cannot be sent over the direction of a """Encodes the amounts that can and cannot be sent over the direction of a
channel. channel.
@ -520,8 +503,9 @@ class LNPathFinder(Logger):
start_node=start_node, start_node=start_node,
end_node=end_node, end_node=end_node,
node_info=node_info) node_info=node_info)
if not route_edge.is_sane_to_use(payment_amt_msat): # Cap cltv of any given edge at 2 weeks (the cost function would not work well for extreme cases)
return float('inf'), 0 # thanks but no thanks if route_edge.cltv_delta > 14 * 144:
return float('inf'), 0
# Distance metric notes: # TODO constants are ad-hoc # Distance metric notes: # TODO constants are ad-hoc
# ( somewhat based on https://github.com/lightningnetwork/lnd/pull/1358 ) # ( somewhat based on https://github.com/lightningnetwork/lnd/pull/1358 )
# - Edges have a base cost. (more edges -> less likely none will fail) # - Edges have a base cost. (more edges -> less likely none will fail)

21
electrum/lnutil.py

@ -1672,9 +1672,24 @@ class PaymentFeeBudget(NamedTuple):
#num_htlc: int #num_htlc: int
@classmethod @classmethod
def default(cls, *, invoice_amount_msat: int) -> 'PaymentFeeBudget': def default(cls, *, invoice_amount_msat: int, config: 'SimpleConfig') -> 'PaymentFeeBudget':
from .lnrouter import get_default_fee_budget_msat millionths_orig = config.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS
millionths = min(max(0, millionths_orig), 250_000) # clamp into [0, 25%]
cutoff_orig = config.LIGHTNING_PAYMENT_FEE_CUTOFF_MSAT
cutoff = min(max(0, cutoff_orig), 10_000_000) # clamp into [0, 10k sat]
if millionths != millionths_orig:
_logger.warning(
f"PaymentFeeBudget. found insane fee millionths in config. "
f"clamped: {millionths_orig}->{millionths}")
if cutoff != cutoff_orig:
_logger.warning(
f"PaymentFeeBudget. found insane fee cutoff in config. "
f"clamped: {cutoff_orig}->{cutoff}")
# for small payments, fees <= constant cutoff are fine
# for large payments, the max fee is percentage-based
fee_msat = invoice_amount_msat * millionths // 1_000_000
fee_msat = max(fee_msat, cutoff)
return PaymentFeeBudget( return PaymentFeeBudget(
fee_msat=get_default_fee_budget_msat(invoice_amount_msat=invoice_amount_msat), fee_msat=fee_msat,
cltv=NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE, cltv=NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE,
) )

31
electrum/lnworker.py

@ -86,7 +86,7 @@ from .channel_db import get_mychannel_info, get_mychannel_policy
from .submarine_swaps import HttpSwapManager from .submarine_swaps import HttpSwapManager
from .channel_db import ChannelInfo, Policy from .channel_db import ChannelInfo, Policy
from .mpp_split import suggest_splits, SplitConfigRating from .mpp_split import suggest_splits, SplitConfigRating
from .trampoline import create_trampoline_route_and_onion, TRAMPOLINE_FEES, is_legacy_relay from .trampoline import create_trampoline_route_and_onion, is_legacy_relay
if TYPE_CHECKING: if TYPE_CHECKING:
from .network import Network from .network import Network
@ -1505,18 +1505,8 @@ class LNWallet(LNWorker):
info = PaymentInfo(payment_hash, amount_to_pay, SENT, PR_UNPAID) info = PaymentInfo(payment_hash, amount_to_pay, SENT, PR_UNPAID)
self.save_payment_info(info) self.save_payment_info(info)
self.wallet.set_label(key, lnaddr.get_description()) self.wallet.set_label(key, lnaddr.get_description())
self.logger.info(
f"pay_invoice starting session for RHASH={payment_hash.hex()}. "
f"using_trampoline={self.uses_trampoline()}. "
f"invoice_features={invoice_features.get_names()}")
if not self.uses_trampoline():
self.logger.info(
f"gossip_db status. sync progress: {self.network.lngossip.get_sync_progress_estimate()}. "
f"num_nodes={self.channel_db.num_nodes}, "
f"num_channels={self.channel_db.num_channels}, "
f"num_policies={self.channel_db.num_policies}.")
self.set_invoice_status(key, PR_INFLIGHT) self.set_invoice_status(key, PR_INFLIGHT)
budget = PaymentFeeBudget.default(invoice_amount_msat=amount_to_pay) budget = PaymentFeeBudget.default(invoice_amount_msat=amount_to_pay, config=self.config)
success = False success = False
try: try:
await self.pay_to_node( await self.pay_to_node(
@ -1587,6 +1577,18 @@ class LNWallet(LNWorker):
) )
self.logs[payment_hash.hex()] = log = [] # TODO incl payment_secret in key (re trampoline forwarding) self.logs[payment_hash.hex()] = log = [] # TODO incl payment_secret in key (re trampoline forwarding)
paysession.logger.info(
f"pay_to_node starting session for RHASH={payment_hash.hex()}. "
f"using_trampoline={self.uses_trampoline()}. "
f"invoice_features={paysession.invoice_features.get_names()}. "
f"{amount_to_pay=} msat. {budget=}")
if not self.uses_trampoline():
self.logger.info(
f"gossip_db status. sync progress: {self.network.lngossip.get_sync_progress_estimate()}. "
f"num_nodes={self.channel_db.num_nodes}, "
f"num_channels={self.channel_db.num_channels}, "
f"num_policies={self.channel_db.num_policies}.")
# when encountering trampoline forwarding difficulties in the legacy case, we # when encountering trampoline forwarding difficulties in the legacy case, we
# sometimes need to fall back to a single trampoline forwarder, at the expense # sometimes need to fall back to a single trampoline forwarder, at the expense
# of privacy # of privacy
@ -1665,6 +1667,7 @@ class LNWallet(LNWorker):
paysession.is_active = False paysession.is_active = False
if paysession.can_be_deleted(): if paysession.can_be_deleted():
self._paysessions.pop(payment_key) self._paysessions.pop(payment_key)
paysession.logger.info(f"pay_to_node ending session for RHASH={payment_hash.hex()}")
async def pay_to_route( async def pay_to_route(
self, *, self, *,
@ -2630,8 +2633,8 @@ class LNWallet(LNWorker):
def fee_estimate(self, amount_sat): def fee_estimate(self, amount_sat):
# Here we have to guess a fee, because some callers (submarine swaps) # Here we have to guess a fee, because some callers (submarine swaps)
# use this method to initiate a payment, which would otherwise fail. # use this method to initiate a payment, which would otherwise fail.
fee_base_msat = TRAMPOLINE_FEES[3]['fee_base_msat'] fee_base_msat = 5000 # FIXME ehh.. there ought to be a better way...
fee_proportional_millionths = TRAMPOLINE_FEES[3]['fee_proportional_millionths'] fee_proportional_millionths = 500 # FIXME
# inverse of fee_for_edge_msat # inverse of fee_for_edge_msat
amount_msat = amount_sat * 1000 amount_msat = amount_sat * 1000
amount_minus_fees = (amount_msat - fee_base_msat) * 1_000_000 // ( 1_000_000 + fee_proportional_millionths) amount_minus_fees = (amount_msat - fee_base_msat) * 1_000_000 // ( 1_000_000 + fee_proportional_millionths)

13
electrum/simple_config.py

@ -1040,6 +1040,19 @@ Note you are at risk of losing the funds in the swap, if the funding transaction
This will result in longer routes; it might increase your fees and decrease the success rate of your payments."""), This will result in longer routes; it might increase your fees and decrease the success rate of your payments."""),
) )
INITIAL_TRAMPOLINE_FEE_LEVEL = ConfigVar('initial_trampoline_fee_level', default=1, type_=int) INITIAL_TRAMPOLINE_FEE_LEVEL = ConfigVar('initial_trampoline_fee_level', default=1, type_=int)
LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS = ConfigVar(
'lightning_payment_fee_max_millionths', default=10_000, # 1%
type_=int,
short_desc=lambda: _("Max lightning fees to pay"),
long_desc=lambda: _("""When sending lightning payments, this value is an upper bound for the fees we allow paying, proportional to the payment amount. The fees are paid in addition to the payment amount, by the sender.
Warning: setting this to too low will result in lots of payment failures."""),
)
LIGHTNING_PAYMENT_FEE_CUTOFF_MSAT = ConfigVar(
'lightning_payment_fee_cutoff_msat', default=10_000, # 10 sat
type_=int,
short_desc=lambda: _("Max lightning fees to pay for small payments"),
)
LIGHTNING_NODE_ALIAS = ConfigVar('lightning_node_alias', default='', type_=str) LIGHTNING_NODE_ALIAS = ConfigVar('lightning_node_alias', default='', type_=str)
EXPERIMENTAL_LN_FORWARD_PAYMENTS = ConfigVar('lightning_forward_payments', default=False, type_=bool) EXPERIMENTAL_LN_FORWARD_PAYMENTS = ConfigVar('lightning_forward_payments', default=False, type_=bool)

105
electrum/trampoline.py

@ -13,45 +13,6 @@ from .logging import get_logger
_logger = get_logger(__name__) _logger = get_logger(__name__)
# trampoline nodes are supposed to advertise their fee and cltv in node_update message
TRAMPOLINE_FEES = [
{
'fee_base_msat': 0,
'fee_proportional_millionths': 0,
'cltv_expiry_delta': 576,
},
{
'fee_base_msat': 1000,
'fee_proportional_millionths': 100,
'cltv_expiry_delta': 576,
},
{
'fee_base_msat': 3000,
'fee_proportional_millionths': 100,
'cltv_expiry_delta': 576,
},
{
'fee_base_msat': 5000,
'fee_proportional_millionths': 500,
'cltv_expiry_delta': 576,
},
{
'fee_base_msat': 7000,
'fee_proportional_millionths': 1000,
'cltv_expiry_delta': 576,
},
{
'fee_base_msat': 12000,
'fee_proportional_millionths': 3000,
'cltv_expiry_delta': 576,
},
{
'fee_base_msat': 100000,
'fee_proportional_millionths': 3000,
'cltv_expiry_delta': 576,
},
]
# hardcoded list # hardcoded list
# TODO for some pubkeys, there are multiple network addresses we could try # TODO for some pubkeys, there are multiple network addresses we could try
TRAMPOLINE_NODES_MAINNET = { TRAMPOLINE_NODES_MAINNET = {
@ -156,27 +117,12 @@ def is_legacy_relay(invoice_features, r_tags) -> Tuple[bool, Set[bytes]]:
return True, set() return True, set()
def trampoline_policy( PLACEHOLDER_FEE = None
trampoline_fee_level: int,
) -> Dict:
"""Return the fee policy for all trampoline nodes.
Raises NoPathFound if the fee level is exhausted."""
# TODO: ideally we want to use individual fee levels for each trampoline node,
# but because at the moment we can't attribute insufficient fee errors to
# downstream trampolines we need to use a global fee level here
if trampoline_fee_level < len(TRAMPOLINE_FEES):
return TRAMPOLINE_FEES[trampoline_fee_level]
else:
raise NoPathFound()
def _extend_trampoline_route( def _extend_trampoline_route(
route: List[TrampolineEdge], route: List[TrampolineEdge],
*, *,
start_node: bytes = None, start_node: bytes = None,
end_node: bytes, end_node: bytes,
trampoline_fee_level: int,
pay_fees: bool = True, pay_fees: bool = True,
): ):
"""Extends the route and modifies it in place.""" """Extends the route and modifies it in place."""
@ -185,17 +131,47 @@ def _extend_trampoline_route(
start_node = route[-1].end_node start_node = route[-1].end_node
trampoline_features = LnFeatures.VAR_ONION_OPT trampoline_features = LnFeatures.VAR_ONION_OPT
# get policy for *start_node* # get policy for *start_node*
policy = trampoline_policy(trampoline_fee_level) # note: trampoline nodes are supposed to advertise their fee and cltv in node_update message.
# However, in the temporary spec, they do not.
# They also don't send their fee policy in the error message if we lowball the fee...
route.append( route.append(
TrampolineEdge( TrampolineEdge(
start_node=start_node, start_node=start_node,
end_node=end_node, end_node=end_node,
fee_base_msat=policy['fee_base_msat'] if pay_fees else 0, fee_base_msat=PLACEHOLDER_FEE if pay_fees else 0,
fee_proportional_millionths=policy['fee_proportional_millionths'] if pay_fees else 0, fee_proportional_millionths=PLACEHOLDER_FEE if pay_fees else 0,
cltv_delta=policy['cltv_expiry_delta'] if pay_fees else 0, cltv_delta=576 if pay_fees else 0,
node_features=trampoline_features)) node_features=trampoline_features))
def _allocate_fee_along_route(
route: List[TrampolineEdge],
*,
budget: PaymentFeeBudget,
trampoline_fee_level: int,
) -> None:
# calculate budget_to_use, based on given max available "budget"
if trampoline_fee_level == 0:
budget_to_use = 0
else:
assert trampoline_fee_level > 0
MAX_LEVEL = 6
if trampoline_fee_level > MAX_LEVEL:
raise NoPathFound()
budget_to_use = budget.fee_msat // (2 ** (MAX_LEVEL - trampoline_fee_level))
_logger.debug(f"_allocate_fee_along_route(). {trampoline_fee_level=}, {budget.fee_msat=}, {budget_to_use=}")
# replace placeholder fees
for edge in route:
assert edge.fee_base_msat in (0, PLACEHOLDER_FEE), edge.fee_base_msat
assert edge.fee_proportional_millionths in (0, PLACEHOLDER_FEE), edge.fee_proportional_millionths
edges_to_update = [
edge for edge in route
if edge.fee_base_msat == PLACEHOLDER_FEE]
for edge in edges_to_update:
edge.fee_base_msat = budget_to_use // len(edges_to_update)
edge.fee_proportional_millionths = 0
def _choose_second_trampoline( def _choose_second_trampoline(
my_trampoline: bytes, my_trampoline: bytes,
trampolines: Iterable[bytes], trampolines: Iterable[bytes],
@ -237,7 +213,7 @@ def create_trampoline_route(
# our first trampoline hop is decided by the channel we use # our first trampoline hop is decided by the channel we use
_extend_trampoline_route( _extend_trampoline_route(
route, start_node=my_pubkey, end_node=my_trampoline, route, start_node=my_pubkey, end_node=my_trampoline,
trampoline_fee_level=trampoline_fee_level, pay_fees=False, pay_fees=False,
) )
if is_legacy: if is_legacy:
@ -245,7 +221,7 @@ def create_trampoline_route(
if use_two_trampolines: if use_two_trampolines:
trampolines = trampolines_by_id() trampolines = trampolines_by_id()
second_trampoline = _choose_second_trampoline(my_trampoline, list(trampolines.keys()), failed_routes) second_trampoline = _choose_second_trampoline(my_trampoline, list(trampolines.keys()), failed_routes)
_extend_trampoline_route(route, end_node=second_trampoline, trampoline_fee_level=trampoline_fee_level) _extend_trampoline_route(route, end_node=second_trampoline)
# the last trampoline onion must contain routing hints for the last trampoline # the last trampoline onion must contain routing hints for the last trampoline
# node to find the recipient # node to find the recipient
invoice_routing_info = encode_routing_info(r_tags) invoice_routing_info = encode_routing_info(r_tags)
@ -267,12 +243,15 @@ def create_trampoline_route(
add_trampoline = True add_trampoline = True
if add_trampoline: if add_trampoline:
second_trampoline = _choose_second_trampoline(my_trampoline, invoice_trampolines, failed_routes) second_trampoline = _choose_second_trampoline(my_trampoline, invoice_trampolines, failed_routes)
_extend_trampoline_route(route, end_node=second_trampoline, trampoline_fee_level=trampoline_fee_level) _extend_trampoline_route(route, end_node=second_trampoline)
# Add final edge. note: eclair requires an encrypted t-onion blob even in legacy case. # Add final edge. note: eclair requires an encrypted t-onion blob even in legacy case.
# Also needed for fees for last TF! # Also needed for fees for last TF!
if route[-1].end_node != invoice_pubkey: if route[-1].end_node != invoice_pubkey:
_extend_trampoline_route(route, end_node=invoice_pubkey, trampoline_fee_level=trampoline_fee_level) _extend_trampoline_route(route, end_node=invoice_pubkey)
# replace placeholder fees in route
_allocate_fee_along_route(route, budget=budget, trampoline_fee_level=trampoline_fee_level)
# check that we can pay amount and fees # check that we can pay amount and fees
if not is_route_within_budget( if not is_route_within_budget(

2
tests/test_lnpeer.py

@ -258,7 +258,7 @@ class MockLNWallet(Logger, EventListener, NetworkRetryManager[LNPeerAddr]):
amount_msat=amount_msat, amount_msat=amount_msat,
paysession=paysession, paysession=paysession,
full_path=full_path, full_path=full_path,
budget=PaymentFeeBudget.default(invoice_amount_msat=amount_msat), budget=PaymentFeeBudget.default(invoice_amount_msat=amount_msat, config=self.config),
)] )]
get_payments = LNWallet.get_payments get_payments = LNWallet.get_payments

Loading…
Cancel
Save