You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

3132 lines
148 KiB

# Copyright (C) 2018 The Electrum developers
# Distributed under the MIT software license, see the accompanying
# file LICENCE or http://www.opensource.org/licenses/mit-license.php
import asyncio
import os
from decimal import Decimal
import random
import time
import operator
import enum
from enum import IntEnum, Enum
from typing import (Optional, Sequence, Tuple, List, Set, Dict, TYPE_CHECKING,
NamedTuple, Union, Mapping, Any, Iterable, AsyncGenerator, DefaultDict, Callable, Awaitable)
import threading
import socket
import json
from datetime import datetime, timezone
from functools import partial, cached_property
from collections import defaultdict
import concurrent
from concurrent import futures
import urllib.parse
import itertools
import aiohttp
import dns.resolver
import dns.exception
from aiorpcx import run_in_thread, NetAddress, ignore_after
from electrum_ecc import ecdsa_der_sig_from_ecdsa_sig64
from . import constants, util
from . import keystore
from .util import profiler, chunks, OldTaskGroup, ESocksProxy
from .invoices import Invoice, PR_UNPAID, PR_EXPIRED, PR_PAID, PR_INFLIGHT, PR_FAILED, PR_ROUTING, LN_EXPIRY_NEVER
from .invoices import BaseInvoice
from .util import NetworkRetryManager, JsonRPCClient, NotEnoughFunds
from .util import EventListener, event_listener
from .keystore import BIP32_KeyStore
from .bitcoin import COIN
from .bitcoin import opcodes, make_op_return, address_to_scripthash
from .transaction import Transaction
from .transaction import get_script_type_from_output_script
from .crypto import sha256
from .bip32 import BIP32Node
from .util import bfh, InvoiceError, resolve_dns_srv, is_ip_address, log_exceptions
from .crypto import chacha20_encrypt, chacha20_decrypt
from .util import ignore_exceptions, make_aiohttp_session
from .util import timestamp_to_datetime, random_shuffled_copy
from .util import MyEncoder, is_private_netaddress, UnrelatedTransactionException
from .logging import Logger
from .lntransport import LNTransport, LNResponderTransport, LNTransportBase, LNPeerAddr, split_host_port, extract_nodeid, ConnStringFormatError
from .lnpeer import Peer, LN_P2P_NETWORK_TIMEOUT
from .lnaddr import lnencode, LnAddr, lndecode
from .lnchannel import Channel, AbstractChannel
from .lnchannel import ChannelState, PeerState, HTLCWithStatus
from .lnrater import LNRater
from . import lnutil
from .lnutil import funding_output_script
from .lnutil import serialize_htlc_key, deserialize_htlc_key
from .bitcoin import DummyAddress
from .lnutil import (Outpoint,
get_compressed_pubkey_from_bech32,
PaymentFailure,
generate_keypair, LnKeyFamily, LOCAL, REMOTE,
MIN_FINAL_CLTV_DELTA_FOR_INVOICE,
NUM_MAX_EDGES_IN_PAYMENT_PATH, SENT, RECEIVED, HTLCOwner,
UpdateAddHtlc, Direction, LnFeatures, ShortChannelID,
HtlcLog, derive_payment_secret_from_payment_preimage,
NoPathFound, InvalidGossipMsg)
from .lnutil import ln_compare_features, IncompatibleLightningFeatures, PaymentFeeBudget
from .transaction import PartialTxOutput, PartialTransaction, PartialTxInput
from .lnonion import decode_onion_error, OnionFailureCode, OnionRoutingFailure, OnionPacket
from .lnmsg import decode_msg
from .i18n import _
from .lnrouter import (RouteEdge, LNPaymentRoute, LNPaymentPath, is_route_within_budget,
NoChannelPolicy, LNPathInconsistent)
from .address_synchronizer import TX_HEIGHT_LOCAL, TX_TIMESTAMP_INF
from . import lnsweep
from .lnwatcher import LNWalletWatcher
from .crypto import pw_encode_with_version_and_mac, pw_decode_with_version_and_mac
from .lnutil import ImportedChannelBackupStorage, OnchainChannelBackupStorage
from .lnchannel import ChannelBackup
from .channel_db import UpdateStatus, ChannelDBNotLoaded
from .channel_db import get_mychannel_info, get_mychannel_policy
from .submarine_swaps import HttpSwapManager
from .channel_db import ChannelInfo, Policy
from .mpp_split import suggest_splits, SplitConfigRating
from .trampoline import create_trampoline_route_and_onion, is_legacy_relay
from .json_db import stored_in
if TYPE_CHECKING:
from .network import Network
from .wallet import Abstract_Wallet
from .channel_db import ChannelDB
from .simple_config import SimpleConfig
SAVED_PR_STATUS = [PR_PAID, PR_UNPAID] # status that are persisted
NUM_PEERS_TARGET = 4
# onchain channel backup data
CB_VERSION = 0
CB_MAGIC_BYTES = bytes([0, 0, 0, CB_VERSION])
FALLBACK_NODE_LIST_TESTNET = (
LNPeerAddr(host='203.132.95.10', port=9735, pubkey=bfh('038863cf8ab91046230f561cd5b386cbff8309fa02e3f0c3ed161a3aeb64a643b9')),
LNPeerAddr(host='2401:d002:4402:0:bf1d:986a:7598:6d49', port=9735, pubkey=bfh('038863cf8ab91046230f561cd5b386cbff8309fa02e3f0c3ed161a3aeb64a643b9')),
LNPeerAddr(host='50.116.3.223', port=9734, pubkey=bfh('03236a685d30096b26692dce0cf0fa7c8528bdf61dbf5363a3ef6d5c92733a3016')),
LNPeerAddr(host='3.16.119.191', port=9735, pubkey=bfh('03d5e17a3c213fe490e1b0c389f8cfcfcea08a29717d50a9f453735e0ab2a7c003')),
LNPeerAddr(host='34.250.234.192', port=9735, pubkey=bfh('03933884aaf1d6b108397e5efe5c86bcf2d8ca8d2f700eda99db9214fc2712b134')),
LNPeerAddr(host='88.99.209.230', port=9735, pubkey=bfh('0260d9119979caedc570ada883ff614c6efb93f7f7382e25d73ecbeba0b62df2d7')),
LNPeerAddr(host='160.16.233.215', port=9735, pubkey=bfh('023ea0a53af875580899da0ab0a21455d9c19160c4ea1b7774c9d4be6810b02d2c')),
LNPeerAddr(host='197.155.6.173', port=9735, pubkey=bfh('0269a94e8b32c005e4336bfb743c08a6e9beb13d940d57c479d95c8e687ccbdb9f')),
LNPeerAddr(host='2c0f:fb18:406::4', port=9735, pubkey=bfh('0269a94e8b32c005e4336bfb743c08a6e9beb13d940d57c479d95c8e687ccbdb9f')),
LNPeerAddr(host='163.172.94.64', port=9735, pubkey=bfh('030f0bf260acdbd3edcad84d7588ec7c5df4711e87e6a23016f989b8d3a4147230')),
LNPeerAddr(host='23.237.77.12', port=9735, pubkey=bfh('02312627fdf07fbdd7e5ddb136611bdde9b00d26821d14d94891395452f67af248')),
LNPeerAddr(host='197.155.6.172', port=9735, pubkey=bfh('02ae2f22b02375e3e9b4b4a2db4f12e1b50752b4062dbefd6e01332acdaf680379')),
LNPeerAddr(host='2c0f:fb18:406::3', port=9735, pubkey=bfh('02ae2f22b02375e3e9b4b4a2db4f12e1b50752b4062dbefd6e01332acdaf680379')),
LNPeerAddr(host='23.239.23.44', port=9740, pubkey=bfh('034fe52e98a0e9d3c21b767e1b371881265d8c7578c21f5afd6d6438da10348b36')),
LNPeerAddr(host='2600:3c01::f03c:91ff:fe05:349c', port=9740, pubkey=bfh('034fe52e98a0e9d3c21b767e1b371881265d8c7578c21f5afd6d6438da10348b36')),
)
FALLBACK_NODE_LIST_MAINNET = [
LNPeerAddr(host='172.81.181.3', port=9735, pubkey=bfh('0214382bdce7750dfcb8126df8e2b12de38536902dc36abcebdaeefdeca1df8284')),
LNPeerAddr(host='35.230.100.60', port=9735, pubkey=bfh('023f5e3582716bed96f6f26cfcd8037e07474d7b4743afdc8b07e692df63464d7e')),
LNPeerAddr(host='40.69.71.114', port=9735, pubkey=bfh('028303182c9885da93b3b25c9621d22cf34475e63c123942e402ab530c0556e675')),
LNPeerAddr(host='94.177.171.73', port=9735, pubkey=bfh('0276e09a267592e7451a939c932cf685f0754de382a3ca85d2fb3a864d4c365ad5')),
LNPeerAddr(host='34.236.113.58', port=9735, pubkey=bfh('02fa50c72ee1e2eb5f1b6d9c3032080c4c864373c4201dfa2966aa34eee1051f97')),
LNPeerAddr(host='52.50.244.44', port=9735, pubkey=bfh('030c3f19d742ca294a55c00376b3b355c3c90d61c6b6b39554dbc7ac19b141c14f')),
LNPeerAddr(host='157.245.68.47', port=9735, pubkey=bfh('03c2abfa93eacec04721c019644584424aab2ba4dff3ac9bdab4e9c97007491dda')),
LNPeerAddr(host='18.221.23.28', port=9735, pubkey=bfh('03abf6f44c355dec0d5aa155bdbdd6e0c8fefe318eff402de65c6eb2e1be55dc3e')),
LNPeerAddr(host='52.224.178.244', port=9735, pubkey=bfh('026b105ac13212c48714c6be9b11577a9ce10f10e1c88a45ce217e6331209faf8b')),
LNPeerAddr(host='34.239.230.56', port=9735, pubkey=bfh('03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f')),
LNPeerAddr(host='46.229.165.136', port=9735, pubkey=bfh('0390b5d4492dc2f5318e5233ab2cebf6d48914881a33ef6a9c6bcdbb433ad986d0')),
LNPeerAddr(host='157.230.28.160', port=9735, pubkey=bfh('0279c22ed7a068d10dc1a38ae66d2d6461e269226c60258c021b1ddcdfe4b00bc4')),
LNPeerAddr(host='74.108.13.152', port=9735, pubkey=bfh('0331f80652fb840239df8dc99205792bba2e559a05469915804c08420230e23c7c')),
LNPeerAddr(host='167.172.44.148', port=9735, pubkey=bfh('0395033b252c6f40e3756984162d68174e2bd8060a129c0d3462a9370471c6d28f')),
LNPeerAddr(host='138.68.14.104', port=9735, pubkey=bfh('03bb88ccc444534da7b5b64b4f7b15e1eccb18e102db0e400d4b9cfe93763aa26d')),
LNPeerAddr(host='3.124.63.44', port=9735, pubkey=bfh('0242a4ae0c5bef18048fbecf995094b74bfb0f7391418d71ed394784373f41e4f3')),
LNPeerAddr(host='2001:470:8:2e1::43', port=9735, pubkey=bfh('03baa70886d9200af0ffbd3f9e18d96008331c858456b16e3a9b41e735c6208fef')),
LNPeerAddr(host='2601:186:c100:6bcd:219:d1ff:fe75:dc2f', port=9735, pubkey=bfh('0298f6074a454a1f5345cb2a7c6f9fce206cd0bf675d177cdbf0ca7508dd28852f')),
LNPeerAddr(host='2001:41d0:e:734::1', port=9735, pubkey=bfh('03a503d8e30f2ff407096d235b5db63b4fcf3f89a653acb6f43d3fc492a7674019')),
LNPeerAddr(host='2a01:4f9:2b:2254::2', port=9735, pubkey=bfh('02f3069a342ae2883a6f29e275f06f28a56a6ea2e2d96f5888a3266444dcf542b6')),
LNPeerAddr(host='2a02:8070:24c1:100:528c:2997:6dbc:a054', port=9735, pubkey=bfh('02a45def9ae014fdd2603dd7033d157faa3a55a72b06a63ae22ef46d9fafdc6e8d')),
LNPeerAddr(host='2600:3c01::f03c:91ff:fe05:349c', port=9736, pubkey=bfh('02731b798b39a09f9f14e90ee601afb6ebb796d6e5797de14582a978770b33700f')),
LNPeerAddr(host='2a00:8a60:e012:a00::21', port=9735, pubkey=bfh('027ce055380348d7812d2ae7745701c9f93e70c1adeb2657f053f91df4f2843c71')),
LNPeerAddr(host='2604:a880:400:d1::8bd:1001', port=9735, pubkey=bfh('03649c72a4816f0cd546f84aafbd657e92a30ab474de7ab795e8b5650a427611f7')),
LNPeerAddr(host='2a01:4f8:c0c:7b31::1', port=9735, pubkey=bfh('02c16cca44562b590dd279c942200bdccfd4f990c3a69fad620c10ef2f8228eaff')),
LNPeerAddr(host='2001:41d0:1:b40d::1', port=9735, pubkey=bfh('026726a4b043d413b45b334876d17b8a98848129604429ec65532ba286a42efeac')),
]
from .trampoline import trampolines_by_id, hardcoded_trampoline_nodes, is_hardcoded_trampoline
class PaymentDirection(IntEnum):
SENT = 0
RECEIVED = 1
SELF_PAYMENT = 2
FORWARDING = 3
class PaymentInfo(NamedTuple):
payment_hash: bytes
amount_msat: Optional[int]
direction: int
status: int
# Note: these states are persisted in the wallet file.
# Do not modify them without performing a wallet db upgrade
class RecvMPPResolution(IntEnum):
WAITING = 0
EXPIRED = 1
ACCEPTED = 2
FAILED = 3
class ReceivedMPPStatus(NamedTuple):
resolution: RecvMPPResolution
expected_msat: int
htlc_set: Set[Tuple[ShortChannelID, UpdateAddHtlc]]
@stored_in('received_mpp_htlcs', tuple)
def from_tuple(resolution, expected_msat, htlc_list) -> 'ReceivedMPPStatus':
htlc_set = set([(ShortChannelID(bytes.fromhex(scid)), UpdateAddHtlc.from_tuple(*x)) for (scid,x) in htlc_list])
return ReceivedMPPStatus(
resolution=RecvMPPResolution(resolution),
expected_msat=expected_msat,
htlc_set=htlc_set)
SentHtlcKey = Tuple[bytes, ShortChannelID, int] # RHASH, scid, htlc_id
class SentHtlcInfo(NamedTuple):
route: LNPaymentRoute
payment_secret_orig: bytes
payment_secret_bucket: bytes
amount_msat: int
bucket_msat: int
amount_receiver_msat: int
trampoline_fee_level: Optional[int]
trampoline_route: Optional[LNPaymentRoute]
class ErrorAddingPeer(Exception): pass
# set some feature flags as baseline for both LNWallet and LNGossip
# note that e.g. DATA_LOSS_PROTECT is needed for LNGossip as many peers require it
BASE_FEATURES = (
LnFeatures(0)
| LnFeatures.OPTION_DATA_LOSS_PROTECT_OPT
| LnFeatures.OPTION_STATIC_REMOTEKEY_OPT
| LnFeatures.VAR_ONION_OPT
| LnFeatures.PAYMENT_SECRET_OPT
| LnFeatures.OPTION_UPFRONT_SHUTDOWN_SCRIPT_OPT
)
# we do not want to receive unrequested gossip (see lnpeer.maybe_save_remote_update)
LNWALLET_FEATURES = (
BASE_FEATURES
| LnFeatures.OPTION_DATA_LOSS_PROTECT_REQ
| LnFeatures.OPTION_STATIC_REMOTEKEY_REQ
| LnFeatures.GOSSIP_QUERIES_REQ
| LnFeatures.VAR_ONION_REQ
| LnFeatures.PAYMENT_SECRET_REQ
| LnFeatures.BASIC_MPP_OPT
| LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT_ELECTRUM
| LnFeatures.OPTION_SHUTDOWN_ANYSEGWIT_OPT
| LnFeatures.OPTION_CHANNEL_TYPE_OPT
| LnFeatures.OPTION_SCID_ALIAS_OPT
| LnFeatures.OPTION_SUPPORT_LARGE_CHANNEL_OPT
)
LNGOSSIP_FEATURES = (
BASE_FEATURES
| LnFeatures.GOSSIP_QUERIES_OPT
| LnFeatures.GOSSIP_QUERIES_REQ
)
class LNWorker(Logger, EventListener, NetworkRetryManager[LNPeerAddr]):
def __init__(self, node_keypair, features: LnFeatures, *, config: 'SimpleConfig'):
Logger.__init__(self)
NetworkRetryManager.__init__(
self,
max_retry_delay_normal=3600,
init_retry_delay_normal=600,
max_retry_delay_urgent=300,
init_retry_delay_urgent=4,
)
self.lock = threading.RLock()
self.node_keypair = node_keypair
self._peers = {} # type: Dict[bytes, Peer] # pubkey -> Peer # needs self.lock
self.taskgroup = OldTaskGroup()
self.listen_server = None # type: Optional[asyncio.AbstractServer]
self.features = features
self.network = None # type: Optional[Network]
self.config = config
self.stopping_soon = False # whether we are being shut down
self._labels_cache = {} # txid -> str
self.register_callbacks()
@property
def channel_db(self):
return self.network.channel_db if self.network else None
def uses_trampoline(self):
return not bool(self.channel_db)
@property
def peers(self) -> Mapping[bytes, Peer]:
"""Returns a read-only copy of peers."""
with self.lock:
return self._peers.copy()
def channels_for_peer(self, node_id: bytes) -> Dict[bytes, Channel]:
return {}
def get_node_alias(self, node_id: bytes) -> Optional[str]:
"""Returns the alias of the node, or None if unknown."""
node_alias = None
if not self.uses_trampoline():
node_info = self.channel_db.get_node_info_for_node_id(node_id)
if node_info:
node_alias = node_info.alias
else:
for k, v in hardcoded_trampoline_nodes().items():
if v.pubkey.startswith(node_id):
node_alias = k
break
return node_alias
async def maybe_listen(self):
# FIXME: only one LNWorker can listen at a time (single port)
listen_addr = self.config.LIGHTNING_LISTEN
if listen_addr:
self.logger.info(f'lightning_listen enabled. will try to bind: {listen_addr!r}')
try:
netaddr = NetAddress.from_string(listen_addr)
except Exception as e:
self.logger.error(f"failed to parse config key '{self.config.cv.LIGHTNING_LISTEN.key()}'. got: {e!r}")
return
addr = str(netaddr.host)
async def cb(reader, writer):
transport = LNResponderTransport(self.node_keypair.privkey, reader, writer)
try:
node_id = await transport.handshake()
except Exception as e:
self.logger.info(f'handshake failure from incoming connection: {e!r}')
return
await self._add_peer_from_transport(node_id=node_id, transport=transport)
try:
self.listen_server = await asyncio.start_server(cb, addr, netaddr.port)
except OSError as e:
self.logger.error(f"cannot listen for lightning p2p. error: {e!r}")
async def main_loop(self):
self.logger.info("starting taskgroup.")
try:
async with self.taskgroup as group:
await group.spawn(asyncio.Event().wait) # run forever (until cancel)
except Exception as e:
self.logger.exception("taskgroup died.")
finally:
self.logger.info("taskgroup stopped.")
async def _maintain_connectivity(self):
while True:
await asyncio.sleep(1)
if self.stopping_soon:
return
now = time.time()
if len(self._peers) >= NUM_PEERS_TARGET:
continue
peers = await self._get_next_peers_to_try()
for peer in peers:
if self._can_retry_addr(peer, now=now):
try:
await self._add_peer(peer.host, peer.port, peer.pubkey)
except ErrorAddingPeer as e:
self.logger.info(f"failed to add peer: {peer}. exc: {e!r}")
async def _add_peer(self, host: str, port: int, node_id: bytes) -> Peer:
if node_id in self._peers:
return self._peers[node_id]
port = int(port)
peer_addr = LNPeerAddr(host, port, node_id)
self._trying_addr_now(peer_addr)
self.logger.info(f"adding peer {peer_addr}")
if node_id == self.node_keypair.pubkey:
raise ErrorAddingPeer("cannot connect to self")
transport = LNTransport(self.node_keypair.privkey, peer_addr,
e_proxy=ESocksProxy.from_network_settings(self.network))
peer = await self._add_peer_from_transport(node_id=node_id, transport=transport)
assert peer
return peer
async def _add_peer_from_transport(self, *, node_id: bytes, transport: LNTransportBase) -> Optional[Peer]:
with self.lock:
existing_peer = self._peers.get(node_id)
if existing_peer:
# Two instances of the same wallet are attempting to connect simultaneously.
# If we let the new connection replace the existing one, the two instances might
# both keep trying to reconnect, resulting in neither being usable.
if existing_peer.is_initialized():
# give priority to the existing connection
return
else:
# Use the new connection. (e.g. old peer might be an outgoing connection
# for an outdated host/port that will never connect)
existing_peer.close_and_cleanup()
peer = Peer(self, node_id, transport)
assert node_id not in self._peers
self._peers[node_id] = peer
await self.taskgroup.spawn(peer.main_loop())
return peer
def peer_closed(self, peer: Peer) -> None:
with self.lock:
peer2 = self._peers.get(peer.pubkey)
if peer2 is peer:
self._peers.pop(peer.pubkey)
def num_peers(self) -> int:
return sum([p.is_initialized() for p in self.peers.values()])
def start_network(self, network: 'Network'):
assert network
assert self.network is None, "already started"
self.network = network
self._add_peers_from_config()
asyncio.run_coroutine_threadsafe(self.main_loop(), self.network.asyncio_loop)
async def stop(self):
if self.listen_server:
self.listen_server.close()
self.unregister_callbacks()
await self.taskgroup.cancel_remaining()
def _add_peers_from_config(self):
peer_list = self.config.LIGHTNING_PEERS or []
for host, port, pubkey in peer_list:
asyncio.run_coroutine_threadsafe(
self._add_peer(host, int(port), bfh(pubkey)),
self.network.asyncio_loop)
def is_good_peer(self, peer: LNPeerAddr) -> bool:
# the purpose of this method is to filter peers that advertise the desired feature bits
# it is disabled for now, because feature bits published in node announcements seem to be unreliable
return True
node_id = peer.pubkey
node = self.channel_db._nodes.get(node_id)
if not node:
return False
try:
ln_compare_features(self.features, node.features)
except IncompatibleLightningFeatures:
return False
#self.logger.info(f'is_good {peer.host}')
return True
def on_peer_successfully_established(self, peer: Peer) -> None:
if isinstance(peer.transport, LNTransport):
peer_addr = peer.transport.peer_addr
# reset connection attempt count
self._on_connection_successfully_established(peer_addr)
if not self.uses_trampoline():
# add into channel db
self.channel_db.add_recent_peer(peer_addr)
# save network address into channels we might have with peer
for chan in peer.channels.values():
chan.add_or_update_peer_addr(peer_addr)
async def _get_next_peers_to_try(self) -> Sequence[LNPeerAddr]:
now = time.time()
await self.channel_db.data_loaded.wait()
# first try from recent peers
recent_peers = self.channel_db.get_recent_peers()
for peer in recent_peers:
if not peer:
continue
if peer.pubkey in self._peers:
continue
if not self._can_retry_addr(peer, now=now):
continue
if not self.is_good_peer(peer):
continue
return [peer]
# try random peer from graph
unconnected_nodes = self.channel_db.get_200_randomly_sorted_nodes_not_in(self.peers.keys())
if unconnected_nodes:
for node_id in unconnected_nodes:
addrs = self.channel_db.get_node_addresses(node_id)
if not addrs:
continue
host, port, timestamp = self.choose_preferred_address(list(addrs))
try:
peer = LNPeerAddr(host, port, node_id)
except ValueError:
continue
if not self._can_retry_addr(peer, now=now):
continue
if not self.is_good_peer(peer):
continue
#self.logger.info('taking random ln peer from our channel db')
return [peer]
# getting desperate... let's try hardcoded fallback list of peers
if constants.net in (constants.BitcoinTestnet,):
fallback_list = FALLBACK_NODE_LIST_TESTNET
elif constants.net in (constants.BitcoinMainnet,):
fallback_list = FALLBACK_NODE_LIST_MAINNET
else:
return [] # regtest??
fallback_list = [peer for peer in fallback_list if self._can_retry_addr(peer, now=now)]
if fallback_list:
return [random.choice(fallback_list)]
# last resort: try dns seeds (BOLT-10)
return await run_in_thread(self._get_peers_from_dns_seeds)
def _get_peers_from_dns_seeds(self) -> Sequence[LNPeerAddr]:
# NOTE: potentially long blocking call, do not run directly on asyncio event loop.
# Return several peers to reduce the number of dns queries.
if not constants.net.LN_DNS_SEEDS:
return []
dns_seed = random.choice(constants.net.LN_DNS_SEEDS)
self.logger.info('asking dns seed "{}" for ln peers'.format(dns_seed))
try:
# note: this might block for several seconds
# this will include bech32-encoded-pubkeys and ports
srv_answers = resolve_dns_srv('r{}.{}'.format(
constants.net.LN_REALM_BYTE, dns_seed))
except dns.exception.DNSException as e:
self.logger.info(f'failed querying (1) dns seed "{dns_seed}" for ln peers: {repr(e)}')
return []
random.shuffle(srv_answers)
num_peers = 2 * NUM_PEERS_TARGET
srv_answers = srv_answers[:num_peers]
# we now have pubkeys and ports but host is still needed
peers = []
for srv_ans in srv_answers:
try:
# note: this might block for several seconds
answers = dns.resolver.resolve(srv_ans['host'])
except dns.exception.DNSException as e:
self.logger.info(f'failed querying (2) dns seed "{dns_seed}" for ln peers: {repr(e)}')
continue
try:
ln_host = str(answers[0])
port = int(srv_ans['port'])
bech32_pubkey = srv_ans['host'].split('.')[0]
pubkey = get_compressed_pubkey_from_bech32(bech32_pubkey)
peers.append(LNPeerAddr(ln_host, port, pubkey))
except Exception as e:
self.logger.info(f'error with parsing peer from dns seed: {repr(e)}')
continue
self.logger.info(f'got {len(peers)} ln peers from dns seed')
return peers
@staticmethod
def choose_preferred_address(addr_list: Sequence[Tuple[str, int, int]]) -> Tuple[str, int, int]:
assert len(addr_list) >= 1
# choose the most recent one that is an IP
for host, port, timestamp in sorted(addr_list, key=lambda a: -a[2]):
if is_ip_address(host):
return host, port, timestamp
# otherwise choose one at random
# TODO maybe filter out onion if not on tor?
choice = random.choice(addr_list)
return choice
@event_listener
def on_event_proxy_set(self, *args):
for peer in self.peers.values():
peer.close_and_cleanup()
self._clear_addr_retry_times()
@log_exceptions
async def add_peer(self, connect_str: str) -> Peer:
node_id, rest = extract_nodeid(connect_str)
peer = self._peers.get(node_id)
if not peer:
if rest is not None:
host, port = split_host_port(rest)
else:
if self.uses_trampoline():
addr = trampolines_by_id().get(node_id)
if not addr:
raise ConnStringFormatError(_('Address unknown for node:') + ' ' + node_id.hex())
host, port = addr.host, addr.port
else:
addrs = self.channel_db.get_node_addresses(node_id)
if not addrs:
raise ConnStringFormatError(_('Don\'t know any addresses for node:') + ' ' + node_id.hex())
host, port, timestamp = self.choose_preferred_address(list(addrs))
port = int(port)
if not self.network.proxy:
# Try DNS-resolving the host (if needed). This is simply so that
# the caller gets a nice exception if it cannot be resolved.
# (we don't do the DNS lookup if a proxy is set, to avoid a DNS-leak)
if host.endswith('.onion'):
raise ConnStringFormatError(_('.onion address, but no proxy configured'))
try:
await asyncio.get_running_loop().getaddrinfo(host, port)
except socket.gaierror:
raise ConnStringFormatError(_('Hostname does not resolve (getaddrinfo failed)'))
# add peer
peer = await self._add_peer(host, port, node_id)
return peer
class LNGossip(LNWorker):
max_age = 14*24*3600
LOGGING_SHORTCUT = 'g'
def __init__(self, config: 'SimpleConfig'):
seed = os.urandom(32)
node = BIP32Node.from_rootseed(seed, xtype='standard')
xprv = node.to_xprv()
node_keypair = generate_keypair(BIP32Node.from_xkey(xprv), LnKeyFamily.NODE_KEY)
LNWorker.__init__(self, node_keypair, LNGOSSIP_FEATURES, config=config)
self.unknown_ids = set()
def start_network(self, network: 'Network'):
super().start_network(network)
for coro in [
self._maintain_connectivity(),
self.maintain_db(),
]:
tg_coro = self.taskgroup.spawn(coro)
asyncio.run_coroutine_threadsafe(tg_coro, self.network.asyncio_loop)
async def maintain_db(self):
await self.channel_db.data_loaded.wait()
while True:
if len(self.unknown_ids) == 0:
self.channel_db.prune_old_policies(self.max_age)
self.channel_db.prune_orphaned_channels()
await asyncio.sleep(120)
async def add_new_ids(self, ids: Iterable[bytes]):
known = self.channel_db.get_channel_ids()
new = set(ids) - set(known)
self.unknown_ids.update(new)
util.trigger_callback('unknown_channels', len(self.unknown_ids))
util.trigger_callback('gossip_peers', self.num_peers())
util.trigger_callback('ln_gossip_sync_progress')
def get_ids_to_query(self) -> Sequence[bytes]:
N = 500
l = list(self.unknown_ids)
self.unknown_ids = set(l[N:])
util.trigger_callback('unknown_channels', len(self.unknown_ids))
util.trigger_callback('ln_gossip_sync_progress')
return l[0:N]
def get_sync_progress_estimate(self) -> Tuple[Optional[int], Optional[int], Optional[int]]:
"""Estimates the gossip synchronization process and returns the number
of synchronized channels, the total channels in the network and a
rescaled percentage of the synchronization process."""
if self.num_peers() == 0:
return None, None, None
nchans_with_0p, nchans_with_1p, nchans_with_2p = self.channel_db.get_num_channels_partitioned_by_policy_count()
num_db_channels = nchans_with_0p + nchans_with_1p + nchans_with_2p
# some channels will never have two policies (only one is in gossip?...)
# so if we have at least 1 policy for a channel, we consider that channel "complete" here
current_est = num_db_channels - nchans_with_0p
total_est = len(self.unknown_ids) + num_db_channels
progress = current_est / total_est if total_est and current_est else 0
progress_percent = (1.0 / 0.95 * progress) * 100
progress_percent = min(progress_percent, 100)
progress_percent = round(progress_percent)
# take a minimal number of synchronized channels to get a more accurate
# percentage estimate
if current_est < 200:
progress_percent = 0
return current_est, total_est, progress_percent
async def process_gossip(self, chan_anns, node_anns, chan_upds):
# note: we run in the originating peer's TaskGroup, so we can safely raise here
# and disconnect only from that peer
await self.channel_db.data_loaded.wait()
self.logger.debug(f'process_gossip {len(chan_anns)} {len(node_anns)} {len(chan_upds)}')
# channel announcements
def process_chan_anns():
for payload in chan_anns:
self.channel_db.verify_channel_announcement(payload)
self.channel_db.add_channel_announcements(chan_anns)
await run_in_thread(process_chan_anns)
# node announcements
def process_node_anns():
for payload in node_anns:
self.channel_db.verify_node_announcement(payload)
self.channel_db.add_node_announcements(node_anns)
await run_in_thread(process_node_anns)
# channel updates
categorized_chan_upds = await run_in_thread(partial(
self.channel_db.add_channel_updates,
chan_upds,
max_age=self.max_age))
orphaned = categorized_chan_upds.orphaned
if orphaned:
self.logger.info(f'adding {len(orphaned)} unknown channel ids')
orphaned_ids = [c['short_channel_id'] for c in orphaned]
await self.add_new_ids(orphaned_ids)
if categorized_chan_upds.good:
self.logger.debug(f'process_gossip: {len(categorized_chan_upds.good)}/{len(chan_upds)}')
class PaySession(Logger):
def __init__(
self,
*,
payment_hash: bytes,
payment_secret: bytes,
initial_trampoline_fee_level: int,
invoice_features: int,
r_tags,
min_final_cltv_delta: int, # delta for last node (typically from invoice)
amount_to_pay: int, # total payment amount final receiver will get
invoice_pubkey: bytes,
uses_trampoline: bool, # whether sender uses trampoline or gossip
use_two_trampolines: bool, # whether legacy payments will try to use two trampolines
):
assert payment_hash
assert payment_secret
self.payment_hash = payment_hash
self.payment_secret = payment_secret
self.payment_key = payment_hash + payment_secret
Logger.__init__(self)
self.invoice_features = LnFeatures(invoice_features)
self.r_tags = r_tags
self.min_final_cltv_delta = min_final_cltv_delta
self.amount_to_pay = amount_to_pay
self.invoice_pubkey = invoice_pubkey
self.sent_htlcs_q = asyncio.Queue() # type: asyncio.Queue[HtlcLog]
self.start_time = time.time()
self.uses_trampoline = uses_trampoline
self.trampoline_fee_level = initial_trampoline_fee_level
self.failed_trampoline_routes = []
self.use_two_trampolines = use_two_trampolines
self._sent_buckets = dict() # psecret_bucket -> (amount_sent, amount_failed)
self._amount_inflight = 0 # what we sent in htlcs (that receiver gets, without fees)
self._nhtlcs_inflight = 0
self.is_active = True # is still trying to send new htlcs?
def diagnostic_name(self):
pkey = sha256(self.payment_key)
return f"{self.payment_hash[:4].hex()}-{pkey[:2].hex()}"
def maybe_raise_trampoline_fee(self, htlc_log: HtlcLog):
if htlc_log.trampoline_fee_level == self.trampoline_fee_level:
self.trampoline_fee_level += 1
self.failed_trampoline_routes = []
self.logger.info(f'raising trampoline fee level {self.trampoline_fee_level}')
else:
self.logger.info(f'NOT raising trampoline fee level, already at {self.trampoline_fee_level}')
def handle_failed_trampoline_htlc(self, *, htlc_log: HtlcLog, failure_msg: OnionRoutingFailure):
# FIXME The trampoline nodes in the path are chosen randomly.
# Some of the errors might depend on how we have chosen them.
# Having more attempts is currently useful in part because of the randomness,
# instead we should give feedback to create_routes_for_payment.
# Sometimes the trampoline node fails to send a payment and returns
# TEMPORARY_CHANNEL_FAILURE, while it succeeds with a higher trampoline fee.
if failure_msg.code in (
OnionFailureCode.TRAMPOLINE_FEE_INSUFFICIENT,
OnionFailureCode.TRAMPOLINE_EXPIRY_TOO_SOON,
OnionFailureCode.TEMPORARY_CHANNEL_FAILURE):
# TODO: parse the node policy here (not returned by eclair yet)
# TODO: erring node is always the first trampoline even if second
# trampoline demands more fees, we can't influence this
self.maybe_raise_trampoline_fee(htlc_log)
elif self.use_two_trampolines:
self.use_two_trampolines = False
elif failure_msg.code in (
OnionFailureCode.UNKNOWN_NEXT_PEER,
OnionFailureCode.TEMPORARY_NODE_FAILURE):
trampoline_route = htlc_log.route
r = [hop.end_node.hex() for hop in trampoline_route]
self.logger.info(f'failed trampoline route: {r}')
if r not in self.failed_trampoline_routes:
self.failed_trampoline_routes.append(r)
else:
pass # maybe the route was reused between different MPP parts
else:
raise PaymentFailure(failure_msg.code_name())
async def wait_for_one_htlc_to_resolve(self) -> HtlcLog:
self.logger.info(f"waiting... amount_inflight={self._amount_inflight}. nhtlcs_inflight={self._nhtlcs_inflight}")
htlc_log = await self.sent_htlcs_q.get()
self._amount_inflight -= htlc_log.amount_msat
self._nhtlcs_inflight -= 1
if self._amount_inflight < 0 or self._nhtlcs_inflight < 0:
raise Exception(f"amount_inflight={self._amount_inflight}, nhtlcs_inflight={self._nhtlcs_inflight}. both should be >= 0 !")
return htlc_log
def add_new_htlc(self, sent_htlc_info: SentHtlcInfo):
self._nhtlcs_inflight += 1
self._amount_inflight += sent_htlc_info.amount_receiver_msat
if self._amount_inflight > self.amount_to_pay: # safety belts
raise Exception(f"amount_inflight={self._amount_inflight} > amount_to_pay={self.amount_to_pay}")
shi = sent_htlc_info
bkey = shi.payment_secret_bucket
# if we sent MPP to a trampoline, add item to sent_buckets
if self.uses_trampoline and shi.amount_msat != shi.bucket_msat:
if bkey not in self._sent_buckets:
self._sent_buckets[bkey] = (0, 0)
amount_sent, amount_failed = self._sent_buckets[bkey]
amount_sent += shi.amount_receiver_msat
self._sent_buckets[bkey] = amount_sent, amount_failed
def on_htlc_fail_get_fail_amt_to_propagate(self, sent_htlc_info: SentHtlcInfo) -> Optional[int]:
shi = sent_htlc_info
# check sent_buckets if we use trampoline
bkey = shi.payment_secret_bucket
if self.uses_trampoline and bkey in self._sent_buckets:
amount_sent, amount_failed = self._sent_buckets[bkey]
amount_failed += shi.amount_receiver_msat
self._sent_buckets[bkey] = amount_sent, amount_failed
if amount_sent != amount_failed:
self.logger.info('bucket still active...')
return None
self.logger.info('bucket failed')
return amount_sent
# not using trampoline buckets
return shi.amount_receiver_msat
def get_outstanding_amount_to_send(self) -> int:
return self.amount_to_pay - self._amount_inflight
def can_be_deleted(self) -> bool:
"""Returns True iff finished sending htlcs AND all pending htlcs have resolved."""
if self.is_active:
return False
# note: no one is consuming from sent_htlcs_q anymore
nhtlcs_resolved = self.sent_htlcs_q.qsize()
assert nhtlcs_resolved <= self._nhtlcs_inflight
return nhtlcs_resolved == self._nhtlcs_inflight
class LNWallet(LNWorker):
lnwatcher: Optional['LNWalletWatcher']
MPP_EXPIRY = 120
TIMEOUT_SHUTDOWN_FAIL_PENDING_HTLCS = 3 # seconds
PAYMENT_TIMEOUT = 120
MPP_SPLIT_PART_FRACTION = 0.2
MPP_SPLIT_PART_MINAMT_MSAT = 5_000_000
def __init__(self, wallet: 'Abstract_Wallet', xprv):
self.wallet = wallet
self.config = wallet.config
self.db = wallet.db
self.node_keypair = generate_keypair(BIP32Node.from_xkey(xprv), LnKeyFamily.NODE_KEY)
self.backup_key = generate_keypair(BIP32Node.from_xkey(xprv), LnKeyFamily.BACKUP_CIPHER).privkey
self.payment_secret_key = generate_keypair(BIP32Node.from_xkey(xprv), LnKeyFamily.PAYMENT_SECRET_KEY).privkey
Logger.__init__(self)
features = LNWALLET_FEATURES
if self.config.ACCEPT_ZEROCONF_CHANNELS:
features |= LnFeatures.OPTION_ZEROCONF_OPT
LNWorker.__init__(self, self.node_keypair, features, config=self.config)
self.lnwatcher = None
self.lnrater: LNRater = None
self.payment_info = self.db.get_dict('lightning_payments') # RHASH -> amount, direction, is_paid
self.preimages = self.db.get_dict('lightning_preimages') # RHASH -> preimage
self._bolt11_cache = {}
# note: this sweep_address is only used as fallback; as it might result in address-reuse
self.logs = defaultdict(list) # type: Dict[str, List[HtlcLog]] # key is RHASH # (not persisted)
# used in tests
self.enable_htlc_settle = True
self.enable_htlc_forwarding = True
# note: accessing channels (besides simple lookup) needs self.lock!
self._channels = {} # type: Dict[bytes, Channel]
channels = self.db.get_dict("channels")
for channel_id, c in random_shuffled_copy(channels.items()):
self._channels[bfh(channel_id)] = Channel(c, lnworker=self)
self._channel_backups = {} # type: Dict[bytes, ChannelBackup]
# order is important: imported should overwrite onchain
for name in ["onchain_channel_backups", "imported_channel_backups"]:
channel_backups = self.db.get_dict(name)
for channel_id, storage in channel_backups.items():
self._channel_backups[bfh(channel_id)] = ChannelBackup(storage, lnworker=self)
self._paysessions = dict() # type: Dict[bytes, PaySession]
self.sent_htlcs_info = dict() # type: Dict[SentHtlcKey, SentHtlcInfo]
self.received_mpp_htlcs = self.db.get_dict('received_mpp_htlcs') # type: Dict[str, ReceivedMPPStatus] # payment_key -> ReceivedMPPStatus
# detect inflight payments
self.inflight_payments = set() # (not persisted) keys of invoices that are in PR_INFLIGHT state
for payment_hash in self.get_payments(status='inflight').keys():
self.set_invoice_status(payment_hash.hex(), PR_INFLIGHT)
# payment forwarding
self.active_forwardings = self.db.get_dict('active_forwardings') # type: Dict[str, List[str]] # Dict: payment_key -> list of htlc_keys
self.forwarding_failures = self.db.get_dict('forwarding_failures') # type: Dict[str, Tuple[str, str]] # Dict: payment_key -> (error_bytes, error_message)
self.downstream_to_upstream_htlc = {} # type: Dict[str, str] # Dict: htlc_key -> htlc_key (not persisted)
# payment_hash -> callback:
self.hold_invoice_callbacks = {} # type: Dict[bytes, Callable[[bytes], Awaitable[None]]]
self.payment_bundles = [] # lists of hashes. todo:persist
self.swap_manager = HttpSwapManager(wallet=self.wallet, lnworker=self)
def has_deterministic_node_id(self) -> bool:
return bool(self.db.get('lightning_xprv'))
def can_have_recoverable_channels(self) -> bool:
return (self.has_deterministic_node_id()
and not self.config.LIGHTNING_LISTEN)
def has_recoverable_channels(self) -> bool:
"""Whether *future* channels opened by this wallet would be recoverable
from seed (via putting OP_RETURN outputs into funding txs).
"""
return (self.can_have_recoverable_channels()
and self.config.LIGHTNING_USE_RECOVERABLE_CHANNELS)
@property
def channels(self) -> Mapping[bytes, Channel]:
"""Returns a read-only copy of channels."""
with self.lock:
return self._channels.copy()
@property
def channel_backups(self) -> Mapping[bytes, ChannelBackup]:
"""Returns a read-only copy of channels."""
with self.lock:
return self._channel_backups.copy()
def get_channel_objects(self) -> Mapping[bytes, AbstractChannel]:
r = self.channel_backups
r.update(self.channels)
return r
def get_channel_by_id(self, channel_id: bytes) -> Optional[Channel]:
return self._channels.get(channel_id, None)
def diagnostic_name(self):
return self.wallet.diagnostic_name()
@ignore_exceptions
@log_exceptions
async def sync_with_local_watchtower(self):
watchtower = self.network.local_watchtower
if watchtower:
while True:
for chan in self.channels.values():
await self.sync_channel_with_watchtower(chan, watchtower.sweepstore)
await asyncio.sleep(5)
@ignore_exceptions
@log_exceptions
async def sync_with_remote_watchtower(self):
while True:
# periodically poll if the user updated 'watchtower_url'
await asyncio.sleep(5)
watchtower_url = self.config.WATCHTOWER_CLIENT_URL
if not watchtower_url:
continue
parsed_url = urllib.parse.urlparse(watchtower_url)
if not (parsed_url.scheme == 'https' or is_private_netaddress(parsed_url.hostname)):
self.logger.warning(f"got watchtower URL for remote tower but we won't use it! "
f"can only use HTTPS (except if private IP): not using {watchtower_url!r}")
continue
# try to sync with the remote watchtower
try:
async with make_aiohttp_session(proxy=self.network.proxy) as session:
watchtower = JsonRPCClient(session, watchtower_url)
watchtower.add_method('get_ctn')
watchtower.add_method('add_sweep_tx')
for chan in self.channels.values():
await self.sync_channel_with_watchtower(chan, watchtower)
except aiohttp.client_exceptions.ClientConnectorError:
self.logger.info(f'could not contact remote watchtower {watchtower_url}')
async def sync_channel_with_watchtower(self, chan: Channel, watchtower):
outpoint = chan.funding_outpoint.to_str()
addr = chan.get_funding_address()
current_ctn = chan.get_oldest_unrevoked_ctn(REMOTE)
watchtower_ctn = await watchtower.get_ctn(outpoint, addr)
for ctn in range(watchtower_ctn + 1, current_ctn):
sweeptxs = chan.create_sweeptxs_for_watchtower(ctn)
for tx in sweeptxs:
await watchtower.add_sweep_tx(outpoint, ctn, tx.inputs()[0].prevout.to_str(), tx.serialize())
def start_network(self, network: 'Network'):
super().start_network(network)
self.lnwatcher = LNWalletWatcher(self, network)
self.swap_manager.start_network(network=network, lnwatcher=self.lnwatcher)
self.lnrater = LNRater(self, network)
for chan in self.channels.values():
if chan.need_to_subscribe():
self.lnwatcher.add_channel(chan.funding_outpoint.to_str(), chan.get_funding_address())
for cb in self.channel_backups.values():
if cb.need_to_subscribe():
self.lnwatcher.add_channel(cb.funding_outpoint.to_str(), cb.get_funding_address())
for coro in [
self.maybe_listen(),
self.lnwatcher.trigger_callbacks(), # shortcut (don't block) if funding tx locked and verified
self.reestablish_peers_and_channels(),
self.sync_with_local_watchtower(),
self.sync_with_remote_watchtower(),
]:
tg_coro = self.taskgroup.spawn(coro)
asyncio.run_coroutine_threadsafe(tg_coro, self.network.asyncio_loop)
async def stop(self):
self.stopping_soon = True
if self.listen_server: # stop accepting new peers
self.listen_server.close()
async with ignore_after(self.TIMEOUT_SHUTDOWN_FAIL_PENDING_HTLCS):
await self.wait_for_received_pending_htlcs_to_get_removed()
await LNWorker.stop(self)
if self.lnwatcher:
await self.lnwatcher.stop()
self.lnwatcher = None
if self.swap_manager: # may not be present in tests
await self.swap_manager.stop()
async def wait_for_received_pending_htlcs_to_get_removed(self):
assert self.stopping_soon is True
# We try to fail pending MPP HTLCs, and wait a bit for them to get removed.
# Note: even without MPP, if we just failed/fulfilled an HTLC, it is good
# to wait a bit for it to become irrevocably removed.
# Note: we don't wait for *all htlcs* to get removed, only for those
# that we can already fail/fulfill. e.g. forwarded htlcs cannot be removed
async with OldTaskGroup() as group:
for peer in self.peers.values():
await group.spawn(peer.wait_one_htlc_switch_iteration())
while True:
if all(not peer.received_htlcs_pending_removal for peer in self.peers.values()):
break
async with OldTaskGroup(wait=any) as group:
for peer in self.peers.values():
await group.spawn(peer.received_htlc_removed_event.wait())
def peer_closed(self, peer):
for chan in self.channels_for_peer(peer.pubkey).values():
chan.peer_state = PeerState.DISCONNECTED
util.trigger_callback('channel', self.wallet, chan)
super().peer_closed(peer)
def get_payments(self, *, status=None) -> Mapping[bytes, List[HTLCWithStatus]]:
out = defaultdict(list)
for chan in self.channels.values():
d = chan.get_payments(status=status)
for payment_hash, plist in d.items():
out[payment_hash] += plist
return out
def get_payment_value(
self, info: Optional['PaymentInfo'],
plist: List[HTLCWithStatus]) -> Tuple[PaymentDirection, int, Optional[int], int]:
""" fee_msat is included in amount_msat"""
assert plist
amount_msat = sum(int(x.direction) * x.htlc.amount_msat for x in plist)
if all(x.direction == SENT for x in plist):
direction = PaymentDirection.SENT
fee_msat = (- info.amount_msat - amount_msat) if info else None
elif all(x.direction == RECEIVED for x in plist):
direction = PaymentDirection.RECEIVED
fee_msat = None
elif amount_msat < 0:
direction = PaymentDirection.SELF_PAYMENT
fee_msat = - amount_msat
else:
direction = PaymentDirection.FORWARDING
fee_msat = - amount_msat
timestamp = min([htlc_with_status.htlc.timestamp for htlc_with_status in plist])
return direction, amount_msat, fee_msat, timestamp
def get_lightning_history(self):
out = {}
for payment_hash, plist in self.get_payments(status='settled').items():
if len(plist) == 0:
continue
key = payment_hash.hex()
info = self.get_payment_info(payment_hash)
direction, amount_msat, fee_msat, timestamp = self.get_payment_value(info, plist)
label = self.wallet.get_label_for_rhash(key)
if not label and direction == PaymentDirection.FORWARDING:
label = _('Forwarding')
preimage = self.get_preimage(payment_hash).hex()
item = {
'type': 'payment',
'label': label,
'timestamp': timestamp or 0,
'date': timestamp_to_datetime(timestamp),
'direction': direction,
'amount_msat': amount_msat,
'fee_msat': fee_msat,
'payment_hash': key,
'preimage': preimage,
}
item['group_id'] = self.swap_manager.get_group_id_for_payment_hash(payment_hash)
out[payment_hash] = item
return out
def get_label_for_txid(self, txid: str) -> str:
return self._labels_cache.get(txid)
def get_onchain_history(self):
out = {}
# add funding events
for chan in itertools.chain(self.channels.values(), self.channel_backups.values()): # type: AbstractChannel
item = chan.get_funding_height()
if item is None:
continue
funding_txid, funding_height, funding_timestamp = item
tx_height = self.wallet.adb.get_tx_height(funding_txid)
self._labels_cache[funding_txid] = _('Open channel') + ' ' + chan.get_id_for_log()
item = {
'channel_id': chan.channel_id.hex(),
'type': 'channel_opening',
'label': self.get_label_for_txid(funding_txid),
'txid': funding_txid,
'amount_msat': chan.balance(LOCAL, ctn=0),
'direction': PaymentDirection.RECEIVED,
'timestamp': tx_height.timestamp,
'monotonic_timestamp': tx_height.timestamp or TX_TIMESTAMP_INF,
'date': timestamp_to_datetime(tx_height.timestamp),
'fee_sat': None,
'fee_msat': None,
'height': tx_height.height,
'confirmations': tx_height.conf,
'txpos_in_block': tx_height.txpos,
} # FIXME this data structure needs to be kept in ~sync with wallet.get_onchain_history
out[funding_txid] = item
item = chan.get_closing_height()
if item is None:
continue
closing_txid, closing_height, closing_timestamp = item
tx_height = self.wallet.adb.get_tx_height(closing_txid)
self._labels_cache[closing_txid] = _('Close channel') + ' ' + chan.get_id_for_log()
item = {
'channel_id': chan.channel_id.hex(),
'txid': closing_txid,
'label': self.get_label_for_txid(closing_txid),
'type': 'channel_closure',
'amount_msat': -chan.balance_minus_outgoing_htlcs(LOCAL),
'direction': PaymentDirection.SENT,
'timestamp': tx_height.timestamp,
'monotonic_timestamp': tx_height.timestamp or TX_TIMESTAMP_INF,
'date': timestamp_to_datetime(tx_height.timestamp),
'fee_sat': None,
'fee_msat': None,
'height': tx_height.height,
'confirmations': tx_height.conf,
'txpos_in_block': tx_height.txpos,
} # FIXME this data structure needs to be kept in ~sync with wallet.get_onchain_history
out[closing_txid] = item
d = self.swap_manager.get_groups_for_onchain_history()
for k,v in d.items():
group_id = v.get('group_id')
group_label = v.get('group_label')
if group_id and group_label:
self._labels_cache[group_id] = group_label
out.update(d)
return out
def get_history(self):
out = list(self.get_lightning_history().values()) + list(self.get_onchain_history().values())
# sort by timestamp
out.sort(key=lambda x: (x.get('timestamp') or float("inf")))
balance_msat = 0
for item in out:
balance_msat += item['amount_msat']
item['balance_msat'] = balance_msat
return out
def channel_peers(self) -> List[bytes]:
node_ids = [chan.node_id for chan in self.channels.values() if not chan.is_closed()]
return node_ids
def channels_for_peer(self, node_id):
assert type(node_id) is bytes
return {chan_id: chan for (chan_id, chan) in self.channels.items()
if chan.node_id == node_id}
def channel_state_changed(self, chan: Channel):
if type(chan) is Channel:
self.save_channel(chan)
self.clear_invoices_cache()
util.trigger_callback('channel', self.wallet, chan)
def save_channel(self, chan: Channel):
assert type(chan) is Channel
if chan.config[REMOTE].next_per_commitment_point == chan.config[REMOTE].current_per_commitment_point:
raise Exception("Tried to save channel with next_point == current_point, this should not happen")
self.wallet.save_db()
util.trigger_callback('channel', self.wallet, chan)
def channel_by_txo(self, txo: str) -> Optional[AbstractChannel]:
for chan in self.channels.values():
if chan.funding_outpoint.to_str() == txo:
return chan
for chan in self.channel_backups.values():
if chan.funding_outpoint.to_str() == txo:
return chan
async def handle_onchain_state(self, chan: Channel):
if type(chan) is ChannelBackup:
util.trigger_callback('channel', self.wallet, chan)
return
if (chan.get_state() in (ChannelState.OPEN, ChannelState.SHUTDOWN)
and chan.should_be_closed_due_to_expiring_htlcs(self.network.get_local_height())):
self.logger.info(f"force-closing due to expiring htlcs")
await self.schedule_force_closing(chan.channel_id)
elif chan.get_state() == ChannelState.FUNDED:
peer = self._peers.get(chan.node_id)
if peer and peer.is_initialized() and chan.peer_state == PeerState.GOOD:
peer.send_channel_ready(chan)
elif chan.get_state() == ChannelState.OPEN:
peer = self._peers.get(chan.node_id)
if peer and peer.is_initialized() and chan.peer_state == PeerState.GOOD:
peer.maybe_update_fee(chan)
peer.maybe_send_announcement_signatures(chan)
elif chan.get_state() == ChannelState.FORCE_CLOSING:
force_close_tx = chan.force_close_tx()
txid = force_close_tx.txid()
height = self.lnwatcher.adb.get_tx_height(txid).height
if height == TX_HEIGHT_LOCAL:
self.logger.info('REBROADCASTING CLOSING TX')
await self.network.try_broadcasting(force_close_tx, 'force-close')
def get_peer_by_scid_alias(self, scid_alias: bytes) -> Optional[Peer]:
for nodeid, peer in self.peers.items():
if scid_alias == self._scid_alias_of_node(nodeid):
return peer
def _scid_alias_of_node(self, nodeid: bytes) -> bytes:
# scid alias for just-in-time channels
return sha256(b'Electrum' + nodeid)[0:8]
def get_scid_alias(self) -> bytes:
return self._scid_alias_of_node(self.node_keypair.pubkey)
@log_exceptions
async def open_channel_just_in_time(
self,
*,
next_peer: Peer,
next_amount_msat_htlc: int,
next_cltv_abs: int,
payment_hash: bytes,
next_onion: OnionPacket,
) -> str:
# if an exception is raised during negotiation, we raise an OnionRoutingFailure.
# this will cancel the incoming HTLC
try:
funding_sat = 2 * (next_amount_msat_htlc // 1000) # try to fully spend htlcs
password = self.wallet.get_unlocked_password() if self.wallet.has_password() else None
channel_opening_fee = next_amount_msat_htlc // 100
if channel_opening_fee // 1000 < self.config.ZEROCONF_MIN_OPENING_FEE:
self.logger.info(f'rejecting JIT channel: payment too low')
raise OnionRoutingFailure(code=OnionFailureCode.INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS, data=b'payment too low')
self.logger.info(f'channel opening fee (sats): {channel_opening_fee//1000}')
next_chan, funding_tx = await self.open_channel_with_peer(
next_peer, funding_sat,
push_sat=0,
zeroconf=True,
public=False,
opening_fee=channel_opening_fee,
password=password,
)
async def wait_for_channel():
while not next_chan.is_open():
await asyncio.sleep(1)
await util.wait_for2(wait_for_channel(), LN_P2P_NETWORK_TIMEOUT)
next_chan.save_remote_scid_alias(self._scid_alias_of_node(next_peer.pubkey))
self.logger.info(f'JIT channel is open')
next_amount_msat_htlc -= channel_opening_fee
# fixme: some checks are missing
htlc = next_peer.send_htlc(
chan=next_chan,
payment_hash=payment_hash,
amount_msat=next_amount_msat_htlc,
cltv_abs=next_cltv_abs,
onion=next_onion)
async def wait_for_preimage():
while self.get_preimage(payment_hash) is None:
await asyncio.sleep(1)
await util.wait_for2(wait_for_preimage(), LN_P2P_NETWORK_TIMEOUT)
except OnionRoutingFailure:
raise
except Exception:
raise OnionRoutingFailure(code=OnionFailureCode.TEMPORARY_NODE_FAILURE, data=b'')
# We have been paid and can broadcast
# todo: if broadcasting raise an exception, we should try to rebroadcast
await self.network.broadcast_transaction(funding_tx)
htlc_key = serialize_htlc_key(next_chan.get_scid_or_local_alias(), htlc.htlc_id)
return htlc_key
@log_exceptions
async def open_channel_with_peer(
self, peer, funding_sat, *,
push_sat: int = 0,
public: bool = False,
zeroconf: bool = False,
opening_fee: int = None,
password=None):
coins = self.wallet.get_spendable_coins(None)
node_id = peer.pubkey
funding_tx = self.mktx_for_open_channel(
coins=coins,
funding_sat=funding_sat,
node_id=node_id,
fee_est=None)
chan, funding_tx = await self._open_channel_coroutine(
peer=peer,
funding_tx=funding_tx,
funding_sat=funding_sat,
push_sat=push_sat,
public=public,
zeroconf=zeroconf,
opening_fee=opening_fee,
password=password)
return chan, funding_tx
@log_exceptions
async def _open_channel_coroutine(
self, *,
peer: Peer,
funding_tx: PartialTransaction,
funding_sat: int,
push_sat: int,
public: bool,
zeroconf=False,
opening_fee=None,
password: Optional[str]) -> Tuple[Channel, PartialTransaction]:
coro = peer.channel_establishment_flow(
funding_tx=funding_tx,
funding_sat=funding_sat,
push_msat=push_sat * 1000,
public=public,
zeroconf=zeroconf,
opening_fee=opening_fee,
temp_channel_id=os.urandom(32))
chan, funding_tx = await util.wait_for2(coro, LN_P2P_NETWORK_TIMEOUT)
util.trigger_callback('channels_updated', self.wallet)
self.wallet.adb.add_transaction(funding_tx) # save tx as local into the wallet
self.wallet.sign_transaction(funding_tx, password)
self.wallet.set_label(funding_tx.txid(), _('Open channel'))
if funding_tx.is_complete() and not zeroconf:
await self.network.try_broadcasting(funding_tx, 'open_channel')
return chan, funding_tx
def add_channel(self, chan: Channel):
with self.lock:
self._channels[chan.channel_id] = chan
self.lnwatcher.add_channel(chan.funding_outpoint.to_str(), chan.get_funding_address())
def add_new_channel(self, chan: Channel):
self.add_channel(chan)
channels_db = self.db.get_dict('channels')
channels_db[chan.channel_id.hex()] = chan.storage
for addr in chan.get_wallet_addresses_channel_might_want_reserved():
self.wallet.set_reserved_state_of_address(addr, reserved=True)
try:
self.save_channel(chan)
except Exception:
chan.set_state(ChannelState.REDEEMED)
self.remove_channel(chan.channel_id)
raise
def cb_data(self, node_id):
return CB_MAGIC_BYTES + node_id[0:16]
def decrypt_cb_data(self, encrypted_data, funding_address):
funding_scripthash = bytes.fromhex(address_to_scripthash(funding_address))
nonce = funding_scripthash[0:12]
return chacha20_decrypt(key=self.backup_key, data=encrypted_data, nonce=nonce)
def encrypt_cb_data(self, data, funding_address):
funding_scripthash = bytes.fromhex(address_to_scripthash(funding_address))
nonce = funding_scripthash[0:12]
# note: we are only using chacha20 instead of chacha20+poly1305 to save onchain space
# (not have the 16 byte MAC). Otherwise, the latter would be preferable.
return chacha20_encrypt(key=self.backup_key, data=data, nonce=nonce)
def mktx_for_open_channel(
self, *,
coins: Sequence[PartialTxInput],
funding_sat: int,
node_id: bytes,
fee_est=None) -> PartialTransaction:
outputs = [PartialTxOutput.from_address_and_value(DummyAddress.CHANNEL, funding_sat)]
if self.has_recoverable_channels():
dummy_scriptpubkey = make_op_return(self.cb_data(node_id))
outputs.append(PartialTxOutput(scriptpubkey=dummy_scriptpubkey, value=0))
tx = self.wallet.make_unsigned_transaction(
coins=coins,
outputs=outputs,
fee=fee_est)
tx.set_rbf(False)
return tx
def suggest_funding_amount(self, amount_to_pay, coins):
""" whether we can pay amount_sat after opening a new channel"""
num_sats_can_send = int(self.num_sats_can_send())
lightning_needed = amount_to_pay - num_sats_can_send
assert lightning_needed > 0
min_funding_sat = lightning_needed + (lightning_needed // 20) + 1000 # safety margin
min_funding_sat = max(min_funding_sat, 100_000) # at least 1mBTC
if min_funding_sat > self.config.LIGHTNING_MAX_FUNDING_SAT:
return
fee_est = partial(self.config.estimate_fee, allow_fallback_to_static_rates=True) # to avoid NoDynamicFeeEstimates
try:
self.mktx_for_open_channel(coins=coins, funding_sat=min_funding_sat, node_id=bytes(32), fee_est=fee_est)
funding_sat = min_funding_sat
except NotEnoughFunds:
return
# if available, suggest twice that amount:
if 2 * min_funding_sat <= self.config.LIGHTNING_MAX_FUNDING_SAT:
try:
self.mktx_for_open_channel(coins=coins, funding_sat=2*min_funding_sat, node_id=bytes(32), fee_est=fee_est)
funding_sat = 2 * min_funding_sat
except NotEnoughFunds:
pass
return funding_sat, min_funding_sat
def open_channel(
self, *,
connect_str: str,
funding_tx: PartialTransaction,
funding_sat: int,
push_amt_sat: int,
public: bool = False,
password: str = None) -> Tuple[Channel, PartialTransaction]:
if funding_sat > self.config.LIGHTNING_MAX_FUNDING_SAT:
raise Exception(_("Requested channel capacity is over maximum."))
fut = asyncio.run_coroutine_threadsafe(self.add_peer(connect_str), self.network.asyncio_loop)
try:
peer = fut.result()
except concurrent.futures.TimeoutError:
raise Exception(_("add peer timed out"))
coro = self._open_channel_coroutine(
peer=peer,
funding_tx=funding_tx,
funding_sat=funding_sat,
push_sat=push_amt_sat,
public=public,
password=password)
fut = asyncio.run_coroutine_threadsafe(coro, self.network.asyncio_loop)
try:
chan, funding_tx = fut.result()
except concurrent.futures.TimeoutError:
raise Exception(_("open_channel timed out"))
return chan, funding_tx
def get_channel_by_short_id(self, short_channel_id: bytes) -> Optional[Channel]:
# First check against *real* SCIDs.
# This e.g. protects against maliciously chosen SCID aliases, and accidental collisions.
for chan in self.channels.values():
if chan.short_channel_id == short_channel_id:
return chan
# Now we also consider aliases.
# TODO we should split this as this search currently ignores the "direction"
# of the aliases. We should only look at either the remote OR the local alias,
# depending on context.
for chan in self.channels.values():
if chan.get_remote_scid_alias() == short_channel_id:
return chan
if chan.get_local_scid_alias() == short_channel_id:
return chan
def can_pay_invoice(self, invoice: Invoice) -> bool:
assert invoice.is_lightning()
return (invoice.get_amount_sat() or 0) <= self.num_sats_can_send()
@log_exceptions
async def pay_invoice(
self, invoice: str, *,
amount_msat: int = None,
attempts: int = None, # used only in unit tests
full_path: LNPaymentPath = None,
channels: Optional[Sequence[Channel]] = None,
) -> Tuple[bool, List[HtlcLog]]:
lnaddr = self._check_invoice(invoice, amount_msat=amount_msat)
min_final_cltv_delta = lnaddr.get_min_final_cltv_delta()
payment_hash = lnaddr.paymenthash
key = payment_hash.hex()
payment_secret = lnaddr.payment_secret
invoice_pubkey = lnaddr.pubkey.serialize()
invoice_features = lnaddr.get_features()
r_tags = lnaddr.get_routing_info('r')
amount_to_pay = lnaddr.get_amount_msat()
status = self.get_payment_status(payment_hash)
if status == PR_PAID:
raise PaymentFailure(_("This invoice has been paid already"))
if status == PR_INFLIGHT:
raise PaymentFailure(_("A payment was already initiated for this invoice"))
if payment_hash in self.get_payments(status='inflight'):
raise PaymentFailure(_("A previous attempt to pay this invoice did not clear"))
info = PaymentInfo(payment_hash, amount_to_pay, SENT, PR_UNPAID)
self.save_payment_info(info)
self.wallet.set_label(key, lnaddr.get_description())
self.set_invoice_status(key, PR_INFLIGHT)
budget = PaymentFeeBudget.default(invoice_amount_msat=amount_to_pay, config=self.config)
success = False
try:
await self.pay_to_node(
node_pubkey=invoice_pubkey,
payment_hash=payment_hash,
payment_secret=payment_secret,
amount_to_pay=amount_to_pay,
min_final_cltv_delta=min_final_cltv_delta,
r_tags=r_tags,
invoice_features=invoice_features,
attempts=attempts,
full_path=full_path,
channels=channels,
budget=budget,
)
success = True
except PaymentFailure as e:
self.logger.info(f'payment failure: {e!r}')
reason = str(e)
except ChannelDBNotLoaded as e:
self.logger.info(f'payment failure: {e!r}')
reason = str(e)
finally:
self.logger.info(f"pay_invoice ending session for RHASH={payment_hash.hex()}. {success=}")
if success:
self.set_invoice_status(key, PR_PAID)
util.trigger_callback('payment_succeeded', self.wallet, key)
else:
self.set_invoice_status(key, PR_UNPAID)
util.trigger_callback('payment_failed', self.wallet, key, reason)
log = self.logs[key]
return success, log
async def pay_to_node(
self, *,
node_pubkey: bytes,
payment_hash: bytes,
payment_secret: bytes,
amount_to_pay: int, # in msat
min_final_cltv_delta: int,
r_tags,
invoice_features: int,
attempts: int = None,
full_path: LNPaymentPath = None,
fwd_trampoline_onion: OnionPacket = None,
budget: PaymentFeeBudget,
channels: Optional[Sequence[Channel]] = None,
fw_payment_key = None,# for forwarding
) -> None:
assert budget
assert budget.fee_msat >= 0, budget
assert budget.cltv >= 0, budget
payment_key = payment_hash + payment_secret
assert payment_key not in self._paysessions
self._paysessions[payment_key] = paysession = PaySession(
payment_hash=payment_hash,
payment_secret=payment_secret,
initial_trampoline_fee_level=self.config.INITIAL_TRAMPOLINE_FEE_LEVEL,
invoice_features=invoice_features,
r_tags=r_tags,
min_final_cltv_delta=min_final_cltv_delta,
amount_to_pay=amount_to_pay,
invoice_pubkey=node_pubkey,
uses_trampoline=self.uses_trampoline(),
use_two_trampolines=self.config.LIGHTNING_LEGACY_ADD_TRAMPOLINE,
)
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
# sometimes need to fall back to a single trampoline forwarder, at the expense
# of privacy
try:
while True:
if (amount_to_send := paysession.get_outstanding_amount_to_send()) > 0:
# 1. create a set of routes for remaining amount.
# note: path-finding runs in a separate thread so that we don't block the asyncio loop
# graph updates might occur during the computation
remaining_fee_budget_msat = (budget.fee_msat * amount_to_send) // amount_to_pay
routes = self.create_routes_for_payment(
paysession=paysession,
amount_msat=amount_to_send,
full_path=full_path,
fwd_trampoline_onion=fwd_trampoline_onion,
channels=channels,
budget=budget._replace(fee_msat=remaining_fee_budget_msat),
)
# 2. send htlcs
async for sent_htlc_info, cltv_delta, trampoline_onion in routes:
await self.pay_to_route(
paysession=paysession,
sent_htlc_info=sent_htlc_info,
min_final_cltv_delta=cltv_delta,
trampoline_onion=trampoline_onion,
fw_payment_key=fw_payment_key,
)
# invoice_status is triggered in self.set_invoice_status when it actually changes.
# It is also triggered here to update progress for a lightning payment in the GUI
# (e.g. attempt counter)
util.trigger_callback('invoice_status', self.wallet, payment_hash.hex(), PR_INFLIGHT)
# 3. await a queue
htlc_log = await paysession.wait_for_one_htlc_to_resolve() # TODO maybe wait a bit, more failures might come
log.append(htlc_log)
if htlc_log.success:
if self.network.path_finder:
# TODO: report every route to liquidity hints for mpp
# in the case of success, we report channels of the
# route as being able to send the same amount in the future,
# as we assume to not know the capacity
self.network.path_finder.update_liquidity_hints(htlc_log.route, htlc_log.amount_msat)
# remove inflight htlcs from liquidity hints
self.network.path_finder.update_inflight_htlcs(htlc_log.route, add_htlcs=False)
return
# htlc failed
# if we get a tmp channel failure, it might work to split the amount and try more routes
# if we get a channel update, we might retry the same route and amount
route = htlc_log.route
sender_idx = htlc_log.sender_idx
failure_msg = htlc_log.failure_msg
if sender_idx is None:
raise PaymentFailure(failure_msg.code_name())
erring_node_id = route[sender_idx].node_id
code, data = failure_msg.code, failure_msg.data
self.logger.info(f"UPDATE_FAIL_HTLC. code={repr(code)}. "
f"decoded_data={failure_msg.decode_data()}. data={data.hex()!r}")
self.logger.info(f"error reported by {erring_node_id.hex()}")
if code == OnionFailureCode.MPP_TIMEOUT:
raise PaymentFailure(failure_msg.code_name())
# errors returned by the next trampoline.
if fwd_trampoline_onion and code in [
OnionFailureCode.TRAMPOLINE_FEE_INSUFFICIENT,
OnionFailureCode.TRAMPOLINE_EXPIRY_TOO_SOON]:
raise failure_msg
# trampoline
if self.uses_trampoline():
paysession.handle_failed_trampoline_htlc(
htlc_log=htlc_log, failure_msg=failure_msg)
else:
self.handle_error_code_from_failed_htlc(
route=route, sender_idx=sender_idx, failure_msg=failure_msg, amount=htlc_log.amount_msat)
# max attempts or timeout
if (attempts is not None and len(log) >= attempts) or (attempts is None and time.time() - paysession.start_time > self.PAYMENT_TIMEOUT):
raise PaymentFailure('Giving up after %d attempts'%len(log))
finally:
paysession.is_active = False
if paysession.can_be_deleted():
self._paysessions.pop(payment_key)
paysession.logger.info(f"pay_to_node ending session for RHASH={payment_hash.hex()}")
async def pay_to_route(
self, *,
paysession: PaySession,
sent_htlc_info: SentHtlcInfo,
min_final_cltv_delta: int,
trampoline_onion: Optional[OnionPacket] = None,
fw_payment_key: str = None,
) -> None:
"""Sends a single HTLC."""
shi = sent_htlc_info
del sent_htlc_info # just renamed
short_channel_id = shi.route[0].short_channel_id
chan = self.get_channel_by_short_id(short_channel_id)
assert chan, ShortChannelID(short_channel_id)
peer = self._peers.get(shi.route[0].node_id)
if not peer:
raise PaymentFailure('Dropped peer')
await peer.initialized
htlc = peer.pay(
route=shi.route,
chan=chan,
amount_msat=shi.amount_msat,
total_msat=shi.bucket_msat,
payment_hash=paysession.payment_hash,
min_final_cltv_delta=min_final_cltv_delta,
payment_secret=shi.payment_secret_bucket,
trampoline_onion=trampoline_onion)
key = (paysession.payment_hash, short_channel_id, htlc.htlc_id)
self.sent_htlcs_info[key] = shi
paysession.add_new_htlc(shi)
if fw_payment_key:
htlc_key = serialize_htlc_key(short_channel_id, htlc.htlc_id)
self.logger.info(f'adding active forwarding {fw_payment_key}')
self.active_forwardings[fw_payment_key].append(htlc_key)
if self.network.path_finder:
# add inflight htlcs to liquidity hints
self.network.path_finder.update_inflight_htlcs(shi.route, add_htlcs=True)
util.trigger_callback('htlc_added', chan, htlc, SENT)
def handle_error_code_from_failed_htlc(
self,
*,
route: LNPaymentRoute,
sender_idx: int,
failure_msg: OnionRoutingFailure,
amount: int) -> None:
assert self.channel_db # cannot be in trampoline mode
assert self.network.path_finder
# remove inflight htlcs from liquidity hints
self.network.path_finder.update_inflight_htlcs(route, add_htlcs=False)
code, data = failure_msg.code, failure_msg.data
# TODO can we use lnmsg.OnionWireSerializer here?
# TODO update onion_wire.csv
# handle some specific error codes
failure_codes = {
OnionFailureCode.TEMPORARY_CHANNEL_FAILURE: 0,
OnionFailureCode.AMOUNT_BELOW_MINIMUM: 8,
OnionFailureCode.FEE_INSUFFICIENT: 8,
OnionFailureCode.INCORRECT_CLTV_EXPIRY: 4,
OnionFailureCode.EXPIRY_TOO_SOON: 0,
OnionFailureCode.CHANNEL_DISABLED: 2,
}
try:
failing_channel = route[sender_idx + 1].short_channel_id
except IndexError:
raise PaymentFailure(f'payment destination reported error: {failure_msg.code_name()}') from None
# TODO: handle unknown next peer?
# handle failure codes that include a channel update
if code in failure_codes:
offset = failure_codes[code]
channel_update_len = int.from_bytes(data[offset:offset+2], byteorder="big")
channel_update_as_received = data[offset+2: offset+2+channel_update_len]
payload = self._decode_channel_update_msg(channel_update_as_received)
if payload is None:
self.logger.info(f'could not decode channel_update for failed htlc: '
f'{channel_update_as_received.hex()}')
blacklist = True
elif payload.get('short_channel_id') != failing_channel:
self.logger.info(f'short_channel_id in channel_update does not match our route')
blacklist = True
else:
# apply the channel update or get blacklisted
blacklist, update = self._handle_chanupd_from_failed_htlc(
payload, route=route, sender_idx=sender_idx, failure_msg=failure_msg)
# we interpret a temporary channel failure as a liquidity issue
# in the channel and update our liquidity hints accordingly
if code == OnionFailureCode.TEMPORARY_CHANNEL_FAILURE:
self.network.path_finder.update_liquidity_hints(
route,
amount,
failing_channel=ShortChannelID(failing_channel))
# if we can't decide on some action, we are stuck
if not (blacklist or update):
raise PaymentFailure(failure_msg.code_name())
# for errors that do not include a channel update
else:
blacklist = True
if blacklist:
self.network.path_finder.add_edge_to_blacklist(short_channel_id=failing_channel)
def _handle_chanupd_from_failed_htlc(
self, payload, *,
route: LNPaymentRoute,
sender_idx: int,
failure_msg: OnionRoutingFailure,
) -> Tuple[bool, bool]:
blacklist = False
update = False
try:
r = self.channel_db.add_channel_update(payload, verify=True)
except InvalidGossipMsg:
return True, False # blacklist
short_channel_id = ShortChannelID(payload['short_channel_id'])
if r == UpdateStatus.GOOD:
self.logger.info(f"applied channel update to {short_channel_id}")
# TODO: add test for this
# FIXME: this does not work for our own unannounced channels.
for chan in self.channels.values():
if chan.short_channel_id == short_channel_id:
chan.set_remote_update(payload)
update = True
elif r == UpdateStatus.ORPHANED:
# maybe it is a private channel (and data in invoice was outdated)
self.logger.info(f"Could not find {short_channel_id}. maybe update is for private channel?")
start_node_id = route[sender_idx].node_id
cache_ttl = None
if failure_msg.code == OnionFailureCode.CHANNEL_DISABLED:
# eclair sends CHANNEL_DISABLED if its peer is offline. E.g. we might be trying to pay
# a mobile phone with the app closed. So we cache this with a short TTL.
cache_ttl = self.channel_db.PRIVATE_CHAN_UPD_CACHE_TTL_SHORT
update = self.channel_db.add_channel_update_for_private_channel(payload, start_node_id, cache_ttl=cache_ttl)
blacklist = not update
elif r == UpdateStatus.EXPIRED:
blacklist = True
elif r == UpdateStatus.DEPRECATED:
self.logger.info(f'channel update is not more recent.')
blacklist = True
elif r == UpdateStatus.UNCHANGED:
blacklist = True
return blacklist, update
@classmethod
def _decode_channel_update_msg(cls, chan_upd_msg: bytes) -> Optional[Dict[str, Any]]:
channel_update_as_received = chan_upd_msg
channel_update_typed = (258).to_bytes(length=2, byteorder="big") + channel_update_as_received
# note: some nodes put channel updates in error msgs with the leading msg_type already there.
# we try decoding both ways here.
try:
message_type, payload = decode_msg(channel_update_typed)
if payload['chain_hash'] != constants.net.rev_genesis_bytes(): raise Exception()
payload['raw'] = channel_update_typed
return payload
except Exception: # FIXME: too broad
try:
message_type, payload = decode_msg(channel_update_as_received)
if payload['chain_hash'] != constants.net.rev_genesis_bytes(): raise Exception()
payload['raw'] = channel_update_as_received
return payload
except Exception:
return None
def _check_invoice(self, invoice: str, *, amount_msat: int = None) -> LnAddr:
"""Parses and validates a bolt11 invoice str into a LnAddr.
Includes pre-payment checks external to the parser.
"""
addr = lndecode(invoice)
if addr.is_expired():
raise InvoiceError(_("This invoice has expired"))
# check amount
if amount_msat: # replace amt in invoice. main usecase is paying zero amt invoices
existing_amt_msat = addr.get_amount_msat()
if existing_amt_msat and amount_msat < existing_amt_msat:
raise Exception("cannot pay lower amt than what is originally in LN invoice")
addr.amount = Decimal(amount_msat) / COIN / 1000
if addr.amount is None:
raise InvoiceError(_("Missing amount"))
# check cltv
if addr.get_min_final_cltv_delta() > lnutil.NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE:
raise InvoiceError("{}\n{}".format(
_("Invoice wants us to risk locking funds for unreasonably long."),
f"min_final_cltv_delta: {addr.get_min_final_cltv_delta()}"))
# check features
addr.validate_and_compare_features(self.features)
return addr
def is_trampoline_peer(self, node_id: bytes) -> bool:
# until trampoline is advertised in lnfeatures, check against hardcoded list
if is_hardcoded_trampoline(node_id):
return True
peer = self._peers.get(node_id)
if not peer:
return False
return (peer.their_features.supports(LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT_ECLAIR)\
or peer.their_features.supports(LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT_ELECTRUM))
def suggest_peer(self) -> Optional[bytes]:
if not self.uses_trampoline():
return self.lnrater.suggest_peer()
else:
return random.choice(list(hardcoded_trampoline_nodes().values())).pubkey
def suggest_splits(
self,
*,
amount_msat: int,
final_total_msat: int,
my_active_channels: Sequence[Channel],
invoice_features: LnFeatures,
r_tags,
) -> List['SplitConfigRating']:
channels_with_funds = {
(chan.channel_id, chan.node_id): int(chan.available_to_spend(HTLCOwner.LOCAL))
for chan in my_active_channels
}
self.logger.info(f"channels_with_funds: {channels_with_funds}")
exclude_single_part_payments = False
if self.uses_trampoline():
# in the case of a legacy payment, we don't allow splitting via different
# trampoline nodes, because of https://github.com/ACINQ/eclair/issues/2127
is_legacy, _ = is_legacy_relay(invoice_features, r_tags)
exclude_multinode_payments = is_legacy
# we don't split within a channel when sending to a trampoline node,
# the trampoline node will split for us
exclude_single_channel_splits = True
else:
exclude_multinode_payments = False
exclude_single_channel_splits = False
if invoice_features.supports(LnFeatures.BASIC_MPP_OPT) and not self.config.TEST_FORCE_DISABLE_MPP:
# if amt is still large compared to total_msat, split it:
if (amount_msat / final_total_msat > self.MPP_SPLIT_PART_FRACTION
and amount_msat > self.MPP_SPLIT_PART_MINAMT_MSAT):
exclude_single_part_payments = True
def get_splits():
return suggest_splits(
amount_msat,
channels_with_funds,
exclude_single_part_payments=exclude_single_part_payments,
exclude_multinode_payments=exclude_multinode_payments,
exclude_single_channel_splits=exclude_single_channel_splits
)
split_configurations = get_splits()
if not split_configurations and exclude_single_part_payments:
exclude_single_part_payments = False
split_configurations = get_splits()
self.logger.info(f'suggest_split {amount_msat} returned {len(split_configurations)} configurations')
return split_configurations
async def create_routes_for_payment(
self, *,
paysession: PaySession,
amount_msat: int, # part of payment amount we want routes for now
fwd_trampoline_onion: OnionPacket = None,
full_path: LNPaymentPath = None,
channels: Optional[Sequence[Channel]] = None,
budget: PaymentFeeBudget,
) -> AsyncGenerator[Tuple[SentHtlcInfo, int, Optional[OnionPacket]], None]:
"""Creates multiple routes for splitting a payment over the available
private channels.
We first try to conduct the payment over a single channel. If that fails
and mpp is supported by the receiver, we will split the payment."""
trampoline_features = LnFeatures.VAR_ONION_OPT
local_height = self.network.get_local_height()
if channels:
my_active_channels = channels
else:
my_active_channels = [
chan for chan in self.channels.values() if
chan.is_active() and not chan.is_frozen_for_sending()]
# try random order
random.shuffle(my_active_channels)
split_configurations = self.suggest_splits(
amount_msat=amount_msat,
final_total_msat=paysession.amount_to_pay,
my_active_channels=my_active_channels,
invoice_features=paysession.invoice_features,
r_tags=paysession.r_tags,
)
for sc in split_configurations:
is_multichan_mpp = len(sc.config.items()) > 1
is_mpp = sc.config.number_parts() > 1
if is_mpp and not paysession.invoice_features.supports(LnFeatures.BASIC_MPP_OPT):
continue
if not is_mpp and self.config.TEST_FORCE_MPP:
continue
if is_mpp and self.config.TEST_FORCE_DISABLE_MPP:
continue
self.logger.info(f"trying split configuration: {sc.config.values()} rating: {sc.rating}")
routes = []
try:
if self.uses_trampoline():
per_trampoline_channel_amounts = defaultdict(list)
# categorize by trampoline nodes for trampoline mpp construction
for (chan_id, _), part_amounts_msat in sc.config.items():
chan = self.channels[chan_id]
for part_amount_msat in part_amounts_msat:
per_trampoline_channel_amounts[chan.node_id].append((chan_id, part_amount_msat))
# for each trampoline forwarder, construct mpp trampoline
for trampoline_node_id, trampoline_parts in per_trampoline_channel_amounts.items():
per_trampoline_amount = sum([x[1] for x in trampoline_parts])
trampoline_route, trampoline_onion, per_trampoline_amount_with_fees, per_trampoline_cltv_delta = create_trampoline_route_and_onion(
amount_msat=per_trampoline_amount,
total_msat=paysession.amount_to_pay,
min_final_cltv_delta=paysession.min_final_cltv_delta,
my_pubkey=self.node_keypair.pubkey,
invoice_pubkey=paysession.invoice_pubkey,
invoice_features=paysession.invoice_features,
node_id=trampoline_node_id,
r_tags=paysession.r_tags,
payment_hash=paysession.payment_hash,
payment_secret=paysession.payment_secret,
local_height=local_height,
trampoline_fee_level=paysession.trampoline_fee_level,
use_two_trampolines=paysession.use_two_trampolines,
failed_routes=paysession.failed_trampoline_routes,
budget=budget._replace(fee_msat=budget.fee_msat // len(per_trampoline_channel_amounts)),
)
# node_features is only used to determine is_tlv
per_trampoline_secret = os.urandom(32)
per_trampoline_fees = per_trampoline_amount_with_fees - per_trampoline_amount
self.logger.info(f'created route with trampoline fee level={paysession.trampoline_fee_level}')
self.logger.info(f'trampoline hops: {[hop.end_node.hex() for hop in trampoline_route]}')
self.logger.info(f'per trampoline fees: {per_trampoline_fees}')
for chan_id, part_amount_msat in trampoline_parts:
chan = self.channels[chan_id]
margin = chan.available_to_spend(LOCAL, strict=True) - part_amount_msat
delta_fee = min(per_trampoline_fees, margin)
# TODO: distribute trampoline fee over several channels?
part_amount_msat_with_fees = part_amount_msat + delta_fee
per_trampoline_fees -= delta_fee
route = [
RouteEdge(
start_node=self.node_keypair.pubkey,
end_node=trampoline_node_id,
short_channel_id=chan.short_channel_id,
fee_base_msat=0,
fee_proportional_millionths=0,
cltv_delta=0,
node_features=trampoline_features)
]
self.logger.info(f'adding route {part_amount_msat} {delta_fee} {margin}')
shi = SentHtlcInfo(
route=route,
payment_secret_orig=paysession.payment_secret,
payment_secret_bucket=per_trampoline_secret,
amount_msat=part_amount_msat_with_fees,
bucket_msat=per_trampoline_amount_with_fees,
amount_receiver_msat=part_amount_msat,
trampoline_fee_level=paysession.trampoline_fee_level,
trampoline_route=trampoline_route,
)
routes.append((shi, per_trampoline_cltv_delta, trampoline_onion))
if per_trampoline_fees != 0:
self.logger.info('not enough margin to pay trampoline fee')
raise NoPathFound()
else:
# We atomically loop through a split configuration. If there was
# a failure to find a path for a single part, we try the next configuration
for (chan_id, _), part_amounts_msat in sc.config.items():
for part_amount_msat in part_amounts_msat:
channel = self.channels[chan_id]
route = await run_in_thread(
partial(
self.create_route_for_single_htlc,
amount_msat=part_amount_msat,
invoice_pubkey=paysession.invoice_pubkey,
min_final_cltv_delta=paysession.min_final_cltv_delta,
r_tags=paysession.r_tags,
invoice_features=paysession.invoice_features,
my_sending_channels=[channel] if is_multichan_mpp else my_active_channels,
full_path=full_path,
budget=budget._replace(fee_msat=budget.fee_msat // sc.config.number_parts()),
)
)
shi = SentHtlcInfo(
route=route,
payment_secret_orig=paysession.payment_secret,
payment_secret_bucket=paysession.payment_secret,
amount_msat=part_amount_msat,
bucket_msat=paysession.amount_to_pay,
amount_receiver_msat=part_amount_msat,
trampoline_fee_level=None,
trampoline_route=None,
)
routes.append((shi, paysession.min_final_cltv_delta, fwd_trampoline_onion))
except NoPathFound:
continue
for route in routes:
yield route
return
raise NoPathFound()
@profiler
def create_route_for_single_htlc(
self, *,
amount_msat: int, # that final receiver gets
invoice_pubkey: bytes,
min_final_cltv_delta: int,
r_tags,
invoice_features: int,
my_sending_channels: List[Channel],
full_path: Optional[LNPaymentPath],
budget: PaymentFeeBudget,
) -> LNPaymentRoute:
my_sending_aliases = set(chan.get_local_scid_alias() for chan in my_sending_channels)
my_sending_channels = {chan.short_channel_id: chan for chan in my_sending_channels
if chan.short_channel_id is not None}
# Collect all private edges from route hints.
# Note: if some route hints are multiple edges long, and these paths cross each other,
# we allow our path finding to cross the paths; i.e. the route hints are not isolated.
private_route_edges = {} # type: Dict[ShortChannelID, RouteEdge]
for private_path in r_tags:
# we need to shift the node pubkey by one towards the destination:
private_path_nodes = [edge[0] for edge in private_path][1:] + [invoice_pubkey]
private_path_rest = [edge[1:] for edge in private_path]
start_node = private_path[0][0]
# remove aliases from direct routes
if len(private_path) == 1 and private_path[0][1] in my_sending_aliases:
self.logger.info(f'create_route: skipping alias {ShortChannelID(private_path[0][1])}')
continue
for end_node, edge_rest in zip(private_path_nodes, private_path_rest):
short_channel_id, fee_base_msat, fee_proportional_millionths, cltv_delta = edge_rest
short_channel_id = ShortChannelID(short_channel_id)
# if we have a routing policy for this edge in the db, that takes precedence,
# as it is likely from a previous failure
channel_policy = self.channel_db.get_policy_for_node(
short_channel_id=short_channel_id,
node_id=start_node,
my_channels=my_sending_channels)
if channel_policy:
fee_base_msat = channel_policy.fee_base_msat
fee_proportional_millionths = channel_policy.fee_proportional_millionths
cltv_delta = channel_policy.cltv_delta
node_info = self.channel_db.get_node_info_for_node_id(node_id=end_node)
route_edge = RouteEdge(
start_node=start_node,
end_node=end_node,
short_channel_id=short_channel_id,
fee_base_msat=fee_base_msat,
fee_proportional_millionths=fee_proportional_millionths,
cltv_delta=cltv_delta,
node_features=node_info.features if node_info else 0)
private_route_edges[route_edge.short_channel_id] = route_edge
start_node = end_node
# now find a route, end to end: between us and the recipient
try:
route = self.network.path_finder.find_route(
nodeA=self.node_keypair.pubkey,
nodeB=invoice_pubkey,
invoice_amount_msat=amount_msat,
path=full_path,
my_sending_channels=my_sending_channels,
private_route_edges=private_route_edges)
except NoChannelPolicy as e:
raise NoPathFound() from e
if not route:
raise NoPathFound()
if not is_route_within_budget(
route, budget=budget, amount_msat_for_dest=amount_msat, cltv_delta_for_dest=min_final_cltv_delta,
):
self.logger.info(f"rejecting route (exceeds budget): {route=}. {budget=}")
raise NoPathFound()
assert len(route) > 0
if route[-1].end_node != invoice_pubkey:
raise LNPathInconsistent("last node_id != invoice pubkey")
# add features from invoice
route[-1].node_features |= invoice_features
return route
def clear_invoices_cache(self):
self._bolt11_cache.clear()
def get_bolt11_invoice(
self, *,
payment_hash: bytes,
amount_msat: Optional[int],
message: str,
expiry: int, # expiration of invoice (in seconds, relative)
fallback_address: Optional[str],
channels: Optional[Sequence[Channel]] = None,
min_final_cltv_expiry_delta: Optional[int] = None,
) -> Tuple[LnAddr, str]:
assert isinstance(payment_hash, bytes), f"expected bytes, but got {type(payment_hash)}"
pair = self._bolt11_cache.get(payment_hash)
if pair:
lnaddr, invoice = pair
assert lnaddr.get_amount_msat() == amount_msat
return pair
assert amount_msat is None or amount_msat > 0
timestamp = int(time.time())
routing_hints = self.calc_routing_hints_for_invoice(amount_msat, channels=channels)
self.logger.info(f"creating bolt11 invoice with routing_hints: {routing_hints}")
invoice_features = self.features.for_invoice()
if not self.uses_trampoline():
invoice_features &= ~ LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT_ELECTRUM
payment_secret = self.get_payment_secret(payment_hash)
amount_btc = amount_msat/Decimal(COIN*1000) if amount_msat else None
if expiry == 0:
expiry = LN_EXPIRY_NEVER
if min_final_cltv_expiry_delta is None:
min_final_cltv_expiry_delta = MIN_FINAL_CLTV_DELTA_FOR_INVOICE
lnaddr = LnAddr(
paymenthash=payment_hash,
amount=amount_btc,
tags=[
('d', message),
('c', min_final_cltv_expiry_delta),
('x', expiry),
('9', invoice_features),
('f', fallback_address),
] + routing_hints,
date=timestamp,
payment_secret=payment_secret)
invoice = lnencode(lnaddr, self.node_keypair.privkey)
pair = lnaddr, invoice
self._bolt11_cache[payment_hash] = pair
return pair
def get_payment_secret(self, payment_hash):
return sha256(sha256(self.payment_secret_key) + payment_hash)
def _get_payment_key(self, payment_hash: bytes) -> bytes:
"""Return payment bucket key.
We bucket htlcs based on payment_hash+payment_secret. payment_secret is included
as it changes over a trampoline path (in the outer onion), and these paths can overlap.
"""
payment_secret = self.get_payment_secret(payment_hash)
return payment_hash + payment_secret
def create_payment_info(self, *, amount_msat: Optional[int], write_to_disk=True) -> bytes:
payment_preimage = os.urandom(32)
payment_hash = sha256(payment_preimage)
info = PaymentInfo(payment_hash, amount_msat, RECEIVED, PR_UNPAID)
self.save_preimage(payment_hash, payment_preimage, write_to_disk=False)
self.save_payment_info(info, write_to_disk=False)
if write_to_disk:
self.wallet.save_db()
return payment_hash
def bundle_payments(self, hash_list):
payment_keys = [self._get_payment_key(x) for x in hash_list]
self.payment_bundles.append(payment_keys)
def get_payment_bundle(self, payment_key: bytes) -> Sequence[bytes]:
for key_list in self.payment_bundles:
if payment_key in key_list:
return key_list
def save_preimage(self, payment_hash: bytes, preimage: bytes, *, write_to_disk: bool = True):
if sha256(preimage) != payment_hash:
raise Exception("tried to save incorrect preimage for payment_hash")
self.preimages[payment_hash.hex()] = preimage.hex()
if write_to_disk:
self.wallet.save_db()
def get_preimage(self, payment_hash: bytes) -> Optional[bytes]:
assert isinstance(payment_hash, bytes), f"expected bytes, but got {type(payment_hash)}"
preimage_hex = self.preimages.get(payment_hash.hex())
if preimage_hex is None:
return None
preimage_bytes = bytes.fromhex(preimage_hex)
if sha256(preimage_bytes) != payment_hash:
raise Exception("found incorrect preimage for payment_hash")
return preimage_bytes
def get_payment_info(self, payment_hash: bytes) -> Optional[PaymentInfo]:
"""returns None if payment_hash is a payment we are forwarding"""
key = payment_hash.hex()
with self.lock:
if key in self.payment_info:
amount_msat, direction, status = self.payment_info[key]
return PaymentInfo(payment_hash, amount_msat, direction, status)
def add_payment_info_for_hold_invoice(self, payment_hash: bytes, lightning_amount_sat: int):
info = PaymentInfo(payment_hash, lightning_amount_sat * 1000, RECEIVED, PR_UNPAID)
self.save_payment_info(info, write_to_disk=False)
def register_hold_invoice(self, payment_hash: bytes, cb: Callable[[bytes], Awaitable[None]]):
self.hold_invoice_callbacks[payment_hash] = cb
def unregister_hold_invoice(self, payment_hash: bytes):
self.hold_invoice_callbacks.pop(payment_hash)
def save_payment_info(self, info: PaymentInfo, *, write_to_disk: bool = True) -> None:
key = info.payment_hash.hex()
assert info.status in SAVED_PR_STATUS
with self.lock:
self.payment_info[key] = info.amount_msat, info.direction, info.status
if write_to_disk:
self.wallet.save_db()
def check_mpp_status(
self, *,
payment_secret: bytes,
short_channel_id: ShortChannelID,
htlc: UpdateAddHtlc,
expected_msat: int,
) -> RecvMPPResolution:
"""Returns the status of the incoming htlc set the given *htlc* belongs to.
ACCEPTED simply means the mpp set is complete, and we can proceed with further
checks before fulfilling (or failing) the htlcs.
In particular, note that hold-invoice-htlcs typically remain in the ACCEPTED state
for quite some time -- not in the "WAITING" state (which would refer to the mpp set
not yet being complete!).
"""
payment_hash = htlc.payment_hash
payment_key = payment_hash + payment_secret
self.update_mpp_with_received_htlc(
payment_key=payment_key, scid=short_channel_id, htlc=htlc, expected_msat=expected_msat)
mpp_resolution = self.received_mpp_htlcs[payment_key.hex()].resolution
# if still waiting, calc resolution now:
if mpp_resolution == RecvMPPResolution.WAITING:
bundle = self.get_payment_bundle(payment_key)
if bundle:
payment_keys = bundle
else:
payment_keys = [payment_key]
first_timestamp = min([self.get_first_timestamp_of_mpp(pkey) for pkey in payment_keys])
if self.get_payment_status(payment_hash) == PR_PAID:
mpp_resolution = RecvMPPResolution.ACCEPTED
elif self.stopping_soon:
# try to time out pending HTLCs before shutting down
mpp_resolution = RecvMPPResolution.EXPIRED
elif all([self.is_mpp_amount_reached(pkey) for pkey in payment_keys]):
mpp_resolution = RecvMPPResolution.ACCEPTED
elif time.time() - first_timestamp > self.MPP_EXPIRY:
mpp_resolution = RecvMPPResolution.EXPIRED
# save resolution, if any.
if mpp_resolution != RecvMPPResolution.WAITING:
for pkey in payment_keys:
if pkey.hex() in self.received_mpp_htlcs:
self.set_mpp_resolution(payment_key=pkey, resolution=mpp_resolution)
return mpp_resolution
def update_mpp_with_received_htlc(
self,
*,
payment_key: bytes,
scid: ShortChannelID,
htlc: UpdateAddHtlc,
expected_msat: int,
):
# add new htlc to set
mpp_status = self.received_mpp_htlcs.get(payment_key.hex())
if mpp_status is None:
mpp_status = ReceivedMPPStatus(
resolution=RecvMPPResolution.WAITING,
expected_msat=expected_msat,
htlc_set=set(),
)
if expected_msat != mpp_status.expected_msat:
self.logger.info(
f"marking received mpp as failed. inconsistent total_msats in bucket. {payment_key.hex()=}")
mpp_status = mpp_status._replace(resolution=RecvMPPResolution.FAILED)
key = (scid, htlc)
if key not in mpp_status.htlc_set:
mpp_status.htlc_set.add(key) # side-effecting htlc_set
self.received_mpp_htlcs[payment_key.hex()] = mpp_status
def set_mpp_resolution(self, *, payment_key: bytes, resolution: RecvMPPResolution):
mpp_status = self.received_mpp_htlcs[payment_key.hex()]
self.logger.info(f'set_mpp_resolution {resolution.name} {len(mpp_status.htlc_set)} {payment_key.hex()}')
self.received_mpp_htlcs[payment_key.hex()] = mpp_status._replace(resolution=resolution)
def is_mpp_amount_reached(self, payment_key: bytes) -> bool:
mpp_status = self.received_mpp_htlcs.get(payment_key.hex())
if not mpp_status:
return False
total = sum([_htlc.amount_msat for scid, _htlc in mpp_status.htlc_set])
return total >= mpp_status.expected_msat
def is_accepted_mpp(self, payment_hash: bytes) -> bool:
payment_key = self._get_payment_key(payment_hash)
status = self.received_mpp_htlcs.get(payment_key.hex())
return status and status.resolution == RecvMPPResolution.ACCEPTED
def get_first_timestamp_of_mpp(self, payment_key: bytes) -> int:
mpp_status = self.received_mpp_htlcs.get(payment_key.hex())
if not mpp_status:
return int(time.time())
return min([_htlc.timestamp for scid, _htlc in mpp_status.htlc_set])
def maybe_cleanup_mpp(
self,
short_channel_id: ShortChannelID,
htlc: UpdateAddHtlc,
) -> None:
htlc_key = (short_channel_id, htlc)
for payment_key_hex, mpp_status in list(self.received_mpp_htlcs.items()):
if htlc_key not in mpp_status.htlc_set:
continue
assert mpp_status.resolution != RecvMPPResolution.WAITING
self.logger.info(f'maybe_cleanup_mpp: removing htlc of MPP {payment_key_hex}')
mpp_status.htlc_set.remove(htlc_key) # side-effecting htlc_set
if len(mpp_status.htlc_set) == 0:
self.logger.info(f'maybe_cleanup_mpp: removing mpp {payment_key_hex}')
self.received_mpp_htlcs.pop(payment_key_hex)
self.maybe_cleanup_forwarding(payment_key_hex)
def maybe_cleanup_forwarding(self, payment_key_hex: str) -> None:
self.active_forwardings.pop(payment_key_hex, None)
self.forwarding_failures.pop(payment_key_hex, None)
def get_payment_status(self, payment_hash: bytes) -> int:
info = self.get_payment_info(payment_hash)
return info.status if info else PR_UNPAID
def get_invoice_status(self, invoice: BaseInvoice) -> int:
invoice_id = invoice.rhash
if invoice_id in self.inflight_payments:
return PR_INFLIGHT
# status may be PR_FAILED
status = self.get_payment_status(bytes.fromhex(invoice_id))
if status == PR_UNPAID and invoice_id in self.logs:
status = PR_FAILED
return status
def set_invoice_status(self, key: str, status: int) -> None:
if status == PR_INFLIGHT:
self.inflight_payments.add(key)
elif key in self.inflight_payments:
self.inflight_payments.remove(key)
if status in SAVED_PR_STATUS:
self.set_payment_status(bfh(key), status)
util.trigger_callback('invoice_status', self.wallet, key, status)
self.logger.info(f"invoice status triggered (2) for key {key} and status {status}")
# liquidity changed
self.clear_invoices_cache()
def set_request_status(self, payment_hash: bytes, status: int) -> None:
if self.get_payment_status(payment_hash) == status:
return
self.set_payment_status(payment_hash, status)
request_id = payment_hash.hex()
req = self.wallet.get_request(request_id)
if req is None:
return
util.trigger_callback('request_status', self.wallet, request_id, status)
def set_payment_status(self, payment_hash: bytes, status: int) -> None:
info = self.get_payment_info(payment_hash)
if info is None:
# if we are forwarding
return
info = info._replace(status=status)
self.save_payment_info(info)
def is_forwarded_htlc(self, htlc_key) -> Optional[str]:
"""Returns whether this was a forwarded HTLC."""
for payment_key, htlcs in self.active_forwardings.items():
if htlc_key in htlcs:
return payment_key
def notify_upstream_peer(self, htlc_key: str) -> None:
"""Called when an HTLC we offered on chan gets irrevocably fulfilled or failed.
If we find this was a forwarded HTLC, the upstream peer is notified.
"""
upstream_key = self.downstream_to_upstream_htlc.pop(htlc_key, None)
if not upstream_key:
return
upstream_chan_scid, _ = deserialize_htlc_key(upstream_key)
upstream_chan = self.get_channel_by_short_id(upstream_chan_scid)
upstream_peer = self.peers.get(upstream_chan.node_id) if upstream_chan else None
if upstream_peer:
upstream_peer.downstream_htlc_resolved_event.set()
upstream_peer.downstream_htlc_resolved_event.clear()
def htlc_fulfilled(self, chan: Channel, payment_hash: bytes, htlc_id: int):
util.trigger_callback('htlc_fulfilled', payment_hash, chan, htlc_id)
htlc_key = serialize_htlc_key(chan.get_scid_or_local_alias(), htlc_id)
fw_key = self.is_forwarded_htlc(htlc_key)
if fw_key:
fw_htlcs = self.active_forwardings[fw_key]
fw_htlcs.remove(htlc_key)
if shi := self.sent_htlcs_info.get((payment_hash, chan.short_channel_id, htlc_id)):
chan.pop_onion_key(htlc_id)
payment_key = payment_hash + shi.payment_secret_orig
paysession = self._paysessions[payment_key]
q = paysession.sent_htlcs_q
htlc_log = HtlcLog(
success=True,
route=shi.route,
amount_msat=shi.amount_receiver_msat,
trampoline_fee_level=shi.trampoline_fee_level)
q.put_nowait(htlc_log)
if paysession.can_be_deleted():
self._paysessions.pop(payment_key)
paysession_active = False
else:
paysession_active = True
else:
if fw_key:
paysession_active = False
else:
key = payment_hash.hex()
self.set_invoice_status(key, PR_PAID)
util.trigger_callback('payment_succeeded', self.wallet, key)
if fw_key:
fw_htlcs = self.active_forwardings[fw_key]
if len(fw_htlcs) == 0 and not paysession_active:
self.notify_upstream_peer(htlc_key)
def htlc_failed(
self,
chan: Channel,
payment_hash: bytes,
htlc_id: int,
error_bytes: Optional[bytes],
failure_message: Optional['OnionRoutingFailure']):
util.trigger_callback('htlc_failed', payment_hash, chan, htlc_id)
htlc_key = serialize_htlc_key(chan.get_scid_or_local_alias(), htlc_id)
fw_key = self.is_forwarded_htlc(htlc_key)
if fw_key:
fw_htlcs = self.active_forwardings[fw_key]
fw_htlcs.remove(htlc_key)
if shi := self.sent_htlcs_info.get((payment_hash, chan.short_channel_id, htlc_id)):
onion_key = chan.pop_onion_key(htlc_id)
payment_okey = payment_hash + shi.payment_secret_orig
paysession = self._paysessions[payment_okey]
q = paysession.sent_htlcs_q
# detect if it is part of a bucket
# if yes, wait until the bucket completely failed
route = shi.route
if error_bytes:
# TODO "decode_onion_error" might raise, catch and maybe blacklist/penalise someone?
try:
failure_message, sender_idx = decode_onion_error(
error_bytes,
[x.node_id for x in route],
onion_key)
except Exception as e:
sender_idx = None
failure_message = OnionRoutingFailure(OnionFailureCode.INVALID_ONION_PAYLOAD, str(e).encode())
else:
# probably got "update_fail_malformed_htlc". well... who to penalise now?
assert failure_message is not None
sender_idx = None
self.logger.info(f"htlc_failed {failure_message}")
amount_receiver_msat = paysession.on_htlc_fail_get_fail_amt_to_propagate(shi)
if amount_receiver_msat is None:
return
if shi.trampoline_route:
route = shi.trampoline_route
htlc_log = HtlcLog(
success=False,
route=route,
amount_msat=amount_receiver_msat,
error_bytes=error_bytes,
failure_msg=failure_message,
sender_idx=sender_idx,
trampoline_fee_level=shi.trampoline_fee_level)
q.put_nowait(htlc_log)
if paysession.can_be_deleted():
self._paysessions.pop(payment_okey)
paysession_active = False
else:
paysession_active = True
else:
if fw_key:
paysession_active = False
else:
self.logger.info(f"received unknown htlc_failed, probably from previous session (phash={payment_hash.hex()})")
key = payment_hash.hex()
self.set_invoice_status(key, PR_UNPAID)
util.trigger_callback('payment_failed', self.wallet, key, '')
if fw_key:
fw_htlcs = self.active_forwardings[fw_key]
can_forward_failure = (len(fw_htlcs) == 0) and not paysession_active
if can_forward_failure:
self.save_forwarding_failure(fw_key, error_bytes=error_bytes, failure_message=failure_message)
self.notify_upstream_peer(htlc_key)
else:
self.logger.info(f"waiting for other htlcs to fail (phash={payment_hash.hex()})")
def calc_routing_hints_for_invoice(self, amount_msat: Optional[int], channels=None):
"""calculate routing hints (BOLT-11 'r' field)"""
routing_hints = []
if self.config.ZEROCONF_TRUSTED_NODE:
node_id, rest = extract_nodeid(self.config.ZEROCONF_TRUSTED_NODE)
alias_or_scid = self.get_scid_alias()
routing_hints.append(('r', [(node_id, alias_or_scid, 0, 0, 144)]))
# no need for more
channels = []
else:
if channels is None:
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
}
for chan in channels:
alias_or_scid = chan.get_remote_scid_alias() or chan.short_channel_id
assert isinstance(alias_or_scid, bytes), alias_or_scid
channel_info = get_mychannel_info(chan.short_channel_id, scid_to_my_channels)
# note: as a fallback, if we don't have a channel update for the
# incoming direction of our private channel, we fill the invoice with garbage.
# the sender should still be able to pay us, but will incur an extra round trip
# (they will get the channel update from the onion error)
# at least, that's the theory. https://github.com/lightningnetwork/lnd/issues/2066
fee_base_msat = fee_proportional_millionths = 0
cltv_delta = 1 # lnd won't even try with zero
missing_info = True
if channel_info:
policy = get_mychannel_policy(channel_info.short_channel_id, chan.node_id, scid_to_my_channels)
if policy:
fee_base_msat = policy.fee_base_msat
fee_proportional_millionths = policy.fee_proportional_millionths
cltv_delta = policy.cltv_delta
missing_info = False
if missing_info:
self.logger.info(
f"Warning. Missing channel update for our channel {chan.short_channel_id}; "
f"filling invoice with incorrect data.")
routing_hints.append(('r', [(
chan.node_id,
alias_or_scid,
fee_base_msat,
fee_proportional_millionths,
cltv_delta)]))
return routing_hints
def delete_payment_info(self, payment_hash_hex: str):
# This method is called when an invoice or request is deleted by the user.
# The GUI only lets the user delete invoices or requests that have not been paid.
# Once an invoice/request has been paid, it is part of the history,
# and get_lightning_history assumes that payment_info is there.
assert self.get_payment_status(bytes.fromhex(payment_hash_hex)) != PR_PAID
with self.lock:
self.payment_info.pop(payment_hash_hex, None)
def get_balance(self, frozen=False):
with self.lock:
return Decimal(sum(
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 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 = 5000 # FIXME ehh.. there ought to be a better way...
fee_proportional_millionths = 500 # FIXME
# inverse of fee_for_edge_msat
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 not self.uses_trampoline():
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_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:
channels = list(self.channels.values())
# we exclude channels that cannot *right now* receive (e.g. peer offline)
channels = [chan for chan in channels
if (chan.is_open() and not chan.is_frozen_for_receiving())]
# Filter out nodes that have low receive capacity compared to invoice amt.
# Even with MPP, below a certain threshold, including these channels probably
# hurts more than help, as they lead to many failed attempts for the sender.
channels = sorted(channels, key=lambda chan: -chan.available_to_spend(REMOTE))
selected_channels = []
running_sum = 0
cutoff_factor = 0.2 # heuristic
for chan in channels:
recv_capacity = chan.available_to_spend(REMOTE)
chan_can_handle_payment_as_single_part = recv_capacity >= amount_msat
chan_small_compared_to_running_sum = recv_capacity < cutoff_factor * running_sum
if not chan_can_handle_payment_as_single_part and chan_small_compared_to_running_sum:
break
running_sum += recv_capacity
selected_channels.append(chan)
channels = selected_channels
del selected_channels
# cap max channels to include to keep QR code reasonably scannable
channels = channels[:10]
return channels
def num_sats_can_receive(self, deltas=None) -> Decimal:
"""
We no longer assume the sender to send MPP on different channels,
because channel liquidities are hard to guess
"""
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_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(recv_chan_msats)
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
suggestions = []
channels = self.get_channels_for_sending() if direction == SENT else self.get_channels_for_receiving()
for chan in channels:
available_sat = chan.available_to_spend(LOCAL if direction == SENT else REMOTE) // 1000
delta = amount_sat - available_sat
delta += self.fee_estimate(amount_sat)
# add safety margin
delta += delta // 100 + 1
if func(deltas={chan:delta}) >= amount_sat:
suggestions.append((chan, delta))
elif direction==RECEIVED and func(deltas={chan:2*delta}) >= amount_sat:
# MPP heuristics has a 0.5 slope
suggestions.append((chan, 2*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 self.uses_trampoline() 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 num_sats_can_rebalance(self, chan1, chan2):
# TODO: we should be able to spend 'max', with variable fee
n1 = chan1.available_to_spend(LOCAL)
n1 -= self.fee_estimate(n1)
n2 = chan2.available_to_spend(REMOTE)
amount_sat = min(n1, n2) // 1000
return amount_sat
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(DummyAddress.SWAP, 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: Channel, chan2: Channel, *, amount_msat: int):
if chan1 == chan2:
raise Exception('Rebalance requires two different channels')
if self.uses_trampoline() and chan1.node_id == chan2.node_id:
raise Exception('Rebalance requires channels from different trampolines')
payment_hash = self.create_payment_info(amount_msat=amount_msat)
lnaddr, invoice = self.get_bolt11_invoice(
payment_hash=payment_hash,
amount_msat=amount_msat,
message='rebalance',
expiry=3600,
fallback_address=None,
channels=[chan2],
)
return await self.pay_invoice(
invoice, channels=[chan1])
def can_receive_invoice(self, invoice: BaseInvoice) -> bool:
assert invoice.is_lightning()
return (invoice.get_amount_sat() or 0) <= self.num_sats_can_receive()
async def close_channel(self, chan_id):
chan = self._channels[chan_id]
peer = self._peers[chan.node_id]
return await peer.close_channel(chan_id)
def _force_close_channel(self, chan_id: bytes) -> Transaction:
chan = self._channels[chan_id]
tx = chan.force_close_tx()
# We set the channel state to make sure we won't sign new commitment txs.
# We expect the caller to try to broadcast this tx, after which it is
# not safe to keep using the channel even if the broadcast errors (server could be lying).
# Until the tx is seen in the mempool, there will be automatic rebroadcasts.
chan.set_state(ChannelState.FORCE_CLOSING)
# Add local tx to wallet to also allow manual rebroadcasts.
try:
self.wallet.adb.add_transaction(tx)
except UnrelatedTransactionException:
pass # this can happen if (~all the balance goes to REMOTE)
return tx
async def force_close_channel(self, chan_id: bytes) -> str:
"""Force-close the channel. Network-related exceptions are propagated to the caller.
(automatic rebroadcasts will be scheduled)
"""
# note: as we are async, it can take a few event loop iterations between the caller
# "calling us" and us getting to run, and we only set the channel state now:
tx = self._force_close_channel(chan_id)
await self.network.broadcast_transaction(tx)
return tx.txid()
def schedule_force_closing(self, chan_id: bytes) -> 'asyncio.Task[bool]':
"""Schedules a task to force-close the channel and returns it.
Network-related exceptions are suppressed.
(automatic rebroadcasts will be scheduled)
Note: this method is intentionally not async so that callers have a guarantee
that the channel state is set immediately.
"""
tx = self._force_close_channel(chan_id)
return asyncio.create_task(self.network.try_broadcasting(tx, 'force-close'))
def remove_channel(self, chan_id):
chan = self.channels[chan_id]
assert chan.can_be_deleted()
with self.lock:
self._channels.pop(chan_id)
self.db.get('channels').pop(chan_id.hex())
for addr in chan.get_wallet_addresses_channel_might_want_reserved():
self.wallet.set_reserved_state_of_address(addr, reserved=False)
util.trigger_callback('channels_updated', self.wallet)
util.trigger_callback('wallet_updated', self.wallet)
@ignore_exceptions
@log_exceptions
async def reestablish_peer_for_given_channel(self, chan: Channel) -> None:
now = time.time()
peer_addresses = []
if self.uses_trampoline():
addr = trampolines_by_id().get(chan.node_id)
if addr:
peer_addresses.append(addr)
else:
# will try last good address first, from gossip
last_good_addr = self.channel_db.get_last_good_address(chan.node_id)
if last_good_addr:
peer_addresses.append(last_good_addr)
# will try addresses for node_id from gossip
addrs_from_gossip = self.channel_db.get_node_addresses(chan.node_id) or []
for host, port, ts in addrs_from_gossip:
peer_addresses.append(LNPeerAddr(host, port, chan.node_id))
# will try addresses stored in channel storage
peer_addresses += list(chan.get_peer_addresses())
# Done gathering addresses.
# Now select first one that has not failed recently.
for peer in peer_addresses:
if self._can_retry_addr(peer, urgent=True, now=now):
await self._add_peer(peer.host, peer.port, peer.pubkey)
return
async def reestablish_peers_and_channels(self):
while True:
await asyncio.sleep(1)
if self.stopping_soon:
return
if self.config.ZEROCONF_TRUSTED_NODE:
peer = LNPeerAddr.from_str(self.config.ZEROCONF_TRUSTED_NODE)
if self._can_retry_addr(peer, urgent=True):
await self._add_peer(peer.host, peer.port, peer.pubkey)
for chan in self.channels.values():
# reestablish
# note: we delegate filtering out uninteresting chans to this:
if not chan.should_try_to_reestablish_peer():
continue
peer = self._peers.get(chan.node_id, None)
if peer:
await peer.taskgroup.spawn(peer.reestablish_channel(chan))
else:
await self.taskgroup.spawn(self.reestablish_peer_for_given_channel(chan))
def current_target_feerate_per_kw(self) -> int:
from .simple_config import FEE_LN_ETA_TARGET, FEERATE_FALLBACK_STATIC_FEE
from .simple_config import FEERATE_PER_KW_MIN_RELAY_LIGHTNING
if constants.net is constants.BitcoinRegtest:
feerate_per_kvbyte = self.network.config.FEE_EST_STATIC_FEERATE
else:
feerate_per_kvbyte = self.network.config.eta_target_to_fee(FEE_LN_ETA_TARGET)
if feerate_per_kvbyte is None:
feerate_per_kvbyte = FEERATE_FALLBACK_STATIC_FEE
return max(FEERATE_PER_KW_MIN_RELAY_LIGHTNING, feerate_per_kvbyte // 4)
def current_low_feerate_per_kw(self) -> int:
from .simple_config import FEE_LN_LOW_ETA_TARGET
from .simple_config import FEERATE_PER_KW_MIN_RELAY_LIGHTNING
if constants.net is constants.BitcoinRegtest:
feerate_per_kvbyte = 0
else:
feerate_per_kvbyte = self.network.config.eta_target_to_fee(FEE_LN_LOW_ETA_TARGET) or 0
low_feerate_per_kw = max(FEERATE_PER_KW_MIN_RELAY_LIGHTNING, feerate_per_kvbyte // 4)
# make sure this is never higher than the target feerate:
low_feerate_per_kw = min(low_feerate_per_kw, self.current_target_feerate_per_kw())
return low_feerate_per_kw
def create_channel_backup(self, channel_id: bytes):
chan = self._channels[channel_id]
# do not backup old-style channels
assert chan.is_static_remotekey_enabled()
peer_addresses = list(chan.get_peer_addresses())
peer_addr = peer_addresses[0]
return ImportedChannelBackupStorage(
node_id = chan.node_id,
privkey = self.node_keypair.privkey,
funding_txid = chan.funding_outpoint.txid,
funding_index = chan.funding_outpoint.output_index,
funding_address = chan.get_funding_address(),
host = peer_addr.host,
port = peer_addr.port,
is_initiator = chan.constraints.is_initiator,
channel_seed = chan.config[LOCAL].channel_seed,
local_delay = chan.config[LOCAL].to_self_delay,
remote_delay = chan.config[REMOTE].to_self_delay,
remote_revocation_pubkey = chan.config[REMOTE].revocation_basepoint.pubkey,
remote_payment_pubkey = chan.config[REMOTE].payment_basepoint.pubkey,
local_payment_pubkey=chan.config[LOCAL].payment_basepoint.pubkey,
)
def export_channel_backup(self, channel_id):
xpub = self.wallet.get_fingerprint()
backup_bytes = self.create_channel_backup(channel_id).to_bytes()
assert backup_bytes == ImportedChannelBackupStorage.from_bytes(backup_bytes).to_bytes(), "roundtrip failed"
encrypted = pw_encode_with_version_and_mac(backup_bytes, xpub)
assert backup_bytes == pw_decode_with_version_and_mac(encrypted, xpub), "encrypt failed"
return 'channel_backup:' + encrypted
async def request_force_close(self, channel_id: bytes, *, connect_str=None) -> None:
if channel_id in self.channels:
chan = self.channels[channel_id]
peer = self._peers.get(chan.node_id)
chan.should_request_force_close = True
if peer:
peer.close_and_cleanup() # to force a reconnect
elif connect_str:
peer = await self.add_peer(connect_str)
await peer.request_force_close(channel_id)
elif channel_id in self.channel_backups:
await self._request_force_close_from_backup(channel_id)
else:
raise Exception(f'Unknown channel {channel_id.hex()}')
def import_channel_backup(self, data):
xpub = self.wallet.get_fingerprint()
cb_storage = ImportedChannelBackupStorage.from_encrypted_str(data, password=xpub)
channel_id = cb_storage.channel_id()
if channel_id.hex() in self.db.get_dict("channels"):
raise Exception('Channel already in wallet')
self.logger.info(f'importing channel backup: {channel_id.hex()}')
d = self.db.get_dict("imported_channel_backups")
d[channel_id.hex()] = cb_storage
with self.lock:
cb = ChannelBackup(cb_storage, lnworker=self)
self._channel_backups[channel_id] = cb
self.wallet.save_db()
util.trigger_callback('channels_updated', self.wallet)
self.lnwatcher.add_channel(cb.funding_outpoint.to_str(), cb.get_funding_address())
def has_conflicting_backup_with(self, remote_node_id: bytes):
""" Returns whether we have an active channel with this node on another device, using same local node id. """
channel_backup_peers = [
cb.node_id for cb in self.channel_backups.values()
if (not cb.is_closed() and cb.get_local_pubkey() == self.node_keypair.pubkey)]
return any(remote_node_id.startswith(cb_peer_nodeid) for cb_peer_nodeid in channel_backup_peers)
def remove_channel_backup(self, channel_id):
chan = self.channel_backups[channel_id]
assert chan.can_be_deleted()
found = False
onchain_backups = self.db.get_dict("onchain_channel_backups")
imported_backups = self.db.get_dict("imported_channel_backups")
if channel_id.hex() in onchain_backups:
onchain_backups.pop(channel_id.hex())
found = True
if channel_id.hex() in imported_backups:
imported_backups.pop(channel_id.hex())
found = True
if not found:
raise Exception('Channel not found')
with self.lock:
self._channel_backups.pop(channel_id)
self.wallet.save_db()
util.trigger_callback('channels_updated', self.wallet)
@log_exceptions
async def _request_force_close_from_backup(self, channel_id: bytes):
cb = self.channel_backups.get(channel_id)
if not cb:
raise Exception(f'channel backup not found {self.channel_backups}')
cb = cb.cb # storage
self.logger.info(f'requesting channel force close: {channel_id.hex()}')
if isinstance(cb, ImportedChannelBackupStorage):
node_id = cb.node_id
privkey = cb.privkey
addresses = [(cb.host, cb.port, 0)]
else:
assert isinstance(cb, OnchainChannelBackupStorage)
privkey = self.node_keypair.privkey
for pubkey, peer_addr in trampolines_by_id().items():
if pubkey.startswith(cb.node_id_prefix):
node_id = pubkey
addresses = [(peer_addr.host, peer_addr.port, 0)]
break
else:
# we will try with gossip (see below)
addresses = []
async def _request_fclose(addresses):
for host, port, timestamp in addresses:
peer_addr = LNPeerAddr(host, port, node_id)
transport = LNTransport(privkey, peer_addr, e_proxy=ESocksProxy.from_network_settings(self.network))
peer = Peer(self, node_id, transport, is_channel_backup=True)
try:
async with OldTaskGroup(wait=any) as group:
await group.spawn(peer._message_loop())
await group.spawn(peer.request_force_close(channel_id))
return True
except Exception as e:
self.logger.info(f'failed to connect {host} {e}')
continue
else:
return False
# try first without gossip db
success = await _request_fclose(addresses)
if success:
return
# try with gossip db
if self.uses_trampoline():
raise Exception(_('Please enable gossip'))
node_id = self.network.channel_db.get_node_by_prefix(cb.node_id_prefix)
addresses_from_gossip = self.network.channel_db.get_node_addresses(node_id)
if not addresses_from_gossip:
raise Exception('Peer not found in gossip database')
success = await _request_fclose(addresses_from_gossip)
if not success:
raise Exception('failed to connect')
def maybe_add_backup_from_tx(self, tx):
funding_address = None
node_id_prefix = None
for i, o in enumerate(tx.outputs()):
script_type = get_script_type_from_output_script(o.scriptpubkey)
if script_type == 'p2wsh':
funding_index = i
funding_address = o.address
for o2 in tx.outputs():
if o2.scriptpubkey.startswith(bytes([opcodes.OP_RETURN])):
encrypted_data = o2.scriptpubkey[2:]
data = self.decrypt_cb_data(encrypted_data, funding_address)
if data.startswith(CB_MAGIC_BYTES):
node_id_prefix = data[4:]
if node_id_prefix is None:
return
funding_txid = tx.txid()
cb_storage = OnchainChannelBackupStorage(
node_id_prefix = node_id_prefix,
funding_txid = funding_txid,
funding_index = funding_index,
funding_address = funding_address,
is_initiator = True)
channel_id = cb_storage.channel_id().hex()
if channel_id in self.db.get_dict("channels"):
return
self.logger.info(f"adding backup from tx")
d = self.db.get_dict("onchain_channel_backups")
d[channel_id] = cb_storage
cb = ChannelBackup(cb_storage, lnworker=self)
self.wallet.save_db()
with self.lock:
self._channel_backups[bfh(channel_id)] = cb
util.trigger_callback('channels_updated', self.wallet)
self.lnwatcher.add_channel(cb.funding_outpoint.to_str(), cb.get_funding_address())
def save_forwarding_failure(
self, payment_key:str, *,
error_bytes: Optional[bytes] = None,
failure_message: Optional['OnionRoutingFailure'] = None):
error_hex = error_bytes.hex() if error_bytes else None
failure_hex = failure_message.to_bytes().hex() if failure_message else None
self.forwarding_failures[payment_key] = (error_hex, failure_hex)
def get_forwarding_failure(self, payment_key: str) -> Tuple[Optional[bytes], Optional['OnionRoutingFailure']]:
error_hex, failure_hex = self.forwarding_failures.get(payment_key, (None, None))
error_bytes = bytes.fromhex(error_hex) if error_hex else None
failure_message = OnionRoutingFailure.from_bytes(bytes.fromhex(failure_hex)) if failure_hex else None
return error_bytes, failure_message