diff --git a/electrum/bolt12.py b/electrum/bolt12.py new file mode 100644 index 000000000000..bd8a53fcbb2e --- /dev/null +++ b/electrum/bolt12.py @@ -0,0 +1,747 @@ +# -*- coding: utf-8 -*- +# +# Electrum - lightweight Bitcoin client +# Copyright (C) 2025 The Electrum developers +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import copy +import io +import time +from dataclasses import dataclass, field, asdict, fields +from functools import cached_property +import re +from typing import Optional, Tuple, Iterable, Type, TypeVar, Any, ClassVar +from abc import ABC, abstractmethod + +import electrum_ecc as ecc + +from . import constants +from .util import chunks +from .lnmsg import OnionWireSerializer, write_bigsize_int, read_bigsize_int +from .lnutil import LnFeatures, validate_features, MIN_FINAL_CLTV_DELTA_ACCEPTED, NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE, LnFeatureContexts +from .onion_message import BlindedPath, BlindedPayInfo +from .segwit_addr import ( + bech32_decode, convertbits, bech32_encode, Encoding, INVALID_BECH32, + CHARSET as BECH32_CHARSET, encode_segwit_address, +) + + +DEFAULT_INVOICE_EXPIRY = 7200 + + +TBOLT12Base = TypeVar("TBOLT12Base", bound="BOLT12Base") + + +@dataclass(frozen=True, kw_only=True) +class BOLT12Base(ABC): + tlv_stream_name: ClassVar[str] + signing_key_path: ClassVar[Optional[tuple[str, ...]]] + hrp: ClassVar[str] # human-readable part of the bech32 encoded string + _unknown_fields: dict[str, Any] = field(default_factory=dict) + + @classmethod + def decode(cls: Type[TBOLT12Base], data: str | bytes) -> TBOLT12Base: + d = bolt12_bech32_to_bytes(data) if isinstance(data, str) else data + with io.BytesIO(d) as fd: + protocol_dict = OnionWireSerializer.read_tlv_stream( + fd=fd, + tlv_stream_name=cls.tlv_stream_name, + signing_key_path=cls.signing_key_path, + ) + return cls.deserialize(protocol_dict) + + def encode(self, *, signing_key: bytes = None, as_bech32: bool = False) -> str | bytes: + if self.signing_key_path: + # if no signing_key is passed we keep the existing signature, else a new one is created + assert signing_key or any(f.name.endswith('_signature') and getattr(self, f.name) for f in fields(self)) + else: + assert signing_key is None, "cannot sign offer" + + data = self.serialize(with_signature=False if signing_key else True) + with io.BytesIO() as fd: + OnionWireSerializer.write_tlv_stream( + fd=fd, + tlv_stream_name=self.tlv_stream_name, + signing_key=signing_key, + **data, + ) + if not as_bech32: + return fd.getvalue() + return bolt12_tlv_bytes_to_bech32(fd.getvalue(), type(self)) + + _ENCODE_MAP = {} + def serialize(self, *, with_signature: bool = False) -> dict: + protocol_dict = copy.deepcopy(self._unknown_fields) + for f in fields(self): + if f.name.startswith('_'): + continue + if f.name.endswith('_signature') and not with_signature: + continue + value = getattr(self, f.name) + if value is None: + continue + if isinstance(value, LnFeatures) and value == LnFeatures(0): + continue + key = 'signature' if f.name.endswith('_signature') else f.name + protocol_dict[key] = self._ENCODE_MAP[f.name](value) + return protocol_dict + + @classmethod + @abstractmethod + def deserialize(cls: Type[TBOLT12Base], protocol_dict: dict) -> TBOLT12Base: + pass + + @property + def is_expired(self) -> bool: + now = int(time.time()) + expiry_time = None + if type(self) == BOLT12Invoice: + expiry_time = self.invoice_created_at + self.invoice_relative_expiry + elif type(self) == BOLT12Offer: + expiry_time = self.offer_absolute_expiry + return now > expiry_time if expiry_time is not None else False + + +@dataclass(frozen=True, kw_only=True) +class BOLT12Offer(BOLT12Base): + """ + https://github.com/lightning/bolts/blob/34455ffe28b308dd7ac7552234d565890af8605b/12-offer-encoding.md?plain=1#L182 + """ + tlv_stream_name = 'offer' + signing_key_path = None # offers are not signed + hrp = 'lno' + + offer_features: Optional[LnFeatures] = None + offer_chains: Optional[list[bytes]] = None + offer_metadata: Optional[bytes] = None + offer_currency: Optional[str] = None + offer_amount: Optional[int] = None + offer_description: Optional[str] = None + offer_absolute_expiry: Optional[int] = None + offer_paths: Optional[tuple[BlindedPath, ...]] = None + offer_issuer: Optional[str] = None + offer_quantity_max: Optional[int] = None + offer_issuer_id: Optional[bytes] = None + + def __post_init__(self): + # if the chain for the invoice is not solely bitcoin: + # MUST specify offer_chains the offer is valid for. + if not matches_our_chain(self.offer_chains): + # instance might be offerless invreq, invreq __post_init__ chain check has priority + if type(self) == BOLT12Offer and 'invreq_chain' not in self._unknown_fields: + raise NoMatchingChainError() + if self.offer_chains is not None and not self.offer_chains: + raise ValueError('empty offer_chains') + # if offer_features contains unknown even bits that are non-zero: MUST NOT respond to the offer + if self.offer_features: + validate_features(self.offer_features, context=LnFeatureContexts.BOLT12_OFFER) + # if offer_amount is set and offer_description is not set: MUST NOT respond to the offer + if self.offer_amount is not None: + if self.offer_amount <= 0: # MUST set `offer_amount` greater than zero. + raise ValueError(f"offer amount must be > 0") + if self.offer_description is None: + raise ValueError('missing offer_description, but has offer_amount') + # if offer_currency is set and offer_amount is not set: MUST NOT respond to the offer + if self.offer_currency is not None and self.offer_amount is None: + raise ValueError('missing offer_amount, but has offer_currency') + # if neither offer_issuer_id nor offer_paths are set: MUST NOT respond to the offer + if not self.offer_issuer_id and not self.offer_paths: + # instance can be offerless invreq, or instantiated in BOLT12InvoiceRequest.deserialize() + if not getattr(self, 'invreq_payer_id', self._unknown_fields.get('invreq_payer_id')): + raise ValueError('neither offer_issuer_id nor offer_paths are given') + if self.offer_issuer_id is not None: + ecc.ECPubkey(b=self.offer_issuer_id) + + @classmethod + def deserialize(cls, o: dict) -> 'BOLT12Offer': + o = copy.deepcopy(o) + if (offer_features := o.pop('offer_features', {}).get('features')) is not None: + offer_features = LnFeatures(int.from_bytes(offer_features, byteorder="big", signed=False)) + if (offer_chains := o.pop('offer_chains', {}).get('chains')) is not None: + offer_chains = [chain for chain in chunks(offer_chains, 32)] + if (offer_paths := o.pop('offer_paths', {}).get('paths')) is not None: + offer_paths = tuple(BlindedPath.from_dict(p) for p in offer_paths) + + return BOLT12Offer( + offer_chains=offer_chains, + offer_metadata=o.pop('offer_metadata', {}).get('data'), + offer_currency=o.pop('offer_currency', {}).get('iso4217'), + offer_amount=o.pop('offer_amount', {}).get('amount'), + offer_description=o.pop('offer_description', {}).get('description'), + offer_features=offer_features, + offer_absolute_expiry=o.pop('offer_absolute_expiry', {}).get('seconds_from_epoch'), + offer_paths=offer_paths, + offer_issuer=o.pop('offer_issuer', {}).get('issuer'), + offer_quantity_max=o.pop('offer_quantity_max', {}).get('max'), + offer_issuer_id=o.pop('offer_issuer_id', {}).get('id'), + _unknown_fields=o, + ) + + _ENCODE_MAP = BOLT12Base._ENCODE_MAP | { + 'offer_chains': lambda v: {'chains': b''.join(v)}, + 'offer_metadata': lambda v: {'data': v}, + 'offer_currency': lambda v: {'iso4217': v}, + 'offer_amount': lambda v: {'amount': v}, + 'offer_description': lambda v: {'description': v}, + 'offer_features': lambda v: {'features': v.to_tlv_bytes()}, + 'offer_absolute_expiry': lambda v: {'seconds_from_epoch': v}, + 'offer_paths': lambda v: {'paths': [asdict(p) for p in v]}, + 'offer_issuer': lambda v: {'issuer': v}, + 'offer_quantity_max': lambda v: {'max': v}, + 'offer_issuer_id': lambda v: {'id': v}, + } + + +@dataclass(frozen=True, kw_only=True) +class BOLT12InvoiceRequest(BOLT12Offer): + """ + https://github.com/lightning/bolts/blob/34455ffe28b308dd7ac7552234d565890af8605b/12-offer-encoding.md?plain=1#L357 + """ + tlv_stream_name = 'invoice_request' + signing_key_path = ('invreq_payer_id', 'key') + hrp = 'lnr' + + invreq_metadata: bytes + invreq_chain: Optional[bytes] = None + invreq_amount: Optional[int] = None + invreq_features: Optional[LnFeatures] = None + invreq_quantity: Optional[int] = None + invreq_payer_id: bytes + invreq_payer_note: Optional[str] = None + invreq_paths: Optional[tuple[BlindedPath, ...]] = None + invreq_bip_353_name: Optional[Tuple[str, str]] = None # name, domain + invreq_signature: Optional[bytes] = None # sig for incoming req is validated in OnionWireSerializer + + def __post_init__(self): + super().__post_init__() + # MUST reject the invoice request if invreq_payer_id or invreq_metadata are not present + if not self.invreq_payer_id or not self.invreq_metadata: + raise ValueError(f"{bool(self.invreq_payer_id)=} or {bool(self.invreq_metadata)=} missing") + if self.invreq_features: + validate_features(self.invreq_features, context=LnFeatureContexts.BOLT12_INVREQ) + # if offer_issuer_id or offer_paths are present (response to an offer): + if self.offer_issuer_id or self.offer_paths: + # if offer_quantity_max is present: + if self.offer_quantity_max is not None: + if self.invreq_quantity is None: + # MUST reject the invoice request if there is no invreq_quantity field. + raise ValueError(f"{self.offer_quantity_max} is given but no invreq_quantity") + # if offer_quantity_max is non-zero: + if self.offer_quantity_max: + # MUST reject the invoice request if invreq_quantity is zero, OR greater than offer_quantity_max + if not self.invreq_quantity or self.invreq_quantity > self.offer_quantity_max: + raise ValueError(f"{self.invreq_quantity=} is zero or greater than offer_quantity_max") + else: + # otherwise: MUST reject the invoice request if there is an invreq_quantity field + if self.invreq_quantity is not None: + raise ValueError("invreq_quantity given but no offer_quantity_max") + # if offer_amount is present: + if (expected_amount := self.offer_amount) is not None: + # MUST calculate the expected amount using the offer_amount + if self.offer_currency and self.offer_currency.upper() != 'BTC': + # TODO: if offer_currency is not the invreq_chain currency, convert to the invreq_chain currency + # also adapt invoice_amount_msat property below + raise NotImplementedError("no fx conversion support yet, will this be used?") + # if invreq_quantity is present, multiply by invreq_quantity.quantity + if self.invreq_quantity: + # NOTE: not allowing self.invreq_quantity of 0 here, this seems unsafe? + expected_amount *= self.invreq_quantity + # if invreq_amount is present + if self.invreq_amount is not None: + # MUST reject the invoice request if invreq_amount.msat is less than the expected amount. + if self.invreq_amount < expected_amount: + raise ValueError(f"{self.invreq_amount=} < {expected_amount=}") + # MAY reject the invoice request if invreq_amount.msat greatly exceeds the expected amount + elif self.invreq_amount > int(expected_amount * 1.5): + raise ValueError(f"{self.invreq_amount=} > {int(expected_amount * 1.5)=}") + # otherwise (no offer_amount): + else: + # MUST reject the invoice request if it does not contain invreq_amount + if self.invreq_amount is None: + raise ValueError("no offer_amount and no invreq_amount") + # otherwise (no offer_issuer_id or offer_paths, not a response to our offer): + else: + # MUST reject the invoice request if any of the following are present: + if self.offer_chains is not None or self.offer_features is not None or self.offer_quantity_max is not None: + raise ValueError("offer_chains, offer_features or offer_quantity_max present") + # MUST reject the invoice request if invreq_amount is not present + if self.invreq_amount is None: + raise ValueError("invreq_amount missing") + if not matches_our_chain([self.invreq_chain] if self.invreq_chain else None): + raise NoMatchingChainError() + if self.invreq_bip_353_name is not None: + name, domain = self.invreq_bip_353_name + if not validate_bip_353_name(name, domain): + raise ValueError(f"invalid bip 353 name: {self.invreq_bip_353_name}") + + @property + def invoice_amount_msat(self) -> int: + # this relies on the __post_init__ validation + if isinstance(self, BOLT12Invoice): + return self.invoice_amount + assert isinstance(self, BOLT12InvoiceRequest) + if self.invreq_amount is not None: + return self.invreq_amount + expected_amount = self.offer_amount + assert expected_amount is not None + if self.invreq_quantity: + expected_amount *= self.invreq_quantity + return expected_amount + + @classmethod + def deserialize(cls, ir: dict) -> 'BOLT12InvoiceRequest': + ir = copy.deepcopy(ir) + offer = BOLT12Offer.deserialize(ir) + d = offer._unknown_fields + if (invreq_features := d.pop('invreq_features', {}).get('features')) is not None: + invreq_features = LnFeatures(int.from_bytes(invreq_features, byteorder="big", signed=False)) + if (invreq_paths := d.pop('invreq_paths', {}).get('paths')) is not None: + invreq_paths = tuple(BlindedPath.from_dict(p) for p in invreq_paths) + if invreq_bip_353_name := d.pop('invreq_bip_353_name', None): + name, domain = invreq_bip_353_name['name'], invreq_bip_353_name['domain'] + invreq_bip_353_name = (name, domain) + + offer_fields = {f.name: getattr(offer, f.name) for f in fields(BOLT12Offer) if not f.name.startswith('_')} + + return BOLT12InvoiceRequest( + **offer_fields, + invreq_metadata=d.pop('invreq_metadata', {}).get('blob'), + invreq_chain=d.pop('invreq_chain', {}).get('chain'), + invreq_amount=d.pop('invreq_amount', {}).get('msat'), + invreq_features=invreq_features, + invreq_quantity=d.pop('invreq_quantity', {}).get('quantity'), + invreq_payer_id=d.pop('invreq_payer_id', {}).get('key'), + invreq_payer_note=d.pop('invreq_payer_note', {}).get('note'), + invreq_paths=invreq_paths, + invreq_bip_353_name=invreq_bip_353_name, + invreq_signature=d.pop('signature', {}).get('sig'), + _unknown_fields=d, + ) + + _ENCODE_MAP = BOLT12Offer._ENCODE_MAP | { + 'invreq_metadata': lambda v: {'blob': v}, + 'invreq_chain': lambda v: {'chain': v}, + 'invreq_amount': lambda v: {'msat': v}, + 'invreq_features': lambda v: {'features': v.to_tlv_bytes()}, + 'invreq_quantity': lambda v: {'quantity': v}, + 'invreq_payer_id': lambda v: {'key': v}, + 'invreq_payer_note': lambda v: {'note': v}, + 'invreq_paths': lambda v: {'paths': [asdict(p) for p in v]}, + 'invreq_bip_353_name': lambda v: {'name': v}, + 'invreq_signature': lambda v: {'sig': v}, + } + + +@dataclass(frozen=True, kw_only=True) +class BOLT12Invoice(BOLT12InvoiceRequest): + tlv_stream_name = 'invoice' + signing_key_path = ('invoice_node_id', 'node_id') + hrp = 'lni' + + invoice_paths: tuple[BlindedPath, ...] + invoice_blindedpay: tuple[BlindedPayInfo, ...] + invoice_created_at: int + invoice_relative_expiry: int = DEFAULT_INVOICE_EXPIRY + invoice_payment_hash: bytes + invoice_amount: int + invoice_fallbacks: Optional[tuple[dict]] = None + invoice_features: Optional[LnFeatures] = None + invoice_node_id: bytes + invoice_signature: Optional[bytes] = None + + def __post_init__(self): + super().__post_init__() + # MUST reject the invoice if invoice_amount is not present + if self.invoice_amount is None: + raise ValueError("invoice_amount missing") + # MUST reject the invoice if invoice_created_at is not present + if self.invoice_created_at is None: + raise ValueError("invoice_created_at missing") + elif self.invoice_created_at > int(time.time()) + 100: + raise ValueError(f"invoice_created_at in the future: {self.invoice_created_at}") + # MUST reject the invoice if invoice_payment_hash is not present + if self.invoice_payment_hash is None: + raise ValueError("invoice_payment_hash missing") + # MUST reject the invoice if invoice_node_id is not present + if self.invoice_node_id is None: + raise ValueError("invoice_node_id missing") + if self.invoice_features: + validate_features(self.invoice_features, context=LnFeatureContexts.BOLT12_INVOICE) + # MUST reject the invoice if invoice_paths is not present or is empty + if not self.invoice_paths: + raise ValueError("invoice_paths missing or empty") + # MUST reject the invoice if invoice_blindedpay is not present. + if not self.invoice_blindedpay: + raise ValueError("invoice_blindedpay missing or empty") + # MUST reject the invoice if invoice_blindedpay does not contain exactly one blinded_payinfo per invoice_paths.blinded_path. + if len(self.invoice_blindedpay) != len(self.invoice_paths): + raise ValueError("invoice_blindedpay length mismatch") + if all(payinfo.requires_unknown_mandatory_features for payinfo in self.invoice_blindedpay): + # MUST reject the invoice if this leaves no usable paths. + raise ValueError("no payinfo with sane features") + if any(p.cltv_expiry_delta > NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE for p in self.invoice_blindedpay): + raise ValueError(f"Invoice wants us to risk locking funds for unreasonably long: {self.invoice_blindedpay}") + # if offer_issuer_id is present (invoice_request for an offer): + if self.offer_issuer_id is not None: + # MUST reject the invoice if invoice_node_id is not equal to offer_issuer_id + if self.invoice_node_id != self.offer_issuer_id: + raise ValueError(f"{self.offer_issuer_id.hex()=} != {self.invoice_node_id.hex()=}") + # otherwise, if offer_paths is present (invoice_request for an offer without id): + elif self.offer_paths is not None: + # MUST reject the invoice if invoice_node_id is not equal to the final blinded_node_id it sent the invoice request to. + # NOTE: check is less strict than the spec, but doesn't require us to keep state to + # which blinded_node_id we sent the invreq to. The benefit is we can (always, implicitly) check it here. + if not any(p.path[-1].blinded_node_id == self.invoice_node_id for p in self.offer_paths): + raise ValueError(f"{self.invoice_node_id=} doesn't match any last blinded node id of offer paths") + if self.invreq_amount is not None: + if self.invoice_amount != self.invreq_amount: + raise ValueError("invoice_amount != invreq_amount") + + @classmethod + def deserialize(cls, inv: dict) -> 'BOLT12Invoice': + inv = copy.deepcopy(inv) + invoice_signature = inv.pop('signature', {}).get('sig') + invreq = BOLT12InvoiceRequest.deserialize(inv) + d = invreq._unknown_fields + + if (invoice_features := d.pop('invoice_features', {}).get('features')) is not None: + invoice_features = LnFeatures(int.from_bytes(invoice_features, byteorder="big", signed=False)) + if (invoice_paths := d.pop('invoice_paths', {}).get('paths')) is not None: + invoice_paths = tuple(BlindedPath.from_dict(p) for p in invoice_paths) + if (invoice_blindedpay := d.pop('invoice_blindedpay', {}).get('payinfo')) is not None: + invoice_blindedpay = tuple(BlindedPayInfo.from_dict(p) for p in invoice_blindedpay) + if (invoice_fallbacks := d.pop('invoice_fallbacks', {}).get('fallbacks')) is not None: + invoice_fallbacks = tuple(invoice_fallbacks) + + parent_fields = {f.name: getattr(invreq, f.name) for f in fields(BOLT12InvoiceRequest) if not f.name.startswith('_')} + + return BOLT12Invoice( + **parent_fields, + invoice_paths=invoice_paths, + invoice_blindedpay=invoice_blindedpay, + invoice_created_at=d.pop('invoice_created_at', {}).get('timestamp'), + invoice_relative_expiry=d.pop('invoice_relative_expiry', {}).get('seconds_from_creation', DEFAULT_INVOICE_EXPIRY), + invoice_payment_hash=d.pop('invoice_payment_hash', {}).get('payment_hash'), + invoice_amount=d.pop('invoice_amount', {}).get('msat'), + invoice_fallbacks=invoice_fallbacks, + invoice_features=invoice_features, + invoice_node_id=d.pop('invoice_node_id', {}).get('node_id'), + invoice_signature=invoice_signature, + _unknown_fields=d + ) + + _ENCODE_MAP = BOLT12InvoiceRequest._ENCODE_MAP | { + 'invoice_paths': lambda v: {'paths': [asdict(p) for p in v]}, + 'invoice_blindedpay': lambda v: {'payinfo': [p.to_dict() for p in v]}, + 'invoice_created_at': lambda v: {'timestamp': v}, + 'invoice_relative_expiry': lambda v: {'seconds_from_creation': v}, + 'invoice_payment_hash': lambda v: {'payment_hash': v}, + 'invoice_amount': lambda v: {'msat': v}, + 'invoice_fallbacks': lambda v: {'fallbacks': list(v)}, + 'invoice_features': lambda v: {'features': v.to_tlv_bytes()}, + 'invoice_node_id': lambda v: {'node_id': v}, + 'invoice_signature': lambda v: {'sig': v}, + } + + @cached_property + def fallback_address(self) -> Optional[str]: + fallbacks = self.invoice_fallbacks or () + for fba in fallbacks: + version_bytes, witprog = fba.get('version'), fba.get('address', b'') + if version_bytes is not None and 2 <= len(witprog) <= 40: + version = int.from_bytes(version_bytes, signed=False, byteorder='big') + if version <= 16: + address = encode_segwit_address(constants.net.SEGWIT_HRP, version, witprog) + return address + return None + + +def extract_shared_fields(source_instance: BOLT12Invoice | BOLT12InvoiceRequest, target_class: type[TBOLT12Base]) -> TBOLT12Base: + """ + Allows to extract the fields of a subclass from the given instance, + e.g. all Offer fields from a given invoice request instance. + """ + return target_class(**{ + f.name: getattr(source_instance, f.name) + for f in fields(target_class) if not f.name.startswith('_') + }) + + +def is_offer(data: str) -> bool: + try: + data = remove_bolt12_whitespace(data) + except ValueError: + return False + d = bech32_decode(data, ignore_long_length=True, with_checksum=False) + if d == INVALID_BECH32: + return False + return d.hrp == 'lno' + + +def matches_our_chain(chains: Optional[Iterable[bytes]]) -> bool: + # chains is a 32 bytes record list stored in a single bytes object (see TODO above lnmsg._read_field) + if not chains: + # empty chains is indicative of only Bitcoin mainnet + return True if constants.net == constants.BitcoinMainnet else False + our_chain_hash = constants.net.rev_genesis_bytes() + return our_chain_hash in chains + + +def bolt12_bech32_to_bytes(data: str) -> bytes: + data = remove_bolt12_whitespace(data) + d = bech32_decode(data, ignore_long_length=True, with_checksum=False) + if d == INVALID_BECH32: + raise ValueError(f"Failed to bech32 decode: {data[:64]=}...") + d = convertbits(d.data, 5, 8, pad=False) + if d is None: + raise ValueError(f"Invalid bech32 data: {data[:64]=}...") + return bytes(d) + + +def bolt12_tlv_bytes_to_bech32(bolt12_tlv: bytes, bolt12_type: type[BOLT12Base]) -> str: + bech32_data = convertbits(list(bolt12_tlv), 8, 5, True) + return bech32_encode(Encoding.BECH32, bolt12_type.hrp, bech32_data, with_checksum=False) + + +@dataclass(frozen=True, kw_only=True) +class BOLT12InvoicePathIDPayload: + """ + Payment information embedded into the BOLT12Invoice blinded path's path_id so we can hand out invoices + statelessly and reconstruct the full payment context when the actual HTLCs arrive. + + TODO: If this is too large some fields might need to be removed (esp the descriptions texts). + We could also cache some less important things like the description + in memory, assuming that the Invoice is usually paid right after being requested. + A filled path_id payload can reach ~270 bytes realistically. + """ + VERSION: ClassVar[bytes] = b'\x01' + + amount_msat: int + created_at: int + relative_expiry: int + payment_preimage: bytes + min_final_cltv_expiry_delta: int + invoice_features: LnFeatures + payer_id: bytes + offer_metadata_digest: Optional[bytes] = None # allows us to associate the payment with an offer (if we'd persist/cache offers) + quantity: Optional[int] = None + payer_note: Optional[str] = None + description: Optional[str] = None + + def __post_init__(self): # some sanity checks + assert isinstance(self.payment_preimage, bytes) and len(self.payment_preimage) == 32, self.payment_preimage + assert isinstance(self.payer_id, bytes) and len(self.payer_id) == 33, self.payer_id + assert self.amount_msat and self.created_at and self.relative_expiry, (self.amount_msat, self.created_at, self.relative_expiry) + assert self.min_final_cltv_expiry_delta >= MIN_FINAL_CLTV_DELTA_ACCEPTED + validate_features(self.invoice_features, context=LnFeatureContexts.BOLT12_INVOICE) + + def encode(self) -> bytes: + flags = 0 + if self.quantity is not None: + assert self.quantity >= 0 + flags |= 0b0001 + if self.offer_metadata_digest is not None: # we could truncate it to save some space? + assert isinstance(self.offer_metadata_digest, bytes) and len(self.offer_metadata_digest) == 32 + flags |= 0b0010 + + payer_note = self.payer_note[:64] if self.payer_note is not None else None + description = self.description[:64] if self.description is not None else None + if payer_note is not None: + flags |= 0b0100 + if description is not None: + flags |= 0b1000 + + with io.BytesIO() as fd: + fd.write(self.VERSION) + fd.write(self.payment_preimage) + fd.write(self.payer_id) + fd.write(write_bigsize_int(self.amount_msat)) + fd.write(write_bigsize_int(self.created_at)) + fd.write(write_bigsize_int(self.relative_expiry)) + fd.write(write_bigsize_int(self.min_final_cltv_expiry_delta)) + features_bytes = self.invoice_features.to_tlv_bytes() + fd.write(write_bigsize_int(len(features_bytes))) + fd.write(features_bytes) + fd.write(bytes([flags])) + if self.quantity is not None: + fd.write(write_bigsize_int(self.quantity)) + if self.offer_metadata_digest is not None: + fd.write(self.offer_metadata_digest) + if payer_note is not None: + payer_note_bytes = payer_note.encode('utf-8') + fd.write(write_bigsize_int(len(payer_note_bytes))) + fd.write(payer_note_bytes) + if description is not None: + description_bytes = description.encode('utf-8') + fd.write(write_bigsize_int(len(description_bytes))) + fd.write(description_bytes) + return fd.getvalue() + + @classmethod + def decode(cls, data: bytes) -> 'BOLT12InvoicePathIDPayload': + """Might raise ValueError on invalid data""" + with io.BytesIO(data) as fd: + version = fd.read(1) + if version != cls.VERSION: + raise ValueError(f"unsupported version: {version!r}") + + payment_preimage = fd.read(32) + if len(payment_preimage) != 32: + raise ValueError("path_id truncated: payment_preimage") + payer_id = fd.read(33) + if len(payer_id) != 33: + raise ValueError("path_id truncated: payer_id") + + amount_msat = read_bigsize_int(fd) + created_at = read_bigsize_int(fd) + relative_expiry = read_bigsize_int(fd) + min_final_cltv_expiry_delta = read_bigsize_int(fd) + if not amount_msat or not created_at or not relative_expiry or not min_final_cltv_expiry_delta: + raise ValueError("path_id truncated: amount_msat, created_at, relative_expiry or min_final_cltv_expiry_delta") + + features_len = read_bigsize_int(fd) + if features_len is None: + raise ValueError("path_id truncated: features_len") + features_bytes = fd.read(features_len) + if len(features_bytes) != features_len: + raise ValueError("path_id truncated: invoice_features") + invoice_features = LnFeatures(int.from_bytes(features_bytes, byteorder="big", signed=False)) + + flags_byte = fd.read(1) + if len(flags_byte) != 1: + raise ValueError("path_id truncated: flags") + flags = flags_byte[0] + + quantity: Optional[int] = None + offer_metadata_digest: Optional[bytes] = None + payer_note: Optional[str] = None + description: Optional[str] = None + + if flags & 0b0001: + quantity = read_bigsize_int(fd) + if quantity is None: + raise ValueError("path_id truncated: quantity") + if flags & 0b0010: + offer_metadata_digest = fd.read(32) + if len(offer_metadata_digest) != 32: + raise ValueError("path_id truncated: offer_metadata_digest") + if flags & 0b0100: + note_len = read_bigsize_int(fd) + if note_len is None: + raise ValueError("path_id truncated: payer_note_len") + note_bytes = fd.read(note_len) + if len(note_bytes) != note_len: + raise ValueError("path_id truncated: payer_note") + payer_note = note_bytes.decode('utf-8') + if flags & 0b1000: + desc_len = read_bigsize_int(fd) + if desc_len is None: + raise ValueError("path_id truncated: description_len") + desc_bytes = fd.read(desc_len) + if len(desc_bytes) != desc_len: + raise ValueError("path_id truncated: description") + description = desc_bytes.decode('utf-8') + + if fd.read(1): + raise ValueError("trailing bytes in path_id?") + + return cls( + amount_msat=amount_msat, + created_at=created_at, + relative_expiry=relative_expiry, + payment_preimage=payment_preimage, + min_final_cltv_expiry_delta=min_final_cltv_expiry_delta, + invoice_features=invoice_features, + payer_id=payer_id, + offer_metadata_digest=offer_metadata_digest, + quantity=quantity, + payer_note=payer_note, + description=description, + ) + + +# offer/request/invoice uses different chain than we do +class NoMatchingChainError(Exception): pass + + +# wraps invoice_error +class Bolt12InvoiceError(Exception): + def __init__(self, msg: str, *, erroneous_field: Optional[int] = None, suggested_value: Optional[bytes] = None): + assert msg + assert suggested_value is None if erroneous_field is None else True + + super().__init__(self, msg) + self.message = msg + self.erroneous_field = erroneous_field + self.suggested_value = suggested_value + + @classmethod + def from_tlv(cls, tlv: bytes) -> 'Bolt12InvoiceError': + try: + with io.BytesIO(tlv) as fd: + invoice_error = OnionWireSerializer.read_tlv_stream(fd=fd, tlv_stream_name='invoice_error') + except Exception: + return cls(msg="malformed invoice error") + return cls( + msg=invoice_error.get('error', {}).get('msg', "received invoice_error without message"), + erroneous_field=invoice_error.get('erroneous_field', {}).get('tlv_fieldnum'), + suggested_value=invoice_error.get('suggested_value', {}).get('value'), + ) + + def to_tlv(self): + data = {'error': {'msg': self.message}} + if self.erroneous_field is not None: + data.update({'erroneous_field': {'tlv_fieldnum': self.erroneous_field}}) + if self.suggested_value is not None: + data.update({'suggested_value': {'value': self.suggested_value}}) + with io.BytesIO() as fd: + OnionWireSerializer.write_tlv_stream(fd=fd, tlv_stream_name='invoice_error', **data) + return fd.getvalue() + + +def remove_bolt12_whitespace(bolt12_bech32: str) -> str: + """ + Readers of a bolt12 string: + if it encounters a + followed by zero or more whitespace characters between two bech32 characters: + MUST remove the + and whitespace. + """ + assert isinstance(bolt12_bech32, str) + res = re.sub( + r'(?<=[' + BECH32_CHARSET + r'])\+\s*(?=[' + BECH32_CHARSET + r'])', + '', + bolt12_bech32, + flags=re.IGNORECASE, + ) + if '+' in res: + raise ValueError('Invalid bolt 12 whitespace') + return res + + +def validate_bip_353_name(name: str, domain: str) -> bool: + """ + MUST reject the (invoice request) if name or domain contain any bytes + which are not 0-9, a-z, A-Z, -, _ or . + """ + for s in (name, domain): + if not re.match(r'^[a-zA-Z0-9._-]+$', s): + return False + return True diff --git a/electrum/commands.py b/electrum/commands.py index 37686f9c9a49..89b458231a15 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -25,6 +25,7 @@ import io import sys import datetime +import dataclasses import time import argparse import json @@ -43,15 +44,18 @@ import electrum_ecc as ecc -from . import util +from . import util, bolt12 +from .bolt12 import BOLT12Invoice, BOLT12Offer, BOLT12InvoiceRequest from .lnmsg import OnionWireSerializer from .lnworker import LN_P2P_NETWORK_TIMEOUT from .logging import Logger from .onion_message import create_blinded_path, send_onion_message_to +from .lnonion import BlindedPath +from .segwit_addr import INVALID_BECH32 from .submarine_swaps import NostrTransport from .util import ( bfh, json_decode, json_normalize, is_hash256_str, is_hex_str, to_bytes, parse_max_spend, to_decimal, - UserFacingException, InvalidPassword + UserFacingException, InvalidPassword, json_encode ) from . import bitcoin from .bitcoin import is_address, hash_160, COIN @@ -1384,6 +1388,73 @@ async def add_request(self, amount, memo='', expiry=3600, lightning=False, force req = wallet.get_request(key) return wallet.export_request(req) + @command('wnl') + async def pay_bolt12_offer( + self, + offer: str, + amount: Optional[Decimal] = None, + payer_note: Optional[str] = None, + wallet: Abstract_Wallet = None + ): + """Retrieve an invoice from a bolt12 offer, and pay that invoice + + arg:str:offer:bolt-12 offer (bech32) + arg:decimal:amount:Amount to send + arg:str:payer_note:Text note for the recipient + """ + amount_msat = satoshis(amount) * 1000 if amount else None + bolt12_offer = bolt12.BOLT12Offer.decode(offer) + offer_amount_msat = bolt12_offer.offer_amount + if offer_amount_msat and amount_msat and offer_amount_msat != amount_msat: + raise ValueError(f"{amount_msat=} different than {offer_amount_msat=}") + send_amount = offer_amount_msat or amount_msat + if not send_amount: + raise ValueError(f"Missing amount to send: {amount_msat=}") + lnworker = wallet.lnworker + bolt12_invoice, invoice_tlv = await lnworker.request_bolt12_invoice( + bolt12_offer, + amount_msat=send_amount, + payer_note=payer_note, + ) + invoice_bech32 = bolt12.bolt12_tlv_bytes_to_bech32(invoice_tlv, bolt12.BOLT12Invoice) + invoice = Invoice.from_bech32(invoice_bech32) + success, log = await lnworker.pay_invoice(invoice) + return { + 'success': success, + 'log': [x.formatted_tuple() for x in log] + } + + @command('wnl') + async def add_lightning_offer( + self, + amount: Optional[Decimal] = None, + description: Optional[str] = None, + relative_expiry: Optional[int] = None, + issuer_name: Optional[str] = None, + allow_unblinded: bool = False, + wallet: Abstract_Wallet = None + ): + """Create a bolt12 offer. + + arg:decimal:amount:Requested amount (in btc) + arg:str:description:Description of the request + arg:int:relative_expiry:Time in seconds. + arg:str:issuer_name:Issuer name string + arg:bool:allow_unblinded:Allow revealing node id for unblinded offers + """ + amount_msat = satoshis(amount) * 1000 if amount else None + offer = wallet.lnworker.create_offer( + amount_msat=amount_msat, + description=description, + relative_expiry=relative_expiry, + issuer_name=issuer_name, + allow_unblinded=allow_unblinded, + ) + + return { + 'offer': offer.encode(as_bech32=True) + } + @command('wnl') async def add_hold_invoice( self, @@ -2265,6 +2336,8 @@ async def send_onion_message(self, node_id_or_blinded_path_hex: str, message: st node_id_or_blinded_path = bfh(node_id_or_blinded_path_hex) assert len(node_id_or_blinded_path) >= 33 + if len(node_id_or_blinded_path) > 33: # assume blinded path + node_id_or_blinded_path = BlindedPath.decode(node_id_or_blinded_path) destination_payload = { 'message': {'text': message.encode('utf-8')} @@ -2308,11 +2381,27 @@ async def get_blinded_path_via(self, node_id: str, dummy_hops: int = 0, wallet: fd=blinded_path_fd, field_type='blinded_path', count=1, - value=blinded_path) + value=dataclasses.asdict(blinded_path)) encoded_blinded_path = blinded_path_fd.getvalue() return encoded_blinded_path.hex() + @command('') + async def decode_bolt12(self, bech32: str): + """Decode bolt12 object + + arg:str:bech32:Offer, Invoice Request or Invoice + """ + dec = bolt12.bech32_decode(bech32, ignore_long_length=True, with_checksum=False) + if dec == INVALID_BECH32: + raise UserFacingException('invalid bech32') + d = { + 'lni': BOLT12Invoice.decode, + 'lno': BOLT12Offer.decode, + 'lnr': BOLT12InvoiceRequest.decode, + }[dec.hrp](bech32) + return json_encode(d.serialize(with_signature=True)) + def plugin_command(s, plugin_name): """Decorator to register a cli command inside a plugin. To be used within a commands.py file diff --git a/electrum/gui/icons/bolt12.png b/electrum/gui/icons/bolt12.png new file mode 100644 index 000000000000..e56d45e27bd9 Binary files /dev/null and b/electrum/gui/icons/bolt12.png differ diff --git a/electrum/gui/qml/components/Bolt12OfferDialog.qml b/electrum/gui/qml/components/Bolt12OfferDialog.qml new file mode 100644 index 000000000000..811d172fd174 --- /dev/null +++ b/electrum/gui/qml/components/Bolt12OfferDialog.qml @@ -0,0 +1,176 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import QtQuick.Controls.Material + +import org.electrum 1.0 + +import "controls" + +ElDialog { + id: dialog + + title: qsTr('Lightning Offer') + iconSource: '../../../icons/lightning.png' + + property var invoiceParser // type: InvoiceParser + + padding: 0 + + property bool commentValid: note.text.length <= 64 + property bool amountValid: amountBtc.textAsSats.satsInt > 0 && amountBtc.textAsSats.satsInt <= Daemon.currentWallet.lightningCanSend.satsInt + property bool valid: commentValid && amountValid + + ColumnLayout { + width: parent.width + + spacing: 0 + + GridLayout { + id: rootLayout + columns: 2 + + Layout.fillWidth: true + Layout.leftMargin: constants.paddingLarge + Layout.rightMargin: constants.paddingLarge + Layout.bottomMargin: constants.paddingLarge + + // qml quirk; first cells cannot colspan without messing up the grid width + Item { Layout.fillWidth: true; Layout.preferredWidth: 1; Layout.preferredHeight: 1 } + Item { Layout.fillWidth: true; Layout.preferredWidth: 1; Layout.preferredHeight: 1 } + + Label { + Layout.columnSpan: 2 + Layout.topMargin: constants.paddingSmall + text: qsTr('Issuer') + color: Material.accentColor + visible: 'issuer' in invoiceParser.offerData + } + TextHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + visible: 'issuer' in invoiceParser.offerData + leftPadding: constants.paddingMedium + Label { + width: parent.width + wrapMode: Text.Wrap + elide: Text.ElideRight + font.pixelSize: constants.fontSizeXLarge + text: 'issuer' in invoiceParser.offerData ? invoiceParser.offerData['issuer'] : '' + } + } + Label { + Layout.columnSpan: 2 + Layout.fillWidth: true + Layout.topMargin: constants.paddingSmall + text: qsTr('Description') + color: Material.accentColor + visible: 'description' in invoiceParser.offerData + } + TextHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + visible: 'description' in invoiceParser.offerData + leftPadding: constants.paddingMedium + Label { + width: parent.width + text: 'description' in invoiceParser.offerData ? invoiceParser.offerData['description'] : '' + wrapMode: Text.Wrap + font.pixelSize: constants.fontSizeXLarge + } + } + Label { + Layout.columnSpan: 2 + Layout.topMargin: constants.paddingSmall + text: qsTr('Amount') + color: Material.accentColor + } + + DialogHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + + ColumnLayout { + width: parent.width + spacing: constants.paddingSmall + + RowLayout { + Layout.fillWidth: true + BtcField { + id: amountBtc + Layout.preferredWidth: rootLayout.width / 3 + text: 'amount_msat' in invoiceParser.offerData + ? Config.formatSatsForEditing(invoiceParser.offerData['amount_msat'] / 1000) + : '' + readOnly: 'amount_msat' in invoiceParser.offerData + // accent color for fixed-amount offers; also overrides gray-out on disabled + color: readOnly ? Material.accentColor : Material.foreground + fiatfield: amountFiat + onTextAsSatsChanged: { + if (textAsSats) + invoiceParser.amountOverride = textAsSats + } + } + Label { + text: Config.baseUnit + color: Material.accentColor + } + } + + RowLayout { + Layout.fillWidth: true + visible: Daemon.fx.enabled + FiatField { + id: amountFiat + Layout.preferredWidth: rootLayout.width / 3 + btcfield: amountBtc + readOnly: btcfield.readOnly + } + Label { + text: Daemon.fx.fiatCurrency + color: Material.accentColor + } + } + } + } + + RowLayout { + Layout.columnSpan: 2 + Layout.fillWidth: true + Layout.topMargin: constants.paddingSmall + Label { + text: qsTr('Note') + color: Material.accentColor + } + Item { Layout.fillWidth: true } + Label { + text: note.text.length + '/64' + font.pixelSize: constants.fontSizeSmall + color: commentValid ? constants.mutedForeground : constants.colorError + } + } + ElTextArea { + id: note + Layout.columnSpan: 2 + Layout.fillWidth: true + Layout.minimumHeight: 100 + wrapMode: TextEdit.Wrap + placeholderText: qsTr('Enter an (optional) message for the receiver') + color: commentValid ? Material.foreground : constants.colorError + } + } + + FlatButton { + Layout.topMargin: constants.paddingLarge + Layout.fillWidth: true + text: qsTr('Request') + icon.source: '../../icons/confirmed.png' + enabled: valid + onClicked: { + invoiceParser.requestInvoiceFromOffer(note.text) + dialog.close() + } + } + } + +} diff --git a/electrum/gui/qml/components/InvoiceDialog.qml b/electrum/gui/qml/components/InvoiceDialog.qml index 3b036a7cda66..dea2b26fbd33 100644 --- a/electrum/gui/qml/components/InvoiceDialog.qml +++ b/electrum/gui/qml/components/InvoiceDialog.qml @@ -17,7 +17,9 @@ ElDialog { signal doPay signal invoiceAmountChanged - title: invoice.invoiceType == Invoice.OnchainInvoice ? qsTr('On-chain Invoice') : qsTr('Lightning Invoice') + title: invoice.invoiceType == Invoice.OnchainInvoice + ? qsTr('On-chain Invoice') + : qsTr('Lightning Invoice') iconSource: Qt.resolvedUrl('../../icons/tab_send.png') padding: 0 @@ -107,6 +109,30 @@ ElDialog { } } + Label { + Layout.columnSpan: 2 + Layout.topMargin: constants.paddingSmall + text: qsTr('Issuer') + visible: 'issuer' in invoice.lnprops && invoice.lnprops.issuer != '' + color: Material.accentColor + } + + TextHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + + visible: 'issuer' in invoice.lnprops && invoice.lnprops.issuer != '' + leftPadding: constants.paddingMedium + + Label { + text: 'issuer' in invoice.lnprops ? invoice.lnprops.issuer : '' + width: parent.width + font.pixelSize: constants.fontSizeXLarge + wrapMode: Text.Wrap + elide: Text.ElideRight + } + } + Label { Layout.columnSpan: 2 Layout.topMargin: constants.paddingSmall @@ -423,6 +449,36 @@ ElDialog { } } + Label { + Layout.columnSpan: 2 + Layout.topMargin: constants.paddingSmall + visible: 'blinded_paths' in invoice.lnprops && invoice.lnprops.blinded_paths.length + text: qsTr('Blinded paths') + color: Material.accentColor + } + + Repeater { + visible: 'blinded_paths' in invoice.lnprops && invoice.lnprops.blinded_paths.length + model: invoice.lnprops.blinded_paths + + DialogHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + + RowLayout { + width: parent.width + + Label { + Layout.fillWidth: true + text: qsTr('via %1 (%2 hops)') + .arg(modelData.first_node) + .arg(modelData.path_length) + wrapMode: Text.Wrap + } + } + } + } + Label { Layout.columnSpan: 2 Layout.topMargin: constants.paddingSmall diff --git a/electrum/gui/qml/components/ReceiveDetailsDialog.qml b/electrum/gui/qml/components/ReceiveDetailsDialog.qml index 964bdd2d0e85..fb9615d0c478 100644 --- a/electrum/gui/qml/components/ReceiveDetailsDialog.qml +++ b/electrum/gui/qml/components/ReceiveDetailsDialog.qml @@ -18,6 +18,7 @@ ElDialog { property alias description: message.text property alias expiry: expires.currentValue property bool isLightning: false + property bool isOffer: false padding: 0 needsSystemBarPadding: false @@ -112,6 +113,13 @@ ElDialog { > amountBtc.textAsSats.satsInt || Daemon.currentWallet.canGetZeroconfChannel) text: qsTr('Lightning') icon.source: '../../icons/lightning.png' + pressAndHoldIndicator: true + onPressAndHold: { + // long-press creates a reusable Lightning offer instead of a single invoice + AppController.haptic() + dialog.isOffer = true + doAccept() + } onClicked: { if (Daemon.currentWallet.lightningCanReceive.satsInt > amountBtc.textAsSats.satsInt) { // can receive on existing channel diff --git a/electrum/gui/qml/components/ReceiveDialog.qml b/electrum/gui/qml/components/ReceiveDialog.qml index b7ebf3cc942c..3cdfe3043838 100644 --- a/electrum/gui/qml/components/ReceiveDialog.qml +++ b/electrum/gui/qml/components/ReceiveDialog.qml @@ -11,10 +11,15 @@ import "controls" ElDialog { id: dialog - title: qsTr('Receive Payment') + title: isOffer ? qsTr('Lightning Offer') : qsTr('Receive Payment') iconSource: Qt.resolvedUrl('../../icons/tab_receive.png') property string key + // when 'offer' is set, the dialog shows a reusable Lightning offer instead of a request + property string offer: '' + readonly property bool isOffer: offer !== '' + property var offerAmountSat: 0 + property string offerMessage: '' property bool isLightning: request.isLightning property string _bolt11: request.bolt11 @@ -94,10 +99,12 @@ ElDialog { Layout.alignment: Qt.AlignHCenter Label { + visible: !dialog.isOffer text: qsTr('Status') color: Material.accentColor } Label { + visible: !dialog.isOffer text: request.status_str } Label { @@ -178,9 +185,11 @@ ElDialog { : _bip21uri ? _bip21uri : _address, - _bolt11 || _bip21uri - ? qsTr('Payment Request') - : qsTr('Onchain address') + dialog.isOffer + ? qsTr('Lightning Offer') + : _bolt11 || _bip21uri + ? qsTr('Payment Request') + : qsTr('Onchain address') ) enabled = true } @@ -203,7 +212,11 @@ ElDialog { } Component.onCompleted: { - request.key = dialog.key + if (dialog.isOffer) { + request.setOffer(dialog.offer, dialog.offerAmountSat, dialog.offerMessage) + } else { + request.key = dialog.key + } } // hack. delay qr rendering until dialog is shown diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index 55f73519db45..918898a82b4e 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -44,7 +44,7 @@ Item { // Android based send dialog if on android var scanner = app.scanDialog.createObject(mainView, { hint: Daemon.currentWallet.isLightning - ? qsTr('Scan an Invoice, an Address, an LNURL, a PSBT or a Channel Backup') + ? qsTr('Scan an Invoice, an Address, an Offer, a LNURL, a PSBT or a Channel Backup') : qsTr('Scan an Invoice, an Address, an LNURL or a PSBT') }) scanner.onFoundText.connect(function(data) { @@ -136,6 +136,11 @@ Item { Daemon.currentWallet.createRequest(qamt, _request_description, _request_expiry, lightning, reuse_address) } + function createOffer() { + var qamt = Config.unitsToSats(_request_amount) + Daemon.currentWallet.createOffer(qamt, _request_description, _request_expiry) + } + function startSweep() { var dialog = sweepDialog.createObject(app) dialog.accepted.connect(function() { @@ -438,6 +443,20 @@ Item { }) dialog.open() } + onBolt12Offer: { + closeSendDialog() + var dialog = bolt12OfferDialog.createObject(app, { + invoiceParser: invoiceParser + }) + dialog.open() + } + onBolt12Invoice: { + closeSendDialog() + var dialog = invoiceDialog.createObject(app, { + invoice: invoiceParser + }) + dialog.open() + } } Bitcoin { @@ -479,6 +498,25 @@ Item { }) dialog.open() } + function onOfferCreateSuccess(offer) { + // reuse the same receive view as bolt11 requests, in offer mode + var qamt = Config.unitsToSats(_request_amount) + var dialog = receiveDialog.createObject(app, { + offer: offer, + offerAmountSat: qamt.satsInt, + offerMessage: _request_description + }) + dialog.open() + } + function onOfferCreateError(error) { + console.log(error) + var dialog = app.messageDialog.createObject(app, { + title: qsTr('Error'), + iconSource: Qt.resolvedUrl('../../icons/warning.png'), + text: error + }) + dialog.open() + } function onOtpRequested() { console.log('OTP requested') var dialog = otpDialog.createObject(mainView) @@ -628,7 +666,11 @@ Item { _request_amount = _receiveDetailsDialog.amount _request_description = _receiveDetailsDialog.description _request_expiry = _receiveDetailsDialog.expiry - createRequest(_receiveDetailsDialog.isLightning, false) + if (_receiveDetailsDialog.isOffer) { + createOffer() + } else { + createRequest(_receiveDetailsDialog.isLightning, false) + } } onRejected: { console.log('rejected') @@ -748,6 +790,16 @@ Item { } } + Component { + id: bolt12OfferDialog + Bolt12OfferDialog { + width: parent.width * 0.9 + anchors.centerIn: parent + + onClosed: destroy() + } + } + Component { id: otpDialog OtpDialog { diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index 7069663f3441..9ab26fe65524 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -6,26 +6,26 @@ from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, pyqtEnum, QTimer, QVariant +from electrum.bolt12 import BOLT12Offer from electrum.i18n import _ from electrum.logging import get_logger +from electrum.util import InvoiceError, event_listener from electrum.invoices import ( Invoice, PR_UNPAID, PR_EXPIRED, PR_UNKNOWN, PR_PAID, PR_INFLIGHT, PR_FAILED, PR_ROUTING, PR_UNCONFIRMED, - PR_BROADCASTING, PR_BROADCAST, LN_EXPIRY_NEVER + PR_BROADCASTING, PR_BROADCAST, LN_EXPIRY_NEVER, ) from electrum.transaction import PartialTxOutput, TxOutput from electrum.lnutil import format_short_channel_id from electrum.lnurl import LNURL6Data -from electrum.bitcoin import COIN, address_to_script -from electrum.payment_identifier import PaymentIdentifier, PaymentIdentifierState, PaymentIdentifierType -from electrum.network import Network -from electrum.util import event_listener - +from electrum.bitcoin import address_to_script +from electrum.payment_identifier import ( + PaymentIdentifier, PaymentIdentifierState, PaymentIdentifierType, invoice_from_payment_identifier +) from electrum.gui.common_qt.util import QtEventListener from .qetypes import QEAmount from .qewallet import QEWallet from .util import status_update_timer_interval -from ...util import InvoiceError class QEInvoice(QObject, QtEventListener): @@ -35,6 +35,7 @@ class Type(IntEnum): OnchainInvoice = 0 LightningInvoice = 1 LNURLPayRequest = 2 + Bolt12Offer = 3 @pyqtEnum class Status(IntEnum): @@ -144,11 +145,11 @@ def expiration(self): return self._effectiveInvoice.exp if self._effectiveInvoice else 0 @pyqtProperty(str, notify=invoiceChanged) - def address(self): + def address(self) -> str: return self._effectiveInvoice.get_address() if self._effectiveInvoice else '' @pyqtProperty(QEAmount, notify=invoiceChanged) - def amount(self): + def amount(self) -> QEAmount: if not self._effectiveInvoice: self._amount.clear() return self._amount @@ -248,24 +249,41 @@ def set_lnprops(self): if not self.invoiceType == QEInvoice.Type.LightningInvoice: return - lnaddr = self._effectiveInvoice._lnaddr - ln_routing_info = lnaddr.get_routing_info('r') - self._logger.debug(str(ln_routing_info)) - - self._lnprops = { - 'pubkey': lnaddr.pubkey.serialize().hex(), - 'payment_hash': lnaddr.paymenthash.hex(), - 'r': [{ - 'node': self.name_for_node_id(x[-1][0]), - 'scid': format_short_channel_id(x[-1][1]) - } for x in ln_routing_info] if ln_routing_info else [] + lnaddr = self._effectiveInvoice + + lnprops = { + 'is_bolt12': bool(lnaddr.bolt12_invoice), + 'payment_hash': lnaddr.rhash, } + if pubkey := lnaddr.issuer_pubkey: + lnprops['pubkey'] = pubkey + + if b12i := lnaddr.bolt12_invoice: + paths = b12i.invoice_paths + lnprops['blinded_paths'] = [{ + 'first_node': self.name_for_node_id(x.first_node_id), + 'path_length': x.hop_count, + } for x in paths] + if issuer := b12i.offer_issuer: + lnprops['issuer'] = issuer + else: + ln_routing_info = lnaddr.bolt11_invoice.get_routing_info('r') + self._logger.debug(str(ln_routing_info)) + lnprops.update({ + 'r': [{ + 'node': self.name_for_node_id(x[-1][0]), + 'scid': format_short_channel_id(x[-1][1]) + } for x in ln_routing_info] if ln_routing_info else [] + }) + + self._lnprops = lnprops + def name_for_node_id(self, node_id): lnworker = self._wallet.wallet.lnworker return (lnworker.lnpeermgr.get_node_alias(node_id) if lnworker else None) or node_id.hex() - def set_effective_invoice(self, invoice: Invoice): + def set_effective_invoice(self, invoice: Optional[Invoice]): self._paid_in_this_session = False self._effectiveInvoice = invoice @@ -277,6 +295,8 @@ def set_effective_invoice(self, invoice: Invoice): else: self.setInvoiceType(QEInvoice.Type.OnchainInvoice) self._isSaved = self._wallet.wallet.get_invoice(invoice.get_id()) is not None + if not self._key: # unset if invoice is not saved and just parsed. We need this for tracking status updates + self._key = invoice.get_id() self.set_lnprops() @@ -366,8 +386,8 @@ def check_can_pay_amount(self, amount: QEAmount) -> Tuple[bool, Optional[str]]: assert self.status in [PR_UNPAID, PR_FAILED] if self.invoiceType == QEInvoice.Type.LightningInvoice: if self.get_max_spendable_lightning() * 1000 >= amount.msatsInt: - lnaddr = self._effectiveInvoice._lnaddr - if lnaddr.amount and amount.msatsInt < lnaddr.amount * COIN * 1000: + invoice_amount_msat = self._effectiveInvoice.amount_msat + if invoice_amount_msat and amount.msatsInt < invoice_amount_msat: return False, _('Cannot pay less than the amount specified in the invoice') else: return True, None @@ -447,6 +467,10 @@ class QEInvoiceParser(QEInvoice): lnurlRetrieved = pyqtSignal() lnurlError = pyqtSignal([str, str], arguments=['code', 'message']) + bolt12Offer = pyqtSignal() + bolt12InvReqError = pyqtSignal([str, str], arguments=['code', 'message']) + bolt12Invoice = pyqtSignal() + busyChanged = pyqtSignal() def __init__(self, parent=None): @@ -454,6 +478,7 @@ def __init__(self, parent=None): self._pi = None # type: Optional[PaymentIdentifier] self._lnurlData = None + self._offerData = None self._busy = False self.clear() @@ -464,6 +489,7 @@ def fromResolvedPaymentIdentifier(self, resolved_pi: PaymentIdentifier) -> None: self.amountOverride = QEAmount() if resolved_pi: assert not resolved_pi.need_resolve() + self.clear() self.validateRecipient(resolved_pi) @pyqtProperty('QVariantMap', notify=lnurlRetrieved) @@ -474,6 +500,14 @@ def lnurlData(self): def isLnurlPay(self): return self._lnurlData is not None + @pyqtProperty('QVariantMap', notify=bolt12Offer) + def offerData(self): + return self._offerData + + @pyqtProperty(bool, notify=bolt12Offer) + def isBolt12Offer(self): + return self._offerData is not None + @pyqtProperty(bool, notify=busyChanged) def busy(self): return self._busy @@ -481,7 +515,9 @@ def busy(self): @pyqtSlot() def clear(self): self.setInvoiceType(QEInvoice.Type.Invalid) + self._key = None self._lnurlData = None + self._offerData = None self.canSave = False self.canPay = False self.userinfo = '' @@ -506,6 +542,12 @@ def setValidLNURLPayRequest(self): self._effectiveInvoice = None self.invoiceChanged.emit() + def setValidBolt12Offer(self): + self._logger.debug('setValidBolt12Offer') + self.setInvoiceType(QEInvoice.Type.Bolt12Offer) + self._effectiveInvoice = None + self.invoiceChanged.emit() + def create_onchain_invoice(self, *, outputs, message, uri): return self._wallet.wallet.create_invoice( outputs=outputs, @@ -524,7 +566,7 @@ def validateRecipient(self, pi: PaymentIdentifier): PaymentIdentifierType.BOLT11, PaymentIdentifierType.LNADDR, PaymentIdentifierType.LNURLP, PaymentIdentifierType.EMAILLIKE, PaymentIdentifierType.DOMAINLIKE, - PaymentIdentifierType.OPENALIAS, + PaymentIdentifierType.OPENALIAS, PaymentIdentifierType.BOLT12_OFFER ]: self.validationError.emit('unknown', _('Unknown invoice')) return @@ -547,6 +589,10 @@ def _update_from_payment_identifier(self): self.on_lnurl_pay(self._pi.lnurl_data) return + if self._pi.type == PaymentIdentifierType.BOLT12_OFFER: + self.on_bolt12_offer(self._pi.bolt12_offer) + return + if self._pi.is_available(): if self._pi.type in [PaymentIdentifierType.SPK, PaymentIdentifierType.OPENALIAS]: outputs = [PartialTxOutput(scriptpubkey=self._pi.spk, value=0)] @@ -555,8 +601,8 @@ def _update_from_payment_identifier(self): self.setValidOnchainInvoice(invoice) self.validationSuccess.emit() return - elif self._pi.type == PaymentIdentifierType.BOLT11: - lninvoice = self._pi.bolt11 + elif self._pi.type in [PaymentIdentifierType.BOLT11]: + lninvoice = invoice_from_payment_identifier(self._pi, self._wallet.wallet) if not self._wallet.wallet.has_lightning() and not lninvoice.get_address(): self.validationError.emit('no_lightning', _('Detected valid Lightning invoice, but Lightning not enabled for wallet and no fallback address found.')) @@ -567,8 +613,8 @@ def _update_from_payment_identifier(self): self.setValidLightningInvoice(lninvoice) self.validationSuccess.emit() elif self._pi.type == PaymentIdentifierType.BIP21: - if self._wallet.wallet.has_lightning() and self._wallet.wallet.lnworker.channels and self._pi.bolt11: - lninvoice = self._pi.bolt11 + if self._wallet.wallet.has_lightning() and self._wallet.wallet.lnworker.channels and self._pi.lightning_invoice: + lninvoice = self._pi.lightning_invoice self.setValidLightningInvoice(lninvoice) self.validationSuccess.emit() else: @@ -605,6 +651,26 @@ def on_lnurl_pay(self, lnurldata: LNURL6Data): self.setValidLNURLPayRequest() self.lnurlRetrieved.emit() + def on_bolt12_offer(self, bolt12_offer: BOLT12Offer) -> None: + self._logger.debug(f'on_bolt12_offer: {bolt12_offer!r}') + # bolt 12 invoices can have fallback addresses, however we don't know until we have actually requested the invoice. + # so it is cleaner to not utilize them so we can validate the user input amount against num_sats_can_send in the GUI + if not self._wallet.wallet.has_lightning(): + self.validationError.emit( + 'no_lightning', + _('Found valid Lightning offer, but Lightning is not enabled for wallet.'), + ) + return + self._offerData = offer_data = {} + if description := bolt12_offer.offer_description: + offer_data['description'] = description + if amount := bolt12_offer.offer_amount: + offer_data['amount_msat'] = amount + if issuer_name := bolt12_offer.offer_issuer: + offer_data['issuer'] = issuer_name + self.setValidBolt12Offer() + self.bolt12Offer.emit() + @pyqtSlot() @pyqtSlot(str) def lnurlGetInvoice(self, comment=None): @@ -628,7 +694,7 @@ def on_finished(pi): else: self.lnurlError.emit('lnurl', pi.get_error()) else: - self.on_lnurl_invoice(self.amountOverride.satsInt, pi.bolt11) + self.on_lnurl_invoice(self.amountOverride.satsInt, pi.lightning_invoice) self._busy = True self.busyChanged.emit() @@ -648,6 +714,37 @@ def on_lnurl_invoice(self, orig_amount, invoice): PaymentIdentifier(self._wallet.wallet, invoice.lightning_invoice) ) + @pyqtSlot() + @pyqtSlot(str) + def requestInvoiceFromOffer(self, note: str = None): + assert self._offerData is not None + assert self._pi.need_finalize() + self._logger.debug(f'{self._offerData!r}') + + amount = self.amountOverride.satsInt + + def on_finished(pi: PaymentIdentifier): + self._busy = False + self.busyChanged.emit() + + if pi.is_error(): + if pi.state == PaymentIdentifierState.INVALID_AMOUNT: + self.bolt12InvReqError.emit('amount', pi.get_error()) + else: + self.bolt12InvReqError.emit('generic', pi.get_error()) + else: + self.on_bolt12_invoice(pi.lightning_invoice) + + self._busy = True + self.busyChanged.emit() + + self._pi.finalize(amount_sat=amount, comment=note, on_finished=on_finished) + + def on_bolt12_invoice(self, bolt12_invoice: Invoice): + self._logger.debug(f'on_bolt12_invoice {bolt12_invoice!r}') + self.set_effective_invoice(bolt12_invoice) + self.bolt12Invoice.emit() + @pyqtSlot(result=bool) def saveInvoice(self) -> bool: if not self._effectiveInvoice: diff --git a/electrum/gui/qml/qeinvoicelistmodel.py b/electrum/gui/qml/qeinvoicelistmodel.py index 551afb19fa84..6d2fce35a7b8 100644 --- a/electrum/gui/qml/qeinvoicelistmodel.py +++ b/electrum/gui/qml/qeinvoicelistmodel.py @@ -23,7 +23,7 @@ class QEAbstractInvoiceListModel(QAbstractListModel): # define listmodel rolemap _ROLE_NAMES=('key', 'is_lightning', 'timestamp', 'date', 'message', 'amount', 'status', 'status_str', 'address', 'expiry', 'type', 'onchain_fallback', - 'lightning_invoice') + 'lightning_invoice', 'is_bolt12') _ROLE_KEYS = range(Qt.ItemDataRole.UserRole, Qt.ItemDataRole.UserRole + len(_ROLE_NAMES)) _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES])) _ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS)) @@ -135,7 +135,7 @@ def invoice_to_model(self, invoice: BaseInvoice): item['date'] = format_time(item['timestamp']) item['amount'] = QEAmount(from_invoice=invoice) item['onchain_fallback'] = invoice.is_lightning() and bool(invoice.get_address()) - + item['is_bolt12'] = False return item def set_status_timer(self): @@ -195,9 +195,10 @@ def on_event_invoice_status(self, wallet, key, status): self._logger.debug(f'invoice status update for key {key} to {status}') self.updateInvoice(key, status) - def invoice_to_model(self, invoice: BaseInvoice): + def invoice_to_model(self, invoice: Invoice): item = super().invoice_to_model(invoice) item['type'] = 'invoice' + item['is_bolt12'] = bool(invoice.bolt12_invoice) return item @@ -230,7 +231,7 @@ def on_event_request_status(self, wallet, key, status): self._logger.debug(f'request status update for key {key} to {status}') self.updateRequest(key, status) - def invoice_to_model(self, invoice: BaseInvoice): + def invoice_to_model(self, invoice: Request): item = super().invoice_to_model(invoice) item['type'] = 'request' diff --git a/electrum/gui/qml/qerequestdetails.py b/electrum/gui/qml/qerequestdetails.py index 6a5b76730c89..b1e26099e793 100644 --- a/electrum/gui/qml/qerequestdetails.py +++ b/electrum/gui/qml/qerequestdetails.py @@ -50,6 +50,8 @@ def __init__(self, parent=None): self._req = None self._timer = None self._amount = None + self._offer = None # type: Optional[str] + self._offer_message = '' self._lnurlData = None # type: Optional[dict] self._busy = False @@ -101,6 +103,8 @@ def key(self, key): @pyqtProperty(int, notify=statusChanged) def status(self): + if not self._req: + return PR_UNPAID return self._wallet.wallet.get_invoice_status(self._req) @pyqtProperty(str, notify=statusChanged) @@ -109,6 +113,8 @@ def status_str(self): @pyqtProperty(bool, notify=detailsChanged) def isLightning(self): + if self._offer is not None: + return True return self._req.is_lightning() if self._req else False @pyqtProperty(str, notify=detailsChanged) @@ -118,6 +124,8 @@ def address(self): @pyqtProperty(str, notify=detailsChanged) def message(self): + if self._offer is not None: + return self._offer_message return self._req.get_message() if self._req else '' @pyqtProperty(QEAmount, notify=detailsChanged) @@ -144,6 +152,9 @@ def paidTxid(self): @pyqtProperty(str, notify=detailsChanged) def bolt11(self): + if self._offer is not None: + # in offer mode this field carries the offer string for the QR code / copy / share + return self._offer wallet = self._wallet.wallet if not wallet.lnworker: return '' @@ -188,6 +199,19 @@ def initRequest(self): self.statusChanged.emit() self.set_status_timer() + @pyqtSlot(str, 'qint64', str) + def setOffer(self, offer: str, amount_sat: int, message: str): + """Populate from a reusable Lightning offer instead of a persisted request, + so the same receive view used for bolt11 can also display offers. + Offers are not persisted (no key) and have no payment status.""" + self._offer = offer + self._offer_message = message or '' + if amount_sat: + self._amount = QEAmount(amount_sat=amount_sat, amount_msat=amount_sat * 1000) + else: + self._amount = QEAmount() + self.detailsChanged.emit() + def set_status_timer(self): if self.status == PR_UNPAID: if self.expiration > 0 and self.expiration != LN_EXPIRY_NEVER: diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index eeabf5261b43..37c30e3e82c8 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -17,6 +17,7 @@ InvalidPassword, event_listener, AddTransactionException, get_asyncio_loop, NotEnoughFunds, NoDynamicFeeEstimates ) from electrum.lnutil import MIN_FUNDING_SAT +from electrum.onion_message import NoOnionMessagePeers from electrum.plugin import run_hook from electrum.wallet import Multisig_Wallet from electrum.crypto import pw_decode_with_version_and_mac @@ -63,6 +64,8 @@ def getInstanceFor(cls, wallet): requestStatusChanged = pyqtSignal([str, int], arguments=['key', 'status']) requestCreateSuccess = pyqtSignal([str], arguments=['key']) requestCreateError = pyqtSignal([str], arguments=['error']) + offerCreateSuccess = pyqtSignal([str], arguments=['offer']) + offerCreateError = pyqtSignal([str], arguments=['error']) invoiceStatusChanged = pyqtSignal([str, int], arguments=['key', 'status']) invoiceCreateSuccess = pyqtSignal() invoiceCreateError = pyqtSignal([str, str], arguments=['code', 'error']) @@ -717,6 +720,27 @@ def createRequest(self, amount: QEAmount, message: str, expiration: int, lightni self.requestModel.add_invoice(self.wallet.get_request(key)) self.requestCreateSuccess.emit(key) + @pyqtSlot(QEAmount, str, int) + def createOffer(self, amount: QEAmount, message: str, expiration: int): + lnworker = self.wallet.lnworker + try: + amount_msat = amount.satsInt * 1000 if amount.satsInt > 0 else None + offer = lnworker.create_offer( + amount_msat=amount_msat, + description=message or None, + relative_expiry=expiration or None, + ) + offer_str = offer.encode(as_bech32=True).upper() + except NoOnionMessagePeers: + msg = [ + _('Cannot create a Lightning offer right now.'), + _('None of your channel peers support the private paths an offer needs.'), + ] + self.offerCreateError.emit(' '.join(msg)) + return + self._logger.debug('created offer') + self.offerCreateSuccess.emit(offer_str) + @pyqtSlot(str) def deleteRequest(self, key: str): self._logger.debug('delete req %s' % key) diff --git a/electrum/gui/qt/invoice_list.py b/electrum/gui/qt/invoice_list.py index dd8bfb35d298..c618b819a10f 100644 --- a/electrum/gui/qt/invoice_list.py +++ b/electrum/gui/qt/invoice_list.py @@ -111,7 +111,10 @@ def update(self): for idx, item in enumerate(self.wallet.get_unpaid_invoices()): key = item.get_id() if item.is_lightning(): - icon_name = 'lightning.png' + if item.bolt12_invoice: + icon_name = 'bolt12.png' + else: + icon_name = 'lightning.png' else: icon_name = 'bitcoin.png' status = self.wallet.get_invoice_status(item) diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 81b5ca9b3b79..04ea6c1720b9 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -50,7 +50,7 @@ import electrum from electrum.gui import messages from electrum import (keystore, constants, util, bitcoin, commands, - lnutil) + lnutil, bolt12) from electrum.bitcoin import COIN, is_address, DummyAddress from electrum.plugin import run_hook from electrum.i18n import _ @@ -1677,15 +1677,15 @@ def show_onchain_invoice(self, invoice: Invoice): d.exec() def show_lightning_invoice(self, invoice: Invoice): - from electrum.util import format_short_id - lnaddr = decode_bolt11_invoice(invoice.lightning_invoice) + assert invoice.is_lightning() # bolt11 or bolt12 d = WindowModalDialog(self, _("Lightning Invoice")) vbox = QVBoxLayout(d) grid = QGridLayout() - pubkey_e = ShowQRLineEdit(lnaddr.pubkey.serialize().hex(), self.config, title=_("Public Key")) - pubkey_e.setMinimumWidth(700) - grid.addWidget(QLabel(_("Public Key") + ':'), 0, 0) - grid.addWidget(pubkey_e, 0, 1) + if pubkey := invoice.issuer_pubkey: + pubkey_e = ShowQRLineEdit(pubkey, self.config, title=_("Public Key")) + pubkey_e.setMinimumWidth(700) + grid.addWidget(QLabel(_("Public Key") + ':'), 0, 0) + grid.addWidget(pubkey_e, 0, 1) grid.addWidget(QLabel(_("Amount") + ':'), 1, 0) amount_str = self.format_amount(invoice.get_amount_sat()) + ' ' + self.base_unit() grid.addWidget(QLabel(amount_str), 1, 1) @@ -1697,11 +1697,11 @@ def show_lightning_invoice(self, invoice: Invoice): grid.addWidget(QLabel(_("Expiration time") + ':'), 4, 0) grid.addWidget(QLabel(format_time(invoice.time + invoice.exp)), 4, 1) grid.addWidget(QLabel(_('Features') + ':'), 5, 0) - grid.addWidget(QLabel(', '.join(lnaddr.get_features().get_names())), 5, 1) - payhash_e = ShowQRLineEdit(lnaddr.paymenthash.hex(), self.config, title=_("Payment Hash")) + grid.addWidget(QLabel(', '.join(invoice.features.get_names())), 5, 1) + payhash_e = ShowQRLineEdit(invoice.rhash, self.config, title=_("Payment Hash")) grid.addWidget(QLabel(_("Payment Hash") + ':'), 6, 0) grid.addWidget(payhash_e, 6, 1) - fallback = lnaddr.get_fallback_address() + fallback = invoice.get_lightning_fallback_address() if fallback: fallback_e = ShowQRLineEdit(fallback, self.config, title=_("Fallback address")) grid.addWidget(QLabel(_("Fallback address") + ':'), 7, 0) @@ -1712,12 +1712,14 @@ def show_lightning_invoice(self, invoice: Invoice): invoice_e.setText(invoice.lightning_invoice) grid.addWidget(QLabel(_('Text') + ':'), 8, 0) grid.addWidget(invoice_e, 8, 1) - r_tags = lnaddr.get_routing_info('r') - r_tags = '\n'.join(repr(r) for r in BOLT11Addr.format_bolt11_routing_info_as_human_readable(r_tags)) - routing_e = QTextEdit(str(r_tags)) - routing_e.setReadOnly(True) - grid.addWidget(QLabel(_("Routing Hints") + ':'), 9, 0) - grid.addWidget(routing_e, 9, 1) + if b11 := invoice.bolt11_invoice: + # TODO: show path/introduction point for b12? + r_tags = b11.get_routing_info('r') + r_tags = '\n'.join(repr(r) for r in BOLT11Addr.format_bolt11_routing_info_as_human_readable(r_tags)) + routing_e = QTextEdit(str(r_tags)) + routing_e.setReadOnly(True) + grid.addWidget(QLabel(_("Routing Hints") + ':'), 9, 0) + grid.addWidget(routing_e, 9, 1) vbox.addLayout(grid) vbox.addLayout(Buttons(CloseButton(d),)) d.exec() diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index cdb97cceb76d..a746b937404c 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -432,7 +432,8 @@ def update_fields(self): lock_recipient = pi.type in [PaymentIdentifierType.LNURL, PaymentIdentifierType.LNURLW, PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR, PaymentIdentifierType.OPENALIAS, - PaymentIdentifierType.BIP21, PaymentIdentifierType.BOLT11] and not pi.need_resolve() + PaymentIdentifierType.BIP21, PaymentIdentifierType.BOLT11, + PaymentIdentifierType.BOLT12_OFFER] and not pi.need_resolve() lock_amount = pi.is_amount_locked() lock_max = lock_amount or pi.type not in [PaymentIdentifierType.SPK, PaymentIdentifierType.BIP21] @@ -474,8 +475,9 @@ def update_fields(self): amount_valid = is_spk_script or bool(self.amount_e.get_amount()) self.send_button.setEnabled(not pi_unusable and amount_valid and not pi.has_expired()) - self.save_button.setEnabled(not pi_unusable and not is_spk_script and not pi.has_expired() and \ - pi.type not in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR]) + self.save_button.setEnabled(not pi_unusable and not is_spk_script and pi.type not in [ \ + PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR, PaymentIdentifierType.BOLT12_OFFER + ]) self.invoice_error.setText(_('Expired') if pi.has_expired() else '') @@ -562,7 +564,7 @@ def on_finalize_done(self, pi: PaymentIdentifier): if pi.error: self.show_error(pi.error) return - invoice = pi.bolt11 + invoice = invoice_from_payment_identifier(pi, self.wallet) self.pending_invoice = invoice self.logger.debug(f'after finalize invoice: {invoice!r}') self.do_pay_invoice(invoice) diff --git a/electrum/invoices.py b/electrum/invoices.py index 55ad4fc5f0b9..5a41c0f971c2 100644 --- a/electrum/invoices.py +++ b/electrum/invoices.py @@ -1,6 +1,6 @@ import time -from typing import TYPE_CHECKING, List, Optional, Union, Dict, Any, Sequence -from decimal import Decimal +from functools import cached_property +from typing import List, Optional, Union, Dict, Any, Sequence import attr @@ -8,11 +8,11 @@ from .i18n import _ from .util import age, InvoiceError, format_satoshis from .bip21 import create_bip21_uri -from .lnutil import hex_to_bytes +from .lnutil import hex_to_bytes, RoutingInfo, BlindedRoutingInfo, UnblindedRoutingInfo, LnFeatures, get_final_cltv_offset +from .lnonion import BlindedPathInfo from .bolt11 import decode_bolt11_invoice, BOLT11Addr -from . import constants +from .bolt12 import BOLT12Invoice from .bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC -from .bitcoin import address_to_script from .transaction import PartialTxOutput from .crypto import sha256d @@ -209,17 +209,19 @@ def _validate_amount(self, attribute, value): @classmethod def from_bech32(cls, invoice: str) -> 'Invoice': - """Constructs Invoice object from BOLT-11 string. + """Constructs Invoice object from BOLT-11 or BOLT-12 string. Might raise InvoiceError. """ + is_bolt12 = invoice.startswith('lni') try: - lnaddr = decode_bolt11_invoice(invoice) + inv_obj = BOLT12Invoice.decode(invoice) if is_bolt12 else decode_bolt11_invoice(invoice) except Exception as e: raise InvoiceError(e) from e - amount_msat = lnaddr.get_amount_msat() - timestamp = lnaddr.date - exp_delay = lnaddr.get_expiry() - message = lnaddr.get_description() + assert isinstance(inv_obj, BOLT12Invoice if is_bolt12 else BOLT11Addr) + amount_msat = inv_obj.invoice_amount if is_bolt12 else inv_obj.get_amount_msat() + timestamp = inv_obj.invoice_created_at if is_bolt12 else inv_obj.date + exp_delay = inv_obj.invoice_relative_expiry if is_bolt12 else inv_obj.get_expiry() + message = inv_obj.offer_description if is_bolt12 else inv_obj.get_description() return Invoice( message=message, amount_msat=amount_msat, @@ -257,12 +259,16 @@ def as_dict(self, status): @attr.s class Invoice(BaseInvoice): lightning_invoice = attr.ib(type=str, kw_only=True) # type: Optional[str] - __lnaddr = None _broadcasting_status = None # can be None or PR_BROADCASTING or PR_BROADCAST def is_lightning(self): return self.lightning_invoice is not None + def set_amount_msat(self, amount_msat: Union[int, str]) -> None: + if self.bolt12_invoice and amount_msat != self.bolt12_invoice.invoice_amount: + raise Exception("cannot overwrite bolt12 invoice amount, request correct invoice beforehand") + super().set_amount_msat(amount_msat) + def get_broadcasting_status(self): return self._broadcasting_status @@ -271,35 +277,102 @@ def get_address(self) -> Optional[str]: if self.outputs: address = self.outputs[0].address if len(self.outputs) > 0 else None if not address and self.is_lightning(): - address = self._lnaddr.get_fallback_address() or None + address = self.get_lightning_fallback_address() return address + def get_lightning_fallback_address(self) -> Optional[str]: + assert self.is_lightning() + if bolt12 := self.bolt12_invoice: + return bolt12.fallback_address + return self.bolt11_invoice.get_fallback_address() or None + @property - def _lnaddr(self) -> BOLT11Addr: - if self.__lnaddr is None: - self.__lnaddr = decode_bolt11_invoice(self.lightning_invoice) - return self.__lnaddr + def bolt12_invoice(self) -> Optional['BOLT12Invoice']: + inv = self._decoded_invoice + return inv if isinstance(inv, BOLT12Invoice) else None + + @property + def bolt11_invoice(self) -> Optional['BOLT11Addr']: + inv = self._decoded_invoice + return inv if isinstance(inv, BOLT11Addr) else None + + @cached_property + def _decoded_invoice(self) -> Optional['BOLT12Invoice | BOLT11Addr']: + # _ prefix is necessary to prevent JsonDB from attempting to persist the cached attribute + if not self.lightning_invoice: + return None + if self.lightning_invoice.startswith('lni'): + return BOLT12Invoice.decode(self.lightning_invoice) + return decode_bolt11_invoice(self.lightning_invoice) + + @property + def features(self) -> LnFeatures: + if b11 := self.bolt11_invoice: + return b11.get_features() + if b12 := self.bolt12_invoice: + return b12.invoice_features or LnFeatures(0) + raise AssertionError(self) + + @property + def payment_hash(self) -> bytes: + if b11 := self.bolt11_invoice: + assert b11.paymenthash + return b11.paymenthash + if b12 := self.bolt12_invoice: + return b12.invoice_payment_hash + raise AssertionError(self) @property def rhash(self) -> str: + return self.payment_hash.hex() + + @property + def issuer_pubkey(self) -> Optional[str]: assert self.is_lightning() - return self._lnaddr.paymenthash.hex() + if b11 := self.bolt11_invoice: + return b11.pubkey.serialize().hex() + # Note: b12 invoice_node_id can be blinded, so showing it to the user could be potentially misleading + return self.bolt12_invoice.invoice_node_id.hex() + + def get_routing_info(self) -> 'RoutingInfo': + assert self.is_lightning(), self + if b12 := self.bolt12_invoice: + return BlindedRoutingInfo( + paths=tuple( + BlindedPathInfo(path=path, payinfo=payinfo) + for path, payinfo in zip(b12.invoice_paths, b12.invoice_blindedpay) + ), + invoice_features=b12.invoice_features or LnFeatures(0), + final_cltv_delta=get_final_cltv_offset(), + ) + else: + b11 = self.bolt11_invoice + final_cltv_delta = b11.get_min_final_cltv_delta() + get_final_cltv_offset() + return UnblindedRoutingInfo( + node_pubkey=b11.pubkey.serialize(), + r_tags=b11.get_routing_info('r'), + payment_secret=b11.payment_secret, + final_cltv_delta=final_cltv_delta, + invoice_features=b11.get_features(), + ) @lightning_invoice.validator def _validate_invoice_str(self, attribute, value): if value is not None: - lnaddr = decode_bolt11_invoice(value) # this checks the str can be decoded - self.__lnaddr = lnaddr # save it, just to avoid having to recompute later + if value.startswith('lni'): + assert isinstance(self.bolt12_invoice, BOLT12Invoice) + else: + assert isinstance(self.bolt11_invoice, BOLT11Addr) def can_be_paid_onchain(self) -> bool: if self.is_lightning(): - return bool(self._lnaddr.get_fallback_address()) or (bool(self.outputs)) + return bool(self.get_lightning_fallback_address()) or (bool(self.outputs)) else: return True def to_debug_json(self) -> Dict[str, Any]: d = self.to_json() - d["lnaddr"] = self._lnaddr.to_debug_json() + d["lnaddr"] = self.bolt11_invoice.to_debug_json() if self.bolt11_invoice else self.bolt12_invoice.serialize(with_signature=True) return d diff --git a/electrum/lnonion.py b/electrum/lnonion.py index 043213a82d21..c7809ee1177e 100644 --- a/electrum/lnonion.py +++ b/electrum/lnonion.py @@ -36,15 +36,18 @@ from .crypto import sha256, hmac_oneshot, chacha20_encrypt, get_ecdh, chacha20_poly1305_encrypt, chacha20_poly1305_decrypt from .util import profiler, xor_bytes, bfh -from .lnutil import (PaymentFailure, NUM_MAX_HOPS_IN_PAYMENT_PATH, - NUM_MAX_EDGES_IN_PAYMENT_PATH, ShortChannelID, OnionFailureCodeMetaFlag) +from .lnutil import (PaymentFailure, NUM_MAX_HOPS_IN_PAYMENT_PATH, LnFeatureContexts, + NUM_MAX_EDGES_IN_PAYMENT_PATH, ShortChannelID, OnionFailureCodeMetaFlag, LnFeatures, + NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE, validate_features, IncompatibleOrInsaneFeatures) from .lnmsg import OnionWireSerializer, read_bigsize_int, write_bigsize_int +from .logging import get_logger from . import lnmsg from . import util if TYPE_CHECKING: - from .lnrouter import LNPaymentRoute + from .lnrouter import LNPaymentRoute, FinalForwardFees +_logger = get_logger(__name__) HOPS_DATA_SIZE = 1300 # also sometimes called routingInfoSize in bolt-04 PER_HOP_HMAC_SIZE = 32 @@ -158,6 +161,116 @@ def onion_hash(self) -> bytes: return sha256(self.to_bytes()) +@dataclass(frozen=True, kw_only=True) +class BlindedPathHop: + blinded_node_id: bytes + enclen: int + encrypted_recipient_data: bytes + + def __post_init__(self): + ecc.ECPubkey(b=self.blinded_node_id) + + +@dataclass(frozen=True, kw_only=True) +class BlindedPath: + """ + https://github.com/lightning/bolts/blob/34455ffe28b308dd7ac7552234d565890af8605b/04-onion-routing.md?plain=1#L441 + """ + first_node_id: bytes + first_path_key: bytes + num_hops: bytes + path: list[BlindedPathHop] + + @property + def hop_count(self) -> int: + return int.from_bytes(self.num_hops, byteorder='big', signed=False) + + def __post_init__(self): + # if num_hops is 0 in any blinded_path in offer_paths: MUST NOT respond to the offer + assert isinstance(self.num_hops, bytes), type(self.num_hops) + assert isinstance(self.path, list), self.path + if self.hop_count == 0: + raise ValueError('invalid num_hops of 0') + if not self.path: + raise ValueError('empty path') + if not len(self.path) == self.hop_count: + raise ValueError(f'{len(self.path)=} != {self.hop_count=}') + # ecc.ECPubkey(b=self.first_node_id) # fails bolt 12 test vectors using dummy node ids + ecc.ECPubkey(b=self.first_path_key) + + @classmethod + def decode(cls, blinded_path: bytes) -> 'BlindedPath': + with io.BytesIO(blinded_path) as blinded_path_fd: + blinded_path = OnionWireSerializer.read_field( + fd=blinded_path_fd, + field_type='blinded_path', + count=1) + return cls.from_dict(blinded_path) + + @classmethod + def from_dict(cls, d: dict) -> 'BlindedPath': + if isinstance(d['path'], Mapping): # single path + d['path'] = [d['path']] + return BlindedPath( + first_node_id=d['first_node_id'], + first_path_key=d['first_path_key'], + num_hops=d['num_hops'], + path=[BlindedPathHop(**p) for p in d['path']], + ) + + +@dataclass(frozen=True) +class BlindedPayInfo: + fee_base_msat: int + fee_proportional_millionths: int + cltv_expiry_delta: int + htlc_minimum_msat: int + htlc_maximum_msat: int + features: LnFeatures + + def __post_init__(self): + if self.cltv_expiry_delta > NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE: + raise ValueError(f"unreasonably long {self.cltv_expiry_delta=}") + + @property + def requires_unknown_mandatory_features(self) -> bool: + """ + MUST NOT use the corresponding invoice_paths.path if payinfo.features has any unknown even bits set. + """ + try: + validate_features(self.features, context=LnFeatureContexts.BLINDED_PATH) + except IncompatibleOrInsaneFeatures: + return True + return False + + @classmethod + def from_dict(cls, d: dict) -> 'BlindedPayInfo': + return BlindedPayInfo( + fee_base_msat=int(d['fee_base_msat']), + fee_proportional_millionths=int(d['fee_proportional_millionths']), + cltv_expiry_delta=int(d['cltv_expiry_delta']), + htlc_minimum_msat=int(d['htlc_minimum_msat']), + htlc_maximum_msat=int(d['htlc_maximum_msat']), + features=LnFeatures(int.from_bytes(d['features'], byteorder="big", signed=False)) + ) + + def to_dict(self) -> dict: + return { + 'fee_base_msat': self.fee_base_msat, + 'fee_proportional_millionths': self.fee_proportional_millionths, + 'cltv_expiry_delta': self.cltv_expiry_delta, + 'htlc_minimum_msat': self.htlc_minimum_msat, + 'htlc_maximum_msat': self.htlc_maximum_msat, + 'flen': len(self.features.to_tlv_bytes()), + 'features': self.features.to_tlv_bytes() + } + + +class BlindedPathInfo(NamedTuple): + path: BlindedPath + payinfo: Optional[BlindedPayInfo] + + def get_bolt04_onion_key(key_type: bytes, secret: bytes) -> bytes: if key_type not in (b'rho', b'mu', b'um', b'ammag', b'pad', b'blinded_node_id'): raise Exception('invalid key_type {}'.format(key_type)) @@ -222,7 +335,7 @@ def new_onion_packet( onion_message: bool = False ) -> OnionPacket: num_hops = len(payment_path_pubkeys) - assert num_hops == len(hops_data) + assert num_hops == len(hops_data), f"{num_hops=}, {hops_data=}" hop_shared_secrets, _ = get_shared_secrets_along_route(payment_path_pubkeys, session_key) payload_size = 0 @@ -314,7 +427,8 @@ def calc_hops_data_for_payment( *, final_cltv_abs: int, total_msat: int, - payment_secret: bytes, + payment_secret: Optional[bytes], + final_forward_fees: Optional['FinalForwardFees'] = None, ) -> Tuple[List[OnionHopsDataSingle], int, int]: """Returns the hops_data to be used for constructing an onion packet, and the amount_msat and cltv_abs to be used on our immediate channel. @@ -326,18 +440,22 @@ def calc_hops_data_for_payment( # payload that will be seen by the last hop: # for multipart payments we need to tell the receiver about the total and # partial amounts - hop_payload = { + final_hop_payload: dict = { "amt_to_forward": {"amt_to_forward": amt}, "outgoing_cltv_value": {"outgoing_cltv_value": cltv_abs}, - "payment_data": { + } + if payment_secret: # None if blinded legacy trampoline payment + final_hop_payload["payment_data"] = { "payment_secret": payment_secret, "total_msat": total_msat, "amount_msat": amt, - }} - hops_data = [OnionHopsDataSingle(payload=hop_payload)] + } + hops_data = [OnionHopsDataSingle(payload=final_hop_payload)] + if final_forward_fees is not None: + amt += final_forward_fees.fee_for_edge(amt) + cltv_abs += final_forward_fees.forwarder_cltv_delta + final_forward_fees.blinded_path_cltv_delta # payloads, backwards from last hop (but excluding the first edge): - for edge_index in range(len(route) - 1, 0, -1): - route_edge = route[edge_index] + for route_edge in reversed(route[1:]): hop_payload = { "amt_to_forward": {"amt_to_forward": amt}, "outgoing_cltv_value": {"outgoing_cltv_value": cltv_abs}, @@ -351,6 +469,80 @@ def calc_hops_data_for_payment( return hops_data, amt, cltv_abs +def calc_hops_data_for_blinded_payment( + route_to_introduction_point: 'LNPaymentRoute', + recipient_amount_msat: int, + *, + final_cltv_abs: int, + total_msat: int, + invoice_blinded_path_info: 'BlindedPathInfo', +) -> Tuple[List[OnionHopsDataSingle], List[bytes], int, int]: + """ + Returns the hops_data to be used for constructing an onion packet, + and the amount_msat and cltv_abs to be used on our immediate channel. + https://github.com/lightning/bolts/blob/444805d12ab98c30006173bb190cd9d6fce9e405/04-onion-routing.md?plain=1#L264 + """ + from .lnrouter import fee_for_edge_msat + invoice_blinded_path, invoice_payinfo = invoice_blinded_path_info.path, invoice_blinded_path_info.payinfo + assert invoice_blinded_path and invoice_payinfo + if len(route_to_introduction_point) > NUM_MAX_EDGES_IN_PAYMENT_PATH: + raise PaymentFailure(f"too long route ({len(route_to_introduction_point)} edges)") + + hops_data = [] + amt = recipient_amount_msat + inv_hops = invoice_blinded_path.path + num_hops = len(inv_hops) + if not invoice_payinfo.htlc_minimum_msat <= recipient_amount_msat <= invoice_payinfo.htlc_maximum_msat: + raise Exception(f'{invoice_payinfo=} htlc limits cannot fit {recipient_amount_msat=}') + + _logger.debug('inv_hops: ' + repr(inv_hops)) + # assemble data for the hops on the given blinded path + for i, inv_hop in enumerate(reversed(inv_hops)): + # each hop gets their encrypted recipient data + payload: dict = { + 'encrypted_recipient_data': {'encrypted_recipient_data': inv_hop.encrypted_recipient_data} + } + if i == 0: # recipient + payload.update({ + 'amt_to_forward': {'amt_to_forward': recipient_amount_msat}, + 'outgoing_cltv_value': {'outgoing_cltv_value': final_cltv_abs}, + 'total_amount_msat': {'total_msat': total_msat}, + }) + if i == num_hops - 1: # introduction point + payload['current_path_key'] = {'path_key': invoice_blinded_path.first_path_key} + _logger.debug(f'inv_hop[{num_hops - 1 - i}].payload: ' + repr(payload)) + hops_data.append(OnionHopsDataSingle(payload=payload)) + + # add the fees + cltv for the (whole) blinded path, amt is then what the introduction point gets + amt += fee_for_edge_msat( + forwarded_amount_msat=recipient_amount_msat, + fee_base_msat=invoice_payinfo.fee_base_msat, + fee_proportional_millionths=invoice_payinfo.fee_proportional_millionths, + ) + cltv_abs = final_cltv_abs + invoice_payinfo.cltv_expiry_delta + _logger.debug(f'blinded payment introduction point {amt=} for {recipient_amount_msat=}, {cltv_abs=}') + + # payloads for the unblinded part of the path, backwards from pre-IP node (excluding the first edge) + for i, route_edge in enumerate(reversed(route_to_introduction_point[1:])): + hop_payload = { + "amt_to_forward": {"amt_to_forward": amt}, + "outgoing_cltv_value": {"outgoing_cltv_value": cltv_abs}, + "short_channel_id": {"short_channel_id": route_edge.short_channel_id}, + } + + hops_data.append(OnionHopsDataSingle(payload=hop_payload)) + amt += route_edge.fee_for_edge(amt) + cltv_abs += route_edge.cltv_delta + + _logger.debug(f'route_edge[{len(route_to_introduction_point) - 1 - i}].payload: ' + repr(hop_payload) + \ + f'\nedge_in_amt: {amt}, edge_in_cltv: {cltv_abs}' + \ + f'\n--> {route_edge.end_node.hex()}') + + hops_data.reverse() + blinded_path_blinded_node_pubkeys = [x.blinded_node_id for x in inv_hops][1:] + return hops_data, blinded_path_blinded_node_pubkeys, amt, cltv_abs + + def _generate_filler(key_type: bytes, hops_data: Sequence[OnionHopsDataSingle], shared_secrets: Sequence[bytes], data_size:int) -> bytes: num_hops = len(hops_data) @@ -389,7 +581,9 @@ class ProcessedOnionPacket(NamedTuple): are_we_final: bool hop_data: OnionHopsDataSingle next_packet: OnionPacket - trampoline_onion_packet: OnionPacket + trampoline_onion_packet: Optional[OnionPacket] + blinded_path_recipient_data: Optional[MappingProxyType] + next_path_key: Optional[bytes] = None @property def amt_to_forward(self) -> Optional[int]: @@ -404,19 +598,32 @@ def outgoing_cltv_value(self) -> Optional[int]: @property def next_chan_scid(self) -> Optional[ShortChannelID]: k1 = k2 = 'short_channel_id' - return self._get_from_payload(k1, k2, ShortChannelID) + if not self.blinded_path_recipient_data: + return self._get_from_payload(k1, k2, ShortChannelID) + return self._get_from_recipient_data(k1, k2, ShortChannelID) @property def total_msat(self) -> Optional[int]: - return self._get_from_payload('payment_data', 'total_msat', int) + if not self.blinded_path_recipient_data: + return self._get_from_payload('payment_data', 'total_msat', int) + return self._get_from_payload('total_amount_msat', 'total_msat', int) @property def payment_secret(self) -> Optional[bytes]: - return self._get_from_payload('payment_data', 'payment_secret', bytes) + if not self.blinded_path_recipient_data: + return self._get_from_payload('payment_data', 'payment_secret', bytes) + return None + + def _get_from_payload(self, k1: str, k2: str, res_type: type): + return self._get_from(self.hop_data.payload, k1, k2, res_type) - def _get_from_payload(self, k1: str, k2: str, res_type: type): + def _get_from_recipient_data(self, k1: str, k2: str, res_type: type): + return self._get_from(self.blinded_path_recipient_data, k1, k2, res_type) + + @staticmethod + def _get_from(payload: Mapping, k1: str, k2: str, res_type: type): try: - result = self.hop_data.payload[k1][k2] + result = payload[k1][k2] return res_type(result) except Exception: return None @@ -429,6 +636,7 @@ def process_onion_packet( *, associated_data: bytes = b'', is_trampoline=False, + current_path_key: Optional[bytes] = None, tlv_stream_name='payload') -> ProcessedOnionPacket: # TODO: check Onion features ( PERM|NODE|3 (required_node_feature_missing ) if onion_packet.version != 0: @@ -436,6 +644,10 @@ def process_onion_packet( if not ecc.ECPubkey.is_pubkey_bytes(onion_packet.public_key): raise InvalidOnionPubkey() is_onion_message = tlv_stream_name == 'onionmsg_tlv' + recipient_data_shared_secret = None + if current_path_key: + recipient_data_shared_secret = get_ecdh(our_onion_private_key, current_path_key) + our_onion_private_key = blinding_privkey(our_onion_private_key, current_path_key) shared_secret = get_ecdh(our_onion_private_key, onion_packet.public_key) # check message integrity mu_key = get_bolt04_onion_key(b'mu', shared_secret) @@ -454,6 +666,21 @@ def process_onion_packet( next_hops_data = xor_bytes(padded_header, stream_bytes) next_hops_data_fd = io.BytesIO(next_hops_data) hop_data = OnionHopsDataSingle.from_fd(next_hops_data_fd, tlv_stream_name=tlv_stream_name) + + blinded_path_recipient_data = {} + encrypted_recipient_data = hop_data.payload.get('encrypted_recipient_data', {}).get('encrypted_recipient_data') + if encrypted_recipient_data: + # we are part of a blinded path + if not current_path_key: # we are the introduction point + current_path_key = hop_data.payload['current_path_key']['path_key'] + recipient_data_shared_secret = get_ecdh(our_onion_private_key, current_path_key) + assert isinstance(encrypted_recipient_data, bytes) + assert isinstance(current_path_key, bytes) and isinstance(recipient_data_shared_secret, bytes) + blinded_path_recipient_data = decrypt_onionmsg_data_tlv( + shared_secret=recipient_data_shared_secret, + encrypted_recipient_data=encrypted_recipient_data, + ) + # trampoline trampoline_onion_packet = hop_data.payload.get('trampoline_onion_packet') if trampoline_onion_packet: @@ -467,13 +694,27 @@ def process_onion_packet( public_key=next_public_key, hops_data=next_hops_data_fd.read(data_size), hmac=hop_data.hmac) - if hop_data.hmac == bytes(PER_HOP_HMAC_SIZE): - # we are the destination / exit node - are_we_final = True - else: - # we are an intermediate node; forwarding + + # decide if we are recipient or forwarder + are_we_final = hop_data.hmac == bytes(PER_HOP_HMAC_SIZE) + next_hop_keys = ('outgoing_node_id', 'outgoing_blinded_paths') + if is_trampoline and any(key in hop_data.payload for key in next_hop_keys): + # we are the final trampoline forwarder during a legacy trampoline payment are_we_final = False - return ProcessedOnionPacket(are_we_final, hop_data, next_onion_packet, trampoline_onion_packet) + + next_path_key = None + if not are_we_final and current_path_key: + assert recipient_data_shared_secret + next_path_key = next_blinding_from_shared_secret(current_path_key, recipient_data_shared_secret) + + return ProcessedOnionPacket( + are_we_final, + hop_data, + next_onion_packet, + trampoline_onion_packet, + util.make_object_immutable(blinded_path_recipient_data), + next_path_key, + ) def compare_trampoline_onions( diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index 4b88f87d6e7d..219cb4397c28 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -21,8 +21,8 @@ from aiorpcx import ignore_after from .lrucache import LRUCache -from .crypto import sha256, sha256d, privkey_to_pubkey -from . import bitcoin, util +from .crypto import sha256, sha256d, privkey_to_pubkey, get_ecdh +from . import bitcoin, util, bolt12 from . import constants from .util import (log_exceptions, ignore_exceptions, chunks, OldTaskGroup, UnrelatedTransactionException, error_text_bytes_to_safe_str, AsyncHangDetector, @@ -35,7 +35,7 @@ from .lnonion import (OnionFailureCode, OnionPacket, obfuscate_onion_error, OnionRoutingFailure, ProcessedOnionPacket, UnsupportedOnionPacketVersion, InvalidOnionMac, InvalidOnionPubkey, OnionFailureCodeMetaFlag, - OnionParsingError) + OnionParsingError, decrypt_onionmsg_data_tlv, BlindedPathInfo) from .lnchannel import Channel, RevokeAndAck, ChannelState, PeerState, ChanCloseOption, CF_ANNOUNCE_CHANNEL from . import lnutil from .lnutil import (Outpoint, LocalConfig, RECEIVED, UpdateAddHtlc, ChannelConfig, LnFeatureContexts, @@ -64,6 +64,7 @@ LN_P2P_NETWORK_TIMEOUT = 20 +ONION_MESSAGE_SEND_DELAY = 0.25 class Peer(Logger, EventListener): @@ -141,6 +142,9 @@ def __init__( self._last_commitsig_sent_time = time.monotonic() self._last_ping_recv_time = min(0, time.monotonic()) + self._onion_message_send_queue = asyncio.Queue(maxsize=1000) # type: asyncio.Queue[tuple[bytes, bytes, bytes]] + self._pending_onion_messages = set() # type: set[bytes] + def send_message(self, message_name: str, **kwargs): assert util.get_running_loop() == util.get_asyncio_loop(), f"this must be run on the asyncio thread!" assert type(message_name) is str @@ -153,6 +157,42 @@ def send_message(self, message_name: str, **kwargs): self.transport.send_bytes(raw_msg) # could `await self.transport.writer.drain()`, but not async + def send_onion_message(self, *, path_key: bytes, onion_message_packet: bytes): + """ + Can be called from any thread and before peer is initialized. + If called with the same message again, while its predecesssor wasn't sent yet, the duplicate attempt gets dropped. + """ + def add(): + msg_id = sha256(path_key + onion_message_packet) + if msg_id in self._pending_onion_messages: + return + try: + self._onion_message_send_queue.put_nowait((path_key, onion_message_packet, msg_id)) + except asyncio.QueueFull: + return # onion messages are unreliable, it's fine to drop it, OnionMessageManager will retry + self._pending_onion_messages.add(msg_id) + + util.run_sync_function_on_asyncio_thread(add, block=False) + + async def _send_onion_messages(self): + await self.initialized + while True: + path_key, onion_message_packet, msg_id = await self._onion_message_send_queue.get() + try: + raw_msg = encode_msg( + 'onion_message', + path_key=path_key, + len=len(onion_message_packet), + onion_message_packet=onion_message_packet, + ) + await self.transport.send_bytes_and_drain(raw_msg) + except Exception: + self.logger.warning(f"failed to send onion message: {onion_message_packet.hex()}, {path_key.hex()}", exc_info=True) + finally: + self._pending_onion_messages.remove(msg_id) + # prevent getting rate limited by blasting the peer after init + await asyncio.sleep(ONION_MESSAGE_SEND_DELAY) + def _store_raw_msg_if_local_update(self, raw_msg: bytes, *, message_name: str, channel_id: Optional[bytes]): is_commitment_signed = message_name == "commitment_signed" if not (message_name.startswith("update_") or is_commitment_signed): @@ -565,6 +605,7 @@ async def main_loop(self): await group.spawn(self._process_gossip()) await group.spawn(self._send_own_gossip()) await group.spawn(self._forward_gossip()) + await group.spawn(self._send_onion_messages()) if self.network.lngossip != self.lnworker: await group.spawn(self.htlc_switch()) @@ -1959,6 +2000,7 @@ def send_htlc( cltv_abs: int, onion: OnionPacket, session_key: Optional[bytes] = None, + next_path_key: Optional[bytes] = None ) -> UpdateAddHtlc: assert chan.can_send_update_add_htlc(), f"cannot send updates: {chan.short_channel_id}" htlc = UpdateAddHtlc(amount_msat=amount_msat, payment_hash=payment_hash, cltv_abs=cltv_abs, timestamp=int(time.time())) @@ -1966,6 +2008,10 @@ def send_htlc( if session_key: chan.set_onion_key(htlc.htlc_id, session_key) # should it be the outer onion secret? self.logger.info(f"starting payment. htlc: {htlc}") + extra = {} + if next_path_key: + extra = {'update_add_htlc_tlvs': {'blinded_path': {'path_key': next_path_key}}} + self.send_message( "update_add_htlc", channel_id=chan.channel_id, @@ -1973,7 +2019,8 @@ def send_htlc( cltv_expiry=htlc.cltv_abs, amount_msat=htlc.amount_msat, payment_hash=htlc.payment_hash, - onion_routing_packet=onion.to_bytes()) + onion_routing_packet=onion.to_bytes(), + **extra) self.maybe_send_commitment(chan) return htlc @@ -1984,7 +2031,8 @@ def pay(self, *, total_msat: int, payment_hash: bytes, min_final_cltv_delta: int, - payment_secret: bytes, + payment_secret: Optional[bytes], + blinded_path_info: Optional['BlindedPathInfo'] = None, trampoline_onion: Optional[OnionPacket] = None, ) -> UpdateAddHtlc: @@ -1999,7 +2047,8 @@ def pay(self, *, payment_hash=payment_hash, min_final_cltv_delta=min_final_cltv_delta, payment_secret=payment_secret, - trampoline_onion=trampoline_onion + blinded_path_info=blinded_path_info, + trampoline_onion=trampoline_onion, ) htlc = self.send_htlc( chan=chan, @@ -2080,12 +2129,14 @@ def on_update_add_htlc(self, chan: Channel, payload): cltv_abs = payload["cltv_expiry"] amount_msat_htlc = payload["amount_msat"] onion_packet = payload["onion_routing_packet"] + path_key = payload.get("update_add_htlc_tlvs", {}).get("blinded_path", {}).get("path_key") htlc = UpdateAddHtlc( amount_msat=amount_msat_htlc, payment_hash=payment_hash, cltv_abs=cltv_abs, timestamp=int(time.time()), - htlc_id=htlc_id) + htlc_id=htlc_id, + path_key=path_key) self.logger.info(f"on_update_add_htlc. chan {chan.short_channel_id}. htlc={str(htlc)}") if chan.get_state() != ChannelState.OPEN: raise RemoteMisbehaving(f"received update_add_htlc while chan.get_state() != OPEN. state was {chan.get_state()!r}") @@ -2101,14 +2152,14 @@ def on_update_add_htlc(self, chan: Channel, payload): chan.receive_htlc(htlc, onion_packet) util.trigger_callback('htlc_added', chan, htlc, RECEIVED) - @staticmethod def _check_accepted_final_htlc( + self, *, chan: Channel, htlc: UpdateAddHtlc, processed_onion: ProcessedOnionPacket, is_trampoline_onion: bool = False, log_fail_reason: Callable[[str], None], - ) -> tuple[bytes, int, int, OnionRoutingFailure]: + ) -> tuple[Optional[bytes], Optional[bytes], int, int, OnionRoutingFailure]: """ Perform checks that are invariant (results do not depend on height, network conditions, etc.) for htlcs of which we are the receiver (forwarding htlcs will have their checks in maybe_forward_htlc). @@ -2129,7 +2180,41 @@ def _check_accepted_final_htlc( exc_incorrect_or_unknown_pd = OnionRoutingFailure( code=OnionFailureCode.INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS, - data=amt_to_forward.to_bytes(8, byteorder="big")) # height will be added later + data=amt_to_forward.to_bytes(8, byteorder="big")) # height will be added later + + path_id_digest = None + payment_secret_from_onion = None + if htlc.path_key: # payment over blinded path + # spec: MUST return an error if the payload contains other tlv fields than allowed_payload_keys + allowed_payload_keys = ['encrypted_recipient_data', 'current_path_key', 'amt_to_forward', 'outgoing_cltv_value', 'total_amount_msat'] + if any(x not in allowed_payload_keys for x in processed_onion.hop_data.payload.keys()): + log_fail_reason(f"unknown key in blinded payload: {processed_onion.hop_data.payload.keys()=}") + raise exc_incorrect_or_unknown_pd + + recipient_data = processed_onion.blinded_path_recipient_data or {} + path_id = recipient_data.get('path_id', {}).get('data') + if not path_id: + log_fail_reason(f"'path_id' missing in recipient_data") + raise exc_incorrect_or_unknown_pd + + try: + path_id_payload = bolt12.BOLT12InvoicePathIDPayload.decode(path_id) + except ValueError as e: + log_fail_reason(f"couldn't decode blinded payment metadata: {e}: {path_id.hex()}") + raise exc_incorrect_or_unknown_pd + + path_id_payment_hash = sha256(path_id_payload.payment_preimage) + if path_id_payment_hash != htlc.payment_hash: + log_fail_reason(f"sender used blinded path out of context? {path_id_payment_hash.hex()=} != {htlc.payment_hash.hex()}") + raise exc_incorrect_or_unknown_pd + + self.lnworker.save_blinded_payment_info(path_id_payload) + # stop sending the invoice onion message + self.lnworker.cancel_bolt12_invoice_response(path_id_payment_hash) + path_id_digest = sha256(path_id) + else: + payment_secret_from_onion = processed_onion.payment_secret + if (total_msat := processed_onion.total_msat) is None: log_fail_reason(f"'total_msat' missing from onion") raise exc_incorrect_or_unknown_pd @@ -2149,11 +2234,11 @@ def _check_accepted_final_htlc( code=OnionFailureCode.FINAL_INCORRECT_HTLC_AMOUNT, data=htlc.amount_msat.to_bytes(8, byteorder="big")) - if (payment_secret_from_onion := processed_onion.payment_secret) is None: + if payment_secret_from_onion is None and path_id_digest is None: log_fail_reason(f"'payment_secret' missing from onion") raise exc_incorrect_or_unknown_pd - return payment_secret_from_onion, total_msat, channel_opening_fee, exc_incorrect_or_unknown_pd + return payment_secret_from_onion, path_id_digest, total_msat, channel_opening_fee, exc_incorrect_or_unknown_pd def _check_unfulfilled_htlc( self, *, @@ -2190,7 +2275,7 @@ def _check_unfulfilled_htlc( return payment_key # parse parameters and perform checks that are invariant - payment_secret_from_onion, total_msat, channel_opening_fee, exc_incorrect_or_unknown_pd = ( + payment_secret_from_onion, path_id_digest, total_msat, channel_opening_fee, exc_incorrect_or_unknown_pd = ( self._check_accepted_final_htlc( chan=chan, htlc=htlc, @@ -2198,11 +2283,12 @@ def _check_unfulfilled_htlc( is_trampoline_onion=bool(outer_onion_payment_secret), log_fail_reason=_log_fail_reason, )) + assert bool(payment_secret_from_onion) != bool(path_id_digest), "need either payment secret or blinded payment" # trampoline htlcs of which we are the final receiver will first get grouped by the outer # onions secret to allow grouping a multi-trampoline mpp in different sets. Once a trampoline # payment part is completed (sum(htlcs) >= (trampoline-)amt_to_forward), its htlcs get moved into # the htlc set representing the whole payment (payment key derived from trampoline/invoice secret). - payment_key = (payment_hash + (outer_onion_payment_secret or payment_secret_from_onion)).hex() + payment_key = (payment_hash + (outer_onion_payment_secret or payment_secret_from_onion or path_id_digest)).hex() # for safety, still enforce MIN_FINAL_CLTV_DELTA here even if payment_hash is in dont_expire_htlcs if blocks_to_expiry < MIN_FINAL_CLTV_DELTA_ACCEPTED: @@ -2233,6 +2319,7 @@ def _check_unfulfilled_htlc( _log_fail_reason(f'incorrect trampoline onion {processed_onion=}\n{trampoline_onion=}') raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') + assert payment_secret_from_onion, "need payment secret for outer trampoline onion" return self._check_unfulfilled_htlc( chan=chan, htlc=htlc, @@ -2261,10 +2348,11 @@ def _check_unfulfilled_htlc( _log_fail_reason(f"got mpp but we requested no mpp in the invoice: {total_msat=} > {htlc.amount_msat=}") raise exc_incorrect_or_unknown_pd - expected_payment_secret = self.lnworker.get_payment_secret(payment_hash) - if not util.constant_time_compare(payment_secret_from_onion, expected_payment_secret): - _log_fail_reason(f'incorrect payment secret: {payment_secret_from_onion.hex()=}') - raise exc_incorrect_or_unknown_pd + if not path_id_digest: + expected_payment_secret = self.lnworker.get_payment_secret(payment_hash) + if not util.constant_time_compare(payment_secret_from_onion, expected_payment_secret): + _log_fail_reason(f'incorrect payment secret: {payment_secret_from_onion.hex()=}') + raise exc_incorrect_or_unknown_pd invoice_msat = info.amount_msat if channel_opening_fee: @@ -2348,6 +2436,7 @@ def _fail_htlc_set( processed_onion_packet = self._process_incoming_onion_packet( onion_packet, payment_hash=payment_hash, + current_path_key=mpp_htlc.htlc.path_key, is_trampoline=False, ) if raw_error: @@ -2869,6 +2958,7 @@ def _run_htlc_switch_iteration(self): processed_onion_packet = self._process_incoming_onion_packet( onion_packet, payment_hash=htlc.payment_hash, + current_path_key=htlc.path_key, is_trampoline=False, ) payment_key: str = self._check_unfulfilled_htlc( @@ -2976,6 +3066,7 @@ def log_fail_reason(reason: str): processed_onion = self._process_incoming_onion_packet( onion_packet=self._parse_onion_packet(mpp_htlc.unprocessed_onion), payment_hash=mpp_htlc.htlc.payment_hash, + current_path_key=mpp_htlc.htlc.path_key, is_trampoline=False, ) onion_payload = processed_onion.hop_data.payload @@ -3028,6 +3119,7 @@ def _check_unfulfilled_htlc_set( processed_onion = self._process_incoming_onion_packet( onion_packet=self._parse_onion_packet(mpp_htlc.unprocessed_onion), payment_hash=payment_hash, + current_path_key=mpp_htlc.htlc.path_key, is_trampoline=False, # this is always the outer onion ) processed_onions[mpp_htlc] = (processed_onion, None) @@ -3284,17 +3376,20 @@ def _process_incoming_onion_packet( self, onion_packet: OnionPacket, *, payment_hash: bytes, + current_path_key: Optional[bytes] = None, is_trampoline: bool = False) -> ProcessedOnionPacket: onion_hash = onion_packet.onion_hash - cache_key = sha256(onion_hash + payment_hash + bytes([is_trampoline])) # type: ignore + cache_key = sha256(onion_hash + payment_hash + bytes([is_trampoline]) + (current_path_key or b'')) # type: ignore if cached_onion := self._processed_onion_cache.get(cache_key): return cached_onion + try: processed_onion = lnonion.process_onion_packet( onion_packet, our_onion_private_key=self.privkey, associated_data=payment_hash, - is_trampoline=is_trampoline) + is_trampoline=is_trampoline, + current_path_key=current_path_key) self._processed_onion_cache[cache_key] = processed_onion except UnsupportedOnionPacketVersion: raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_VERSION, data=onion_hash) diff --git a/electrum/lnrouter.py b/electrum/lnrouter.py index 790303e77c28..4e6348be6550 100644 --- a/electrum/lnrouter.py +++ b/electrum/lnrouter.py @@ -30,9 +30,11 @@ import threading from threading import RLock from math import inf +from dataclasses import dataclass import attr +from .lnonion import BlindedPathInfo from .util import profiler, with_lock from .logging import Logger from .lnutil import (NUM_MAX_EDGES_IN_PAYMENT_PATH, ShortChannelID, LnFeatures, @@ -41,6 +43,7 @@ if TYPE_CHECKING: from .lnchannel import Channel + from .lnonion import BlindedPayInfo DEFAULT_PENALTY_BASE_MSAT = 500 # how much base fee we apply for unknown sending capability of a channel DEFAULT_PENALTY_PROPORTIONAL_MILLIONTH = 100 # how much relative fee we apply for unknown sending capability of a channel @@ -115,7 +118,8 @@ def is_trampoline(self) -> bool: @attr.s class TrampolineEdge(RouteEdge): - invoice_routing_info = attr.ib(type=Sequence[bytes], default=None) + # r-tags (non e2e bolt11) or a sequence of `payment_blinded_path` (non e2e bolt12) + invoice_routing_info = attr.ib(type=Sequence[bytes] | Sequence[BlindedPathInfo], default=None) invoice_features = attr.ib(type=int, default=None) # this is re-defined from parent just to specify a default value: short_channel_id = attr.ib(default=ShortChannelID(8), repr=lambda val: str(val)) @@ -129,20 +133,45 @@ def is_trampoline(self): LNPaymentTRoute = Sequence[TrampolineEdge] +@dataclass(frozen=True, kw_only=True) +class FinalForwardFees: + fee_base_msat: int + fee_proportional_millionths: int + + # cltv budget for the last forwarder (e.g. legacy trampoline) + # this is counted into our PaymentFeeBudget + forwarder_cltv_delta: int = 0 + + # also for the last forwarder, but specifically to account for subsequent blinded path + # this is excluded from our PaymentFeeBudget (see comment in PaymentFeeBudget) + blinded_path_cltv_delta: int = 0 + + def fee_for_edge(self, amount_msat: int) -> int: + return fee_for_edge_msat( + forwarded_amount_msat=amount_msat, + fee_base_msat=self.fee_base_msat, + fee_proportional_millionths=self.fee_proportional_millionths, + ) + + def is_route_within_budget( route: LNPaymentRoute, *, budget: PaymentFeeBudget, amount_msat_for_dest: int, # that final receiver gets - cltv_delta_for_dest: int, # that final receiver gets + cltv_delta_for_dest: int, # that final receiver gets (IP cltv_delta or recipient min_final_cltv_delta) + final_forward_fees: Optional['FinalForwardFees'] = None, ) -> bool: """Run some sanity checks on the whole route, before attempting to use it. called when we are paying; so e.g. lower cltv is better """ - if len(route) > NUM_MAX_EDGES_IN_PAYMENT_PATH: + if len(route) > NUM_MAX_EDGES_IN_PAYMENT_PATH: # doesn't count blinded path hops return False amt = amount_msat_for_dest cltv_cost_of_route = 0 # excluding cltv_delta_for_dest + if final_forward_fees: + amt += final_forward_fees.fee_for_edge(amt) + cltv_cost_of_route += final_forward_fees.forwarder_cltv_delta for route_edge in reversed(route[1:]): amt += route_edge.fee_for_edge(amt) cltv_cost_of_route += route_edge.cltv_delta diff --git a/electrum/lnutil.py b/electrum/lnutil.py index 8baff6182aed..401866866111 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -1,8 +1,11 @@ # 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 os from enum import IntFlag, IntEnum import enum +from abc import ABC, abstractmethod +from collections import defaultdict from typing import NamedTuple, List, Tuple, Mapping, Optional, TYPE_CHECKING, Union, Dict, Set, Sequence, FrozenSet import sys import time @@ -14,7 +17,7 @@ import dataclasses import attr -from .util import bfh, UserFacingException, list_enabled_bits, is_hex_str +from .util import bfh, UserFacingException, list_enabled_bits, is_hex_str, randrange from .util import ShortID as ShortChannelID, format_short_id as format_short_channel_id from .crypto import sha256, pw_decode_with_version_and_mac @@ -35,7 +38,7 @@ if TYPE_CHECKING: from .lnchannel import Channel, AbstractChannel from .lnrouter import LNPaymentRoute - from .lnonion import OnionRoutingFailure + from .lnonion import OnionRoutingFailure, BlindedPathInfo, BlindedPayInfo from .simple_config import SimpleConfig @@ -532,6 +535,18 @@ class LNProtocolWarning(Exception): TIME_FOR_OFFERED_HTLCS_TO_GET_FAILED_OFFCHAIN_ON_RESTART = 30 +def get_final_cltv_offset() -> int: + """ + Return offset to be added to the expiry height of the recipient. + The offset makes it harder for intermediate nodes to guess their position in the route. + The offset is taken from lightning-kmp (Phoenix). + """ + # https://github.com/lightning/bolts/blob/94eb038c42e664dd7862faeec6508ccd25f63ff8/04-onion-routing.md?plain=1#L274-L277 + # https://github.com/ACINQ/lightning-kmp/blob/0a857347dc5a6363693ba7ac05cddc247a018f72/modules/core/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt#L111-L127 + random_offset = 71 + randrange(144 - 71) # [72; 143] + return random_offset + + class RevocationStore: # closely based on code in lightningnetwork/lnd @@ -1713,6 +1728,8 @@ def name_minimal(self): | LnFeatures.OPTION_ANCHORS_OPT | LnFeatures.OPTION_ANCHORS_REQ | LnFeatures.OPTION_UPFRONT_SHUTDOWN_SCRIPT_OPT | LnFeatures.OPTION_UPFRONT_SHUTDOWN_SCRIPT_REQ | LnFeatures.OPTION_SUPPORT_LARGE_CHANNEL_OPT | LnFeatures.OPTION_SUPPORT_LARGE_CHANNEL_REQ + | LnFeatures.OPTION_ONION_MESSAGE_OPT | LnFeatures.OPTION_ONION_MESSAGE_REQ + | LnFeatures.OPTION_ROUTE_BLINDING_OPT | LnFeatures.OPTION_ROUTE_BLINDING_REQ ) @@ -1904,6 +1921,7 @@ class LnKeyFamily(IntEnum): PAYMENT_SECRET_KEY = 8 | BIP32_PRIME NOSTR_KEY = 9 | BIP32_PRIME FUNDING_ROOT_KEY = 10 | BIP32_PRIME + BOLT12_SECRET_KEY = 11 | BIP32_PRIME def generate_keypair(node: BIP32Node, key_family: LnKeyFamily) -> Keypair: @@ -1931,16 +1949,18 @@ class UpdateAddHtlc: cltv_abs: int htlc_id: Optional[int] = dataclasses.field(default=None) timestamp: int = dataclasses.field(default_factory=lambda: int(time.time())) + path_key: Optional[bytes] = None @staticmethod @stored_at('/channels/*/log/*/adds/*', tuple) - def from_tuple(amount_msat, rhash, cltv_abs, htlc_id, timestamp) -> 'UpdateAddHtlc': + def from_tuple(amount_msat, rhash, cltv_abs, htlc_id, timestamp, path_key = None) -> 'UpdateAddHtlc': return UpdateAddHtlc( amount_msat=amount_msat, payment_hash=bytes.fromhex(rhash), cltv_abs=cltv_abs, htlc_id=htlc_id, - timestamp=timestamp) + timestamp=timestamp, + path_key=None if not path_key else bytes.fromhex(path_key)) def to_json(self): self._validate() @@ -2134,3 +2154,56 @@ def reverse_from_total_amount(cls, *, total_amount_msat: int, config: 'SimpleCon fees_msat = max(total_amount_msat - amount_minus_fees, cutoff_clamped) fees_msat = min(fees_msat, total_amount_msat) # to handle (invalid?) inputs below cutoff_clamped return fees_msat + + def subtract_blinded_path_fees(self, blinded_payinfo: 'BlindedPayInfo', amount_msat: int) -> 'PaymentFeeBudget': + """Subtract the blinded path's aggregate fees from this budget, returning the remaining budget + available for the non-blinded part of the route (e.g. trampoline fees). + + Raises FeeBudgetExceeded if the blinded path fees or cltv exceed the budget. + """ + from .lnrouter import fee_for_edge_msat + blinded_fee_msat = fee_for_edge_msat( + forwarded_amount_msat=amount_msat, + fee_base_msat=blinded_payinfo.fee_base_msat, + fee_proportional_millionths=blinded_payinfo.fee_proportional_millionths, + ) + remaining_fee_msat = self.fee_msat - blinded_fee_msat + remaining_cltv = self.cltv - blinded_payinfo.cltv_expiry_delta + if remaining_fee_msat < 0 or remaining_cltv < 0: + raise FeeBudgetExceeded( + f"blinded path fees exceed budget: " + f"{blinded_fee_msat=}, fee budget: {self.fee_msat}, " + f"{blinded_payinfo.cltv_expiry_delta=}, cltv budget: {self.cltv=}" + ) + return PaymentFeeBudget(fee_msat=remaining_fee_msat, cltv=remaining_cltv) + + +@dataclasses.dataclass(kw_only=True, frozen=True) +class UnblindedRoutingInfo: + node_pubkey: bytes + payment_secret: bytes + final_cltv_delta: int # invoice + random offset + r_tags: Sequence[Sequence[Sequence[bytes | int]]] + invoice_features: LnFeatures + + @property + def id(self): + """key to prevent concurrent attempts of this payment (invoice)""" + return self.payment_secret + +@dataclasses.dataclass(kw_only=True, frozen=True) +class BlindedRoutingInfo: + paths: tuple['BlindedPathInfo', ...] + final_cltv_delta: int # random offset + invoice_features: LnFeatures + + def __post_init__(self): + assert self.paths + assert all(p.payinfo for p in self.paths) + + @property + def id(self): + first_path = self.paths[0].path + return sha256(first_path.first_node_id + first_path.first_path_key) + +RoutingInfo = Union[UnblindedRoutingInfo, BlindedRoutingInfo] diff --git a/electrum/lnwire/onion_wire.csv b/electrum/lnwire/onion_wire.csv index d63d8401d5b5..1dccc1df0276 100644 --- a/electrum/lnwire/onion_wire.csv +++ b/electrum/lnwire/onion_wire.csv @@ -16,13 +16,15 @@ tlvdata,payload,payment_metadata,payment_metadata,byte,... tlvtype,payload,total_amount_msat,18 tlvdata,payload,total_amount_msat,total_msat,tu64, tlvtype,payload,invoice_features,66097 -tlvdata,payload,invoice_features,invoice_features,u64, +tlvdata,payload,invoice_features,invoice_features,byte,... tlvtype,payload,outgoing_node_id,66098 tlvdata,payload,outgoing_node_id,outgoing_node_id,byte,33 tlvtype,payload,invoice_routing_info,66099 tlvdata,payload,invoice_routing_info,invoice_routing_info,byte,... tlvtype,payload,trampoline_onion_packet,66100 tlvdata,payload,trampoline_onion_packet,trampoline_onion_packet,byte,... +tlvtype,payload,outgoing_blinded_paths,66102 +tlvdata,payload,outgoing_blinded_paths,paths,payment_blinded_path,... tlvtype,encrypted_data_tlv,padding,1 tlvdata,encrypted_data_tlv,padding,padding,byte,... tlvtype,encrypted_data_tlv,short_channel_id,2 @@ -244,6 +246,9 @@ subtypedata,blinded_payinfo,htlc_minimum_msat,u64, subtypedata,blinded_payinfo,htlc_maximum_msat,u64, subtypedata,blinded_payinfo,flen,u16, subtypedata,blinded_payinfo,features,byte,flen +subtype,payment_blinded_path +subtypedata,payment_blinded_path,blinded_path,blinded_path, +subtypedata,payment_blinded_path,payment_info,blinded_payinfo, subtype,fallback_address subtypedata,fallback_address,version,byte, subtypedata,fallback_address,len,u16, diff --git a/electrum/lnwire/peer_wire.csv b/electrum/lnwire/peer_wire.csv index 2dafaede3ed3..e5d6c7baaa97 100644 --- a/electrum/lnwire/peer_wire.csv +++ b/electrum/lnwire/peer_wire.csv @@ -116,8 +116,9 @@ msgdata,update_add_htlc,amount_msat,u64, msgdata,update_add_htlc,payment_hash,sha256, msgdata,update_add_htlc,cltv_expiry,u32, msgdata,update_add_htlc,onion_routing_packet,byte,1366 -tlvtype,update_add_htlc_tlvs,blinding_point,0 -tlvdata,update_add_htlc_tlvs,blinding_point,blinding,point, +msgdata,update_add_htlc,tlvs,update_add_htlc_tlvs, +tlvtype,update_add_htlc_tlvs,blinded_path,0 +tlvdata,update_add_htlc_tlvs,blinded_path,path_key,point, msgtype,update_fulfill_htlc,130 msgdata,update_fulfill_htlc,channel_id,channel_id, msgdata,update_fulfill_htlc,id,u64, diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 6e65cf626854..7686b6299a37 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -4,9 +4,11 @@ import asyncio import os +import io from decimal import Decimal import random import time +import hmac from enum import IntEnum from typing import ( Optional, Sequence, Tuple, List, Set, Dict, TYPE_CHECKING, NamedTuple, Mapping, Any, Iterable, AsyncGenerator, @@ -27,14 +29,15 @@ import aiohttp import dns.asyncresolver import dns.exception +from electrum_ecc import ECPrivkey, ECPubkey from aiorpcx import run_in_thread, NetAddress, ignore_after +from .bolt12 import BOLT12Offer, BOLT12Invoice, BOLT12InvoiceRequest, Bolt12InvoiceError, extract_shared_fields from .logging import Logger from .i18n import _ from .channel_db import UpdateStatus, ChannelDBNotLoaded, get_mychannel_info, get_mychannel_policy -from . import constants, util, lnutil -from . import bitcoin +from . import constants, util, lnutil, bitcoin, bolt12 from .util import ( profiler, OldTaskGroup, ESocksProxy, NetworkRetryManager, JsonRPCClient, NotEnoughFunds, EventListener, event_listener, bfh, InvoiceError, resolve_dns_srv, is_ip_address, log_exceptions, ignore_exceptions, @@ -54,10 +57,14 @@ Transaction, get_script_type_from_output_script, PartialTxOutput, PartialTransaction, PartialTxInput ) from .crypto import ( - sha256, chacha20_encrypt, chacha20_decrypt, pw_encode_with_version_and_mac, pw_decode_with_version_and_mac + sha256, chacha20_encrypt, chacha20_decrypt, pw_encode_with_version_and_mac, pw_decode_with_version_and_mac, + hmac_oneshot ) -from .onion_message import OnionMessageManager +from .onion_message import ( + OnionMessageManager, get_blinded_reply_paths, NoOnionMessagePeers, get_blinded_paths_to_me, + NoRouteBlindingChannelPeers, +) from .lntransport import ( LNTransport, LNResponderTransport, LNTransportBase, LNPeerAddr, split_host_port, extract_nodeid, ConnStringFormatError @@ -73,16 +80,17 @@ OnchainChannelBackupStorage, ln_compare_features, IncompatibleLightningFeatures, PaymentFeeBudget, NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE, GossipForwardingMessage, MIN_FUNDING_SAT, MIN_FINAL_CLTV_DELTA_BUFFER_INVOICE, RecvMPPResolution, ReceivedMPPStatus, ReceivedMPPHtlc, - PaymentSuccess, ChannelType, LocalConfig, Keypair, ZEROCONF_TIMEOUT, + PaymentSuccess, ChannelType, LocalConfig, Keypair, ZEROCONF_TIMEOUT, UnblindedRoutingInfo, BlindedRoutingInfo, RoutingInfo ) from .lnonion import ( - decode_onion_error, OnionFailureCode, OnionRoutingFailure, OnionPacket, - ProcessedOnionPacket, calc_hops_data_for_payment, new_onion_packet, + decode_onion_error, OnionFailureCode, OnionRoutingFailure, OnionPacket, BlindedPath, + ProcessedOnionPacket, calc_hops_data_for_payment, new_onion_packet, calc_hops_data_for_blinded_payment, + BlindedPathInfo, ) -from .lnmsg import decode_msg +from .lnmsg import decode_msg, OnionWireSerializer from .lnrouter import ( RouteEdge, LNPaymentRoute, LNPaymentPath, is_route_within_budget, NoChannelPolicy, - LNPathInconsistent, fee_for_edge_msat, + LNPathInconsistent, fee_for_edge_msat, FinalForwardFees, ) from .lnwatcher import LNWatcher from .submarine_swaps import SwapManager @@ -91,6 +99,7 @@ create_trampoline_route_and_onion, is_legacy_relay, trampolines_by_id, hardcoded_trampoline_nodes, is_hardcoded_trampoline, decode_routing_info, encode_next_trampolines, decode_next_trampolines ) +from .lrucache import LRUCache from .stored_dict import StoredDict if TYPE_CHECKING: @@ -132,7 +141,7 @@ class PaymentInfo: amount_msat: Optional[int] direction: lnutil.Direction status: int - min_final_cltv_delta: int + min_final_cltv_delta: Optional[int] expiry_delay: int creation_ts: int = dataclasses.field(default_factory=lambda: int(time.time())) invoice_features: LnFeatures @@ -148,7 +157,12 @@ def validate(self): if self.direction == RECEIVED: assert self.amount_msat != 0 # use amount_msat=None instead! assert isinstance(self.status, int) - assert isinstance(self.min_final_cltv_delta, int) + if self.min_final_cltv_delta is None: + # when sending blinded payments we don't know the *final* cltv delta as we only see the aggregate + # cltv delta of the given blinded path + assert self.direction == lnutil.Direction.SENT + else: + assert isinstance(self.min_final_cltv_delta, int) assert isinstance(self.expiry_delay, int) and self.expiry_delay > 0, repr(self.expiry_delay) assert isinstance(self.creation_ts, int) assert isinstance(self.invoice_features, LnFeatures) @@ -170,13 +184,14 @@ def calc_db_key(cls, *, payment_hash_hex: str, direction: lnutil.Direction) -> s class SentHtlcInfo(NamedTuple): route: LNPaymentRoute - payment_secret_orig: bytes - payment_secret_bucket: bytes + payment_key: bytes amount_msat: int bucket_msat: int amount_receiver_msat: int trampoline_fee_level: Optional[int] trampoline_route: Optional[LNPaymentRoute] + per_trampoline_payment_secret: Optional[bytes] + blinded_path: Optional[BlindedPathInfo] class ErrorAddingPeer(Exception): pass @@ -210,6 +225,8 @@ class ErrorAddingPeer(Exception): pass | LnFeatures.OPTION_SCID_ALIAS_OPT | LnFeatures.OPTION_SUPPORT_LARGE_CHANNEL_OPT | LnFeatures.OPTION_CHANNEL_TYPE_REQ + | LnFeatures.OPTION_ONION_MESSAGE_OPT + | LnFeatures.OPTION_ROUTE_BLINDING_OPT ) LNGOSSIP_FEATURES = ( @@ -856,28 +873,21 @@ class PaySession(Logger): def __init__( self, *, + routing_info: 'RoutingInfo', 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 ): assert payment_hash - assert payment_secret self.payment_hash = payment_hash - self.payment_secret = payment_secret - self.payment_key = payment_hash + payment_secret + self.routing_info = routing_info + self.payment_key = payment_hash + routing_info.id 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() @@ -960,9 +970,10 @@ def add_new_htlc(self, sent_htlc_info: SentHtlcInfo): 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 + bkey = shi.per_trampoline_payment_secret # if we sent MPP to a trampoline, add item to sent_buckets if self.uses_trampoline and shi.amount_msat != shi.bucket_msat: + assert bkey is not None if bkey not in self._sent_buckets: self._sent_buckets[bkey] = (0, 0) amount_sent, amount_failed = self._sent_buckets[bkey] @@ -972,8 +983,9 @@ def add_new_htlc(self, sent_htlc_info: SentHtlcInfo): 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 + bkey = shi.per_trampoline_payment_secret if self.uses_trampoline and bkey in self._sent_buckets: + assert bkey is not None amount_sent, amount_failed = self._sent_buckets[bkey] amount_failed += shi.amount_receiver_msat self._sent_buckets[bkey] = amount_sent, amount_failed @@ -988,6 +1000,14 @@ def on_htlc_fail_get_fail_amt_to_propagate(self, sent_htlc_info: SentHtlcInfo) - def get_outstanding_amount_to_send(self) -> int: return self.amount_to_pay - self._amount_inflight + def get_blinded_path_to_try(self) -> Optional[BlindedPathInfo]: + # TODO: check direct channels + # TODO: consider amount to send + # TODO: consider previously failed paths + if isinstance(self.routing_info, BlindedRoutingInfo): + return self.routing_info.paths[0] + return None + def can_be_deleted(self) -> bool: """Returns True iff finished sending htlcs AND all pending htlcs have resolved.""" if self.is_active: @@ -1006,6 +1026,7 @@ class LNWallet(Logger): PAYMENT_TIMEOUT = 120 MPP_SPLIT_PART_FRACTION = 0.2 MPP_SPLIT_PART_MINAMT_MSAT = 5_000_000 + MAX_PENDING_ONION_MESSAGES_INVOICE_REQUEST = 100 def __init__(self, wallet: 'Abstract_Wallet', xprv, *, features: LnFeatures = None): self.wallet = wallet @@ -1016,6 +1037,7 @@ def __init__(self, wallet: 'Abstract_Wallet', xprv, *, features: LnFeatures = No self.backup_key = generate_keypair(BIP32Node.from_xkey(xprv), LnKeyFamily.BACKUP_CIPHER).privkey self.static_payment_key = generate_keypair(BIP32Node.from_xkey(xprv), LnKeyFamily.PAYMENT_BASE) self.payment_secret_key = generate_keypair(BIP32Node.from_xkey(xprv), LnKeyFamily.PAYMENT_SECRET_KEY).privkey + self.bolt12_secret_key = generate_keypair(BIP32Node.from_xkey(xprv), LnKeyFamily.BOLT12_SECRET_KEY).privkey self.funding_root_keypair = generate_keypair(BIP32Node.from_xkey(xprv), LnKeyFamily.FUNDING_ROOT_KEY) Logger.__init__(self) if features is None: @@ -1088,6 +1110,11 @@ def __init__(self, wallet: 'Abstract_Wallet', xprv, *, features: LnFeatures = No # preimage available. Might be used in combination with dont_expire_htlcs. self.dont_settle_htlcs = self.db.get_dict('dont_settle_htlcs') # type: Dict[str, None] + # invoice requests awaiting bolt 12 invoice response. path_id: concurrent.futures.Future[BOLT12Invoice, invoice_tlv] + self._pending_bolt12_invoice_requests = {} # type: dict[bytes, asyncio.Future[tuple[BOLT12Invoice, bytes]]] + self._bolt12_invoice_cache = LRUCache(maxsize=100) # type: LRUCache[bytes, tuple[bytes, bytes]] + self._outgoing_bolt12_invoice_responses = LRUCache(maxsize=1000) # type: LRUCache[bytes, bytes] # payment_hash -> onion message key + # payment_hash -> callback: self.hold_invoice_callbacks = {} # type: Dict[bytes, Callable[[bytes], Awaitable[None]]] self._payment_bundles_pkey_to_canon = {} # type: Dict[bytes, bytes] # TODO: persist @@ -1895,16 +1922,18 @@ async def pay_invoice( When paying a hold-invoice, or during a submarine swap, it is often the case that the recipient does not YET know the preimage, and hence they cannot take the money until later. """ - bolt11 = invoice.lightning_invoice - lnaddr = self._check_bolt11_invoice(bolt11, 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() + assert invoice.is_lightning(), invoice + if bolt12_invoice := invoice.bolt12_invoice: + self._check_bolt12_invoice(bolt12_invoice, amount_msat=amount_msat) + elif bolt11 := invoice.lightning_invoice: + bolt11_addr = self._check_bolt11_invoice(bolt11, amount_msat=amount_msat) + # synchronize amount with invoice, otherwise we potentially have three different amount_msat? + invoice.set_amount_msat(bolt11_addr.get_amount_msat()) + + key = rhash = invoice.rhash + payment_hash = bytes.fromhex(rhash) + amount_to_pay = invoice.get_amount_msat() + routing_info = invoice.get_routing_info() status = self.get_payment_status(payment_hash, direction=SENT) if status == PR_PAID: raise PaymentFailure(_("This invoice has been paid already")) @@ -1917,12 +1946,12 @@ async def pay_invoice( amount_msat=amount_to_pay, direction=SENT, status=PR_UNPAID, - min_final_cltv_delta=min_final_cltv_delta, + min_final_cltv_delta=None, expiry_delay=LN_EXPIRY_NEVER, - invoice_features=invoice_features, + invoice_features=invoice.features, ) self.save_payment_info(info) - self.wallet.set_label(key, lnaddr.get_description()) + self.wallet.set_label(key, invoice.get_message()) self.set_invoice_status(key, PR_INFLIGHT) if budget is None: budget = PaymentFeeBudget.from_invoice_amount(invoice_amount_msat=amount_to_pay, config=self.config) @@ -1932,13 +1961,10 @@ async def pay_invoice( success = False try: await self.pay_to_node( - node_pubkey=invoice_pubkey, + routing_info=routing_info, 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, + invoice_features=invoice.features, attempts=attempts, full_path=full_path, channels=channels, @@ -1965,12 +1991,9 @@ async def pay_invoice( @log_exceptions async def pay_to_node( self, *, - node_pubkey: bytes, + routing_info: 'RoutingInfo', 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, @@ -1988,26 +2011,25 @@ async def pay_to_node( assert budget.fee_msat >= 0, budget assert budget.cltv >= 0, budget - payment_key = payment_hash + payment_secret + payment_key = payment_hash + routing_info.id assert payment_key not in self._paysessions self._paysessions[payment_key] = paysession = PaySession( + routing_info=routing_info, 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(), ) self.logs[payment_hash.hex()] = log = [] # TODO incl payment_secret in key (re trampoline forwarding) + route_info = f"{len(routing_info.paths)} blinded path(s)" if isinstance(routing_info, BlindedRoutingInfo) \ + else "r_tags:" + str(BOLT11Addr.format_bolt11_routing_info_as_human_readable(routing_info.r_tags)) 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"r_tags={BOLT11Addr.format_bolt11_routing_info_as_human_readable(r_tags)}. " + f"route_info={route_info}. " f"{amount_to_pay=} msat. {budget=}") if not self.uses_trampoline(): self.logger.info( @@ -2150,15 +2172,27 @@ async def pay_to_route( if not peer: raise PaymentFailure('Dropped peer') await peer.initialized + + if shi.blinded_path: + payment_secret = None + elif shi.per_trampoline_payment_secret: + assert trampoline_onion + payment_secret = shi.per_trampoline_payment_secret + else: + assert isinstance(paysession.routing_info, UnblindedRoutingInfo) + payment_secret = paysession.routing_info.payment_secret + htlc = peer.pay( route=shi.route, chan=chan, amount_msat=shi.amount_msat, total_msat=shi.bucket_msat, payment_hash=paysession.payment_hash, + payment_secret=payment_secret, + blinded_path_info=shi.blinded_path, min_final_cltv_delta=min_final_cltv_delta, - payment_secret=shi.payment_secret_bucket, - trampoline_onion=trampoline_onion) + trampoline_onion=trampoline_onion, + ) key = (paysession.payment_hash, short_channel_id, htlc.htlc_id) self.sent_htlcs_info[key] = shi @@ -2322,6 +2356,17 @@ def _check_bolt11_invoice(self, bolt11_invoice: str, *, amount_msat: int = None) addr.validate_and_compare_features(self.features) return addr + def _check_bolt12_invoice(self, b12i: 'BOLT12Invoice', *, amount_msat: int = None) -> None: + """pre-payment checks for bolt12 invoice external to the parser/__post_init__.""" + # most variables are checked in BlindedPayInfo.__post_init__ + if b12i.is_expired: + raise InvoiceError(_("This invoice has expired")) + # check amount + if amount_msat is not None and amount_msat != b12i.invoice_amount: + raise ValueError("should have requested a higher amount invoice beforehand") + if b12i.invoice_features is not None: + lnutil.ln_compare_features(self.features.for_bolt12_invoice(), b12i.invoice_features) + 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): @@ -2345,8 +2390,7 @@ def suggest_payment_splits( final_total_msat: int, my_active_channels: Sequence[Channel], invoice_features: LnFeatures, - r_tags: Sequence[Sequence[Sequence[Any]]], - receiver_pubkey: bytes, + routing_info: 'RoutingInfo', ) -> List['SplitConfigRating']: channels_with_funds = { (chan.channel_id, chan.node_id): ( int(chan.available_to_spend(HTLCOwner.LOCAL)), chan.htlc_slots_left(HTLCOwner.LOCAL)) @@ -2354,19 +2398,33 @@ def suggest_payment_splits( } # if we have a direct channel it's preferable to send a single part directly through this # channel, so this bool will disable excluding single part payments - have_direct_channel = any(chan.node_id == receiver_pubkey for chan in my_active_channels) + if isinstance(routing_info, BlindedRoutingInfo): + recipient_pubkeys = {p.path.first_node_id for p in routing_info.paths} + else: + recipient_pubkeys = [routing_info.node_pubkey] + have_direct_channel = any(chan.node_id in recipient_pubkeys for chan in my_active_channels) self.logger.info(f"channels_with_funds: {channels_with_funds}, {have_direct_channel=}") exclude_single_part_payments = False + exclude_multinode_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) + if isinstance(routing_info, UnblindedRoutingInfo): + # 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, routing_info.r_tags) + else: + # In blinded payments the total_amount has to be included in the payment recipients onion. + # During blinded legacy payments the last trampoline forwarder gets the blinded path(s) in his trampoline onion + # and constructs the onion for the recipient. There is no specified mechanism to tell the + # last trampoline forwarder the total payment amount, and Eclair just uses the amount_to_forward + # as total_amount when forwarding the payment, so multinode cannot work. See: + # https://github.com/ACINQ/eclair/blob/2dda79468a8b69a2acf7962cdac63245f7cc3ee8/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/NodeRelay.scala#L281 + is_legacy, _ = is_legacy_relay(invoice_features, None) + is_legacy = True # FIXME: blinded payments are always legacy, see create_trampoline_route(). 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 = not self.config.TEST_FORCE_MPP 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: @@ -2395,7 +2453,6 @@ async def create_routes_for_payment( 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. @@ -2403,6 +2460,8 @@ async def create_routes_for_payment( and mpp is supported by the receiver, we will split the payment.""" trampoline_features = LnFeatures.VAR_ONION_OPT local_height = self.wallet.adb.get_local_height() + routing_info = paysession.routing_info + blinded_path = paysession.get_blinded_path_to_try() fee_related_error = None # type: Optional[FeeBudgetExceeded] if channels: my_active_channels = channels @@ -2417,8 +2476,7 @@ async def create_routes_for_payment( final_total_msat=paysession.amount_to_pay, my_active_channels=my_active_channels, invoice_features=paysession.invoice_features, - r_tags=paysession.r_tags, - receiver_pubkey=paysession.invoice_pubkey, + routing_info=paysession.routing_info, ) for sc in split_configurations: is_multichan_mpp = len(sc.config.items()) > 1 @@ -2432,7 +2490,9 @@ async def create_routes_for_payment( self.logger.info(f"trying split configuration: {sc.config.values()} rating: {sc.rating}") routes = [] try: - is_direct_path = all(node_id == paysession.invoice_pubkey for (chan_id, node_id) in sc.config.keys()) + destination_pubkey = blinded_path.path.first_node_id if blinded_path else routing_info.node_pubkey + # FIXME: determine is_direct_path per sc key + is_direct_path = all(node_id == destination_pubkey for (chan_id, node_id) in sc.config.keys()) if self.uses_trampoline() and not is_direct_path: if fwd_trampoline_onion: raise NoPathFound() @@ -2448,25 +2508,22 @@ async def create_routes_for_payment( 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, + my_trampoline=trampoline_node_id, payment_hash=paysession.payment_hash, - payment_secret=paysession.payment_secret, local_height=local_height, trampoline_fee_level=paysession.trampoline_fee_level, next_trampolines=paysession.next_trampolines.get(trampoline_node_id, {}), failed_routes=paysession.failed_trampoline_routes, budget=budget._replace(fee_msat=budget.fee_msat // len(per_trampoline_channel_amounts)), + routing_info=routing_info, + blinded_path=blinded_path, ) # 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'trampoline hops: {[hop.end_node.hex() for hop in trampoline_route.edges]}') self.logger.info(f'per trampoline fees: {per_trampoline_fees}') for chan_id, part_amount_msat in trampoline_parts: chan = self._channels[chan_id] @@ -2488,13 +2545,15 @@ async def create_routes_for_payment( 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, + payment_key=paysession.payment_key, 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, + trampoline_route=trampoline_route.edges, + per_trampoline_payment_secret=per_trampoline_secret, + # blinded path is embedded in the trampoline onion for last trampoline forwarder + blinded_path=None, ) routes.append((shi, per_trampoline_cltv_delta, trampoline_onion)) if per_trampoline_fees != 0: @@ -2507,39 +2566,47 @@ async def create_routes_for_payment( for (chan_id, _), part_amounts_msat in sc.config.items(): for part_amount_msat in part_amounts_msat: channel = self._channels[chan_id] - if is_direct_path: - route = self.create_direct_route( - amount_msat=part_amount_msat, - channel=channel, - ) + if is_direct_path: # to recipient or blinded path introduction node + route = self.create_direct_route(channel=channel) else: assert not self.uses_trampoline() route = await run_in_thread(partial( self.create_route_for_single_htlc, amount_msat=part_amount_msat, - invoice_pubkey=paysession.invoice_pubkey, - r_tags=paysession.r_tags, - invoice_features=paysession.invoice_features, + routing_info=routing_info, + blinded_path=blinded_path, my_sending_channels=[channel] if is_multichan_mpp else my_active_channels, full_path=full_path, )) + final_forward_fee = None + if blinded_path: + payinfo = blinded_path.payinfo + final_forward_fee = FinalForwardFees( + fee_base_msat=payinfo.fee_base_msat, + fee_proportional_millionths=payinfo.fee_proportional_millionths, + ) if not is_route_within_budget( - route, budget=budget, - amount_msat_for_dest=amount_msat, - cltv_delta_for_dest=paysession.min_final_cltv_delta): + route, + budget=budget, + amount_msat_for_dest=amount_msat, + cltv_delta_for_dest=blinded_path.payinfo.cltv_expiry_delta + routing_info.final_cltv_delta \ + if blinded_path else routing_info.final_cltv_delta, + final_forward_fees=final_forward_fee, + ): self.logger.info(f"rejecting route (exceeds budget): {route=}. {budget=}") raise FeeBudgetExceeded() shi = SentHtlcInfo( route=route, - payment_secret_orig=paysession.payment_secret, - payment_secret_bucket=paysession.payment_secret, + payment_key=paysession.payment_key, amount_msat=part_amount_msat, bucket_msat=paysession.amount_to_pay, amount_receiver_msat=part_amount_msat, trampoline_fee_level=None, trampoline_route=None, + per_trampoline_payment_secret=None, + blinded_path=blinded_path, ) - routes.append((shi, paysession.min_final_cltv_delta, fwd_trampoline_onion)) + routes.append((shi, routing_info.final_cltv_delta, fwd_trampoline_onion)) except NoPathFound: continue except FeeBudgetExceeded as e: @@ -2554,7 +2621,6 @@ async def create_routes_for_payment( def create_direct_route( self, *, - amount_msat: int, # that final receiver gets channel: Channel, ) -> LNPaymentRoute: self.logger.info(f'create_direct_route {channel.node_id.hex()}') @@ -2581,23 +2647,59 @@ def create_direct_route( def create_route_for_single_htlc( self, *, amount_msat: int, # that final receiver gets - invoice_pubkey: bytes, - r_tags, - invoice_features: int, + routing_info: 'RoutingInfo', + blinded_path: Optional['BlindedPathInfo'], my_sending_channels: List[Channel], full_path: Optional[LNPaymentPath], ) -> 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} + + private_route_edges = {} + if not blinded_path: + assert isinstance(routing_info, UnblindedRoutingInfo) + private_route_edges = self._create_private_route_edges_from_r_tags( + routing_info=routing_info, + my_sending_aliases=my_sending_aliases, + my_sending_channels=my_sending_channels, + ) + + # now find a route, end to end: between us and the recipient (unblinded) or introduction point (blinded) + dest_node = blinded_path.path.first_node_id if blinded_path else routing_info.node_pubkey + try: + route = self.network.path_finder.find_route( + nodeA=self.node_keypair.pubkey, + nodeB=dest_node, + 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() + assert len(route) > 0 + if route[-1].end_node != dest_node: + route_target = "blinded path introduction point" if isinstance(routing_info, BlindedRoutingInfo) else "invoice pubkey" + raise LNPathInconsistent(f"last node_id != {route_target}") + # add recipient features from invoice if unblinded or blinded path features for IP from blinded payinfo + route[-1].node_features |= blinded_path.payinfo.features if blinded_path else routing_info.invoice_features + return route + + def _create_private_route_edges_from_r_tags( + self, + routing_info: 'UnblindedRoutingInfo', + my_sending_aliases: set[Optional[bytes]], + my_sending_channels: dict['ShortChannelID', 'Channel'], + ) -> Dict[ShortChannelID, RouteEdge]: # 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: + for private_path in routing_info.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_nodes = [edge[0] for edge in private_path][1:] + [routing_info.node_pubkey] private_path_rest = [edge[1:] for edge in private_path] start_node = private_path[0][0] # remove aliases from direct routes @@ -2624,34 +2726,17 @@ def create_route_for_single_htlc( 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) + 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() - 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 + + return private_route_edges def _prepare_invoice_features(self, base_features: LnFeatures, *, amount_msat: Optional[int]) -> LnFeatures: if not all((not c.is_open() or c.is_frozen_for_receiving()) or self.is_trampoline_peer(c.node_id) \ @@ -3168,8 +3253,7 @@ def htlc_fulfilled(self, chan: Channel, payment_hash: bytes, htlc_id: int): shi = self.sent_htlcs_info.get((payment_hash, chan.short_channel_id, htlc_id)) if shi and htlc_id in chan.onion_keys: chan.pop_onion_key(htlc_id) - payment_key = payment_hash + shi.payment_secret_orig - paysession = self._paysessions[payment_key] + paysession = self._paysessions[shi.payment_key] q = paysession.sent_htlcs_q htlc_log = HtlcLog( success=True, @@ -3178,7 +3262,7 @@ def htlc_fulfilled(self, chan: Channel, payment_hash: bytes, htlc_id: int): trampoline_fee_level=shi.trampoline_fee_level) q.put_nowait(htlc_log) if paysession.can_be_deleted(): - self._paysessions.pop(payment_key) + self._paysessions.pop(shi.payment_key) paysession_active = False else: paysession_active = True @@ -3214,8 +3298,7 @@ def htlc_failed( shi = self.sent_htlcs_info.get((payment_hash, chan.short_channel_id, htlc_id)) if shi and htlc_id in chan.onion_keys: onion_key = chan.pop_onion_key(htlc_id) - payment_okey = payment_hash + shi.payment_secret_orig - paysession = self._paysessions[payment_okey] + paysession = self._paysessions[shi.payment_key] q = paysession.sent_htlcs_q # detect if it is part of a bucket # if yes, wait until the bucket completely failed @@ -3250,7 +3333,7 @@ def htlc_failed( trampoline_fee_level=shi.trampoline_fee_level) q.put_nowait(htlc_log) if paysession.can_be_deleted(): - self._paysessions.pop(payment_okey) + self._paysessions.pop(shi.payment_key) paysession_active = False else: paysession_active = True @@ -3987,10 +4070,21 @@ def log_fail_reason(reason: str): raise OnionRoutingFailure(code=OnionFailureCode.TEMPORARY_NODE_FAILURE, data=b'') if (next_chan_scid := processed_onion.next_chan_scid) is None: raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') - if (next_amount_msat_htlc := processed_onion.amt_to_forward) is None: - raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') - if (next_cltv_abs := processed_onion.outgoing_cltv_value) is None: - raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') + if not processed_onion.blinded_path_recipient_data: + if (next_amount_msat_htlc := processed_onion.amt_to_forward) is None: + raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') + if (next_cltv_abs := processed_onion.outgoing_cltv_value) is None: + raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') + else: + # blinded path, take from recipient_data + payment_relay = processed_onion.blinded_path_recipient_data.get('payment_relay') + if not payment_relay: + raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') + next_amount_msat_htlc = htlc.amount_msat + next_amount_msat_htlc -= int(next_amount_msat_htlc * payment_relay.get('fee_proportional_millionths') / 1_000_000) + next_amount_msat_htlc -= payment_relay.get('fee_base_msat') + + next_cltv_abs = htlc.cltv_abs - payment_relay.get('cltv_expiry_delta') next_chan = self.get_channel_by_short_id(next_chan_scid) @@ -4064,6 +4158,7 @@ def log_fail_reason(reason: str): amount_msat=next_amount_msat_htlc, cltv_abs=next_cltv_abs, onion=processed_onion.next_packet, + next_path_key=processed_onion.next_path_key, ) except BaseException as e: log_fail_reason(f"error sending message to next_peer={next_chan.node_id.hex()}") @@ -4081,7 +4176,7 @@ async def _maybe_forward_trampoline( any_trampoline_onion: ProcessedOnionPacket, # any trampoline onion of the incoming htlc set, they should be similar fw_payment_key: str, ) -> None: - + # todo: blinded payment forwarding forwarding_enabled = self.network.config.EXPERIMENTAL_LN_FORWARD_PAYMENTS forwarding_trampoline_enabled = self.network.config.EXPERIMENTAL_LN_FORWARD_TRAMPOLINE_PAYMENTS if not (forwarding_enabled and forwarding_trampoline_enabled): @@ -4097,7 +4192,7 @@ async def _maybe_forward_trampoline( if "invoice_features" in payload: self.logger.info('forward_trampoline: legacy') next_trampoline_onion = None - invoice_features = payload["invoice_features"]["invoice_features"] + invoice_features = int.from_bytes(payload["invoice_features"]["invoice_features"], byteorder="big") invoice_routing_info = payload["invoice_routing_info"]["invoice_routing_info"] r_tags = decode_routing_info(invoice_routing_info) self.logger.info(f'r_tags {BOLT11Addr.format_bolt11_routing_info_as_human_readable(r_tags)}') @@ -4163,6 +4258,7 @@ async def _maybe_forward_trampoline( min_final_cltv_delta=cltv_budget_for_rest_of_route, payment_secret=payment_secret, trampoline_onion=next_trampoline_onion, + blinded_path_info=None, ) await self.open_channel_just_in_time( next_peer=next_peer, @@ -4178,14 +4274,18 @@ async def _maybe_forward_trampoline( if budget.cltv < 576: raise OnionRoutingFailure(code=OnionFailureCode.TRAMPOLINE_EXPIRY_TOO_SOON, data=b'') + routing_info = UnblindedRoutingInfo( + node_pubkey=outgoing_node_id, + payment_secret=payment_secret, + final_cltv_delta=cltv_budget_for_rest_of_route, + r_tags=r_tags, + invoice_features=LnFeatures(invoice_features), + ) try: await self.pay_to_node( - node_pubkey=outgoing_node_id, + routing_info=routing_info, payment_hash=payment_hash, - payment_secret=payment_secret, amount_to_pay=amt_to_forward, - min_final_cltv_delta=cltv_budget_for_rest_of_route, - r_tags=r_tags, invoice_features=invoice_features, fwd_trampoline_onion=next_trampoline_onion, budget=budget, @@ -4236,24 +4336,45 @@ def create_onion_for_route( total_msat: int, payment_hash: bytes, min_final_cltv_delta: int, - payment_secret: bytes, + payment_secret: Optional[bytes], + blinded_path_info: Optional['BlindedPathInfo'], trampoline_onion: Optional[OnionPacket] = None, ): + if trampoline_onion: + assert not blinded_path_info, "blinded path should be in trampoline onion, not passed to outer onion" + assert payment_secret, "Need outer payment secret for onion including the trampoline onion" + else: + assert bool(payment_secret) ^ bool(blinded_path_info), "requires either blinded path or payment secret" + # add features learned during "init" for direct neighbour: route[0].node_features |= self.features local_height = self.network.get_local_height() final_cltv_abs = local_height + min_final_cltv_delta - hops_data, amount_msat, cltv_abs = calc_hops_data_for_payment( - route, - amount_msat, - final_cltv_abs=final_cltv_abs, - total_msat=total_msat, - payment_secret=payment_secret) + if not blinded_path_info: + # can still be a blinded trampoline payment + hops_data, amount_msat, cltv_abs = calc_hops_data_for_payment( + route, + amount_msat, + final_cltv_abs=final_cltv_abs, + total_msat=total_msat, + payment_secret=payment_secret) + payment_path_pubkeys = [x.node_id for x in route] + else: + assert blinded_path_info.path and blinded_path_info.payinfo, blinded_path_info + hops_data, blinded_node_ids, amount_msat, cltv_abs = calc_hops_data_for_blinded_payment( + route_to_introduction_point=route, + recipient_amount_msat=amount_msat, + final_cltv_abs=final_cltv_abs, + total_msat=total_msat, + invoice_blinded_path_info=blinded_path_info, + ) + payment_path_pubkeys = [x.node_id for x in route] + blinded_node_ids + + assert final_cltv_abs <= cltv_abs, (final_cltv_abs, cltv_abs) self.logger.info(f"pay len(route)={len(route)}. for payment_hash={payment_hash.hex()}") for i in range(len(route)): self.logger.info(f" {i}: edge={route[i].short_channel_id} hop_data={hops_data[i]!r}") - assert final_cltv_abs <= cltv_abs, (final_cltv_abs, cltv_abs) - session_key = os.urandom(32) # session_key + # if we are forwarding a trampoline payment, add trampoline onion if trampoline_onion: self.logger.info(f'adding trampoline onion to final payload') @@ -4268,8 +4389,8 @@ def create_onion_for_route( self.logger.info(f"lnpeer.pay len(t_route)={len(t_route)}") for i in range(len(t_route)): self.logger.info(f" {i}: t_node={t_route[i].end_node.hex()} hop_data={t_hops_data[i]!r}") - # create onion packet - payment_path_pubkeys = [x.node_id for x in route] + + session_key = os.urandom(32) # session_key onion = new_onion_packet(payment_path_pubkeys, session_key, hops_data, associated_data=payment_hash) # must use another sessionkey self.logger.info(f"starting payment. len(route)={len(hops_data)}.") # create htlc @@ -4293,3 +4414,466 @@ def get_forwarding_failure(self, payment_key: str) -> Tuple[Optional[bytes], Opt 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 + + async def request_bolt12_invoice( + self, + bolt12_offer: BOLT12Offer, + *, + amount_msat: int, + payer_note: Optional[str] = None, + quantity: Optional[int] = None, + timeout: int = LN_P2P_NETWORK_TIMEOUT, + ) -> tuple[BOLT12Invoice, bytes]: + """ + Creates an invoice_request, sends it to payee, returns the Invoice response once it arrived. + """ + unsigned_invreq, invreq_signing_key = self.create_bolt12_invoice_request( + offer=bolt12_offer, + amount_msat=amount_msat, + payer_note=payer_note, + quantity=quantity, + ) + + node_id_or_blinded_paths = bolt12_offer.offer_paths or bolt12_offer.offer_issuer_id + assert node_id_or_blinded_paths + invreq_tlv = unsigned_invreq.encode(signing_key=invreq_signing_key.get_secret_bytes(), as_bech32=False) + path_id = invreq_signing_key.get_secret_bytes() + reply_paths = get_blinded_reply_paths(self, path_id=path_id, max_paths=1) + req_payload = { + 'invoice_request': {'invoice_request': invreq_tlv}, + 'reply_path': {'path': dataclasses.asdict(reply_paths[0].path)}, + } + self._pending_bolt12_invoice_requests[invreq_signing_key.get_secret_bytes()] = fut = asyncio.Future() + self.logger.debug(f"sending bolt 12 invoice request") + send_task = self.onion_message_manager.submit_send( + payload=req_payload, + node_id_or_blinded_paths=node_id_or_blinded_paths, + ) + try: + async with util.async_timeout(timeout): + return await fut + finally: + del self._pending_bolt12_invoice_requests[path_id] + send_task.cancel() + + def create_bolt12_invoice_request( + self, + offer: Optional[BOLT12Offer], + *, + amount_msat: int, + payer_note: Optional[str] = None, + quantity: Optional[int] = None, + allow_unblinded: bool = False, + invreq_expiry: Optional[int] = None, + ) -> tuple[BOLT12InvoiceRequest, ECPrivkey]: + """ + Creates an unsigned invoice_request, as response to an offer (payment request), or standalone (e.g. voucher). + """ + invreq_chain = None if constants.net == constants.BitcoinMainnet else constants.net.rev_genesis_bytes() + payer_note = payer_note[:10_000] if payer_note else None # keep the size somewhat sane + + # we could also put the signing key into the path_id, saving some bytes in the invreq, however this is simpler + invreq_key_derivation_entropy = os.urandom(16) + invreq_signing_key = ECPrivkey(hmac_oneshot( + key=self.bolt12_secret_key, + msg=b'invreq_key' + invreq_key_derivation_entropy, + digest='sha-256', + )) + + if offer: # we got an offer and want to pay it, the 'default' flow. + assert invreq_expiry is None, "invreq_expiry is intended for standalone invreq" + if offer.offer_amount is not None: + assert amount_msat >= offer.offer_amount, (amount_msat, offer.offer_amount) + + invreq = BOLT12InvoiceRequest( + **offer.__dict__, + invreq_amount=amount_msat, + invreq_metadata=invreq_key_derivation_entropy, + invreq_payer_id=invreq_signing_key.get_public_key_bytes(), + invreq_payer_note=payer_note, + invreq_features=None, + invreq_chain=invreq_chain, + invreq_quantity=quantity, + ) + else: # standalone invoice_request as offer to pay someone + assert not quantity, "quantity is only allowed for offer requests" + reply_path_infos = None + try: + reply_path_infos = get_blinded_reply_paths(self, path_id=invreq_signing_key.get_secret_bytes()) + except NoOnionMessagePeers as e: + self.logger.debug(f"create_offer: no onion message peers: {str(e)}") + if not allow_unblinded: + raise + + reply_paths = tuple(p.path for p in reply_path_infos) if reply_path_infos else None + invreq_signing_key = invreq_signing_key if reply_paths else ECPrivkey(self.node_keypair.privkey) + invreq = BOLT12InvoiceRequest( + offer_description=payer_note, + offer_absolute_expiry=int(time.time()) + invreq_expiry if invreq_expiry else None, + invreq_amount=amount_msat, + # add some randomness, even when using the node keypair (unblinded), to prevent creating equal invreqs + invreq_metadata=invreq_key_derivation_entropy, + invreq_payer_id=invreq_signing_key.get_public_key_bytes(), + invreq_features=None, + invreq_chain=invreq_chain, + invreq_paths=reply_paths, + ) + + signed_invreq = invreq.encode(signing_key=invreq_signing_key.get_secret_bytes(), as_bech32=False) + # this allows us to verify the authenticity of invreq & offer fields contained in an invoice we receive. + # we can re-assemble & sign the invreq without the signed_invreq hash and compare the hashes + new_metadata = invreq.invreq_metadata + sha256(signed_invreq) + unsigned_invreq = dataclasses.replace(invreq, invreq_metadata=new_metadata) + + return unsigned_invreq, invreq_signing_key + + def on_bolt12_invoice(self, recipient_data: dict, payload: dict): + # if we hand out an unblinded invreq we might get an empty recipient_data (or malicious fake data?) + path_id: Optional[bytes] = recipient_data.get('path_id', {}).get('data') + # we embedded our random signing key as path_id, so the sender can't guess any valid path id + pending_invoice_request: Optional[asyncio.Future] = self._pending_bolt12_invoice_requests.get(path_id or b'') + + if invoice_error_tlv := payload.get('invoice_error', {}).get('invoice_error'): + self.logger.debug("received bolt 12 invoice error") + if pending_invoice_request: + pending_invoice_request.set_exception(Bolt12InvoiceError.from_tlv(invoice_error_tlv)) + return + + try: + invoice_tlv = payload['invoice']['invoice'] + invoice = BOLT12Invoice.decode(invoice_tlv) # __post_init__ does spec validation + except Exception as e: + if pending_invoice_request: + pending_invoice_request.set_exception(Bolt12InvoiceError(f"received invalid invoice: {e}")) + return + + # TODO: persist paid offerless invreqs, early return if already paid or ongoing payment attempt + is_offerless_invreq = invoice.invreq_paths or invoice.invreq_payer_id == self.node_keypair.pubkey + if not pending_invoice_request and not is_offerless_invreq: + return + + if not self._verify_bolt12_invoice_requested_by_us(invoice): + if pending_invoice_request: + pending_invoice_request.set_exception(Bolt12InvoiceError(f"unable to verify invoice content {invoice=}")) + return + self.logger.debug(f'received bolt 12 invoice: {invoice=}') + + # the usage of our reply path for offer based invreqs is already indirectly verified through the + # pending_invoice_request lookup above (if sender uses incorrect path we simply don't handle the invoice) + if invoice.invreq_paths: + assert is_offerless_invreq + encrypted_recipient_data = payload['encrypted_recipient_data']['encrypted_recipient_data'] + if encrypted_recipient_data not in (p.path[-1].encrypted_recipient_data for p in invoice.invreq_paths): + self.logger.warning(f"received invoice for offerless invreq on incorrect path") + return + + if invoice.is_expired: + if pending_invoice_request: + pending_invoice_request.set_exception(Bolt12InvoiceError(f"received expired invoice: {invoice=}")) + return + + if invoice.invoice_features is not None: + try: + ln_compare_features(self.features.for_bolt12_invoice(), invoice.invoice_features) + except lnutil.IncompatibleLightningFeatures: + if pending_invoice_request: + pending_invoice_request.set_exception(Bolt12InvoiceError(f"incompatible features: {invoice.invoice_features.get_names()}")) + return + + if pending_invoice_request: + pending_invoice_request.set_result((invoice, invoice_tlv)) + return + + if is_offerless_invreq: # and not already_paid: + raise NotImplementedError("initiate payment of invoice?") + + def _verify_bolt12_invoice_requested_by_us(self, invoice: BOLT12Invoice) -> bool: + if not invoice.invreq_metadata or not len(invoice.invreq_metadata) == 48: + return False + invreq_from_invoice: BOLT12InvoiceRequest = extract_shared_fields(invoice, BOLT12InvoiceRequest) + invreq_sig_digest, remaining_metadata = invoice.invreq_metadata[-32:], invoice.invreq_metadata[:-32] + assert len(invreq_sig_digest) == 32 and len(remaining_metadata) == 16, invoice + if invoice.invreq_payer_id == self.node_keypair.pubkey: + signing_key = self.node_keypair.privkey + else: + signing_key = hmac_oneshot( + key=self.bolt12_secret_key, + msg=b'invreq_key' + remaining_metadata, + digest='sha-256', + ) + signed_invreq = dataclasses.replace(invreq_from_invoice, invreq_metadata=remaining_metadata) + our_sig = signed_invreq.encode(signing_key=signing_key, as_bech32=False) + if not util.constant_time_compare(sha256(our_sig), invreq_sig_digest): + return False + return True + + def create_offer( + self, + *, + amount_msat: Optional[int] = None, + description: Optional[str] = None, + relative_expiry: Optional[int] = None, + issuer_name: Optional[str] = None, + allow_unblinded: bool = False, + ) -> 'BOLT12Offer': + """ + Create n bolt12 offer. + - allow_unblinded only makes sense if node_id is public, or for testing with direct electrum peer. + """ + # We always set an offer_issuer_id which we then can use as invoice_node_id for the invoice creation, + # otherwise we would need to use the blinded node id (and its secret) on which the invoice_request arrived to + # sign the corresponding invoice. This approach is simpler to reason about and implement. + # If we include blinded paths in this offer the path_id is the random private key for offer_issuer_id. + # If we don't include blinded paths (and allow_unblinded is True) we simply use the node keypair. + random_offer_issuer_id_key = ECPrivkey.generate_random_key() + path_id = random_offer_issuer_id_key.get_secret_bytes() + reply_path_infos = None + try: + reply_path_infos = get_blinded_reply_paths(self, path_id) + except NoOnionMessagePeers as e: + self.logger.debug(f"create_offer: no onion message peers: {str(e)}") + if not allow_unblinded: + raise + + reply_paths = tuple(p.path for p in reply_path_infos) if reply_path_infos else None + chains = [constants.net.rev_genesis_bytes()] if constants.net != constants.BitcoinMainnet else None + + # To be able to verify the offer fields inside a received invoice_request we first serialize an offer + # without metadata (or some randomness), then create a hmac on it which will be included as metadata of the final offer. + offer = BOLT12Offer( + # add some randomness if there are no (random) reply_paths (unblinded offer) so we create unique offers + offer_metadata=os.urandom(8) if not reply_paths else b'', + # description must not be None if amount is given (spec) + offer_description=(description or '') if amount_msat is not None else description, + offer_chains=chains, + offer_amount=amount_msat, + offer_absolute_expiry=int(time.time()) + relative_expiry if relative_expiry else None, + offer_issuer_id=self.node_keypair.pubkey if not reply_paths else random_offer_issuer_id_key.get_public_key_bytes(), + offer_issuer=issuer_name, + offer_paths=reply_paths, + ) + encoded_offer = offer.encode(as_bech32=False) + mac = hmac_oneshot(key=self.bolt12_secret_key, msg=b'offer' + encoded_offer, digest='sha-256') + new_metadata = offer.offer_metadata + mac + offer = dataclasses.replace(offer, offer_metadata=new_metadata) + + assert allow_unblinded if offer.offer_issuer_id == self.node_keypair.pubkey else offer.offer_paths, offer + return offer + + def _verify_bolt_12_offer_created_by_us(self, offer: BOLT12Offer) -> bool: + """Verify that given bolt12 offer was created by us.""" + if not offer.offer_metadata or len(offer.offer_metadata) < 32: + return False + # the last 32 bytes should be our mac + offer_mac, remaining_metadata = offer.offer_metadata[-32:], offer.offer_metadata[:-32] + offer_without_mac = dataclasses.replace(offer, offer_metadata=remaining_metadata) + encoded_offer = offer_without_mac.encode(as_bech32=False) + our_mac = hmac_oneshot(key=self.bolt12_secret_key, msg=b'offer' + encoded_offer, digest='sha-256') + if not util.constant_time_compare(our_mac, offer_mac): + return False + return True + + def on_bolt12_invoice_request(self, recipient_data: dict, payload: dict): + # protect against someone bloating our OnionMessageManager + if len(self.onion_message_manager.pending) > self.MAX_PENDING_ONION_MESSAGES_INVOICE_REQUEST: + self.logger.warning(f"not responding to invoice_request, {len(self.onion_message_manager.pending)=} too large") + return + + encrypted_recipient_data = payload['encrypted_recipient_data']['encrypted_recipient_data'] + invreq_tlv = payload['invoice_request']['invoice_request'] + invreq = bolt12.BOLT12InvoiceRequest.decode(invreq_tlv) + invreq_metadata_digest = sha256(invreq.invreq_metadata) + self.logger.info(f'invoice_request: {invreq=}') + + # two possible scenarios: + # 1) not in response to offer (no offer_issuer_id or offer_paths) + # 2) response to offer. + is_response_to_offer = invreq.offer_issuer_id or invreq.offer_paths + + if is_response_to_offer: + reply_path_or_node_id = [BlindedPath.from_dict(payload['reply_path']['path'])] + else: + # spec: MUST use invreq_paths if present, otherwise MUST use invreq_payer_id as the node id to send to. + reply_path_or_node_id = invreq.invreq_paths or invreq.invreq_payer_id + + # we are only sending one response per unique invreq received as the + # sender might throw the same invreq at us many times + response_key = sha256(invreq_tlv)[:8] + if response_key in self.onion_message_manager.pending: + self.logger.debug(f"dropping incoming invreq, response already pending") + return + + try: + if is_response_to_offer: + offer: BOLT12Offer = extract_shared_fields(invreq, BOLT12Offer) + # verify the offer was created by us + if not self._verify_bolt_12_offer_created_by_us(offer): + raise Bolt12InvoiceError("no matching offer for this invoice request") + if offer.offer_paths: + # verify the invreq arrived on a blinded path intended by us for this offer + # MUST ignore the invoice_request if it did not arrive via one of those paths. + if encrypted_recipient_data not in (p.path[-1].encrypted_recipient_data for p in offer.offer_paths): + self.logger.debug(f"ignoring invoice_request arriving not on offer path") + return + else: + # MUST ignore any invoice_request if it arrived via a blinded path. + if payload.get('path_id', {}).get('data') is not None: + self.logger.debug(f"ignoring invoice_request arriving on blinded path for unblinded offer") + return + if offer.offer_absolute_expiry and int(time.time()) > offer.offer_absolute_expiry: + raise Bolt12InvoiceError('offer already expired') + + # spec: if offer_issuer_id is present: MUST set invoice_node_id to the offer_issuer_id + assert invreq.offer_issuer_id, "we only create offers with offer_issuer_id" + if offer.offer_issuer_id == self.node_keypair.pubkey: + invoice_signing_key = self.node_keypair.privkey + else: + invoice_signing_key = recipient_data['path_id']['data'] + else: + # TODO: this could be made deterministic so we can proof later on that we signed given invoice? + invoice_signing_key = os.urandom(32) + + cached_invoice = self._bolt12_invoice_cache.get(invreq_metadata_digest) + if invreq.offer_issuer_id and cached_invoice: + # if offer_issuer_id is present, and invreq_metadata is identical to a previous invoice_request: + # -> MAY simply reply with the previous invoice. + serialized_invoice, payment_hash = cached_invoice + else: + serialized_invoice, payment_hash = self._create_bolt12_invoice_for_invoice_request( + invreq=invreq, + invoice_signing_key=invoice_signing_key, + ) + self._bolt12_invoice_cache[invreq_metadata_digest] = serialized_invoice, payment_hash + except Bolt12InvoiceError as e: + self.logger.debug(f"failed to create bolt12 invoice, sending invoice_error: {str(e)}") + error_payload = {'invoice_error': {'invoice_error': e.to_tlv()}} + self.onion_message_manager.submit_send( + payload=error_payload, + node_id_or_blinded_paths=reply_path_or_node_id, + key=response_key, + ) + return + + destination_payload = { + 'invoice': {'invoice': serialized_invoice}, + } + self._outgoing_bolt12_invoice_responses[payment_hash] = response_key + self.onion_message_manager.submit_send( + payload=destination_payload, + node_id_or_blinded_paths=reply_path_or_node_id, + key=response_key, + ) + + def _create_bolt12_invoice_for_invoice_request( + self, + invreq: 'BOLT12InvoiceRequest', + invoice_signing_key: bytes, + ) -> tuple[bytes, bytes]: # invoice, payment_hash + """invreq is already validated, and it is safe to hand an invoice out for it""" + assert isinstance(invoice_signing_key, bytes) and len(invoice_signing_key) == 32, (invreq, invoice_signing_key) + amount_msat = invreq.invoice_amount_msat + invoice_features = self._prepare_invoice_features(self.features.for_bolt12_invoice(), amount_msat=amount_msat) + payment_preimage = os.urandom(32) + now = int(time.time()) + relative_expiry = bolt12.DEFAULT_INVOICE_EXPIRY + + # we include all the payment metadata into the blinded path so we can reconstruct + # the payment when we actually receive it and don't have to persist anything until then + path_id_payload = bolt12.BOLT12InvoicePathIDPayload( + amount_msat=amount_msat, + created_at=now, + relative_expiry=relative_expiry, + payment_preimage=payment_preimage, + min_final_cltv_expiry_delta=MIN_FINAL_CLTV_DELTA_ACCEPTED, + invoice_features=invoice_features, + payer_id=invreq.invreq_payer_id, + offer_metadata_digest=sha256(invreq.offer_metadata) if invreq.offer_metadata else None, + quantity=invreq.invreq_quantity, + payer_note=invreq.invreq_payer_note, + description=invreq.offer_description, + ) + recipient_data = { + 'path_id': {'data': path_id_payload.encode()}, + } + + # collect suitable channels for payment + invoice_channels = [ + chan for chan in self.channels.values() + if chan.is_active() and chan.can_receive(amount_msat=amount_msat, check_frozen=False) + ] + if not invoice_channels: + raise Bolt12InvoiceError( + 'no active channels with sufficient receive capacity, cannot receive this payment.') + + try: + invoice_path_info = get_blinded_paths_to_me( + self, final_recipient_data=recipient_data, my_channels=invoice_channels) + except NoRouteBlindingChannelPeers as e: + raise Bolt12InvoiceError("no peers with route blinding support") from e + + assert invoice_path_info + payment_hash = sha256(path_id_payload.payment_preimage) + try: + invoice = BOLT12Invoice( + **invreq.__dict__, + invoice_amount=path_id_payload.amount_msat, + invoice_created_at=path_id_payload.created_at, + invoice_relative_expiry=path_id_payload.relative_expiry, + invoice_payment_hash=payment_hash, + invoice_features=path_id_payload.invoice_features, + invoice_node_id=ECPrivkey(invoice_signing_key).get_public_key_bytes(), + invoice_paths=tuple(p.path for p in invoice_path_info), + invoice_blindedpay=tuple(p.payinfo for p in invoice_path_info), + ) + except Exception as e: + raise Bolt12InvoiceError(str(e)) from e + + signed_invoice = invoice.encode(signing_key=invoice_signing_key, as_bech32=False) + assert isinstance(signed_invoice, bytes) + return signed_invoice, payment_hash + + def save_blinded_payment_info(self, path_id_payload: bolt12.BOLT12InvoicePathIDPayload) -> None: + """ + Store the payment metadata which we included in a BOLT12Invoice when we receive it again with the payment htlcs. + """ + payment_hash = sha256(path_id_payload.payment_preimage) + if payment_hash.hex() not in self._preimages: + self.save_preimage(payment_hash, path_id_payload.payment_preimage) + payment_info_key = PaymentInfo.calc_db_key(payment_hash_hex=payment_hash.hex(), direction=lnutil.Direction.RECEIVED) + if payment_info_key not in self.payment_info: + payment_info = PaymentInfo( + payment_hash=payment_hash, + amount_msat=path_id_payload.amount_msat, + direction=lnutil.Direction.RECEIVED, + status=PR_UNPAID, + min_final_cltv_delta=path_id_payload.min_final_cltv_expiry_delta, + expiry_delay=path_id_payload.relative_expiry, + creation_ts=path_id_payload.created_at, + invoice_features=path_id_payload.invoice_features, + ) + self.save_payment_info(payment_info, write_to_disk=False) + # save request for GUI + message = path_id_payload.description or '' + if payer_note := path_id_payload.payer_note: + if message: + message += f" [{_('payer:')} {payer_note}]" # "our description [payer: payer note]" + else: + message = payer_note + height = self.wallet.adb.get_local_height() + req = Request( + outputs=None, + message=message, + time=payment_info.creation_ts, + amount_msat=payment_info.amount_msat, + exp=payment_info.expiry_delay, + height=height, + payment_hash=payment_hash, + ) + self.wallet.add_payment_request(req, write_to_disk=True) + + def cancel_bolt12_invoice_response(self, payment_hash: bytes): + """Stops sending the invoice response onion message for an invoice_request we received previously""" + onion_message_key = self._outgoing_bolt12_invoice_responses.get(payment_hash) + if onion_message_key: + self.onion_message_manager.remove_pending_message(onion_message_key) + self.logger.debug(f"stopping invoice response for rhash={payment_hash.hex()}") diff --git a/electrum/onion_message.py b/electrum/onion_message.py index 6aa1050eca4c..c3d86ae705d1 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -27,9 +27,9 @@ import os import threading import time +import dataclasses import random - -from typing import TYPE_CHECKING, Optional, Sequence, NamedTuple, Tuple, Union +from typing import TYPE_CHECKING, Optional, Sequence, NamedTuple, Tuple, Union, Mapping import electrum_ecc as ecc @@ -37,13 +37,13 @@ from electrum.lnrouter import PathEdge, NoChannelPolicy from electrum.logging import get_logger, Logger from electrum.crypto import sha256, get_ecdh -from electrum.lnmsg import OnionWireSerializer from electrum.lnonion import (get_bolt04_onion_key, OnionPacket, process_onion_packet, blinding_privkey, OnionHopsDataSingle, decrypt_onionmsg_data_tlv, encrypt_onionmsg_data_tlv, get_shared_secrets_along_route, new_onion_packet, encrypt_hops_recipient_data, - next_blinding_from_shared_secret) + next_blinding_from_shared_secret, BlindedPayInfo, BlindedPath, BlindedPathHop, + BlindedPathInfo) from electrum.lnutil import (LnFeatures, MIN_FINAL_CLTV_DELTA_ACCEPTED, MAXIMUM_REMOTE_TO_SELF_DELAY_ACCEPTED, - MIN_FINAL_CLTV_DELTA_BUFFER_INVOICE) + validate_features, IncompatibleOrInsaneFeatures, MIN_FINAL_CLTV_DELTA_BUFFER_INVOICE) from electrum.util import OldTaskGroup, log_exceptions, random_shuffled_copy @@ -84,7 +84,7 @@ def create_blinded_path( hop_extras: Optional[Sequence[dict]] = None, dummy_hops: Optional[int] = 0, channels: Optional[Sequence['Channel']] = None, -) -> dict: +) -> 'BlindedPath': # dummy hops could be inserted anywhere in the path, but for compatibility just add them at the end # because blinded paths are usually constructed towards ourselves, and we know we can handle dummy hops. if dummy_hops: @@ -95,7 +95,7 @@ def create_blinded_path( blinding = ecc.ECPrivkey(session_key).get_public_key_bytes() - onionmsg_hops = [] + onionmsg_hops: list[BlindedPathHop] = [] shared_secrets, blinded_node_ids = get_shared_secrets_along_route(path, session_key) for i, node_id in enumerate(path): is_non_final_node = i < len(path) - 1 @@ -122,31 +122,19 @@ def create_blinded_path( encrypted_recipient_data = encrypt_onionmsg_data_tlv(shared_secret=shared_secrets[i], **recipient_data) - hopdata = { - 'blinded_node_id': blinded_node_ids[i], - 'enclen': len(encrypted_recipient_data), - 'encrypted_recipient_data': encrypted_recipient_data - } + hopdata = BlindedPathHop( + blinded_node_id=blinded_node_ids[i], + enclen=len(encrypted_recipient_data), + encrypted_recipient_data=encrypted_recipient_data, + ) onionmsg_hops.append(hopdata) - blinded_path = { - 'first_node_id': introduction_point, - 'first_path_key': blinding, - 'num_hops': bytes([len(onionmsg_hops)]), - 'path': onionmsg_hops - } - - return blinded_path - - -def encode_blinded_path(blinded_path: dict): - with io.BytesIO() as blinded_path_fd: - OnionWireSerializer.write_field( - fd=blinded_path_fd, - field_type='blinded_path', - count=1, - value=blinded_path) - return blinded_path_fd.getvalue() + return BlindedPath( + first_node_id=introduction_point, + first_path_key=blinding, + num_hops=bytes([len(onionmsg_hops)]), + path=onionmsg_hops, + ) def is_onion_message_node(node_id: bytes, node_info: Optional['NodeInfo']) -> bool: @@ -205,7 +193,7 @@ def create_onion_message_route_to(lnwallet: 'LNWallet', node_id: bytes) -> Seque def create_route_to_introduction_point( lnwallet: 'LNWallet', - blinded_path: dict, + blinded_path: BlindedPath, introduction_point: bytes, session_key: bytes ): @@ -216,7 +204,7 @@ def create_route_to_introduction_point( # if blinded path introduction point is our direct peer, no need to route-find if peer: # start of blinded path is our peer - path_key = blinded_path['first_path_key'] + path_key = blinded_path.first_path_key return peer, path_key, hops_data, blinded_node_ids path = create_onion_message_route_to(lnwallet, introduction_point) @@ -250,7 +238,7 @@ def create_route_to_introduction_point( tlv_stream_name='onionmsg_tlv', blind_fields={ 'next_node_id': {'node_id': introduction_point}, - 'next_path_key_override': {'path_key': blinded_path['first_path_key']}, + 'next_path_key_override': {'path_key': blinded_path.first_path_key}, }, ) hops_data.append(final_hop_pre_ip) @@ -263,88 +251,74 @@ def create_route_to_introduction_point( def send_onion_message_to( lnwallet: 'LNWallet', - node_id_or_blinded_path: bytes, + node_id_or_blinded_path: bytes | BlindedPath, destination_payload: dict, session_key: bytes = None ) -> None: if session_key is None: session_key = os.urandom(32) - if len(node_id_or_blinded_path) > 33: # assume blinded path - with io.BytesIO(node_id_or_blinded_path) as blinded_path_fd: - try: - blinded_path = OnionWireSerializer.read_field( - fd=blinded_path_fd, - field_type='blinded_path', - count=1) - logger.debug(f'blinded path: {blinded_path!r}') - except Exception as e: - logger.error(f'e!r') - raise - - introduction_point = blinded_path['first_node_id'] - if len(introduction_point) != 33: - raise Exception('first_node_id not a nodeid but a sciddir, which is not supported') - # Note: blinded_path specifies type sciddir_or_nodeid for first_node_id - # but only nodeid is supported in onion_message context; - # https://github.com/lightning/bolts/blob/master/04-onion-routing.md - # "MUST set first_node_id to N0" - - if lnwallet.node_keypair.pubkey == introduction_point: - hops_data = [] - blinded_node_ids = [] - - # blinded path introduction point is me - our_blinding = blinded_path['first_path_key'] - our_payload = blinded_path['path'][0] - remaining_blinded_path = blinded_path['path'][1:] - assert len(remaining_blinded_path) > 0, 'sending to myself?' - - # decrypt - shared_secret = get_ecdh(lnwallet.node_keypair.privkey, our_blinding) - recipient_data = decrypt_onionmsg_data_tlv( - shared_secret=shared_secret, - encrypted_recipient_data=our_payload['encrypted_recipient_data'] - ) - - peer = lnwallet.lnpeermgr.get_peer_by_pubkey(recipient_data['next_node_id']['node_id']) - assert peer, 'next_node_id not a peer' - - # blinding override? - next_path_key_override = recipient_data.get('next_path_key_override') - if next_path_key_override: - next_path_key = next_path_key_override.get('path_key') - else: - next_path_key = next_blinding_from_shared_secret(our_blinding, shared_secret) + if isinstance(node_id_or_blinded_path, BlindedPath): + blinded_path = node_id_or_blinded_path + introduction_point = blinded_path.first_node_id + if len(introduction_point) != 33: + raise Exception('first_node_id not a nodeid but a sciddir, which is not supported') + # Note: blinded_path specifies type sciddir_or_nodeid for first_node_id + # but only nodeid is supported in onion_message context; + # https://github.com/lightning/bolts/blob/master/04-onion-routing.md + # "MUST set first_node_id to N0" + + if lnwallet.node_keypair.pubkey == introduction_point: + hops_data = [] + blinded_node_ids = [] + + # blinded path introduction point is me + our_blinding = blinded_path.first_path_key + our_payload = blinded_path.path[0] + remaining_blinded_path = blinded_path.path[1:] + assert len(remaining_blinded_path) > 0, 'sending to myself?' + + # decrypt + shared_secret = get_ecdh(lnwallet.node_keypair.privkey, our_blinding) + recipient_data = decrypt_onionmsg_data_tlv( + shared_secret=shared_secret, + encrypted_recipient_data=our_payload.encrypted_recipient_data, + ) - path_key = next_path_key + peer = lnwallet.lnpeermgr.get_peer_by_pubkey(recipient_data['next_node_id']['node_id']) + assert peer, 'next_node_id not a peer' + # blinding override? + next_path_key_override = recipient_data.get('next_path_key_override') + if next_path_key_override: + next_path_key = next_path_key_override.get('path_key') else: - # we need a route to introduction point - r = create_route_to_introduction_point(lnwallet, blinded_path, introduction_point, session_key) - peer, path_key, hops_data, blinded_node_ids = r - - remaining_blinded_path = blinded_path['path'] - if not isinstance(remaining_blinded_path, list): # doesn't return list when num items == 1 - remaining_blinded_path = [remaining_blinded_path] - - # append (remaining) blinded path and payload - blinded_path_blinded_ids = [] - for i, onionmsg_hop in enumerate(remaining_blinded_path): - blinded_path_blinded_ids.append(onionmsg_hop.get('blinded_node_id')) - payload = { - 'encrypted_recipient_data': {'encrypted_recipient_data': onionmsg_hop['encrypted_recipient_data']} - } - if i == len(remaining_blinded_path) - 1: # final hop - payload.update(destination_payload) - hop = OnionHopsDataSingle(tlv_stream_name='onionmsg_tlv', payload=payload) - hops_data.append(hop) + next_path_key = next_blinding_from_shared_secret(our_blinding, shared_secret) + path_key = next_path_key + else: + # we need a route to introduction point + r = create_route_to_introduction_point(lnwallet, blinded_path, introduction_point, session_key) + peer, path_key, hops_data, blinded_node_ids = r + remaining_blinded_path = blinded_path.path + + # append (remaining) blinded path and payload + blinded_path_blinded_ids = [] + for i, onionmsg_hop in enumerate(remaining_blinded_path): + blinded_path_blinded_ids.append(onionmsg_hop.blinded_node_id) + payload = { + 'encrypted_recipient_data': {'encrypted_recipient_data': onionmsg_hop.encrypted_recipient_data} + } + if i == len(remaining_blinded_path) - 1: # final hop + payload.update(destination_payload) + hop = OnionHopsDataSingle(tlv_stream_name='onionmsg_tlv', payload=payload) + hops_data.append(hop) payment_path_pubkeys = blinded_node_ids + blinded_path_blinded_ids packet = new_onion_packet(payment_path_pubkeys, session_key, hops_data, onion_message=True) packet_b = packet.to_bytes() else: # node pubkey + assert isinstance(node_id_or_blinded_path, bytes) and len(node_id_or_blinded_path) == 33 pubkey = node_id_or_blinded_path if lnwallet.node_keypair.pubkey == pubkey: @@ -381,16 +355,14 @@ def send_onion_message_to( hop_shared_secrets, blinded_node_ids = get_shared_secrets_along_route(payment_path_pubkeys, session_key) encrypt_hops_recipient_data(hops_data, hop_shared_secrets) - packet = new_onion_packet(blinded_node_ids, session_key, hops_data) + packet = new_onion_packet(blinded_node_ids, session_key, hops_data, onion_message=True) packet_b = packet.to_bytes() path_key = ecc.ECPrivkey(session_key).get_public_key_bytes() - peer.send_message( - "onion_message", + peer.send_onion_message( path_key=path_key, - len=len(packet_b), - onion_message_packet=packet_b + onion_message_packet=packet_b, ) @@ -399,11 +371,11 @@ def get_blinded_reply_paths( path_id: bytes, *, max_paths: int = REQUEST_REPLY_PATHS_MAX, -) -> Sequence[dict]: +) -> Sequence[BlindedPathInfo]: """construct a list of blinded reply-paths for onion message. """ mydata = {'path_id': {'data': path_id}} # same path_id used in every reply path - paths, payinfo = get_blinded_paths_to_me(lnwallet, mydata, max_paths=max_paths, onion_message=True) + paths = get_blinded_paths_to_me(lnwallet, mydata, max_paths=max_paths, onion_message=True) return paths @@ -414,7 +386,7 @@ def get_blinded_paths_to_me( max_paths: int = PAYMENT_PATHS_MAX, my_channels: Optional[Sequence['Channel']] = None, onion_message: bool = False -) -> Tuple[Sequence[dict], Sequence[dict]]: +) -> Sequence[BlindedPathInfo]: """construct a list of blinded paths. current logic: - uses active channel peers if my_channels not provided @@ -425,6 +397,7 @@ def get_blinded_paths_to_me( - reply_path introduction points are direct peers only (TODO: longer paths) """ # TODO: build longer paths and/or add dummy hops to increase privacy + assert final_recipient_data['path_id']['data'], f"missing path_id: {final_recipient_data}" if not my_channels: my_channels = [chan for chan in lnwallet.channels.values() if chan.is_active()] @@ -433,19 +406,17 @@ def get_blinded_paths_to_me( lnwallet.lnpeermgr.get_peer_by_pubkey(chan.node_id).their_features.supports(required_features)] result = [] - payinfos = [] mynodeid = lnwallet.node_keypair.pubkey if my_channels: rchans = random_shuffled_copy(my_channels) for chan in rchans[:max_paths]: - hop_extras = None + payinfo, hop_extras = None, None if not onion_message: # add hop_extras and payinfo, assumption: len(blinded_path) == 2 (us and peer) try: payinfo, hop_extras = _get_payinfo_for_blinded_path(chan, lnwallet) except NoChannelPolicy: logger.warning(f"missing remote channel_update for {chan.short_channel_id}") continue - payinfos.append(payinfo) blinded_path = create_blinded_path( session_key=os.urandom(32), path=[chan.node_id, mynodeid], @@ -453,7 +424,10 @@ def get_blinded_paths_to_me( hop_extras=hop_extras, channels=[chan] if not onion_message else None, ) - result.append(blinded_path) + result.append(BlindedPathInfo( + path=blinded_path, + payinfo=payinfo, + )) if not result: if not onion_message: @@ -467,13 +441,16 @@ def get_blinded_paths_to_me( rpeers = random_shuffled_copy(my_onionmsg_peers) for peer in rpeers[:max_paths]: blinded_path = create_blinded_path(os.urandom(32), [peer.pubkey, mynodeid], final_recipient_data) - result.append(blinded_path) + result.append(BlindedPathInfo( + path=blinded_path, + payinfo=None, + )) assert result - return result, payinfos + return result -def _get_payinfo_for_blinded_path(chan: 'Channel', lnwallet: 'LNWallet'): +def _get_payinfo_for_blinded_path(chan: 'Channel', lnwallet: 'LNWallet') -> tuple[BlindedPayInfo, list[dict]]: cp = get_mychannel_policy(chan.short_channel_id, chan.node_id, {chan.short_channel_id: chan}) if not cp: raise NoChannelPolicy(chan.short_channel_id) @@ -509,15 +486,14 @@ def _get_payinfo_for_blinded_path(chan: 'Channel', lnwallet: 'LNWallet'): 'htlc_minimum_msat': blinded_path_min_htlc_msat } }] - payinfo = { - 'fee_base_msat': sum_fee_base_msat, - 'fee_proportional_millionths': sum_fee_proportional_millionths, - 'cltv_expiry_delta': sum_cltv_expiry_delta + MIN_FINAL_CLTV_DELTA_ACCEPTED + MIN_FINAL_CLTV_DELTA_BUFFER_INVOICE, - 'htlc_minimum_msat': blinded_path_min_htlc_msat, - 'htlc_maximum_msat': blinded_path_max_htlc_msat, - 'flen': 0, - 'features': b'', - } + payinfo = BlindedPayInfo( + fee_base_msat=sum_fee_base_msat, + fee_proportional_millionths=sum_fee_proportional_millionths, + cltv_expiry_delta=sum_cltv_expiry_delta + MIN_FINAL_CLTV_DELTA_ACCEPTED + MIN_FINAL_CLTV_DELTA_BUFFER_INVOICE, + htlc_minimum_msat=blinded_path_min_htlc_msat, + htlc_maximum_msat=blinded_path_max_htlc_msat, + features=LnFeatures(0), + ) return payinfo, hop_extras @@ -544,22 +520,23 @@ class OnionMessageManager(Logger): FORWARD_MAX_QUEUE = 3 class Request: - def __init__(self, *, payload: dict, node_id_or_blinded_paths: Union[bytes, Sequence[bytes]]): + def __init__(self, *, payload: dict, node_id_or_blinded_paths: Union[bytes, Sequence[BlindedPath]]): self.future = asyncio.Future() self.payload = payload self.node_id_or_blinded_paths = node_id_or_blinded_paths self.current_index: int = 0 - # ensure node_id_or_blinded_paths is list + # ensure node_id_or_blinded_paths is list, and route_not_found_for matches list length if isinstance(self.node_id_or_blinded_paths, bytes): self.node_id_or_blinded_paths = [self.node_id_or_blinded_paths] + self.route_not_found_for: list = [None] * len(self.node_id_or_blinded_paths) - def get_next_destination(self) -> bytes: + def get_next_destination(self) -> tuple[bytes, int]: """get next path (round-robin)""" dests = self.node_id_or_blinded_paths - dest = dests[self.current_index] + dest, i = dests[self.current_index], self.current_index self.current_index = (self.current_index + 1) % len(dests) - return dest + return dest, i def __init__(self, lnwallet: 'LNWallet'): Logger.__init__(self) @@ -571,6 +548,8 @@ def __init__(self, lnwallet: 'LNWallet'): self.send_queue = asyncio.PriorityQueue() self.forward_queue = asyncio.PriorityQueue() + self.send_direct_connect_fallback = lnwallet.config.ONION_MESSAGE_OPEN_DIRECT_CONNECTIONS + def start_network(self, *, network: 'Network') -> None: assert network assert self.network is None, "already started" @@ -610,11 +589,9 @@ async def process_forward_queue(self) -> None: self.logger.debug('forward dropped, next peer is not ONION_MESSAGE capable') continue - next_peer.send_message( - "onion_message", + next_peer.send_onion_message( path_key=blinding, - len=len(onion_packet_b), - onion_message_packet=onion_packet_b + onion_message_packet=onion_packet_b, ) except BaseException as e: self.logger.debug(f'error while sending {node_id=} e={e!r}') @@ -654,18 +631,16 @@ async def process_send_queue(self) -> None: await asyncio.sleep(self.SLEEP_DELAY) # sleep here, as the first queue item wasn't due yet continue try: - self._send_pending_message(key) + await self._send_pending_message(key) except BaseException as e: self.logger.debug(f'error while sending {key=}: ', exc_info=True) req.future.set_exception(copy.copy(e)) # NOTE: above, when passing the caught exception instance e directly it leads to GeneratorExit() in - if isinstance(e, NoRouteFound) and e.peer_address: - await self.lnwallet.lnpeermgr.add_peer(str(e.peer_address)) else: self.logger.debug(f'resubmit {key=}') self.send_queue.put_nowait((now() + self.REQUEST_REPLY_RETRY_DELAY, expires, key)) - def _remove_pending_message(self, key: bytes) -> None: + def remove_pending_message(self, key: bytes) -> None: with self.pending_lock: if key in self.pending: del self.pending[key] @@ -673,7 +648,7 @@ def _remove_pending_message(self, key: bytes) -> None: def submit_send( self, *, payload: dict, - node_id_or_blinded_paths: Union[bytes, Sequence[bytes]], + node_id_or_blinded_paths: Union[bytes, Sequence[BlindedPath]], key: Optional[bytes] = None) -> 'Task': """Add onion message to queue for sending. Queued onion message payloads are supplied with a path_id and a reply_path to determine which request @@ -705,13 +680,13 @@ async def _wait_task(self, key: bytes, future: asyncio.Future): try: return await future finally: - self._remove_pending_message(key) + self.remove_pending_message(key) - def _send_pending_message(self, key: bytes) -> None: + async def _send_pending_message(self, key: bytes) -> None: """adds reply_path to payload""" req = self.pending[key] payload = req.payload - dest = req.get_next_destination() + dest, dest_index = req.get_next_destination() self.logger.debug(f'send_pending_message {key=} {payload=} {dest=}') @@ -721,12 +696,22 @@ def _send_pending_message(self, key: bytes) -> None: # unless explicitly set in payload, generate reply_path here path_id = self._path_id_from_payload_and_key(payload, key) reply_paths = get_blinded_reply_paths(self.lnwallet, path_id, max_paths=1) - final_payload['reply_path'] = {'path': reply_paths} + final_payload['reply_path'] = {'path': [dataclasses.asdict(x.path) for x in reply_paths]} - # NOTE: we could also try alternate paths to introduction point (the non-blinded part of the route) - # when retrying, this is currently not done. - # (send_onion_message_to decides path, without knowledge of prev attempts) - send_onion_message_to(self.lnwallet, dest, final_payload) + try: + # NOTE: we could also try alternate paths to introduction point (the non-blinded part of the route) + # when retrying, this is currently not done. + # (send_onion_message_to decides path, without knowledge of prev attempts) + send_onion_message_to(self.lnwallet, dest, final_payload) + except NoRouteFound as e: + req.route_not_found_for[dest_index] = True + if all(req.route_not_found_for) and self.send_direct_connect_fallback and e.peer_address: + # we have a peer address hint and we've tried all blinded paths: try direct peer connection + # TODO: this will only attempt direct connection to the last blinded path ip node, we could try the others too. + self.logger.info(f'No route to destination, attempting direct peer connection to {str(e.peer_address)}') + await self.lnwallet.lnpeermgr.add_peer(str(e.peer_address)) + else: + raise def _path_id_from_payload_and_key(self, payload: dict, key: bytes) -> bytes: # TODO: use payload to determine prefix? @@ -783,10 +768,26 @@ def on_onion_message_received_unsolicited(self, recipient_data: dict, payload: d # e.g. via a decorator, something like # @onion_message_request_handler(payload_key='invoice_request') for BOLT12 invoice requests. - if 'message' not in payload: + known_payloads = ('message', 'invoice', 'invoice_error', 'invoice_request') + if not any(known_payload in payload for known_payload in known_payloads): self.logger.error('Unsupported onion message payload') return + if 'invoice' in payload or 'invoice_error' in payload: + try: + self.lnwallet.on_bolt12_invoice(recipient_data, payload) + except Exception as e: + self.logger.warning(f"failed to handle incoming invoice: {e!r}") + return + + if 'invoice_request' in payload: + try: + self.lnwallet.on_bolt12_invoice_request(recipient_data, payload) + except Exception as e: + self.logger.warning(f"failed to handle invoice_request: {e!r}") + return + + # log 'message' payload if 'text' not in payload['message'] or not isinstance(payload['message']['text'], bytes): self.logger.error('Malformed \'message\' payload') return @@ -865,9 +866,9 @@ def on_onion_message(self, payload: dict) -> None: onion_packet = OnionPacket.from_bytes(packet) self.process_onion_message_packet(path_key, onion_packet) - def process_onion_message_packet(self, blinding: bytes, onion_packet: OnionPacket) -> None: - our_privkey = blinding_privkey(self.lnwallet.node_keypair.privkey, blinding) - processed_onion_packet = process_onion_packet(onion_packet, our_privkey, tlv_stream_name='onionmsg_tlv') + def process_onion_message_packet(self, path_key: bytes, onion_packet: OnionPacket) -> None: + processed_onion_packet = process_onion_packet( + onion_packet, self.lnwallet.node_keypair.privkey, current_path_key=path_key, tlv_stream_name='onionmsg_tlv') payload = processed_onion_packet.hop_data.payload self.logger.debug(f'onion peeled: {processed_onion_packet!r}') @@ -878,7 +879,7 @@ def process_onion_message_packet(self, blinding: bytes, onion_packet: OnionPacke return # decrypt - shared_secret = get_ecdh(self.lnwallet.node_keypair.privkey, blinding) + shared_secret = get_ecdh(self.lnwallet.node_keypair.privkey, path_key) recipient_data = decrypt_onionmsg_data_tlv( shared_secret=shared_secret, encrypted_recipient_data=payload['encrypted_recipient_data']['encrypted_recipient_data'] @@ -889,4 +890,4 @@ def process_onion_message_packet(self, blinding: bytes, onion_packet: OnionPacke if processed_onion_packet.are_we_final: self.on_onion_message_received(recipient_data, payload) else: - self.on_onion_message_forward(recipient_data, processed_onion_packet.next_packet, blinding, shared_secret) + self.on_onion_message_forward(recipient_data, processed_onion_packet.next_packet, path_key, shared_secret) diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index 7fe8a2f1ed7a..179180e721e1 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -6,13 +6,13 @@ from enum import IntEnum from typing import NamedTuple, Optional, Callable, List, TYPE_CHECKING, Tuple, Union -from . import bitcoin +from . import bitcoin, bolt12 from .contacts import AliasNotFoundException from .i18n import _ from .invoices import Invoice from .logging import Logger -from .util import parse_max_spend, InvoiceError -from .util import get_asyncio_loop, log_exceptions +from .util import get_asyncio_loop, log_exceptions, UserFacingException, parse_max_spend, InvoiceError, \ + send_exception_to_crash_reporter from .transaction import PartialTxOutput from .lnurl import (decode_lnurl, request_lnurl, callback_lnurl, LNURLError, lightning_address_to_url, try_resolve_lnurlpay, LNURL6Data, @@ -32,6 +32,8 @@ def maybe_extract_bech32_lightning_payment_identifier(data: str) -> Optional[str data = remove_uri_prefix(data, prefix=LIGHTNING_URI_SCHEME) if not data.startswith('ln'): return None + if bolt12.is_offer(data): + return data decoded_bech32 = bech32_decode(data, ignore_long_length=True) if not decoded_bech32.hrp or not decoded_bech32.data: return None @@ -76,6 +78,7 @@ class PaymentIdentifierState(IntEnum): NEED_RESOLVE = 3 # PI contains a recognized destination format, but needs an online resolve step LNURLP_FINALIZE = 4 # PI contains a resolved LNURLp, but needs amount and comment to resolve to a bolt11 LNURLW_FINALIZE = 5 # PI contains resolved LNURLw, user needs to enter amount and initiate withdraw + BOLT12_OFFER_FINALIZE = 8 # PI contains a bolt12 offer, but needs amount and comment to resolve to a bolt12 invoice ERROR = 50 # generic error NOT_FOUND = 51 # PI contains a recognized destination format, but resolve step was unsuccessful MERCHANT_ERROR = 52 # PI failed notifying the merchant after broadcasting onchain TX @@ -95,6 +98,7 @@ class PaymentIdentifierType(IntEnum): OPENALIAS = 10 LNADDR = 11 DOMAINLIKE = 12 + BOLT12_OFFER = 13 class FieldsForGUI(NamedTuple): @@ -116,6 +120,7 @@ class PaymentIdentifier(Logger): * lightning-URI (containing bolt11 or lnurl) * lnurl-URI (lud17 lnurlw/lnurlp URI) * bolt11 invoice + * bolt12 offer * lnurl * lightning address """ @@ -133,7 +138,7 @@ def __init__(self, wallet: Optional['Abstract_Wallet'], text: str): # more than one of those may be set self.multiline_outputs = None self._is_max = False - self.bolt11 = None # type: Optional[Invoice] + self.lightning_invoice = None # type: Optional[Invoice] self.bip21 = None self.spk = None self.spk_is_address = False @@ -144,6 +149,8 @@ def __init__(self, wallet: Optional['Abstract_Wallet'], text: str): # self.lnurl = None # type: Optional[str] self.lnurl_data = None # type: Optional[LNURLData] + # + self.bolt12_offer = None # type: Optional[bolt12.BOLT12Offer] self.parse(text) @@ -163,7 +170,7 @@ def need_resolve(self): return self._state == PaymentIdentifierState.NEED_RESOLVE def need_finalize(self): - return self._state == PaymentIdentifierState.LNURLP_FINALIZE + return self._state in [PaymentIdentifierState.LNURLP_FINALIZE, PaymentIdentifierState.BOLT12_OFFER_FINALIZE] def is_valid(self): return self._state not in [PaymentIdentifierState.INVALID, PaymentIdentifierState.EMPTY] @@ -172,16 +179,17 @@ def is_available(self): return self._state in [PaymentIdentifierState.AVAILABLE] def is_lightning(self): - return bool(self.lnurl) or bool(self.bolt11) + return bool(self.lnurl) or bool(self.lightning_invoice) or bool(self.bolt12_offer) def is_onchain(self): if self._type in [PaymentIdentifierType.SPK, PaymentIdentifierType.MULTILINE, PaymentIdentifierType.OPENALIAS]: return True - if self._type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.BOLT11, PaymentIdentifierType.LNADDR]: - return bool(self.bolt11) and bool(self.bolt11.get_address()) + if self._type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.BOLT11, PaymentIdentifierType.LNADDR, + PaymentIdentifierType.BOLT12_OFFER]: + return bool(self.lightning_invoice) and bool(self.lightning_invoice.get_address()) if self._type == PaymentIdentifierType.BIP21: - return bool(self.bip21.get('address', None)) or (bool(self.bolt11) and bool(self.bolt11.get_address())) + return bool(self.bip21.get('address', None)) or (bool(self.lightning_invoice) and bool(self.lightning_invoice.get_address())) def is_multiline(self): return bool(self.multiline_outputs) @@ -193,7 +201,9 @@ def is_amount_locked(self): if self._type == PaymentIdentifierType.BIP21: return bool(self.bip21.get('amount')) elif self._type == PaymentIdentifierType.BOLT11: - return bool(self.bolt11.get_amount_sat()) + return bool(self.lightning_invoice.get_amount_sat()) + elif self._type == PaymentIdentifierType.BOLT12_OFFER: + return bool(self.bolt12_offer.offer_amount) elif self._type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR]: # amount limits known after resolve, might be specific amount or locked to range if self.need_resolve(): @@ -225,20 +235,31 @@ def parse(self, text: str): self.set_state(PaymentIdentifierState.INVALID) else: self.set_state(PaymentIdentifierState.AVAILABLE) - elif invoice_or_lnurl := maybe_extract_bech32_lightning_payment_identifier(text): - if invoice_or_lnurl.startswith('lnurl'): + elif valid_bech32_lightning_pi := maybe_extract_bech32_lightning_payment_identifier(text): + if valid_bech32_lightning_pi.startswith('lnurl'): self._type = PaymentIdentifierType.LNURL try: - self.lnurl = decode_lnurl(invoice_or_lnurl) + self.lnurl = decode_lnurl(valid_bech32_lightning_pi) self.set_state(PaymentIdentifierState.NEED_RESOLVE) except Exception as e: self.error = _("Error parsing LNURL") + f":\n{e}" self.set_state(PaymentIdentifierState.INVALID) return + elif bolt12.is_offer(valid_bech32_lightning_pi): + self.logger.debug(f'BOLT12 offer') + try: + self.bolt12_offer = bolt12.BOLT12Offer.decode(valid_bech32_lightning_pi) + self._type = PaymentIdentifierType.BOLT12_OFFER + self.set_state(PaymentIdentifierState.BOLT12_OFFER_FINALIZE) + except Exception as e: + self.logger.debug(f"error parsing bolt12 offer", exc_info=True) + self.error = _("Error parsing BOLT12 offer") + f":\n{e}" + self.set_state(PaymentIdentifierState.INVALID) + return else: self._type = PaymentIdentifierType.BOLT11 try: - self.bolt11 = Invoice.from_bech32(invoice_or_lnurl) + self.lightning_invoice = Invoice.from_bech32(valid_bech32_lightning_pi) except InvoiceError as e: self.error = self._get_error_from_invoiceerror(e) self.set_state(PaymentIdentifierState.INVALID) @@ -262,12 +283,12 @@ def parse(self, text: str): bolt11 = out.get('lightning') if bolt11: try: - self.bolt11 = Invoice.from_bech32(bolt11) + self.lightning_invoice = Invoice.from_bech32(bolt11) # carry BIP21 onchain address in Invoice.outputs in case bolt11 doesn't contain a fallback # address but the BIP21 URI has one. if bip21_address := self.bip21.get('address'): amount = self.bip21.get('amount', 0) - self.bolt11.outputs = [PartialTxOutput.from_address_and_value(bip21_address, amount)] + self.lightning_invoice.outputs = [PartialTxOutput.from_address_and_value(bip21_address, amount)] except InvoiceError as e: self.logger.debug(self._get_error_from_invoiceerror(e)) elif not self.bip21.get('address'): @@ -307,7 +328,7 @@ def parse(self, text: str): self.set_state(PaymentIdentifierState.INVALID) def resolve(self, *, on_finished: Callable[['PaymentIdentifier'], None]) -> None: - assert self._state == PaymentIdentifierState.NEED_RESOLVE + assert self.need_resolve() coro = self._do_resolve(on_finished=on_finished) asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop()) @@ -371,7 +392,7 @@ def finalize( comment: str = None, on_finished: Callable[['PaymentIdentifier'], None] = None, ): - assert self._state == PaymentIdentifierState.LNURLP_FINALIZE + assert self.need_finalize() coro = self._do_finalize(amount_sat=amount_sat, comment=comment, on_finished=on_finished) asyncio.run_coroutine_threadsafe(coro, get_asyncio_loop()) @@ -385,39 +406,63 @@ async def _do_finalize( ): from .invoices import Invoice try: - if not self.lnurl_data: - raise Exception("Unexpected missing LNURL data") + if self._state == PaymentIdentifierState.LNURLP_FINALIZE: + assert self.lnurl_data, "Unexpected missing LNURL data" - if not (self.lnurl_data.min_sendable_sat <= amount_sat <= self.lnurl_data.max_sendable_sat): - self.error = _('Amount must be between {} and {} sat.').format( - self.lnurl_data.min_sendable_sat, self.lnurl_data.max_sendable_sat) - self.set_state(PaymentIdentifierState.INVALID_AMOUNT) - return + if not (self.lnurl_data.min_sendable_sat <= amount_sat <= self.lnurl_data.max_sendable_sat): + self.error = _('Amount must be between {} and {} sat.').format( + self.lnurl_data.min_sendable_sat, self.lnurl_data.max_sendable_sat) + self.set_state(PaymentIdentifierState.INVALID_AMOUNT) + return - if self.lnurl_data.comment_allowed == 0: - comment = None - params = {'amount': amount_sat * 1000} - if comment: - params['comment'] = comment + if self.lnurl_data.comment_allowed == 0: + comment = None + params = {'amount': amount_sat * 1000} + if comment: + params['comment'] = comment - try: - invoice_data = await callback_lnurl(self.lnurl_data.callback_url, params=params) - except LNURLError as e: - self.error = f"LNURL request encountered error: {e}" - self.set_state(PaymentIdentifierState.ERROR) - return + try: + invoice_data = await callback_lnurl(self.lnurl_data.callback_url, params=params) + except LNURLError as e: + self.error = f"LNURL request encountered error: {e}" + self.set_state(PaymentIdentifierState.ERROR) + return - bolt11_invoice = invoice_data.get('pr') - invoice = Invoice.from_bech32(bolt11_invoice) - if invoice.get_amount_sat() != amount_sat: - raise Exception("lnurl returned invoice with wrong amount") - # this will change what is returned by get_fields_for_GUI - self.bolt11 = invoice - self.set_state(PaymentIdentifierState.AVAILABLE) + bolt11_invoice = invoice_data.get('pr') + invoice = Invoice.from_bech32(bolt11_invoice) + if invoice.get_amount_sat() != amount_sat: + raise Exception("lnurl returned invoice with wrong amount") + # this will change what is returned by get_fields_for_GUI + self.lightning_invoice = invoice + self.set_state(PaymentIdentifierState.AVAILABLE) + elif self._state == PaymentIdentifierState.BOLT12_OFFER_FINALIZE: + assert self.bolt12_offer + try: + if not self.wallet.has_lightning(): + raise UserFacingException(_('Wallet is not lightning-enabled')) + amount_msat = amount_sat * 1000 + try: + invoice, invoice_tlv = await self.wallet.lnworker.request_bolt12_invoice( + self.bolt12_offer, + amount_msat=amount_msat, + payer_note=comment, + ) + except Exception as e: + raise UserFacingException(_("Failed to request Invoice:") + f" {str(e)}") from e + invoice_bech32 = bolt12.bolt12_tlv_bytes_to_bech32(invoice_tlv, bolt12.BOLT12Invoice) + self.lightning_invoice = Invoice.from_bech32(invoice_bech32) + assert self.lightning_invoice.get_amount_msat() == amount_msat, (self.lightning_invoice, amount_msat) + self.set_state(PaymentIdentifierState.AVAILABLE) + self.logger.debug(f'BOLT12 invoice_request reply: {invoice!r}') + except asyncio.TimeoutError: + self.error = _('Timeout requesting invoice') + self.set_state(PaymentIdentifierState.NOT_FOUND) except Exception as e: - self.error = str(e) + self.error = str(e) or repr(e) self.logger.error(f"_do_finalize() got error: {e!r}") self.set_state(PaymentIdentifierState.ERROR) + if not isinstance(e, UserFacingException): + send_exception_to_crash_reporter(e) finally: if on_finished: on_finished(self) @@ -541,8 +586,17 @@ def get_fields_for_GUI(self) -> FieldsForGUI: description = name recipient = key + ' <' + address + '>' - elif self.bolt11: - recipient, amount, description = self._get_bolt11_fields() + elif self.lightning_invoice: + recipient = self.lightning_invoice.issuer_pubkey + amount = self.lightning_invoice.get_amount_sat() + description = self.lightning_invoice.get_message() + + elif self.bolt12_offer: + offer_amount = self.bolt12_offer.offer_amount + if offer_amount: + amount = Decimal(offer_amount) / 1000 # msat->sat + description = self.bolt12_offer.offer_description + recipient = self.bolt12_offer.offer_issuer elif self.lnurl and self.lnurl_data: assert isinstance(self.lnurl_data, LNURL6Data), f"{self.lnurl_data=}" @@ -575,18 +629,6 @@ def get_fields_for_GUI(self) -> FieldsForGUI: return FieldsForGUI(recipient=recipient, amount=amount, description=description, comment=comment, validated=validated, amount_range=amount_range) - def _get_bolt11_fields(self): - lnaddr = self.bolt11._lnaddr # TODO: improve access to lnaddr - pubkey = lnaddr.pubkey.serialize().hex() - for k, v in lnaddr.tags: - if k == 'd': - description = v - break - else: - description = '' - amount = lnaddr.get_amount_sat() - return pubkey, amount, description - async def resolve_openalias(self, key: str) -> Optional[dict]: parts = key.split(sep=',') # assuming single line if parts and len(parts) > 0 and bitcoin.is_address(parts[0]): @@ -603,8 +645,10 @@ async def resolve_openalias(self, key: str) -> Optional[dict]: return None def has_expired(self): - if self.bolt11: - return self.bolt11.has_expired() + if self.lightning_invoice: + return self.lightning_invoice.has_expired() + elif self.bolt12_offer: + return self.bolt12_offer.is_expired elif self.bip21: expires = self.bip21.get('exp') + self.bip21.get('time') if self.bip21.get('exp') else 0 return bool(expires) and expires < time.time() @@ -614,17 +658,16 @@ def has_expired(self): def invoice_from_payment_identifier( pi: 'PaymentIdentifier', wallet: 'Abstract_Wallet', - amount_sat: Union[int, str], - message: str = None + amount_sat: Optional[Union[int, str]] = None, + message: Optional[str] = None ) -> Optional[Invoice]: assert pi.state in [PaymentIdentifierState.AVAILABLE,] assert pi.is_onchain() if amount_sat == '!' else True # MAX should only be allowed if pi has onchain destination if pi.is_lightning() and not amount_sat == '!': - invoice = pi.bolt11 - if not invoice: - return - if invoice._lnaddr.get_amount_msat() is None: + if not (invoice := pi.lightning_invoice): + return None + if invoice.get_amount_msat() is None and amount_sat is not None: invoice.set_amount_msat(int(amount_sat * 1000)) return invoice else: diff --git a/electrum/segwit_addr.py b/electrum/segwit_addr.py index d94f6277c23c..42666f97f4bb 100644 --- a/electrum/segwit_addr.py +++ b/electrum/segwit_addr.py @@ -74,7 +74,7 @@ def bech32_verify_checksum(hrp, data): return None -def bech32_create_checksum(encoding: Encoding, hrp: str, data: List[int]) -> List[int]: +def bech32_create_checksum(encoding: Encoding, hrp: str, data: Sequence[int]) -> List[int]: """Compute the checksum values given HRP and data.""" values = bech32_hrp_expand(hrp) + data const = BECH32M_CONST if encoding == Encoding.BECH32M else BECH32_CONST @@ -82,9 +82,9 @@ def bech32_create_checksum(encoding: Encoding, hrp: str, data: List[int]) -> Lis return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)] -def bech32_encode(encoding: Encoding, hrp: str, data: List[int]) -> str: +def bech32_encode(encoding: Encoding, hrp: str, data: Sequence[int], *, with_checksum=True) -> str: """Compute a Bech32 or Bech32m string given HRP and data values.""" - combined = data + bech32_create_checksum(encoding, hrp, data) + combined = (data + bech32_create_checksum(encoding, hrp, data)) if with_checksum else data return hrp + '1' + ''.join([CHARSET[d] for d in combined]) diff --git a/electrum/simple_config.py b/electrum/simple_config.py index cf76ffa3f409..e8be3d6ca245 100644 --- a/electrum/simple_config.py +++ b/electrum/simple_config.py @@ -726,6 +726,13 @@ def __setattr__(self, name, value): short_desc=lambda: _('Show Fiat balances'), ) + ONION_MESSAGE_OPEN_DIRECT_CONNECTIONS = ConfigVar( + 'onion_message_direct_connections', default=True, type_=bool, + short_desc=lambda: _("Open direct connections to deliver onion messages"), + long_desc=lambda: _("Opening a direct network connection to deliver an onion message allows delivery even when no " + "route to the destination can be found via the public lightning network graph. The trade-off " + "is that the node you connect to directly will see your IP address (unless a proxy like Tor is used)."), + ) LIGHTNING_LISTEN = ConfigVar( 'lightning_listen', default=None, type_=str, long_desc=lambda: _("""By default the client does not listen on any port for incoming BOLT-08 transports. diff --git a/electrum/trampoline.py b/electrum/trampoline.py index 92ae616b0972..c0a39ab3c20b 100644 --- a/electrum/trampoline.py +++ b/electrum/trampoline.py @@ -4,15 +4,17 @@ import dataclasses from fractions import Fraction from typing import Mapping, Tuple, Optional, List, Iterable, Sequence, Set, Any, TYPE_CHECKING -from types import MappingProxyType import electrum_ecc as ecc -from .lnutil import LnFeatures, PaymentFeeBudget, FeeBudgetExceeded +from .lnutil import ( + LnFeatures, PaymentFeeBudget, FeeBudgetExceeded, UnblindedRoutingInfo, BlindedRoutingInfo, + MIN_FINAL_CLTV_DELTA_ACCEPTED, +) from .lnonion import ( - calc_hops_data_for_payment, new_onion_packet, OnionPacket + new_onion_packet, OnionPacket, calc_hops_data_for_blinded_payment, calc_hops_data_for_payment, BlindedPathInfo ) -from .lnrouter import TrampolineEdge, is_route_within_budget, LNPaymentTRoute +from .lnrouter import TrampolineEdge, is_route_within_budget, fee_for_edge_msat, FinalForwardFees from .lnutil import NoPathFound from .lntransport import LNPeerAddr from . import constants @@ -20,6 +22,7 @@ from .util import random_shuffled_copy if TYPE_CHECKING: + from .lnutil import RoutingInfo from .lnchannel import Channel @@ -147,17 +150,22 @@ def decode_routing_info(rinfo: bytes) -> Sequence[Sequence[Sequence[Any]]]: return r_tags -def is_legacy_relay(invoice_features, r_tags) -> Tuple[bool, Set[bytes]]: +def is_legacy_relay(invoice_features, r_tags: Optional[Iterable]) -> Tuple[bool, Set[bytes]]: """Returns if we deal with a legacy payment and the list of trampoline pubkeys in the invoice. """ invoice_features = LnFeatures(invoice_features) # trampoline-supporting wallets: if invoice_features.supports(LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT_ECLAIR)\ or invoice_features.supports(LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT_ELECTRUM): + # Unblinded payment (BOLT11): # If there are no r_tags (routing hints) included, the wallet doesn't have # private channels and is probably directly connected to a trampoline node. # Any trampoline node should be able to figure out a path to the receiver and # we can use an e2e payment. + # Blinded payment (BOLT12): + # https://github.com/lightning/bolts/blob/bc7a1a0bc97b2293e7f43dd8a06529e5fdcf7cd2/proposals/trampoline.md#with-bolt-12-invoices + # r_tags are always None, if the invoice signals trampoline we assume every node id on the + # blinded path is a trampoline node. Otherwise, it is 'legacy', but without the risks of bolt11 legacy payments. if not r_tags: return False, set() else: @@ -179,6 +187,21 @@ def is_legacy_relay(invoice_features, r_tags) -> Tuple[bool, Set[bytes]]: PLACEHOLDER_FEE = None +DEFAULT_TRAMPOLINE_CLTV_DELTA = 576 + + +@dataclasses.dataclass(kw_only=True) +class _FinalForwardPlaceholder: + """Mutable placeholder for route fee allocation""" + fee_base_msat: Optional[int] = PLACEHOLDER_FEE + fee_proportional_millionths: Optional[int] = PLACEHOLDER_FEE + + +@dataclasses.dataclass(kw_only=True) +class TrampolineRoute: + edges: List[TrampolineEdge] + is_legacy: bool + final_forward_fees: Optional[FinalForwardFees] def _extend_trampoline_route( @@ -197,7 +220,7 @@ def _extend_trampoline_route( # note: trampoline nodes are supposed to advertise their fee and cltv in node_update message. # However, in the temporary spec, they do not. # They also don't send their fee policy in the error message if we lowball the fee... - fee_base, fee_proportional, cltv_delta = fee_info if fee_info else (PLACEHOLDER_FEE, PLACEHOLDER_FEE, 576) + fee_base, fee_proportional, cltv_delta = fee_info if fee_info else (PLACEHOLDER_FEE, PLACEHOLDER_FEE, DEFAULT_TRAMPOLINE_CLTV_DELTA) route.append( TrampolineEdge( start_node=start_node, @@ -221,7 +244,7 @@ def get_trampoline_budget(trampoline_fee_level: int, budget_msat: int) -> int: def _allocate_fee_budget_among_route( - route: Sequence[TrampolineEdge], + route: Sequence[TrampolineEdge | _FinalForwardPlaceholder], *, usable_budget_msat: int, amount_msat_for_dest: int, @@ -291,47 +314,68 @@ def _choose_next_trampoline( def create_trampoline_route( *, amount_msat: int, - min_final_cltv_delta: int, - invoice_pubkey: bytes, - invoice_features: int, + routing_info: 'RoutingInfo', my_pubkey: bytes, my_trampoline: bytes, # the first trampoline in the path; which we are directly connected to - r_tags, trampoline_fee_level: int, next_trampolines: dict, failed_routes: Iterable[Sequence[str]], budget: PaymentFeeBudget, -) -> LNPaymentTRoute: + blinded_path: Optional['BlindedPathInfo'], +) -> TrampolineRoute: # we decide whether to convert to a legacy payment - is_legacy, invoice_trampolines = is_legacy_relay(invoice_features, r_tags) + unblinded_payment_r_tags = routing_info.r_tags if isinstance(routing_info, UnblindedRoutingInfo) else None + is_legacy, invoice_trampolines = is_legacy_relay(routing_info.invoice_features, unblinded_payment_r_tags) # we can be in the invoice_trampolines e.g. if we have a direct channel with the recipient invoice_trampolines.discard(my_pubkey) - _logger.debug(f"Creating trampoline route for invoice_pubkey={invoice_pubkey.hex()}, {is_legacy=}") + if blinded_path: + assert isinstance(routing_info, BlindedRoutingInfo) and blinded_path.payinfo + # FIXME: eclair doesn't yet implement end-to-end blinded payments, so we have to fall back to legacy. + # see: https://github.com/ACINQ/eclair/pull/2819 + is_legacy = True + + _logger.debug(f"Creating trampoline route for blinded payment. " + f"IP={blinded_path.path.first_node_id.hex()} {blinded_path.path.hop_count=}, {is_legacy=}") + else: + assert isinstance(routing_info, UnblindedRoutingInfo) + _logger.debug(f"Creating trampoline route for invoice_pubkey={routing_info.node_pubkey.hex()}, {is_legacy=}") # we build a route of trampoline hops and extend the route list in place - route = [] + route: list[TrampolineEdge] = [] + final_node_pubkey = blinded_path.path.first_node_id if blinded_path else routing_info.node_pubkey # our first trampoline hop is decided by the channel we use _extend_trampoline_route( route, start_node=my_pubkey, end_node=my_trampoline, fee_info=(0, 0, 0) ) + next_trampolines = next_trampolines.copy() # don't mutate PaySessions next_trampolines next_trampolines.pop(my_pubkey, None) + # prevent routing through the final node + next_trampolines.pop(final_node_pubkey, None) next_trampolines_ids = list(next_trampolines.keys()) + final_forward_placeholder: Optional[_FinalForwardPlaceholder] = None if is_legacy: if next_trampolines: trampoline_id = _choose_next_trampoline(my_trampoline, next_trampolines_ids, failed_routes) _extend_trampoline_route(route, end_node=trampoline_id, fee_info = next_trampolines[trampoline_id]) - # the last trampoline onion must contain routing hints for the last trampoline - # node to find the recipient - # Due to space constraints it is not guaranteed for all route hints to get included in the onion - invoice_routing_info: List[bytes] = encode_routing_info(r_tags) - assert invoice_routing_info == encode_routing_info(decode_routing_info(b''.join(invoice_routing_info))) - # lnwire invoice_features for trampoline is u64 - invoice_features = invoice_features & 0xffffffffffffffff - route[-1].invoice_routing_info = invoice_routing_info - route[-1].invoice_features = invoice_features - route[-1].outgoing_node_id = invoice_pubkey + # the last trampoline onion must contain routing information for the last trampoline node to find the recipient + route[-1].invoice_features = routing_info.invoice_features + if blinded_path: + # The last trampoline forwarder will use one of the provided paths and forward to its introduction point. + # We could include multiple blinded paths and let the trampoline node decide which one to use. + # However, considering that we ourselves are stuffing a lot of (recipient-) data into the blinded path + # space might get tight when putting multiple paths into the trampoline onion. Also, fee allocation + # for the last trampoline node seems simpler when we know which path will be used, otherwise we'd have + # to assume it uses the most expensive one to not under-allocate? + route[-1].invoice_routing_info = [blinded_path] + else: + # Due to space constraints it is not guaranteed for all route hints to get included in the onion + invoice_routing_info: List[bytes] = encode_routing_info(routing_info.r_tags) + assert invoice_routing_info == encode_routing_info(decode_routing_info(b''.join(invoice_routing_info))) + route[-1].invoice_routing_info = invoice_routing_info + # set fee placeholder for final trampoline so it can pay for the route to the recipient/IP + final_forward_placeholder = _FinalForwardPlaceholder() else: next_trampoline = my_trampoline # maybe add second trampoline @@ -342,96 +386,190 @@ def create_trampoline_route( if invoice_trampolines and next_trampoline not in invoice_trampolines: invoice_trampoline = _choose_next_trampoline(next_trampoline, invoice_trampolines, failed_routes) _extend_trampoline_route(route, end_node=invoice_trampoline) - - # Add final edge. note: eclair requires an encrypted t-onion blob even in legacy case. - # Also needed for fees for last TF! - if route[-1].end_node != invoice_pubkey: - _extend_trampoline_route(route, end_node=invoice_pubkey) - - # replace placeholder fees in route - usable_budget_msat = get_trampoline_budget(trampoline_fee_level, budget.fee_msat) + # the recipient is itself a trampoline node, so it is the last hop and gets its own trampoline onion + if route[-1].end_node != final_node_pubkey: + # fee_info is not passed for e2e blinded paths, even though we know from the blinded_payinfo, + # because `calc_hops_data_for_blinded_payment` itself takes the payinfo and allocates the fee + _extend_trampoline_route(route, end_node=final_node_pubkey) + + # trampoline fee allocation + # if there is a blinded path, the fees required for the blinded path are accounted into the available budget. + unblinded_path_budget = budget.subtract_blinded_path_fees(blinded_path.payinfo, amount_msat) if blinded_path else budget + usable_budget_msat = get_trampoline_budget(trampoline_fee_level, unblinded_path_budget.fee_msat) _logger.debug(f"create_trampoline_route: {trampoline_fee_level=}, {usable_budget_msat=}") + # the trampolines need to carry the fee for the blinded path, so add it to the recipient (IP) amount for the allocation + amount_for_dest = amount_msat + fee_for_edge_msat( + forwarded_amount_msat=amount_msat, + fee_base_msat=blinded_path.payinfo.fee_base_msat, + fee_proportional_millionths=blinded_path.payinfo.fee_proportional_millionths, + ) if blinded_path else amount_msat + fee_carrying_edges: list[TrampolineEdge | _FinalForwardPlaceholder] = list(route) + if final_forward_placeholder is not None: + fee_carrying_edges.append(final_forward_placeholder) _allocate_fee_budget_among_route( - route, + fee_carrying_edges, usable_budget_msat=usable_budget_msat, - amount_msat_for_dest=amount_msat, + amount_msat_for_dest=amount_for_dest, # dest is IP (blinded) or payment recipient (unblinded) ) + # Allocate additional blinded path fees to the budget for the last unblinded trampoline so it can pay for the blinded path. + blinded_payinfo = blinded_path.payinfo if blinded_path else None + final_forward_fees: Optional[FinalForwardFees] = None + budget_final_forward_fees: Optional[FinalForwardFees] = None # only used for the budget check below, excluding bp cltv + if final_forward_placeholder is not None: + # unblinded or blinded legacy payment + assert final_forward_placeholder.fee_base_msat is not None, "no base trampoline fee allocated?" + budget_final_forward_fees = final_forward_fees = FinalForwardFees( + fee_base_msat=final_forward_placeholder.fee_base_msat \ + + (blinded_payinfo.fee_base_msat if blinded_payinfo else 0), + fee_proportional_millionths=final_forward_placeholder.fee_proportional_millionths \ + + (blinded_payinfo.fee_proportional_millionths if blinded_payinfo else 0), + forwarder_cltv_delta=DEFAULT_TRAMPOLINE_CLTV_DELTA, + blinded_path_cltv_delta=blinded_payinfo.cltv_expiry_delta if blinded_payinfo else 0, + ) + elif blinded_payinfo is not None: + # e2e blinded payment, the recipient/IP is already made part of the route above and got its own allocation. + # But we still need to consider the blinded path fees in the budget check below. They have not been added to + # the route edge as 'calc_hops_data_for_blinded_payment' handles them internally. + budget_final_forward_fees = FinalForwardFees( + fee_base_msat=blinded_payinfo.fee_base_msat, + fee_proportional_millionths=blinded_payinfo.fee_proportional_millionths, + ) + + trampoline_route = TrampolineRoute(edges=route, is_legacy=is_legacy, final_forward_fees=final_forward_fees) + # check that we can pay amount and fees + min_final_cltv_delta = blinded_path.payinfo.cltv_expiry_delta + routing_info.final_cltv_delta \ + if blinded_path else routing_info.final_cltv_delta if not is_route_within_budget( - route=route, - budget=budget, + route=trampoline_route.edges, # unblinded route (forwarders only) + budget=budget, # whole budget amount_msat_for_dest=amount_msat, cltv_delta_for_dest=min_final_cltv_delta, + final_forward_fees=budget_final_forward_fees, ): - raise FeeBudgetExceeded(f"route exceeds budget: budget: {budget}") - return route + raise FeeBudgetExceeded(f"route exceeds budget: budget: {budget=} {unblinded_path_budget=}") + + return trampoline_route def create_trampoline_onion( *, - route: LNPaymentTRoute, + trampoline_route: TrampolineRoute, + routing_info: 'BlindedRoutingInfo | UnblindedRoutingInfo', + blinded_path: Optional['BlindedPathInfo'], amount_msat: int, - final_cltv_abs: int, + local_height: int, total_msat: int, payment_hash: bytes, - payment_secret: bytes, ) -> Tuple[OnionPacket, int, int]: - # all edges are trampoline - hops_data, amount_msat, cltv_abs = calc_hops_data_for_payment( - route, - amount_msat, - final_cltv_abs=final_cltv_abs, - total_msat=total_msat, - payment_secret=payment_secret) - # detect trampoline hops. - payment_path_pubkeys = [x.node_id for x in route] - num_hops = len(payment_path_pubkeys) + route, is_legacy = trampoline_route.edges, trampoline_route.is_legacy + final_cltv_abs = local_height + routing_info.final_cltv_delta + if blinded_path and not is_legacy: # every node on the blinded path is a trampoline + assert blinded_path.payinfo + hops_data, blinded_path_pubkeys, amount_msat, cltv_abs = calc_hops_data_for_blinded_payment( + route_to_introduction_point=route, + recipient_amount_msat=amount_msat, + final_cltv_abs=final_cltv_abs, + total_msat=total_msat, + invoice_blinded_path_info=blinded_path, + ) + else: + hops_data, amount_msat, cltv_abs = calc_hops_data_for_payment( + route, + amount_msat, + final_cltv_abs=final_cltv_abs, + total_msat=total_msat, + payment_secret=routing_info.payment_secret if not blinded_path else None, + final_forward_fees=trampoline_route.final_forward_fees, + ) + + # detect trampoline hops. mutate hops_data payloads to make them trampoline payloads. + payment_path_pubkeys = [e.end_node for e in trampoline_route.edges] + num_unblinded_hops = len(payment_path_pubkeys) routing_info_payload_index: Optional[int] = None - for i in range(num_hops): + for i in range(num_unblinded_hops): route_edge = route[i] assert route_edge.is_trampoline() payload = dict(hops_data[i].payload) - if i < num_hops - 1: - payload.pop('short_channel_id') + + # replace `short_channel_id` with next `outgoing_node_id` + if i < num_unblinded_hops - 1: next_edge = route[i+1] assert next_edge.is_trampoline() + del payload["short_channel_id"] payload["outgoing_node_id"] = {"outgoing_node_id": next_edge.node_id} - # only for final - if i == num_hops - 1: - payload["payment_data"] = { - "payment_secret": payment_secret, - "total_msat": total_msat - } - # legacy - if i == num_hops - 2 and route_edge.invoice_features: - payload["invoice_features"] = {"invoice_features": route_edge.invoice_features} - routing_info_payload_index = i - payload["payment_data"] = { - "payment_secret": payment_secret, - "total_msat": total_msat - } + + # final (unblinded) hop + elif i == num_unblinded_hops - 1: + if blinded_path: + assert "payment_data" not in payload, hops_data + if is_legacy: + assert route_edge.invoice_routing_info is not None, route + # include blinded paths so the trampoline can route to the recipient + routing_info_payload_index = i + payload["invoice_features"] = {"invoice_features": LnFeatures(route_edge.invoice_features).to_tlv_bytes()} + else: + assert "short_channel_id" not in payload, hops_data + assert "current_path_key" in payload, payload + else: + payload["payment_data"] = { + "payment_secret": routing_info.payment_secret, + "total_msat": total_msat + } + if is_legacy: + # payload for the last trampoline forwarder to find the recipient + assert route_edge.invoice_features is not None, route + routing_info_payload_index = i + payload["outgoing_node_id"] = {"outgoing_node_id": routing_info.node_pubkey} + payload["invoice_features"] = { + "invoice_features": LnFeatures(route_edge.invoice_features).to_tlv_bytes() + } + hops_data[i] = dataclasses.replace(hops_data[i], payload=payload) + if blinded_path and not is_legacy: + payment_path_pubkeys = payment_path_pubkeys + blinded_path_pubkeys + assert len(hops_data) == len(payment_path_pubkeys), (hops_data, payment_path_pubkeys) + if (index := routing_info_payload_index) is not None: - # fill the remaining payload space with available routing hints (r_tags) payload = dict(hops_data[index].payload) - # try different r_tag order on each attempt - invoice_routing_info = random_shuffled_copy(route[index].invoice_routing_info) remaining_payload_space = TRAMPOLINE_HOPS_MAX_DATA_SIZE - sum(len(hop.to_bytes()) for hop in hops_data) - routing_info_to_use = [] - for encoded_r_tag in invoice_routing_info: - if remaining_payload_space < 50: - break # no r_tag will fit here anymore - r_tag_size = len(encoded_r_tag) - if r_tag_size > remaining_payload_space: - continue - routing_info_to_use.append(encoded_r_tag) - remaining_payload_space -= r_tag_size - # add the chosen r_tags to the payload - payload["invoice_routing_info"] = {"invoice_routing_info": b''.join(routing_info_to_use)} + if blinded_path: + assert route[index].invoice_routing_info, route + paths = [] + hop_base_size = len(hops_data[index].to_bytes()) + for bpi in route[index].invoice_routing_info: + assert isinstance(bpi, BlindedPathInfo) + candidate_paths = paths + [{ + 'blinded_path': dataclasses.asdict(bpi.path), + 'payment_info': bpi.payinfo.to_dict(), + }] + candidate_payload = {**payload, "outgoing_blinded_paths": {"paths": candidate_paths}} + paths_size = len(dataclasses.replace(hops_data[index], payload=candidate_payload).to_bytes()) - hop_base_size + if paths_size > remaining_payload_space: + if not paths: + raise NoPathFound(f"couldn't fit a single blinded path in trampoline onion. {paths_size=}") + break + paths = candidate_paths + payload["outgoing_blinded_paths"] = {"paths": paths} + else: + # fill the remaining payload space with available routing hints (r_tags) + # try different r_tag order on each attempt + invoice_routing_info = random_shuffled_copy(route[index].invoice_routing_info) + routing_info_to_use = [] + for encoded_r_tag in invoice_routing_info: + if remaining_payload_space < 50: + break # no r_tag will fit here anymore + r_tag_size = len(encoded_r_tag) + if r_tag_size > remaining_payload_space: + continue + routing_info_to_use.append(encoded_r_tag) + remaining_payload_space -= r_tag_size + # add the chosen r_tags to the payload + payload["invoice_routing_info"] = {"invoice_routing_info": b''.join(routing_info_to_use)} + _logger.debug(f"Using {len(routing_info_to_use)} of {len(invoice_routing_info)} r_tags") hops_data[index] = dataclasses.replace(hops_data[index], payload=payload) - _logger.debug(f"Using {len(routing_info_to_use)} of {len(invoice_routing_info)} r_tags") trampoline_session_key = os.urandom(32) trampoline_onion = new_onion_packet(payment_path_pubkeys, trampoline_session_key, hops_data, associated_data=payment_hash, trampoline=True) @@ -447,42 +585,39 @@ def create_trampoline_route_and_onion( *, amount_msat: int, # that final receiver gets total_msat: int, - min_final_cltv_delta: int, - invoice_pubkey: bytes, - invoice_features, + routing_info: 'UnblindedRoutingInfo | BlindedRoutingInfo', my_pubkey: bytes, - node_id: bytes, - r_tags, + my_trampoline: bytes, payment_hash: bytes, - payment_secret: bytes, local_height: int, trampoline_fee_level: int, next_trampolines: dict, failed_routes: Iterable[Sequence[str]], budget: PaymentFeeBudget, -) -> Tuple[LNPaymentTRoute, OnionPacket, int, int]: + blinded_path: Optional['BlindedPathInfo'], +) -> Tuple[TrampolineRoute, OnionPacket, int, int]: # create route for the trampoline_onion + # blinded path hops are not appended to this route trampoline_route = create_trampoline_route( amount_msat=amount_msat, - min_final_cltv_delta=min_final_cltv_delta, + routing_info=routing_info, my_pubkey=my_pubkey, - invoice_pubkey=invoice_pubkey, - invoice_features=invoice_features, - my_trampoline=node_id, - r_tags=r_tags, + my_trampoline=my_trampoline, trampoline_fee_level=trampoline_fee_level, next_trampolines=next_trampolines, failed_routes=failed_routes, budget=budget, + blinded_path=blinded_path, ) # compute onion and fees - final_cltv_abs = local_height + min_final_cltv_delta trampoline_onion, amount_with_fees, bucket_cltv_abs = create_trampoline_onion( - route=trampoline_route, + trampoline_route=trampoline_route, + routing_info=routing_info, + blinded_path=blinded_path, amount_msat=amount_msat, - final_cltv_abs=final_cltv_abs, + local_height=local_height, total_msat=total_msat, payment_hash=payment_hash, - payment_secret=payment_secret) + ) bucket_cltv_delta = bucket_cltv_abs - local_height return trampoline_route, trampoline_onion, amount_with_fees, bucket_cltv_delta diff --git a/electrum/wallet.py b/electrum/wallet.py index 70573de8c238..e952f49786ab 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -74,7 +74,9 @@ AddressSynchronizer, TX_HEIGHT_LOCAL, TX_HEIGHT_UNCONF_PARENT, TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_FUTURE, TX_TIMESTAMP_INF ) -from .invoices import BaseInvoice, Invoice, Request, PR_PAID, PR_UNPAID, PR_EXPIRED, PR_UNCONFIRMED +from .invoices import ( + BaseInvoice, Invoice, Request, PR_PAID, PR_UNPAID, PR_EXPIRED, PR_UNCONFIRMED +) from .contacts import Contacts from .mnemonic import Mnemonic from .lnworker import LNWallet diff --git a/tests/blinded-payment-onion-test.json b/tests/blinded-payment-onion-test.json new file mode 100644 index 000000000000..2d14e1788dbd --- /dev/null +++ b/tests/blinded-payment-onion-test.json @@ -0,0 +1,183 @@ +{ + "comment": "test vector for a payment onion sent to a partially blinded route", + "generate": { + "comment": "This section contains test data for creating a payment onion that sends to the provided blinded route.", + "session_key": "0303030303030303030303030303030303030303030303030303030303030303", + "associated_data": "4242424242424242424242424242424242424242424242424242424242424242", + "final_amount_msat": 100000, + "final_cltv": 749000, + "blinded_payinfo": { + "comment": "total costs for using the blinded path", + "fee_base_msat": 10100, + "fee_proportional_millionths": 251, + "cltv_expiry_delta": 150 + }, + "blinded_route": { + "comment": "This section contains a blinded route that the sender will use for his payment, usually obtained from a Bolt 12 invoice.", + "first_node_id": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c", + "first_path_key": "024d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766", + "hops": [ + { + "alias": "Bob", + "blinded_node_id": "03da173ad2aee2f701f17e59fbd16cb708906d69838a5f088e8123fb36e89a2c25", + "encrypted_data": "cd7b00ff9c09ed28102b210ac73aa12d63e90852cebc496c49f57c499a2888b49f2e72b19446f7e60a818aa2938d8c625415b992b8928a7321edb8f7cea40de362bed082ad51acc6156dca5532fb68" + }, + { + "alias": "Carol", + "blinded_node_id": "02e466727716f044290abf91a14a6d90e87487da160c2a3cbd0d465d7a78eb83a7", + "encrypted_data": "cc0f16524fd7f8bb0f4e8d40ad71709ef140174c76faa574cac401bb8992fef76c4d004aa485dd599ed1cf2715f570f656a5aaecaf1ee8dc9d0fa1d424759be1932a8f29fac08bc2d2a1ed7159f28b" + }, + { + "alias": "Dave", + "blinded_node_id": "036861b366f284f0a11738ffbf7eda46241a8977592878fe3175ae1d1e4754eccf", + "encrypted_data": "0fa1a72cff3b64a3d6e1e4903cf8c8b0a17144aeb249dcb86561adee1f679ee8db3e561d9e49895fd4bcebf6f58d6f61a6d41a9bf5aa4b0453437856632e8255c351873143ddf2bb2b0832b091e1b4" + }, + { + "alias": "Eve", + "blinded_node_id": "021982a48086cb8984427d3727fe35a03d396b234f0701f5249daa12e8105c8dae", + "encrypted_data": "da1c7e5f7881219884beae6ae68971de73bab4c3055d9865b1afb60722a63c688768042ade22f2c22f5724767d171fd221d3e579e43b354cc72e3ef146ada91a892d95fc48662f5b158add0af457da" + } + ] + }, + "full_route": { + "comment": "The sender adds one normal hop through Alice, who doesn't support blinded payments (and doesn't charge a fee). The sender provides the initial blinding point in Bob's onion payload, and encrypted_data for each node in the blinded route.", + "hops": [ + { + "alias": "Alice", + "pubkey": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619", + "payload": "14020301ae2d04030b6e5e0608000000000000000a", + "tlvs": { + "outgoing_channel_id": "0x0x10", + "amt_to_forward": 110125, + "outgoing_cltv_value": 749150 + } + }, + { + "alias": "Bob", + "pubkey": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c", + "payload": "740a4fcd7b00ff9c09ed28102b210ac73aa12d63e90852cebc496c49f57c499a2888b49f2e72b19446f7e60a818aa2938d8c625415b992b8928a7321edb8f7cea40de362bed082ad51acc6156dca5532fb680c21024d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766", + "tlvs": { + "current_path_key": "024d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766", + "encrypted_recipient_data": { + "padding": "0000000000000000000000000000000000000000000000000000000000000000", + "short_channel_id": "0x0x1", + "payment_relay": { + "cltv_expiry_delta": 50, + "fee_proportional_millionths": 0, + "fee_base_msat": 10000 + }, + "payment_constraints": { + "max_cltv_expiry": 750150, + "htlc_minimum_msat": 50 + }, + "allowed_features": { + "features": [] + } + } + } + }, + { + "alias": "Carol", + "pubkey": "02e466727716f044290abf91a14a6d90e87487da160c2a3cbd0d465d7a78eb83a7", + "payload": "510a4fcc0f16524fd7f8bb0f4e8d40ad71709ef140174c76faa574cac401bb8992fef76c4d004aa485dd599ed1cf2715f570f656a5aaecaf1ee8dc9d0fa1d424759be1932a8f29fac08bc2d2a1ed7159f28b", + "tlvs": { + "encrypted_recipient_data": { + "short_channel_id": "0x0x2", + "next_path_key_override": "031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", + "payment_relay": { + "cltv_expiry_delta": 75, + "fee_proportional_millionths": 150, + "fee_base_msat": 100 + }, + "payment_constraints": { + "max_cltv_expiry": 750100, + "htlc_minimum_msat": 50 + }, + "allowed_features": { + "features": [] + } + } + } + }, + { + "alias": "Dave", + "pubkey": "036861b366f284f0a11738ffbf7eda46241a8977592878fe3175ae1d1e4754eccf", + "payload": "510a4f0fa1a72cff3b64a3d6e1e4903cf8c8b0a17144aeb249dcb86561adee1f679ee8db3e561d9e49895fd4bcebf6f58d6f61a6d41a9bf5aa4b0453437856632e8255c351873143ddf2bb2b0832b091e1b4", + "tlvs": { + "encrypted_recipient_data": { + "padding": "00000000000000000000000000000000000000000000000000000000000000000000", + "short_channel_id": "0x0x3", + "payment_relay": { + "cltv_expiry_delta": 25, + "fee_proportional_millionths": 100 + }, + "payment_constraints": { + "max_cltv_expiry": 750025, + "htlc_minimum_msat": 50 + }, + "allowed_features": { + "features": [] + } + } + } + }, + { + "alias": "Eve", + "pubkey": "021982a48086cb8984427d3727fe35a03d396b234f0701f5249daa12e8105c8dae", + "payload": "6002030186a004030b6dc80a4fda1c7e5f7881219884beae6ae68971de73bab4c3055d9865b1afb60722a63c688768042ade22f2c22f5724767d171fd221d3e579e43b354cc72e3ef146ada91a892d95fc48662f5b158add0af457da12030249f0", + "tlvs": { + "amt_to_forward": 100000, + "total_amount_msat": 150000, + "outgoing_cltv_value": 749000, + "encrypted_recipient_data": { + "padding": "00000000000000000000000000000000000000000000000000000000", + "path_id": "c9cf92f45ade68345bc20ae672e2012f4af487ed4415", + "payment_constraints": { + "max_cltv_expiry": 750000, + "htlc_minimum_msat": 50 + }, + "allowed_features": { + "features": [] + } + } + } + } + ] + }, + "onion": "0002531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337dadf610256c6ab518495dce9cdedf9391e21a71dada75be905267ba82f326c0513dda706908cfee834996700f881b2aed106585d61a2690de4ebe5d56ad2013b520af2a3c49316bc590ee83e8c31b1eb11ff766dad27ca993326b1ed582fb451a2ad87fbf6601134c6341c4a2deb6850e25a355be68dbb6923dc89444fdd74a0f700433b667bda345926099f5547b07e97ad903e8a01566a78ae177366239e793dac719de805565b6d0a1d290e273f705cfc56873f8b5e28225f7ded7a1d4ceffae63f91e477be8c917c786435976102a924ba4ba3de6150c829ce01c25428f2f5d05ef023be7d590ecdf6603730db3948f80ca1ed3d85227e64ef77200b9b557f427b6e1073cfa0e63e4485441768b98ab11ba8104a6cee1d7af7bb5ee9c05cf9cf4718901e92e09dfe5cb3af336a953072391c1e91fc2f4b92e124b38e0c6d17ef6ba7bbe93f02046975bb01b7f766fcfc5a755af11a90cc7eb3505986b56e07a7855534d03b79f0dfbfe645b0d6d4185c038771fd25b800aa26b2ed2e30b1e713659468618a2fea04fcd0473284598f76b11b0d159d343bc9711d3bea8d561547bcc8fff12317c0e7b1ee75bcb8082d762b6417f99d0f71ff7c060f6b564ad6827edaffa72eefcc4ce633a8da8d41c19d8f6aebd8878869eb518ccc16dccae6a94c690957598ce0295c1c46af5d7a2f0955b5400526bfd1430f554562614b5d00feff3946427be520dee629b76b6a9c2b1da6701c8ca628a69d6d40e20dd69d6e879d7a052d9c16f544b49738c7ff3cdd0613e9ed00ead7707702d1a6a0b88de1927a50c36beb78f4ff81e3dd97b706307596eebb363d418a891e1cb4589ce86ce81cdc0e1473d7a7dd5f6bb6e147c1f7c46fa879b4512c25704da6cdbb3c123a72e3585dc07b3e5cbe7fecf3a08426eee8c70ddc46ebf98b0bcb14a08c469cb5cfb6702acc0befd17640fa60244eca491280a95fbbc5833d26e4be70fcf798b55e06eb9fcb156942dcf108236f32a5a6c605687ba4f037eddbb1834dcbcd5293a0b66c621346ca5d893d239c26619b24c71f25cecc275e1ab24436ac01c80c0006fab2d95e82e3a0c3ea02d08ec5b24eb39205c49f4b549dcab7a88962336c4624716902f4e08f2b23cfd324f18405d66e9da3627ac34a6873ba2238386313af20d5a13bbd507fdc73015a17e3bd38fae1145f7f70d7cb8c5e1cdf9cf06d1246592a25d56ec2ae44cd7f75aa7f5f4a2b2ee49a41a26be4fab3f3f2ceb7b08510c5e2b7255326e4c417325b333cafe96dde1314a15dd6779a7d5a8a40622260041e936247eec8ec39ca29a1e18161db37497bdd4447a7d5ef3b8d22a2acd7f486b152bb66d3a15afc41dc9245a8d75e1d33704d4471e417ccc8d31645fdd647a2c191692675cf97664951d6ce98237d78b0962ad1433b5a3e49ddddbf57a391b14dcce00b4d7efe5cbb1e78f30d5ef53d66c381a45e275d2dcf6be559acb3c42494a9a2156eb8dcf03dd92b2ebaa697ea628fa0f75f125e4a7daa10f8dcf56ebaf7814557708c75580fad2bbb33e66ad7a4788a7aaac792aaae76138d7ff09df6a1a1920ddcf22e5e7007b15171b51ff81799355232ce39f7d5ceeaf704255d790041d6390a69f42816cba641ec81faa3d7c0fdec59dfe4ca41f31a692eaffc66b083995d86c575aea4514a3e09e8b3a1fa4d1591a2505f253ad0b6bfd9d87f063d2be414d3a427c0506a88ac5bdbef9b50d73bce876f85c196dca435e210e1d6713695b529ddda3350fb5065a6a8288abd265380917bac8ebbc7d5ced564587471dddf90c22ce6dbadea7e7a6723438d4cf6ac6dae27d033a8cadd77ab262e8defb33445ddb2056ec364c7629c33745e2338" + }, + "decrypt": { + "comment": "This section contains the internal values generated by intermediate nodes when decrypting their payload.", + "hops": [ + { + "alias": "Alice", + "onion": "0002531fe6068134503d2723133227c867ac8fa6c83c537e9a44c3c5bdbdcb1fe337dadf610256c6ab518495dce9cdedf9391e21a71dada75be905267ba82f326c0513dda706908cfee834996700f881b2aed106585d61a2690de4ebe5d56ad2013b520af2a3c49316bc590ee83e8c31b1eb11ff766dad27ca993326b1ed582fb451a2ad87fbf6601134c6341c4a2deb6850e25a355be68dbb6923dc89444fdd74a0f700433b667bda345926099f5547b07e97ad903e8a01566a78ae177366239e793dac719de805565b6d0a1d290e273f705cfc56873f8b5e28225f7ded7a1d4ceffae63f91e477be8c917c786435976102a924ba4ba3de6150c829ce01c25428f2f5d05ef023be7d590ecdf6603730db3948f80ca1ed3d85227e64ef77200b9b557f427b6e1073cfa0e63e4485441768b98ab11ba8104a6cee1d7af7bb5ee9c05cf9cf4718901e92e09dfe5cb3af336a953072391c1e91fc2f4b92e124b38e0c6d17ef6ba7bbe93f02046975bb01b7f766fcfc5a755af11a90cc7eb3505986b56e07a7855534d03b79f0dfbfe645b0d6d4185c038771fd25b800aa26b2ed2e30b1e713659468618a2fea04fcd0473284598f76b11b0d159d343bc9711d3bea8d561547bcc8fff12317c0e7b1ee75bcb8082d762b6417f99d0f71ff7c060f6b564ad6827edaffa72eefcc4ce633a8da8d41c19d8f6aebd8878869eb518ccc16dccae6a94c690957598ce0295c1c46af5d7a2f0955b5400526bfd1430f554562614b5d00feff3946427be520dee629b76b6a9c2b1da6701c8ca628a69d6d40e20dd69d6e879d7a052d9c16f544b49738c7ff3cdd0613e9ed00ead7707702d1a6a0b88de1927a50c36beb78f4ff81e3dd97b706307596eebb363d418a891e1cb4589ce86ce81cdc0e1473d7a7dd5f6bb6e147c1f7c46fa879b4512c25704da6cdbb3c123a72e3585dc07b3e5cbe7fecf3a08426eee8c70ddc46ebf98b0bcb14a08c469cb5cfb6702acc0befd17640fa60244eca491280a95fbbc5833d26e4be70fcf798b55e06eb9fcb156942dcf108236f32a5a6c605687ba4f037eddbb1834dcbcd5293a0b66c621346ca5d893d239c26619b24c71f25cecc275e1ab24436ac01c80c0006fab2d95e82e3a0c3ea02d08ec5b24eb39205c49f4b549dcab7a88962336c4624716902f4e08f2b23cfd324f18405d66e9da3627ac34a6873ba2238386313af20d5a13bbd507fdc73015a17e3bd38fae1145f7f70d7cb8c5e1cdf9cf06d1246592a25d56ec2ae44cd7f75aa7f5f4a2b2ee49a41a26be4fab3f3f2ceb7b08510c5e2b7255326e4c417325b333cafe96dde1314a15dd6779a7d5a8a40622260041e936247eec8ec39ca29a1e18161db37497bdd4447a7d5ef3b8d22a2acd7f486b152bb66d3a15afc41dc9245a8d75e1d33704d4471e417ccc8d31645fdd647a2c191692675cf97664951d6ce98237d78b0962ad1433b5a3e49ddddbf57a391b14dcce00b4d7efe5cbb1e78f30d5ef53d66c381a45e275d2dcf6be559acb3c42494a9a2156eb8dcf03dd92b2ebaa697ea628fa0f75f125e4a7daa10f8dcf56ebaf7814557708c75580fad2bbb33e66ad7a4788a7aaac792aaae76138d7ff09df6a1a1920ddcf22e5e7007b15171b51ff81799355232ce39f7d5ceeaf704255d790041d6390a69f42816cba641ec81faa3d7c0fdec59dfe4ca41f31a692eaffc66b083995d86c575aea4514a3e09e8b3a1fa4d1591a2505f253ad0b6bfd9d87f063d2be414d3a427c0506a88ac5bdbef9b50d73bce876f85c196dca435e210e1d6713695b529ddda3350fb5065a6a8288abd265380917bac8ebbc7d5ced564587471dddf90c22ce6dbadea7e7a6723438d4cf6ac6dae27d033a8cadd77ab262e8defb33445ddb2056ec364c7629c33745e2338", + "node_privkey": "4141414141414141414141414141414141414141414141414141414141414141" + }, + { + "alias": "Bob", + "onion": "000280caa47c2a0ea677f6a77529e46caa04212153a8d5f829bee1e7339b17e2e2a9a3461d10472364a4ff12344beb6df96fb0c38ec47d1e956ddff5a665190fcca5ed02c3a3903fd8bbd4a4b95b197867c378b67b08f0624cfe80734ba512869c0fa22099beb1f6f1ea325b07ce7449736d7ffad79178b428d8ea2d7bc6578f12dbd788ef933f3b5ba352797c41f6786c3820c96726acf8bddf2cfa5d9c617d2b0bd5ab7b93f7964c98f44cf47db8422f47d11100236a29579f1cafcd38bd979814e1d2bf6d625edf50e1e21bfaf6268e3180dd7aafd3892da281c6dd53c1c366d0fdaf670b6ad84a38d6e8a3f4a80d132d686fd3b7443bc2250023bdb9303190f74c9220481cf99da30b5ec2bdb5a49028f5014e3eaeaa48429a0c78ebd3bb7c7d582c22b7d547cd269f0c4490373a81bf92687e73dac2075b4bda189ce0be225f5f510655e37a6e724a1415bede0a076b92a882cc2a82878ba67aaedf71454eb42b7f8638df8e21d5f708006e5112e2dc0a4afbcfed9f2c7959be812853ca8e313fbc99a0f38f1ee4479c96ccb836632b0808401db159bd2637f7a664013241e4664e994a0a9a3940115a702c60381e66d291e1ade1be2802e1226e311e3201a7c9682b6bc4354caff3d439adb1dfee53ad3fb3dd5e169d64796853bb323129f41213b166a7cac00f728c3e33bd7e59aa2ac0d1341cdb1532b507a0f446e51022a882ac16405442347b70f78c9b6e122f8e70096a4fae4c0405db5b869e0b7b59b09519c4dbf4d4980483906e837da0bee93f668ffaad37d6a4764211a02f95ad2dc2d942c198796741c20a3baf8efb5a53bd9c1a0148318d60a97d0013ab63269097ea295d62c1426d064f0b31c02e74a348ee0509998e701069f5a1e0c1086aed38d2ec87da69fb57a992d88ace3b4a16b0960f5a94936e2e684a9926cf4f911969a2a5d31fed0c7616d30197848253170e51274278873b11f3f5cc1b04b14aa5812524e4d86cbf08306c2aa671288324d7a009b2be533b1d7d0ce6defeeb630b86a9655f1e6424fcb559ed67457c115fba0d0719374802ea68fab299fd3f273be86fa3d2e7456020db2f47c6ec16c21ce6ec65de495e20af1941a5dcd65d910c1cb93f22e1318c173c645c81aed681c9704a8a541ac3d6ff604f46d0260468acbfec1b771b9eb8cd49a2124468dae786571895a569aae18438eaee6343ab2634823119fa2439634645d12e3b4a748b9cc0398b8416a834eb5d9e5cf619bbfaba4894d1c574c738caf530d0862f4cc75eb52bd3921d2d9edb09940edb1e3776423b0046d870ccdcc5d61f72e0440b97a93eeef21fb246a779d339be301a5971400749d6cc9911dfbf9de8ae86fac83c860fdd0e2bfa40af37c99d50e50fd6e5ae86597a201112ed404042b55e132f243dec481a2adc1d5e4b71e1efdea806ea900b2907ce877742d5ecf700ff3640f737863d0dd7207e462ee8d0e17d52047a88ae7446f419560d23968bf64957949e36953155b0ac2511c66be2890b4036329a21e132efb635297a64431899e0c351e50c6682c9b4d79b5d122466d02cd84f206369417d9c194a9349d3c631d72eb7857a9cd542906fc02ad6cdcf9bcf25ace3d826b6623fa5164351e14d3f0de5c8445a2ba3aae26595d0e31c3e307c1d56d4274f61f056145c1b8d6880872b9b10a8bfa4a923cad2edbcf5c50eba48936ed2bcc0be60eb721a74b46704aaae5ad24e2797852195dfacbb30a777d33b63d4dc4f35cfbe5e88fd1944c55a54fd53581446ea061ad29f4671da819ad7488c5dfc700f5f7a1b2af0d6a6e9d9ffc570a6d3209614ab4dc43728f3f0cd7eb4ce36ccd98936bbcbd32627384434bd01e9c0f93b2a5173fba184685e19b9af78afe876aa4e4b4242382b293133771d95a2bd83fa9c62", + "node_privkey": "4242424242424242424242424242424242424242424242424242424242424242", + "next_path_key": "034e09f450a80c3d252b258aba0a61215bf60dda3b0dc78ffb0736ea1259dfd8a0" + }, + { + "alias": "Carol", + "onion": "000288b48876fb0dc0d7375233ccaf2910dc0dc81ba52e5a7906f00d75e0d58dbd4bb7c2714870529410735f0951e72cbe981e2e167c0d8f3de33a36e39e78465aea2acad1e23c78b6fd342d63e37d214c912b4a0be344618f779138edc1b42a5ca3218ca2fea4be427f6cd0d387160db2bf6c2ba8e82941c8cf3626bd6bed7187f633012ef49df38f6b12963cb639e9eed1b9d269dcebcbd0b25287aa536ec85e7320b02e193122199a745ccbaaebd37f5d4b71f52f9b50feeb793eeef56924a046bc5e7003f6253e0284a8d3fe2e42c3564050f1e753cd32cc258ac0ffa6e05eecad5ba1286f78252e60dd884a65405ab673a85ba52adfa65c1086d4bb37ba2e0848adb2b04379775ad798492b14e8997f30ffa9cf5d432bdf5b246fce008fd876399beed827db58195f4f6192f6ff4ec63cb17fdcb497cb7aec26846a71dd8dca02fc3bb14dd7231a4d62a981bec54b71eb20331096dfa214a0ff4489ee96db663826ae8c850e9f06baa52a47b8eb576363f97e742aab2dc616acc6e74588e1d2ac16694febc90abaf5b1c684163c0e615a68d32633f01934adc8c6bf91fa3fd7aad033b7596d60402494e45e2c1632c40f7bfbd88a81a896a1d28ed6338c83e1eeaa467945d59998eb456c95f94bf1892e8f326ec2d5e0196b7073f106febc6ab8ca5bcc23f77ffc819bc1b5debce418ccc7d8391bbf33bceee6110beba170121bd99f54c956e64970bdab31227b03ee0ea3f01fbd9bd74015f6f82d04fab072e8f85f4370d09f41ee3e48eb959767bd989abb4eea42c4daa0437a7f747d7f9b70eb87b9f9b0b6f283b8205912601a432999b8869fd9fe5bad3572edac24da7184f9298f21ff60923db277264d29c846dd2f228f6fc53b6b60364237de64773f803f174ed10229c374f603ccc5fd3a62cb413ffe6f5630dc646bb33f231b2350537ec39e5d3f2fe1a1cb019ed0b18ad14019cad27afcca8ad70387ca110394c0432774f1aa1fa404b2e086c84a55388d3bd102501c78ef925cce89d76fa04c3f20f2d1f0ce507ac8b37b7913e3949ba12bbc5a4f6bac37c2415622d365bc8b83709a28e3d46f3850c89a3ff4d027fef6e3e4ce5c6c85f663c7eaec3c9730106fb82f53249a905533cfabee812aae51965b24b42f7ab471967bc8e73354e69141ee26a1f03684d5fb9c256a34de8257210e0390dd3962db521ae0a3bdab28300610ab2a634b699e5f092da5a061609ef6414bd805c8171f54ad6f285fb64ce0becca0b61188badcf8ef21190dad629e3fb3e89f55ebba829919540ebf5f8ae4283836d3c9133c1ca3365f6b9394916730411650686e0c2ab9c53b6cda9efdd5cfcb53ba9b6962bb6aa49d0a83a87460b60a9c7d2643ee99afe652883795f14014ec5df61b1e30c041c1fa6487f3c82f1ded5f83ffbef5017e197b7fb77be3b36e284a15e57d45bf9316dcaf97eb78ee4642b731ba05c5063bce1333fab4af6da97c80a96ee599b4df823efbedc250c0abba9783da7ddf2414b2a4774ff2880a7dc6791103e18b8631e39743cf9e87aed71700daa5dc72fdae520324741f92ea3d510ff555dea5e45f15cda87272d4559a12d4777680acb06993840e3c748da82c16cae556015fb2acd0335da11a3388575394048ab71199793ab706abc9d68add2075d79a5cc0f779845ee8b98951be61fd293d6c15b9d4653935bf17cf50bd31f8b79e60dba0e7fd6864754fd94262485a4f65e7eb3e1922f51b1a4dd2b4fd2c20d94d1213fbe90bd603dfc7e15176382e3ce0f43f980d44d23bf3c57f54a15f42c171a8f2511e28ac178c6f01396e50397a57ffb09c5e6c315bd3ae7983577c1a0386c6d5d9a2223438e321b0fedfdee58fa452d57dc11a256834bb49ac9deeec88e4bf563c7340f44a240caec941c7e50f09cf", + "node_privkey": "4343434343434343434343434343434343434343434343434343434343434343", + "next_path_key": "031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f" + }, + { + "alias": "Dave", + "onion": "0003f25471c0f2ff549a7fd7859100306bb6c294c209f34c77f782897f184b967c498efc246bdb8e060a6d1cf8dd0d4d732e33311fb96c9e9f1274005fa3d08b41704a1b7224c6300a7caead7baa0a8263eba2e0de6956ee8e4a1958264f47e4cf20d194eb576f5bd249ee4fece563f80fd76dc3eaca8f956188406d83195752b5c90c4b2a5e7ac3a8d5c62b17b551aff48ef6842a7e9326832c9a4a2fd415011150a9e71beb901fd9747bac8add1c694b612730dc86b5b19a0bbbc675947a953316e3303d7b30c182f94def9206671edac9a3ec3e52d28fc28247a1c73ab751bf61c82c3950f617e758f79bd0ba294defb20466eaf1e801462046baad3aec3e5b8868a7b037f23d73a47a7e74c77107334f37388cff863e452820c61d89728fa75c84bc7cdfc06dcdd1911f5f803353926d073efd65251380e174913aae03318ea5b6f0ec83998c55ab99bef62803ea2da9f6d1ea892b90efc4f8ffb685a5201a781da2e6ac5923645638c9709ae32171a00c0cd3d8c7eedfb06b4eedc7d3e566987e2e3805a038f21d78ded5d6c7137a5e8e592f3180ee4d5f4e1289176f67fc38690d0958bc82e240b72b10577f340f1e14b8633f0b6d9729ff4618be2a972400a015a871ba33be70335f652a8d70f2bd32421d6ac2af781d667dad787d6aef4505a15d046579e46eebe757444cffca6d0610f0dd36a7ce57af969bd0c3f7006298ef406a25f689daf58f875d44d2423ebf195b503f11c37c506ea6abe50a463f7bb5e9b964604d832724de768513f6b38bf71715e1feea8a6e86797788d487146891564919da1372016ed8f08c7fcbff66a4a65a3d0fcd8e3daac6eba41f5d65ef2d8075364a9e78b3a273549f6eac4abb72e912e237990367e0d43e89945f8ac3907be5a6c662139485a50cb5ce3f0ba08586c39f6c515368ec3f91b72295f1b7a73a9df322ae9a45d363d6c616be3300083764cbdee31221f25a318f095feacb09f957c96db30fccca47a0215b576c3ed925a0bad05d6400abe318c11f36628c387a4ee38832182cd44b3cd48e5422c1f1e3b57218dfe72c611f5415127720e60f6e2400607e61841b76de1704bcbeb0daf1377ccb2253916de2b6d490bb71ba0a44fea2e94f2423d723934557d5905e01b2b80232a884e258d46dc92ea11e0818d0ece5b914f02049866e151801ab8c9aea155479b354dc91151fb9ba43277458f9760dd859faaa139e3b9ab36a1dbc36a93ef2c90598b20cb30ef3c4f23a2d6178b4d1da668fb328a25d84d30a132d9f2a6a988cbe2e5c2be01cb6db4b4725a50d6cdacf5fb083e7d650a25bec1407fbc047d26076c7596429a29606ad527e97ef0824ad6c1b05831a3e5b71c63a528918a3301cdd4061fc1fcce3da601961f2602a2b002ac8404125c2d52666263858a923e197efcda873c32d86897352e4f2264ad6a1b48acc0fe78ff55cb442cb2bb5fa2880810e1d00aa0247057fb80b7ed36cf9647af41b44ee4a63ee2d6f652526404572520a7d2d9dcde4e62df0c3be89f8471550594cdd16a51a9cacc58729c092c68506162fe65edc2314055d389f724ced189d826a546b5c4d08a43d977b3cf033de5760b71a7cc38ee5851592031aafb467a89b3b6c7ed67b15d44c48d6baedce3e95e08ec7c55038f3eba90ccb900895734f0fb7efe54961ce493369cc56416898a9bed7c2482871c15a7f1eb5ed17c33657fc31333539c2dfb59461af09e7049228113b5c9feea5a6e9959c18c51b19c90995afb9c76f2c0c820964cd7989c993a73925818a656c6a18dcd1a1e3782b2eae06dd5a41250ec2d1c203626ab9920c1673339eff04b1eb0cab85ef5909f571f9b83cdf21697c9f5cfa1c76e7bca955510e2126b3bb989a4ac21cf948f965e48bc363d2997437797b4f770e8b65", + "node_privkey": "4444444444444444444444444444444444444444444444444444444444444444", + "next_path_key": "03e09038ee76e50f444b19abf0a555e8697e035f62937168b80adf0931b31ce52a" + }, + { + "alias": "Eve", + "onion": "0002ef43c4dfe9aee14248c445406ac7980474ce106c504d9025f57963739130adfd06eb26201baee8866af2d1b7a7ab26595349dad002af0590193aaa8f400ab394f5994ec831aeeecb64421c566e3556cbdd7e7e50deb1fc49fd5e007308ab6494415514abff978899623f9b6065ca1e243bb78170118e8b8c8b53b750b59cc1ec017d167adbb3aabab7c2d84fbf94f5d827239f4c2b9d2c3cfe68fe5641f25e386202a4b6edff2a71e700229df7230c8ca31bd5588f04799e9640c9c20a47cba713f3cc5ad3202e14bb520880f2a8409d8e7835cae21b48a651c2d47fe6af785889ab98f1416f6e4ad67a66ae681e9a8828bad3f9b6890221c4a7ec80531d6b63eb30843f613ce644795bc8bcee60e8f7b36f3fd04de762f103c52efaf36a2f3bbbaac482d6271dc4180c10bcc076c04d06ea7fd8fb6a647e0e10523b05da2d89e4139fb55c2315cd01bdcbd57587fef8442d7ff5620630fd2d2e79739d90be811bf2cba60415d6cba2cea14ba1859f3122cd905c4e12e3e2a1ab6fab54b2ec40e434626e2d3c3195c02c82a8bd64d226c2328ac72ca12197d9908eaf54333717448ce6ed73adc0ac05e2ee1d735131d87918beb8995993dc8f63fe10f2c8eba2be7ab8bb44d9f78f59ef3e4c180bd75e4eef2381450c6f0480d543997305f1d07815993b5aca8d88d474966d9abec93bb069a16aa2da75b87f94576e01d08a17d3e0e3d0370f010733a7d7affb12cdf94c259a62607fce71003535c4727305de5ff7bba3840922844b3a45f62c29715fccf440517ef121450f6962396fba9b07036d085582405dcae6ee95964b66bc7c85b8d02d90091500db3cebf6de584f86b7b55335a8c9aa26381b00747f055cc458a2cadfccf9c29702bf941447beaca6583cca09492a57d4b03b2ca00dbaf41dfd6a9b249381626a7debe475735a7e39e77a363eccf14669046f656cc09ad448da8d8b545e6a604f46dc481786d09a94c63cf23f49ba367d2929466364dbce2a8ffce3dadf8f4cef8a56e1fefa1a3304a953fe83018e57d8a95694b02d994fea2630a9a3d5f1e2f6d6142d503ec4152871f7122d7e566a03261f554639e7a759e0e73846f71d5cace37d91336fc9ca9396bf64ca2cf45fa2db779b3b5c63b04f1c0c1fb79fdfcf5a82b0202df934ae1720a7ce1e047cbec3f82737b50168c974f4623cacce87e3f5bd5232caca7956d28ffedcf11ac5998662c5f6b13c6126584ca2e894d3fcbad4d130bbe22e88a135e0020cdd43853e0b3af3800e9544854d211e873cf68ab683578d501d69ec5dc7fce42ac436d58243880c1b88227b0681c6c9dd8a8ad0793202b15ab63b787b748e258da3e68d0e649fc4ac081a71de8adbc891c113d5f722686b6ac4ed9e3cc247bc4a4643416f480627e9de20f7307f434a499f5c6951c2e8b3ff51d455bf65ceb5ee3dee47b968ac2642e13d8a68f903b73627c2e75788fecca5836371a908eea4f1ea44db2315bc185f77e478efeaaa4da2da13fe7aeaa79ed1d04876a8b2b7b333c5de8c4c9a50274c2eb7b9bd2a3630c57173174781fc9785235f830cefa1c82080eaffdef257f18eedc9ddfd25a696a11a3dc56cd836be72f5f4a2cbb6316d5d3b1ad91a7ec7d877f28d2c29a5525b0b24362699281b0e3b48f38caf1085045fe9089f9e6fb29e4b47aa4cecf68c9bf72073469bd9beeea5e88bfe554cb6a81231149ba7fe7784c154fd8b0f9179ecdf1e9fd5c2939ec1ab16df9cbe9359101ebce933d4f65d3f66f87afaecfe9c046b52f4878b6c430329df7bd879fba8864fcbd9b782bf545734699b9b5a66b466dcedc0c9368803b5b0f1232950cef398ad3e057a5db964bd3e5c8a5717b30b41601a4f11ad63afe404cb6f1e8ea5fd7a8e085b65ca5136146febf4d47928dcc9a9e0", + "node_privkey": "4545454545454545454545454545454545454545454545454545454545454545", + "next_path_key": "038fc6859a402b96ce4998c537c823d6ab94d1598fca02c788ba5dd79fbae83589" + } + ] + } +} diff --git a/tests/bolt12_format_string_test.json b/tests/bolt12_format_string_test.json new file mode 100644 index 000000000000..46e543b8b29b --- /dev/null +++ b/tests/bolt12_format_string_test.json @@ -0,0 +1,62 @@ +[ + { + "comment": "A complete string is valid", + "valid": true, + "string": "lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "comment": "Uppercase is valid", + "valid": true, + "string": "LNO1PQPS7SJQPGTYZM3QV4UXZMTSD3JJQER9WD3HY6TSW35K7MSJZFPY7NZ5YQCNYGRFDEJ82UM5WF5K2UCKYYPWA3EYT44H6TXTXQUQH7LZ5DJGE4AFGFJN7K4RGRKUAG0JSD5XVXG" + }, + { + "comment": "+ can join anywhere", + "valid": true, + "string": "l+no1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "comment": "Multiple + can join", + "valid": true, + "string": "lno1pqps7sjqpgt+yzm3qv4uxzmtsd3jjqer9wd3hy6tsw3+5k7msjzfpy7nz5yqcn+ygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd+5xvxg" + }, + { + "comment": "+ can be followed by whitespace", + "valid": true, + "string": "lno1pqps7sjqpgt+ yzm3qv4uxzmtsd3jjqer9wd3hy6tsw3+ 5k7msjzfpy7nz5yqcn+\nygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd+\r\n 5xvxg" + }, + { + "comment": "+ can be followed by whitespace, UPPERCASE", + "valid": true, + "string": "LNO1PQPS7SJQPGT+ YZM3QV4UXZMTSD3JJQER9WD3HY6TSW3+ 5K7MSJZFPY7NZ5YQCN+\nYGRFDEJ82UM5WF5K2UCKYYPWA3EYT44H6TXTXQUQH7LZ5DJGE4AFGFJN7K4RGRKUAG0JSD+\r\n 5XVXG" + }, + { + "comment": "Mixed case is invalid", + "valid": false, + "string": "LnO1PqPs7sJqPgTyZm3qV4UxZmTsD3JjQeR9Wd3hY6TsW35k7mSjZfPy7nZ5YqCnYgRfDeJ82uM5Wf5k2uCkYyPwA3EyT44h6tXtXqUqH7Lz5dJgE4AfGfJn7k4rGrKuAg0jSd5xVxG" + }, + { + "comment": "+ must be surrounded by bech32 characters", + "valid": false, + "string": "lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg+" + }, + { + "comment": "+ must be surrounded by bech32 characters", + "valid": false, + "string": "lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg+ " + }, + { + "comment": "+ must be surrounded by bech32 characters", + "valid": false, + "string": "+lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "comment": "+ must be surrounded by bech32 characters", + "valid": false, + "string": "+ lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "comment": "+ must be surrounded by bech32 characters", + "valid": false, + "string": "ln++o1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + } +] diff --git a/tests/bolt12_offers_test.json b/tests/bolt12_offers_test.json new file mode 100644 index 000000000000..b3343effeda1 --- /dev/null +++ b/tests/bolt12_offers_test.json @@ -0,0 +1,596 @@ +[ + { + "description": "Minimal bolt12 offer", + "valid": true, + "bolt12": "lno1zcss9mk8y3wkklfvevcrszlmu23kfrxh49px20665dqwmn4p72pksese", + "fields": [ + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with description (but no amount)", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyuckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg", + "field info": "description is 'Test vectors'", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "for testnet", + "valid": true, + "bolt12": "lno1qgsyxjtl6luzd9t3pr62xr7eemp6awnejusgf6gw45q75vcfqqqqqqq2p32x2um5ypmx2cm5dae8x93pqthvwfzadd7jejes8q9lhc4rvjxd022zv5l44g6qah82ru5rdpnpj", + "field info": "chains[0] is testnet", + "fields": [ + { + "type": 2, + "length": 32, + "hex": "43497fd7f826957108f4a30fd9cec3aeba79972084e90ead01ea330900000000" + }, + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "for bitcoin (redundant)", + "valid": true, + "bolt12": "lno1qgsxlc5vp2m0rvmjcxn2y34wv0m5lyc7sdj7zksgn35dvxgqqqqqqqq2p32x2um5ypmx2cm5dae8x93pqthvwfzadd7jejes8q9lhc4rvjxd022zv5l44g6qah82ru5rdpnpj", + "field info": "chains[0] is bitcoin", + "fields": [ + { + "type": 2, + "length": 32, + "hex": "6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000" + }, + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "for bitcoin or liquidv1", + "valid": true, + "bolt12": "lno1qfqpge38tqmzyrdjj3x2qkdr5y80dlfw56ztq6yd9sme995g3gsxqqm0u2xq4dh3kdevrf4zg6hx8a60jv0gxe0ptgyfc6xkryqqqqqqqq9qc4r9wd6zqan9vd6x7unnzcss9mk8y3wkklfvevcrszlmu23kfrxh49px20665dqwmn4p72pksese", + "field info": "chains[0] is liquidv1, chains[1] is bitcoin", + "fields": [ + { + "type": 2, + "length": 64, + "hex": "1466275836220db2944ca059a3a10ef6fd2ea684b0688d2c379296888a2060036fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000" + }, + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with metadata", + "valid": true, + "bolt12": "lno1qsgqqqqqqqqqqqqqqqqqqqqqqqqqqzsv23jhxapqwejkxar0wfe3vggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvs", + "field info": "metadata is 16 zero bytes", + "fields": [ + { + "type": 4, + "length": 16, + "hex": "00000000000000000000000000000000" + }, + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with amount", + "valid": true, + "bolt12": "lno1pqpzwyq2p32x2um5ypmx2cm5dae8x93pqthvwfzadd7jejes8q9lhc4rvjxd022zv5l44g6qah82ru5rdpnpj", + "field info": "amount is 10000msat", + "fields": [ + { + "type": 8, + "length": 2, + "hex": "2710" + }, + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with currency", + "valid": true, + "bolt12": "lno1qcp4256ypqpzwyq2p32x2um5ypmx2cm5dae8x93pqthvwfzadd7jejes8q9lhc4rvjxd022zv5l44g6qah82ru5rdpnpj", + "field info": "amount is USD $100.00", + "fields": [ + { + "type": 6, + "length": 3, + "hex": "555344" + }, + { + "type": 8, + "length": 2, + "hex": "2710" + }, + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with expiry", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucwq3ay997czcss9mk8y3wkklfvevcrszlmu23kfrxh49px20665dqwmn4p72pksese", + "field info": "expiry is 2035-01-01", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 14, + "length": 4, + "hex": "7a4297d8" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with issuer", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucjy358garswvaz7tmzdak8gvfj9ehhyeeqgf85c4p3xgsxjmnyw4ehgunfv4e3vggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvs", + "field info": "issuer is 'https://bolt12.org BOLT12 industries'", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 18, + "length": 36, + "hex": "68747470733a2f2f626f6c7431322e6f726720424f4c54313220696e6475737472696573" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with quantity", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyuc5qyz3vggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvs", + "field info": "quantity_max is 5", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 20, + "length": 1, + "hex": "05" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with unlimited (or unknown) quantity", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyuc5qqtzzqhwcuj966ma9n9nqwqtl032xeyv6755yeflt235pmww58egx6rxry", + "field info": "quantity_max is unknown/unlimited", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 20, + "length": 0, + "hex": "" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with single quantity (weird but valid)", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyuc5qyq3vggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvs", + "field info": "quantity_max is 1", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 20, + "length": 1, + "hex": "01" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with feature", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucvp5yqqqqqqqqqqqqqqqqqqqqkyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg", + "field info": "feature bit 99 set", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 12, + "length": 13, + "hex": "08000000000000000000000000" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with blinded path via Bob (0x424242...), path_key 020202...", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucs5ypjgef743p5fzqq9nqxh0ah7y87rzv3ud0eleps9kl2d5348hq2k8qzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgqpqqqqqqqqqqqqqqqqqqqqqqqqqqqzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqqzq3zyg3zyg3zyg3vggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvs", + "field info": "path is [id=02020202..., enc=0x00*16], [id=02020202..., enc=0x11*8]", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 16, + "length": 161, + "hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c0202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020200100000000000000000000000000000000002020202020202020202020202020202020202020202020202020202020202020200081111111111111111" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "same, with blinded path first_node_id using sciddir", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucs3yqqqqqqqqqqqqp2qgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqqyqqqqqqqqqqqqqqqqqqqqqqqqqqqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqqgzyg3zyg3zyg3z93pqthvwfzadd7jejes8q9lhc4rvjxd022zv5l44g6qah82ru5rdpnpj", + "field info": "short_channel_id is 0x0x42, direction is 0", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 16, + "length": 137, + "hex": "00000000000000002a0202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020200100000000000000000000000000000000002020202020202020202020202020202020202020202020202020202020202020200081111111111111111" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "with no issuer_id and blinded path via Bob (0x424242...), path_key 020202...", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucs5ypjgef743p5fzqq9nqxh0ah7y87rzv3ud0eleps9kl2d5348hq2k8qzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgqpqqqqqqqqqqqqqqqqqqqqqqqqqqqzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqqzq3zyg3zyg3zygs", + "field info": "path is [id=02020202..., enc=0x00*16], [id=02020202..., enc=0x11*8]", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 16, + "length": 161, + "hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c0202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020200100000000000000000000000000000000002020202020202020202020202020202020202020202020202020202020202020200081111111111111111" + } + ] + }, + { + "description": "... and with second blinded path via 1x2x3 (direction 1), path_key 020202...", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucsl5qj5qeyv5l2cs6y3qqzesrth7mlzrlp3xg7xhulusczm04x6g6nms9trspqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqqsqqqqqqqqqqqqqqqqqqqqqqqqqqpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsqpqg3zyg3zyg3zygpqqqqzqqqqgqqxqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqqgqqqqqqqqqqqqqqqqqqqqqqqqqqqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgqqsg3zyg3zyg3zygtzzqhwcuj966ma9n9nqwqtl032xeyv6755yeflt235pmww58egx6rxry", + "field info": "path is [id=02020202..., enc=0x00*16], [id=02020202..., enc=0x22*8]", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 16, + "length": 298, + "hex": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c02020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202001000000000000000000000000000000000020202020202020202020202020202020202020202020202020202020202020202000811111111111111110100000100000200030202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020200100000000000000000000000000000000002020202020202020202020202020202020202020202020202020202020202020200082222222222222222" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + } + ] + }, + { + "description": "unknown odd field", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyuckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxfppf5x2mrvdamk7unvvs", + "field info": "type 33 is 'helloworld'", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + }, + { + "type": 33, + "length": 10, + "hex": "68656c6c6f776f726c64" + } + ] + }, + { + "description": "unknown odd experimental field", + "valid": true, + "bolt12": "lno1pgx9getnwss8vetrw3hhyuckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvx078wdv5gg2dpjkcmr0wahhymry", + "field info": "type 1000000033 is 'helloworld'", + "fields": [ + { + "type": 10, + "length": 12, + "hex": "5465737420766563746f7273" + }, + { + "type": 22, + "length": 33, + "hex": "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + }, + { + "type": 1000000033, + "length": 10, + "hex": "68656c6c6f776f726c64" + } + ] + }, + { + "description": "Malformed: fields out of order", + "valid": false, + "bolt12": "lno1zcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszpgz5znzfgdzs" + }, + { + "description": "Malformed: unknown even TLV type 78", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3vggzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpysgr0u2xq4dh3kdevrf4zg6hx8a60jv0gxe0ptgyfc6xkryqqqqqqqq" + }, + { + "description": "Malformed: empty", + "valid": false, + "bolt12": "lno1" + }, + { + "description": "Malformed: truncated at type", + "valid": false, + "bolt12": "lno1pg" + }, + { + "description": "Malformed: truncated in length", + "valid": false, + "bolt12": "lno1pt7s" + }, + { + "description": "Malformed: truncated after length", + "valid": false, + "bolt12": "lno1pgpq" + }, + { + "description": "Malformed: truncated in description", + "valid": false, + "bolt12": "lno1pgpyz" + }, + { + "description": "Malformed: invalid offer_chains length", + "valid": false, + "bolt12": "lno1qgqszzs9g9xyjs69zcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: truncated currency UTF-8", + "valid": false, + "bolt12": "lno1qcqcqzs9g9xyjs69zcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: invalid currency UTF-8", + "valid": false, + "bolt12": "lno1qcplllhapqpq86q2q4qkc6trv5tzzq6muh550qsfva9fdes0ruph7ctk2s8aqq06r4jxj3msc448wzwy9s" + }, + { + "description": "Malformed: truncated description UTF-8", + "valid": false, + "bolt12": "lno1pgqcq93pqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqy" + }, + { + "description": "Malformed: invalid description UTF-8", + "valid": false, + "bolt12": "lno1pgpgqsgkyypqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqs" + }, + { + "description": "Malformed: truncated offer_paths", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3qqgpzcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: zero num_hops in blinded_path", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3qqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsqzcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: truncated onionmsg_hop in blinded_path", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3qqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqspqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqgkyypqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqs" + }, + { + "description": "Malformed: bad first_node_id in blinded_path", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3qqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqspqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqgqzcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: bad path_key in blinded_path", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3qqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcpqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqgqzcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: bad blinded_node_id in onionmsg_hop", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3qqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqspqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqgqzcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: truncated issuer UTF-8", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3yqvqzcssyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz" + }, + { + "description": "Malformed: invalid issuer UTF-8", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3yq5qgytzzqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqg" + }, + { + "description": "Malformed: invalid offer_issuer_id", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3vggzqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvpsxqcrqvps" + }, + { + "description": "Contains type >= 80", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3vggzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgp9qgr0u2xq4dh3kdevrf4zg6hx8a60jv0gxe0ptgyfc6xkryqqqqqqqq" + }, + { + "description": "Contains type > 1999999999", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3vggzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgp06ae4jsq9qgr0u2xq4dh3kdevrf4zg6hx8a60jv0gxe0ptgyfc6xkryqqqqqqqq" + }, + { + "description": "Contains unknown even type (1000000002)", + "valid": false, + "bolt12": "lno1pgz5znzfgdz3vggzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgp06wu6egp9qgr0u2xq4dh3kdevrf4zg6hx8a60jv0gxe0ptgyfc6xkryqqqqqqqq" + }, + { + "description": "Contains unknown feature 122", + "valid": false, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucvzqzqqqqqqqqqqqqqqqqqqqqqqqqpvggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvs" + }, + { + "description": "Missing offer_description, but has offer_amount", + "valid": false, + "bolt12": "lno1pqpzwyqkyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "description": "Missing offer_amount with offer_currency", + "valid": false, + "bolt12": "lno1qcp4256ypgx9getnwss8vetrw3hhyuckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg" + }, + { + "description": "Missing offer_issuer_id and no offer_path", + "valid": false, + "bolt12": "lno1pgx9getnwss8vetrw3hhyuc" + }, + { + "description": "Second offer_path is empty", + "valid": false, + "bolt12": "lno1pgx9getnwss8vetrw3hhyucsespjgef743p5fzqq9nqxh0ah7y87rzv3ud0eleps9kl2d5348hq2k8qzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgqpqqqqqqqqqqqqqqqqqqqqqqqqqqqzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqqzq3zyg3zyg3zygszqqqqyqqqqsqqvpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsq" + }, + { + "description": "offer_chains with zero entries", + "valid": false, + "bolt12": "lno1qgqpvggrt0j7j3uzp9n549hxpu0sxlmpwe2ql5qplgwkg628wrzk5acfcskq" + } +] diff --git a/tests/lnhelpers.py b/tests/lnhelpers.py index a36f9335d967..5b7178cdd39c 100644 --- a/tests/lnhelpers.py +++ b/tests/lnhelpers.py @@ -2,7 +2,9 @@ import copy import os from pprint import pformat -from typing import NamedTuple, Tuple, Dict, Mapping, TYPE_CHECKING, Sequence +from typing import NamedTuple, Tuple, Dict, Mapping, TYPE_CHECKING, Optional + +from electrum_ecc import ECPrivkey import electrum import electrum.trampoline @@ -15,15 +17,16 @@ from electrum.lnpeer import Peer from electrum.lnutil import ( LnFeatures, PaymentFeeBudget, LOCAL, REMOTE, ChannelType, LocalConfig, RemoteConfig, - OnlyPubkeyKeypair, secret_to_pubkey, + OnlyPubkeyKeypair, secret_to_pubkey, UnblindedRoutingInfo, ) from electrum.lnchannel import ChannelState, Channel from electrum.lnrouter import LNPathFinder from electrum.channel_db import ChannelDB -from electrum.lnworker import LNWallet, PaySession +from electrum.lnworker import LNWallet, PaySession, LNWALLET_FEATURES from electrum.simple_config import SimpleConfig from electrum.fee_policy import FeeTimeEstimates, FEE_ETA_TARGETS from electrum.wallet import Standard_Wallet +from electrum.lnonion import BlindedPathInfo, BlindedPayInfo, BlindedPath, BlindedPathHop from . import restore_wallet_from_text__for_unittest @@ -155,15 +158,19 @@ async def stop(self): await self.channel_db.stopped_event.wait() async def create_routes_from_invoice(self, amount_msat: int, decoded_invoice: BOLT11Addr, *, full_path=None): + routing_info = UnblindedRoutingInfo( + node_pubkey=decoded_invoice.pubkey.serialize(), + r_tags=decoded_invoice.get_routing_info('r'), + payment_secret=decoded_invoice.payment_secret, + final_cltv_delta=decoded_invoice.get_min_final_cltv_delta() + lnutil.get_final_cltv_offset(), + invoice_features=decoded_invoice.get_features(), + ) paysession = PaySession( + routing_info=routing_info, payment_hash=decoded_invoice.paymenthash, - payment_secret=decoded_invoice.payment_secret, initial_trampoline_fee_level=0, invoice_features=decoded_invoice.get_features(), - r_tags=decoded_invoice.get_routing_info('r'), - min_final_cltv_delta=decoded_invoice.get_min_final_cltv_delta(), amount_to_pay=amount_msat, - invoice_pubkey=decoded_invoice.pubkey.serialize(), uses_trampoline=False, ) payment_key = decoded_invoice.paymenthash + decoded_invoice.payment_secret @@ -530,3 +537,29 @@ def create_test_channels( assert alice.channel_id == bob.channel_id return alice, bob + + +def get_dummy_paths(first_node_id: Optional[bytes] = None) -> list[BlindedPathInfo]: + ip_node_id = first_node_id or ECPrivkey.generate_random_key().get_public_key_bytes() + first_path_key = ECPrivkey.generate_random_key().get_public_key_bytes() + dummy_paths = [BlindedPathInfo( + path=BlindedPath( + first_node_id=ip_node_id, + first_path_key=first_path_key, + num_hops=(1).to_bytes(1, 'big'), + path=[BlindedPathHop( + blinded_node_id=ECPrivkey.generate_random_key().get_public_key_bytes(), # dummy + enclen=5, + encrypted_recipient_data=b'12345', + )], + ), + payinfo=BlindedPayInfo( + fee_base_msat=1000, + fee_proportional_millionths=1000, + cltv_expiry_delta=221, + htlc_minimum_msat=1, + htlc_maximum_msat=10000000000, + features=LNWALLET_FEATURES.for_blinded_path(), + ), + )] + return dummy_paths diff --git a/tests/regtest.py b/tests/regtest.py index 9c2de7b36243..a4b1405062c6 100644 --- a/tests/regtest.py +++ b/tests/regtest.py @@ -147,6 +147,9 @@ class TestLightningABC(TestLightning): } } + def test_bolt12(self): + self.run_shell(['bolt12']) + def test_fw_fail_htlc(self): self.run_shell(['fw_fail_htlc']) diff --git a/tests/regtest/regtest.sh b/tests/regtest/regtest.sh index 47ee7e437e1d..64f39d4460d3 100755 --- a/tests/regtest/regtest.sh +++ b/tests/regtest/regtest.sh @@ -484,7 +484,7 @@ if [[ $1 == "lnwatcher_waits_until_fees_go_down" ]]; then assert_utxo_exists $ctx_id $htlc_output_index2 # fee levels rise. now small htlc is ~dust $alice test_inject_fee_etas "{2:300000}" - new_blocks 300 # this goes past the CLTV of the HTLC-output in ctx + new_blocks 450 # this goes past the CLTV of the HTLC-output in ctx wait_until_spent $ctx_id $htlc_output_index2 assert_utxo_exists $ctx_id $htlc_output_index1 new_blocks 24 # note: >20 blocks depth is considered "DEEP" by lnwatcher @@ -603,7 +603,7 @@ if [[ $1 == "redeem_offered_htlcs" ]]; then new_blocks 1 sleep 3 echo "alice balance after closing channel:" $($alice getbalance) - new_blocks 150 + new_blocks 300 # cltv delta + random offset [72;143] sleep 10 new_blocks 1 sleep 3 @@ -707,8 +707,8 @@ if [[ $1 == "breach_with_spent_htlc" ]]; then fi echo "wait for cltv_expiry blocks" # note: this will let alice redeem both to_local and the htlc. - # (to_local needs to_self_delay blocks; htlc needs whatever we put in invoice) - new_blocks 150 + # (to_local needs to_self_delay blocks; htlc needs whatever we put in invoice and the offset add on top when sending) + new_blocks 300 $alice stop $alice daemon -d $alice load_wallet -w /tmp/alice/regtest/wallets/toxic_wallet @@ -770,6 +770,25 @@ if [[ $1 == "watchtower" ]]; then wait_until_spent $ctx_id $output_index # alice's to_local gets punished fi +if [[ $1 == "bolt12" ]]; then +# $carol enable_htlc_settle false + bob_node=$($bob nodeid) + wait_for_balance carol 1 + echo "alice and carol open channels with bob" + chan_id1=$($alice open_channel $bob_node 0.15 --password='' --push_amount=0.075) + chan_id2=$($carol open_channel $bob_node 0.15 --password='' --push_amount=0.075) + new_blocks 3 + wait_until_channel_open alice + wait_until_channel_open carol + echo "alice pays carol" + offer=$($carol add_lightning_offer --amount=0.001| jq '.offer') + result=$($alice pay_bolt12_offer $offer) + echo $result + if [[ $(echo $result|jq '.success') == false ]]; then + exit 1 + fi +fi + if [[ $1 == "fw_fail_htlc" ]]; then $carol enable_htlc_settle false bob_node=$($bob nodeid) @@ -793,7 +812,7 @@ if [[ $1 == "fw_fail_htlc" ]]; then ctx_id=$($bob close_channel $chan_id2 --force) new_blocks 1 sleep 1 - new_blocks 150 # cltv before bob can broadcast + new_blocks 300 # cltv before bob can broadcast # index of htlc if [ $TEST_SRK_CHANNELS != True ] ; then # anchors output_index=2 diff --git a/tests/route-blinding-test.json b/tests/route-blinding-test.json new file mode 100644 index 000000000000..fdd195b3253d --- /dev/null +++ b/tests/route-blinding-test.json @@ -0,0 +1,179 @@ +{ + "comment": "test vector for using blinded routes", + "generate": { + "comment": "This section contains test data for creating a blinded route. This route is the concatenation of two blinded routes: one from Dave to Eve and one from Bob to Carol.", + "hops": [ + { + "comment": "Bob creates a Bob -> Carol route with the following session_key and concatenates it with the Dave -> Eve route.", + "session_key": "0202020202020202020202020202020202020202020202020202020202020202", + "alias": "Bob", + "node_id": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c", + "tlvs": { + "padding": "0000000000000000000000000000000000000000000000000000", + "short_channel_id": "0x0x1729", + "payment_relay": { + "cltv_expiry_delta": 36, + "fee_proportional_millionths": 150, + "fee_base_msat": 10000 + }, + "payment_constraints": { + "max_cltv_expiry": 748005, + "htlc_minimum_msat": 1500 + }, + "allowed_features": { + "features": [] + }, + "unknown_tag_561": "123456" + }, + "encoded_tlvs": "011a0000000000000000000000000000000000000000000000000000020800000000000006c10a0800240000009627100c06000b69e505dc0e00fd023103123456", + "path_privkey": "0202020202020202020202020202020202020202020202020202020202020202", + "path_key": "024d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766", + "shared_secret": "76771bab0cc3d0de6e6f60147fd7c9c7249a5ced3d0612bdfaeec3b15452229d", + "rho": "ba217b23c0978d84c4a19be8a9ff64bc1b40ed0d7ecf59521567a5b3a9a1dd48", + "encrypted_data": "cd4100ff9c09ed28102b210ac73aa12d63e90852cebc496c49f57c49982088b49f2e70b99287fdee0aa58aa39913ab405813b999f66783aa2fe637b3cda91ffc0913c30324e2c6ce327e045183e4bffecb", + "blinded_node_id": "03da173ad2aee2f701f17e59fbd16cb708906d69838a5f088e8123fb36e89a2c25" + }, + { + "comment": "Notice the next_path_key_override tlv in Carol's payload, indicating that Bob concatenated his route with another blinded route starting at Dave.", + "alias": "Carol", + "node_id": "027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007", + "tlvs": { + "short_channel_id": "0x0x1105", + "next_path_key_override": "031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", + "payment_relay": { + "cltv_expiry_delta": 48, + "fee_proportional_millionths": 100, + "fee_base_msat": 500 + }, + "payment_constraints": { + "max_cltv_expiry": 747969, + "htlc_minimum_msat": 1500 + }, + "allowed_features": { + "features": [] + } + }, + "encoded_tlvs": "020800000000000004510821031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f0a0800300000006401f40c06000b69c105dc0e00", + "path_privkey": "0a2aa791ac81265c139237b2b84564f6000b1d4d0e68d4b9cc97c5536c9b61c1", + "path_key": "034e09f450a80c3d252b258aba0a61215bf60dda3b0dc78ffb0736ea1259dfd8a0", + "shared_secret": "dc91516ec6b530a3d641c01f29b36ed4dc29a74e063258278c0eeed50313d9b8", + "rho": "d1e62bae1a8e169da08e6204997b60b1a7971e0f246814c648125c35660f5416", + "encrypted_data": "cc0f16524fd7f8bb0b1d8d40ad71709ef140174c76faa574cac401bb8992fef76c4d004aa485dd599ed1cf2715f57ff62da5aaec5d7b10d59b04d8a9d77e472b9b3ecc2179334e411be22fa4c02b467c7e", + "blinded_node_id": "02e466727716f044290abf91a14a6d90e87487da160c2a3cbd0d465d7a78eb83a7" + }, + { + "comment": "Eve creates a Dave -> Eve blinded route using the following session_key.", + "session_key": "0101010101010101010101010101010101010101010101010101010101010101", + "alias": "Dave", + "node_id": "032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e668680991", + "tlvs": { + "padding": "0000000000000000000000000000000000000000000000000000000000000000000000", + "short_channel_id": "0x0x561", + "payment_relay": { + "cltv_expiry_delta": 144, + "fee_proportional_millionths": 250 + }, + "payment_constraints": { + "max_cltv_expiry": 747921, + "htlc_minimum_msat": 1500 + }, + "allowed_features": { + "features": [] + } + }, + "encoded_tlvs": "01230000000000000000000000000000000000000000000000000000000000000000000000020800000000000002310a060090000000fa0c06000b699105dc0e00", + "path_privkey": "0101010101010101010101010101010101010101010101010101010101010101", + "path_key": "031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", + "shared_secret": "dc46f3d1d99a536300f17bc0512376cc24b9502c5d30144674bfaa4b923d9057", + "rho": "393aa55d35c9e207a8f28180b81628a31dff558c84959cdc73130f8c321d6a06", + "encrypted_data": "0fa0a72cff3b64a3d6e1e4903cf8c8b0a17144aeb249dcb86561adee1f679ee8db3e561d9c43815fd4bcebf6f58c546da0cd8a9bf5cebd0d554802f6c0255e28e4a27343f761fe518cd897463187991105", + "blinded_node_id": "036861b366f284f0a11738ffbf7eda46241a8977592878fe3175ae1d1e4754eccf" + }, + { + "comment": "Eve is the final recipient, so she included a path_id in her own payload to verify that the route is used when she expects it.", + "alias": "Eve", + "node_id": "02edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145", + "tlvs": { + "padding": "0000000000000000000000000000000000000000000000000000", + "path_id": "deadbeef", + "payment_constraints": { + "max_cltv_expiry": 747777, + "htlc_minimum_msat": 1500 + }, + "allowed_features": { + "features": [113] + }, + "unknown_tag_65535": "06c1" + }, + "encoded_tlvs": "011a00000000000000000000000000000000000000000000000000000604deadbeef0c06000b690105dc0e0f020000000000000000000000000000fdffff0206c1", + "path_privkey": "62e8bcd6b5f7affe29bec4f0515aab2eebd1ce848f4746a9638aa14e3024fb1b", + "path_key": "03e09038ee76e50f444b19abf0a555e8697e035f62937168b80adf0931b31ce52a", + "shared_secret": "352a706b194c2b6d0a04ba1f617383fb816dc5f8f9ac0b60dd19c9ae3b517289", + "rho": "719d0307340b1c68b79865111f0de6e97b093a30bc603cebd1beb9eef116f2d8", + "encrypted_data": "da1a7e5f7881219884beae6ae68971de73bab4c3055d9865b1afb60724a2e4d3f0489ad884f7f3f77149209f0df51efd6b276294a02e3949c7254fbc8b5cab58212d9a78983e1cf86fe218b30c4ca8f6d8", + "blinded_node_id": "021982a48086cb8984427d3727fe35a03d396b234f0701f5249daa12e8105c8dae" + } + ] + }, + "route": { + "comment": "This section contains the resulting blinded route, which can then be used inside onion messages or payments.", + "first_node_id": "0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c", + "first_path_key": "024d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766", + "hops": [ + { + "blinded_node_id": "03da173ad2aee2f701f17e59fbd16cb708906d69838a5f088e8123fb36e89a2c25", + "encrypted_data": "cd4100ff9c09ed28102b210ac73aa12d63e90852cebc496c49f57c49982088b49f2e70b99287fdee0aa58aa39913ab405813b999f66783aa2fe637b3cda91ffc0913c30324e2c6ce327e045183e4bffecb" + }, + { + "blinded_node_id": "02e466727716f044290abf91a14a6d90e87487da160c2a3cbd0d465d7a78eb83a7", + "encrypted_data": "cc0f16524fd7f8bb0b1d8d40ad71709ef140174c76faa574cac401bb8992fef76c4d004aa485dd599ed1cf2715f57ff62da5aaec5d7b10d59b04d8a9d77e472b9b3ecc2179334e411be22fa4c02b467c7e" + }, + { + "blinded_node_id": "036861b366f284f0a11738ffbf7eda46241a8977592878fe3175ae1d1e4754eccf", + "encrypted_data": "0fa0a72cff3b64a3d6e1e4903cf8c8b0a17144aeb249dcb86561adee1f679ee8db3e561d9c43815fd4bcebf6f58c546da0cd8a9bf5cebd0d554802f6c0255e28e4a27343f761fe518cd897463187991105" + }, + { + "blinded_node_id": "021982a48086cb8984427d3727fe35a03d396b234f0701f5249daa12e8105c8dae", + "encrypted_data": "da1a7e5f7881219884beae6ae68971de73bab4c3055d9865b1afb60724a2e4d3f0489ad884f7f3f77149209f0df51efd6b276294a02e3949c7254fbc8b5cab58212d9a78983e1cf86fe218b30c4ca8f6d8" + } + ] + }, + "unblind": { + "comment": "This section contains test data for unblinding the route at each intermediate hop.", + "hops": [ + { + "alias": "Bob", + "node_privkey": "4242424242424242424242424242424242424242424242424242424242424242", + "path_key": "024d4b6cd1361032ca9bd2aeb9d900aa4d45d9ead80ac9423374c451a7254d0766", + "blinded_privkey": "d12fec0332c3e9d224789a17ebd93595f37d37bd8ef8bd3d2e6ce50acb9e554f", + "decrypted_data": "011a0000000000000000000000000000000000000000000000000000020800000000000006c10a0800240000009627100c06000b69e505dc0e00fd023103123456", + "next_path_key": "034e09f450a80c3d252b258aba0a61215bf60dda3b0dc78ffb0736ea1259dfd8a0" + }, + { + "alias": "Carol", + "node_privkey": "4343434343434343434343434343434343434343434343434343434343434343", + "path_key": "034e09f450a80c3d252b258aba0a61215bf60dda3b0dc78ffb0736ea1259dfd8a0", + "blinded_privkey": "bfa697fbbc8bbc43ca076e6dd60d306038a32af216b9dc6fc4e59e5ae28823c1", + "decrypted_data": "020800000000000004510821031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f0a0800300000006401f40c06000b69c105dc0e00", + "next_path_key": "03af5ccc91851cb294e3a364ce63347709a08cdffa58c672e9a5c587ddd1bbca60", + "next_path_key_override": "031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f" + }, + { + "alias": "Dave", + "node_privkey": "4444444444444444444444444444444444444444444444444444444444444444", + "path_key": "031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", + "blinded_privkey": "cebc115c7fce4c295dc396dea6c79115b289b8ceeceea2ed61cf31428d88fc4e", + "decrypted_data": "01230000000000000000000000000000000000000000000000000000000000000000000000020800000000000002310a060090000000fa0c06000b699105dc0e00", + "next_path_key": "03e09038ee76e50f444b19abf0a555e8697e035f62937168b80adf0931b31ce52a" + }, + { + "alias": "Eve", + "node_privkey": "4545454545454545454545454545454545454545454545454545454545454545", + "path_key": "03e09038ee76e50f444b19abf0a555e8697e035f62937168b80adf0931b31ce52a", + "blinded_privkey": "ff4e07da8d92838bedd019ce532eb990ed73b574e54a67862a1df81b40c0d2af", + "decrypted_data": "011a00000000000000000000000000000000000000000000000000000604deadbeef0c06000b690105dc0e0f020000000000000000000000000000fdffff0206c1", + "next_path_key": "038fc6859a402b96ce4998c537c823d6ab94d1598fca02c788ba5dd79fbae83589" + } + ] + } +} diff --git a/tests/test_blinded_payment_onion.py b/tests/test_blinded_payment_onion.py new file mode 100644 index 000000000000..203b4141faea --- /dev/null +++ b/tests/test_blinded_payment_onion.py @@ -0,0 +1,104 @@ +import os + +from electrum.lnonion import ( + new_onion_packet, calc_hops_data_for_blinded_payment, OnionPacket, BlindedPathInfo, BlindedPath, + BlindedPathHop, BlindedPayInfo, +) +from electrum.lnutil import LnFeatures, ShortChannelID +from electrum.util import read_json_file, bfh +from electrum.lnrouter import RouteEdge + +from tests import ElectrumTestCase + +# test vectors https://github.com/lightning/bolts/pull/765/files +path = os.path.join(os.path.dirname(__file__), 'blinded-payment-onion-test.json') +test_vectors = read_json_file(path) +generate = test_vectors['generate'] +full_route = generate['full_route'] +alice_hop = full_route['hops'][0] + +first_node_id = bfh(generate['blinded_route']['first_node_id']) +first_path_key = bfh(generate['blinded_route']['first_path_key']) +blinded_route_hops = generate['blinded_route']['hops'] + +blinded_path_hops = [BlindedPathHop( + blinded_node_id=bfh(hop['blinded_node_id']), + encrypted_recipient_data=bfh(hop['encrypted_data']), + enclen=len(bfh(hop['encrypted_data'])), +) for hop in blinded_route_hops] + +blinded_path = BlindedPath( + path=blinded_path_hops, + first_path_key=first_path_key, + first_node_id=bytes(32), + num_hops=bytes([len(blinded_path_hops)]) +) + +# blinded_payinfo = BlindedPayInfo.from_dict(generate['blinded_payinfo']) +blinded_payinfo = BlindedPayInfo( + fee_base_msat=generate['blinded_payinfo']['fee_base_msat'], + fee_proportional_millionths=generate['blinded_payinfo']['fee_proportional_millionths'], + cltv_expiry_delta=generate['blinded_payinfo']['cltv_expiry_delta'], + htlc_minimum_msat=0, + htlc_maximum_msat=999999999999999, + features=LnFeatures(0), +) + +ONION_MESSAGE_PACKET = bfh(generate['onion']) +session_key = bfh(generate['session_key']) +associated_data = bfh(generate['associated_data']) + + +class TestPaymentRouteBlinding(ElectrumTestCase): + + def test_blinded_payment_onion(self): + # us -> alice -> bob (introduction point) -> remaining blinded path hops + # route[0] is us -> alice edge, skipped by calc_hops_data_for_blinded_payment as we don't need a payload for ourselves + alice_outgoing_channel_id = ShortChannelID.from_str(alice_hop["tlvs"]["outgoing_channel_id"]) + route = [ + RouteEdge( + start_node=bytes(33), # our (sender's) pubkey + end_node=bfh(alice_hop['pubkey']), + short_channel_id=ShortChannelID(0), # sender's channel, not used in payloads + fee_base_msat=0, + fee_proportional_millionths=0, + cltv_delta=0, + node_features=0), + RouteEdge( + start_node=bfh(alice_hop['pubkey']), + end_node=first_node_id, # Bob (introduction point) + short_channel_id=alice_outgoing_channel_id, + fee_base_msat=0, + fee_proportional_millionths=0, + cltv_delta=0, + node_features=0), + ] + total_msat = 150000 + amount_msat = generate["final_amount_msat"] + final_cltv = generate["final_cltv"] + hops_data, blinded_hops_pubkeys, amt, cltv_abs = calc_hops_data_for_blinded_payment( + route_to_introduction_point=route, + recipient_amount_msat=amount_msat, + final_cltv_abs=final_cltv, + total_msat=total_msat, + invoice_blinded_path_info=BlindedPathInfo(path=blinded_path, payinfo=blinded_payinfo), + ) + + # route provides unblinded pubkeys (Alice, Bob) + payment_path_pubkeys = [x.node_id for x in route] + blinded_hops_pubkeys + + # assert payloads + for i, h in enumerate(hops_data): + payload = h.to_bytes().hex()[0:-64] + ref_payload = generate['full_route']['hops'][i]['payload'] + self.assertEqual(payload, ref_payload) + + packet = new_onion_packet( + payment_path_pubkeys, + session_key, + hops_data, + associated_data=associated_data, + ) + # test final packet + ref_packet = OnionPacket.from_bytes(ONION_MESSAGE_PACKET) + self.assertEqual(packet.to_bytes(), ONION_MESSAGE_PACKET) diff --git a/tests/test_bolt12.py b/tests/test_bolt12.py new file mode 100644 index 000000000000..52e171e12dba --- /dev/null +++ b/tests/test_bolt12.py @@ -0,0 +1,583 @@ +import io +import json +import os +import time +from dataclasses import fields +from pathlib import Path + +from electrum_ecc import ECPrivkey + +from electrum import segwit_addr, lnutil +from electrum.bolt12 import ( + is_offer, bolt12_bech32_to_bytes, BOLT12Offer, BOLT12InvoiceRequest, BOLT12Invoice, NoMatchingChainError, + extract_shared_fields, BOLT12InvoicePathIDPayload, Bolt12InvoiceError +) +from electrum.crypto import privkey_to_pubkey +from electrum.lnmsg import UnknownMandatoryTLVRecordType, MsgInvalidSignature, OnionWireSerializer, \ + MsgInvalidFieldOrder, MalformedMsg +from electrum.lnonion import OnionHopsDataSingle +from electrum.lnutil import LnFeatures, UnknownEvenFeatureBits +from electrum.segwit_addr import INVALID_BECH32, bech32_encode, Encoding, convertbits +from electrum.util import bfh +from electrum.lnworker import LNWALLET_FEATURES + +from . import ElectrumTestCase + + +def bech32_decode(x): + return segwit_addr.bech32_decode(x, ignore_long_length=True, with_checksum=False) + + +class TestBolt12(ElectrumTestCase): + + def test_bolt12_bech32_to_bytes(self): + valid_bolt12_strings = ( + ("lno1pqpzacq2qqgwuquxfmcztl0gldv8mxy3sm8x5jscdz27u39fy6luxu8zcdn9j73l3up5nwlwchur9zukwx743mvm0rvftrhskna22pcvtkyhufn5rc97j3gzqffs859lkadpfasgwxj47xvml7jgekez0lpfuwzhegyxsn2lzdx86qpny7xrmgwj6lphxcfauu22kenqnty4tqdlgnh8tyg87lamqe84nmh2vn0a2n908l7z7cfjghjsuusv7k079upfw0x7dpzavqpwj8swx9ee9q9cumg07fk4gvlajyhy6lfjv0cfe9gqxg0gykehtgjkxwzz24rqdssj4fjcm8xhv2rwel04ed4up2h5sf8n6y7scr0q5rt65k06s6u3mvefzer7qq", + "08022ee00a0010ee03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f0349bbeec5f8328b9671bd58ed9b78d8958ef0b4faa5070c5d897e26741e0be94502025303d0bfb75a14f60871a55f199bffa48cdb227fc29e3857ca08684d5f134c7d0033278c3da1d2d7c373613de714ab66609ac95581bf44ee759107f7fbb064f59eeea64dfd54caf3ffc2f613245e50e720cf59fe2f02973cde6845d6002e91e0e31739280b8e6d0ff26d5433fd912e4d7d3263f09c9500321e825b375a25633842554606c212aa658d9cd76286ecfdf5cb6bc0aaf4824f3d13d0c0de0a0d7aa59fa86b91db3291647e00"), + ("lno1pqpzacq2qqgwuquxfmcztl0gldv8mxy3sm8x5jscdz27u39fy6luxu8zcdn9j73l3uprukkghkdufdz6adxl0ejhy0lmzfykj08u6df9v4v2c93qknz8eggzq2jyyszrrt35mmkyl7efrv5x8a3wspk07pghey4a5kcm4ef76p0ksqpnh7fqgmq9eaf7ntqspcksqkqk8ngvjtjp585mqw3qata3xe8aycgkpprk87yqcxhh705dxauxkghsc9xywqpez7lt5gw67kqwejl83unmuc7r44h32durffs4rmpcgrhxa8x8y9gqxgy9w2tqgpxqk0tl487a9ssuchyh5p9t3le3n5ylhevggk6ly8wzxvds0jawct4spe2tzqfp5d34kah9ss", + "08022ee00a0010ee03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f023e5ac8bd9bc4b45aeb4df7e65723ffb1249693cfcd35256558ac1620b4c47ca10202a44240431ae34deec4ffb291b2863f62e806cff0517c92bda5b1bae53ed05f680033bf92046c05cf53e9ac100e2d0058163cd0c92e41a1e9b03a20eafb1364fd26116084763f880c1af7f3e8d37786b22f0c14c47003917beba21daf580eccbe78f27be63c3ad6f1537834a6151ec3840ee6e9cc7215003208572960404c0b3d7fa9fdd2c21cc5c97a04ab8ff319d09fbe58845b5f21dc2331b07cbaec2eb00e54b10121a3635b76e584"), + ("lno1qsgve2uaxjsvf885prfgawzf6d09vzsqpczxnzdgesgwuquxfmcztl0gldv8mxy3sm8x5jscdz27u39fy6luxu8zcdn9j73l3upc0taarq42pfspgzxdytktyf8jmx8symyy4p3w6wr26m3c3l3munszqwpuu8l2fxnf0awqf3fq069lw2ple9zlxuqx0whmxnl6sjavs98h6qpngsrfw245mh995tw4qlkh87ulgg00fm8p90s70sslvm23yv77qk6e70dq6a9c5fa9qg74w8gpfd2tqs3eavpaedey0agxmzacdrcd9vwrmgjemeym4ge0p5unp66tkz47wh7x4asqxtv35cx0sc23qkgt27az82w7n6tngn4r9wzuam3ztpps7q3sd60d42dltr37gagfy2asevymltrchy43zgtzzq68dcmv6rprk4yzfv4wl65vw6q2uz8tzc0d40v4ltdcuwnw9t3eyq", + "0410ccab9d34a0c49cf408d28eb849d35e560a000e046989a8cc10ee03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f0387afbd182aa0a601408cd22ecb224f2d98f026c84a862ed386ad6e388fe3be4e020383ce1fea49a697f5c04c5207e8bf7283fc945f370067bafb34ffa84bac814f7d00334406972ab4ddca5a2dd507ed73fb9f421ef4ece12be1e7c21f66d51233de05b59f3da0d74b8a27a5023d571d014b54b04239eb03dcb7247f506d8bb868f0d2b1c3da259de49baa32f0d3930eb4bb0abe75fc6af60032d91a60cf861510590b57ba23a9de9e97344ea32b85ceee2258430f02306e9edaa9bf58e3e4750922bb0cb09bfac78b92b112162103476e36cd0c23b54824b2aefea8c7680ae08eb161edabd95fadb8e3a6e2ae3920"), + ("lno1pqpq05q2qqgwuquxfmcztl0gldv8mxy3sm8x5jscdz27u39fy6luxu8zcdn9j73l3upgkdz0l3yd0yt3u9kmp5cg6kfw65kh0n0q9m022xe8y0mq69wmm9gzq0ckt2qqs73uyx2pe3tcmrhf7hszkh3393gwc420x2uew4846tgk5qpn0clyjyaa0cfxc9ryff0qfx48wv8u65w4a7fxfsqeln9ahjz9qcf0t0umrgxnzcly4es6ct97ddjcvgcehvpc09sqwsjc0cusxrwvx44j62pskt9t75ex5px84294a48c0h5jx8gqx20wkjusrgktsa53c23ksrhpuncrmdvetfjhdpgmnu9tej3m9nlm9m9tzhnl39mje2gxdsu2zlejvp63xy", + "080207d00a0010ee03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f028b344ffc48d79171e16db0d308d592ed52d77cde02edea51b2723f60d15dbd950203f165a80087a3c21941cc578d8ee9f5e02b5e312c50ec554f32b99754f5d2d16a00337e3e4913bd7e126c14644a5e049aa7730fcd51d5ef9264c019fccbdbc8450612f5bf9b1a0d3163e4ae61ac2cbe6b65862319bb03879600742587e39030dcc356b2d2830b2cabf5326a04c7aa8b5ed4f87de9231d00329eeb4b901a2cb87691c2a3680ee1e4f03db5995a6576851b9f0abcca3b2cffb2ecab15e7f89772ca9066c38a17f326075131") + ) + for bolt12_str, expected_bytes_hex in valid_bolt12_strings: + result = bolt12_bech32_to_bytes(bolt12_str) + self.assertEqual(result.hex(), expected_bytes_hex) + + def test_bolt12_string_formatting(self): + """ + Test if we handle string formatting according to bolt 12 using the format-string-test.json + test vector. + https://github.com/lightning/bolts/blob/5f31faa0b6e2cdbe32171d79464305f90bda9585/bolt12/format-string-test.json + """ + with open(Path(__file__).parent / 'bolt12_format_string_test.json', 'r') as f: + tests = json.load(f) + for test in tests: + valid, string, msg = test['valid'], test['string'], f"{test['comment']}: {test['string']}" + if valid: + self.assertTrue(is_offer(string), msg=msg) + result = BOLT12Offer.decode(string) + self.assertIsInstance(result, BOLT12Offer) + else: + self.assertFalse(is_offer(string), msg=msg) + with self.assertRaises(ValueError, msg=msg): + BOLT12Offer.decode(string) + + def test_decode(self): + # https://bootstrap.bolt12.org/examples + offer = 'lno1pg257enxv4ezqcneype82um50ynhxgrwdajx293pqglnyxw6q0hzngfdusg8umzuxe8kquuz7pjl90ldj8wadwgs0xlmc' + d = bech32_decode(offer) + self.assertNotEqual(d, INVALID_BECH32, "bech32 decode error") + self.assertEqual(d.hrp, 'lno', "wrong hrp") + self.assertTrue(is_offer(offer)) + od = BOLT12Offer.decode(offer) + self.assertEqual(od.offer_description, "Offer by rusty's node") + self.assertEqual(od.offer_issuer_id, bfh('023f3219da03ee29a12de4107e6c5c364f607382f065f2bfed91ddd6b91079bfbc')) + + offer = 'lno1pqqnyzsmx5cx6umpwssx6atvw35j6ut4v9h8g6t50ysx7enxv4epyrmjw4ehgcm0wfczucm0d5hxzag5qqtzzq3lxgva5qlw9xsjmeqs0ek9cdj0vpec9ur972l7mywa66u3q7dlhs' + d = bech32_decode(offer) + self.assertNotEqual(d, INVALID_BECH32, "bech32 decode error") + self.assertEqual(d.hrp, 'lno', "wrong hrp") + od = BOLT12Offer.decode(offer) + self.assertEqual(od.offer_amount, 50) + self.assertEqual(od.offer_description, '50msat multi-quantity offer') + self.assertEqual(od.offer_issuer, 'rustcorp.com.au') + self.assertEqual(od.offer_quantity_max, 0) + self.assertEqual(od.offer_issuer_id, bfh('023f3219da03ee29a12de4107e6c5c364f607382f065f2bfed91ddd6b91079bfbc')) + + # TODO: tests below use recurrence (tlv record type 26) which is not supported/generated from wire specs + # (c-lightning carries patches re-adding these, but for now we ignore them) + + offer = 'lno1pqqkgzs5xycrqmtnv96zqetkv4e8jgrdd9h82ar9zgg8yatnw3ujumm6d3skyuewdaexw93pqglnyxw6q0hzngfdusg8umzuxe8kquuz7pjl90ldj8wadwgs0xlmcxszqq7q' + d = bech32_decode(offer) + self.assertNotEqual(d, INVALID_BECH32, "bech32 decode error") + self.assertEqual(d.hrp, 'lno', "wrong hrp") + with self.assertRaises(UnknownMandatoryTLVRecordType): + BOLT12Offer.decode(offer) + + offer = 'lno1pqqkgz38xycrqmtnv96zqetkv4e8jgrdd9h82ar99ss82upqw3hjqargwfjk2gr5d9kk2ucjzpe82um50yhx77nvv938xtn0wfn3vggz8uepnksrac56zt0yzplxchpkfas88qhsvhetlmv3mhttjyreh77p5qsq8s0qzqs' + d = bech32_decode(offer) + self.assertNotEqual(d, INVALID_BECH32, "bech32 decode error") + self.assertEqual(d.hrp, 'lno', "wrong hrp") + with self.assertRaises(UnknownMandatoryTLVRecordType): + BOLT12Offer.decode(offer) + + offer = 'lno1pqqkgz3zxycrqmtnv96zqetkv4e8jgryv9ujcgrxwfhk6gp3949xzm3dxgcryvgjzpe82um50yhx77nvv938xtn0wfn3vggz8uepnksrac56zt0yzplxchpkfas88qhsvhetlmv3mhttjyreh77p5qspqysq2q2laenqq' + d = bech32_decode(offer) + self.assertNotEqual(d, INVALID_BECH32, "bech32 decode error") + self.assertEqual(d.hrp, 'lno', "wrong hrp") + with self.assertRaises(UnknownMandatoryTLVRecordType): + BOLT12Offer.decode(offer) + + offer = 'lno1pqpq86q2fgcnqvpsd4ekzapqv4mx2uneyqcnqgryv9uhxtpqveex7mfqxyk55ctw95erqv339ss8qcteyqcksu3qvfjkvmmjv5s8gmeqxcczqum9vdhkuernypkxzar9zgg8yatnw3ujumm6d3skyuewdaexw93pqglnyxw6q0hzngfdusg8umzuxe8kquuz7pjl90ldj8wadwgs0xlmcxszqy9pcpsqqq8pqqpuyqzszhlwvcqq' + d = bech32_decode(offer) + self.assertNotEqual(d, INVALID_BECH32, "bech32 decode error") + self.assertEqual(d.hrp, 'lno', "wrong hrp") + with self.assertRaises(UnknownMandatoryTLVRecordType): + BOLT12Offer.decode(offer) + + offer = 'lno1pqpq86q2xycnqvpsd4ekzapqv4mx2uneyqcnqgryv9uhxtpqveex7mfqxyk55ctw95erqv339ss8qun094exzarpzgg8yatnw3ujumm6d3skyuewdaexw93pqglnyxw6q0hzngfdusg8umzuxe8kquuz7pjl90ldj8wadwgs0xlmcxszqy9pczqqp5hsqqgd9uqzqpgptlhxvqq' + d = bech32_decode(offer) + self.assertNotEqual(d, INVALID_BECH32, "bech32 decode error") + self.assertEqual(d.hrp, 'lno', "wrong hrp") + with self.assertRaises(UnknownMandatoryTLVRecordType): + BOLT12Offer.decode(offer) + + offer = 'lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0yfpqun4wd68jtn00fkxzcnn9ehhyeckyypr7vsemgp7u2dp9hjpqlnvtsmy7crnstcxtu4lakgam44ezpuml0q6qgqsz' + d = bech32_decode(offer) + self.assertNotEqual(d, INVALID_BECH32, "bech32 decode error") + self.assertEqual(d.hrp, 'lno', "wrong hrp") + with self.assertRaises(UnknownMandatoryTLVRecordType): + BOLT12Offer.decode(offer) + + def test_decode_offer(self): + # default test + offer = 'lno1pggxv6tjwd6zqar9wd6zqmmxvejhy93pq02rpdcl6l20pakl2ad70k0n8v862jwp2twq8a8uz0hz5wfafg495' + d = bech32_decode(offer) + self.assertNotEqual(d, INVALID_BECH32, "bech32 decode error") + self.assertEqual(d.hrp, 'lno', "wrong hrp") + self.assertTrue(is_offer(offer)) + + od = BOLT12Offer.decode(offer) + self.assertEqual(od.offer_description, 'first test offer') + self.assertEqual(od.offer_issuer_id, bfh('03d430b71fd7d4f0f6df575be7d9f33b0fa549c152dc03f4fc13ee2a393d4a2a5a')) + + # now test the bolt12 test vectors + # https://github.com/lightning/bolts/blob/34455ffe28b308dd7ac7552234d565890af8605b/bolt12/offers-test.json + with open(Path(__file__).parent / 'bolt12_offers_test.json', 'r') as f: + tests = json.load(f) + for test in tests: + valid, string, msg = test['valid'], test['bolt12'], f"{test['description']}: {test['bolt12']}" + try: + if valid: + self.assertTrue(is_offer(string), msg=msg) + try: + BOLT12Offer.decode(string) + except NoMatchingChainError: + continue # this unittest runs on mainnet, there are some testnet vectors + else: + expected_exc = (ValueError, MsgInvalidFieldOrder, UnknownMandatoryTLVRecordType, NoMatchingChainError, MalformedMsg, UnknownEvenFeatureBits) + with self.assertRaises(expected_exc, msg=msg): + BOLT12Offer.decode(string) + except Exception as e: + raise Exception(msg) from e + + def test_decode_invreq(self): + invreq_bech32 = "lnr1qqyqqqqqqqqqqqqqqcp4256ypqqkgzshgysy6ct5dpjk6ct5d93kzmpq23ex2ct5d9ek293pqthvwfzadd7jejes8q9lhc4rvjxd022zv5l44g6qah82ru5rdpnpjkppqvjx204vgdzgsqpvcp4mldl3plscny0rt707gvpdh6ndydfacz43euzqhrurageg3n7kafgsek6gz3e9w52parv8gs2hlxzk95tzeswywffxlkeyhml0hh46kndmwf4m6xma3tkq2lu04qz3slje2rfthc89vss" + with self.assertRaises(NotImplementedError): + # TODO: no currency conversion support + BOLT12InvoiceRequest.decode(invreq_bech32) + + # minimal invreq from eclair repo + privkey = ECPrivkey(bfh("527d410ec920b626ece685e8af9abc976a48dbf2fe698c1b35d90a1c5fa2fbca")) + invreq_bech32 = "lnr1qqp6hn00zcssxr0juddeytv7nwawhk9nq9us0arnk8j8wnsq8r2e86vzgtfneupe2gp9yzzcyypymkt4c0n6rhcdw9a7ay2ptuje2gvehscwcchlvgntump3x7e7tc0sgp9k43qeu892gfnz2hrr7akh2x8erh7zm2tv52884vyl462dm5tfcahgtuzt7j0npy7getf4trv5d4g78a9fkwu3kke6hcxdr6t2n7vz" + invreq = BOLT12InvoiceRequest.decode(invreq_bech32) + + self.assertEqual(invreq.invreq_amount, 21_000) + self.assertEqual(invreq.invreq_metadata, bfh("abcdef")) + self.assertEqual(invreq.invreq_payer_id, privkey.get_public_key_bytes()) + + data = { + 'offer_issuer_id': {'id': invreq.offer_issuer_id}, + 'invreq_amount': {'msat': invreq.invreq_amount}, + 'invreq_metadata': {'blob': invreq.invreq_metadata}, + 'invreq_payer_id': {'key': privkey.get_public_key_bytes()}, + } + self.assertEqual(invreq.serialize(with_signature=False), data) + + def test_invreq_offer_quantity_max(self): + """Tests the offer_quantity_max/invreq_quantity checks""" + def make_invreq(*, offer_quantity_max=None, invreq_quantity=None): + return BOLT12InvoiceRequest( + offer_issuer_id=bfh('03d430b71fd7d4f0f6df575be7d9f33b0fa549c152dc03f4fc13ee2a393d4a2a5a'), + invreq_amount=5555, + invreq_payer_id=privkey_to_pubkey(bfh('42' * 32)), + invreq_metadata=bfh('deadbeef'), + offer_quantity_max=offer_quantity_max, + invreq_quantity=invreq_quantity, + ) + + # 0 offer_quantity_max allows arbitrary invreq_quantity + self.assertEqual(7, make_invreq(offer_quantity_max=0, invreq_quantity=7).invreq_quantity) + + # non-zero max: quantity in [1, max] is accepted, zero or above max is rejected + self.assertEqual(3, make_invreq(offer_quantity_max=5, invreq_quantity=3).invreq_quantity) + with self.assertRaises(ValueError): + make_invreq(offer_quantity_max=5, invreq_quantity=6) + with self.assertRaises(ValueError): + make_invreq(offer_quantity_max=5, invreq_quantity=0) + + # max present requires an invreq_quantity; max absent forbids one + with self.assertRaises(ValueError): + make_invreq(offer_quantity_max=0, invreq_quantity=None) + with self.assertRaises(ValueError): + make_invreq(offer_quantity_max=None, invreq_quantity=2) + + def test_encode_offer(self): + data = { + 'offer_description': {'description': 'first test offer'}, + 'offer_issuer_id': {'id': bfh('03d430b71fd7d4f0f6df575be7d9f33b0fa549c152dc03f4fc13ee2a393d4a2a5a')} + } + offer_tlv = BOLT12Offer.deserialize(data).encode(as_bech32=False) + self.assertEqual(offer_tlv, bfh('0a1066697273742074657374206f66666572162103d430b71fd7d4f0f6df575be7d9f33b0fa549c152dc03f4fc13ee2a393d4a2a5a')) + offer_tlv_5bit = convertbits(list(offer_tlv), 8, 5) + bech32_offer = bech32_encode(Encoding.BECH32, 'lno', offer_tlv_5bit, with_checksum=False) + self.assertEqual(bech32_offer, 'lno1pggxv6tjwd6zqar9wd6zqmmxvejhy93pq02rpdcl6l20pakl2ad70k0n8v862jwp2twq8a8uz0hz5wfafg495') + self.assertEqual(BOLT12Offer.decode(bech32_offer).serialize(with_signature=False), data) + + def test_encode_invreq(self): + payer_key = bfh('4242424242424242424242424242424242424242424242424242424242424242') + kp = lnutil.Keypair(privkey_to_pubkey(payer_key), payer_key) + + data = { + 'offer_description': {'description': 'first test offer'}, + 'offer_issuer_id': {'id': bfh('03d430b71fd7d4f0f6df575be7d9f33b0fa549c152dc03f4fc13ee2a393d4a2a5a')}, + 'invreq_amount': {'msat': 5555}, + 'invreq_payer_note': {'note': 'invreq for first test offer'}, + 'invreq_payer_id': {'key': kp.pubkey}, + 'invreq_metadata': {'blob': bfh('deadbeef')} + } + invreq_tlv = BOLT12InvoiceRequest.deserialize(data).encode(signing_key=payer_key, as_bech32=False) + self.assertEqual(invreq_tlv, bfh('0004deadbeef0a1066697273742074657374206f66666572162103d430b71fd7d4f0f6df575be7d9f33b0fa549c152dc03f4fc13ee2a393d4a2a5a520215b358210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c591b696e7672657120666f722066697273742074657374206f66666572f0406b3de34892023353e1d0f5765e7c34b5e952e6d6a9492b91a2f98c0817434362bd07a6c216dce7709bd16a2b533dec22cf8a9303310a29b7621e090d27f9dfb1')) + self.assertEqual(BOLT12InvoiceRequest.decode(invreq_tlv).serialize(with_signature=False), data) + + def test_encode_invoice(self): + signing_key = bfh('4141414141414141414141414141414141414141414141414141414141414141') + kp = lnutil.Keypair(privkey_to_pubkey(signing_key), signing_key) + + data = { + 'offer_metadata': {'data': bfh('01020304050607')}, + 'offer_amount': {'amount': 1}, + 'offer_description': {'description': 'test_encode_invoice'}, + 'offer_issuer_id': {'id': kp.pubkey}, + 'invreq_payer_id': {'key': kp.pubkey}, + 'invreq_metadata': {'blob': bfh('deadbeef')}, + 'invoice_node_id': {'node_id': kp.pubkey}, + 'invoice_amount': {'msat': 21_000}, + 'invoice_created_at': {'timestamp': 1770883131}, + 'invoice_payment_hash': {'payment_hash': bfh('cab10c2a10467d7dc4512a910530ef35d1028662b65c8a30656136ff957c6589')}, + 'invoice_relative_expiry': {'seconds_from_creation': 7200}, + 'invoice_paths': {'paths': [ + { + 'first_node_id': kp.pubkey, + 'first_path_key': kp.pubkey, + 'num_hops': bytes([1]), + 'path': [{ + 'blinded_node_id': kp.pubkey, + 'enclen': 5, + 'encrypted_recipient_data': b'12345', + }], + } + ]}, + 'invoice_blindedpay': {'payinfo': [ + { + 'fee_base_msat': 100, + 'fee_proportional_millionths': 1000, + 'cltv_expiry_delta': 200, + 'htlc_minimum_msat': 1, + 'htlc_maximum_msat': 1_000_000, + 'flen': len(LnFeatures(0).to_tlv_bytes()), + 'features': LnFeatures(0).to_tlv_bytes(), + } + ]} + } + invoice = BOLT12Invoice.deserialize(data) + invoice_tlv = invoice.encode(signing_key=kp.privkey, as_bech32=False) + self.assertEqual(invoice_tlv, bfh('0004deadbeef0407010203040506070801010a13746573745f656e636f64655f696e766f696365162102eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619582102eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619a06b02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f28368661902eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f2836866190102eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f28368661900053132333435a21c00000064000003e800c8000000000000000100000000000f42400000a404698d883ba6021c20a820cab10c2a10467d7dc4512a910530ef35d1028662b65c8a30656136ff957c6589aa025208b02102eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619f040d4541fa9209eea9e31ce9acaff42314e0de12adc85b2b64fef6ae954295cbe023d85023a9b53ca18e17de17df3b8431723554d182e271545edb6b8519c8eb35b')) + # also test decoding back + invoice = BOLT12Invoice.decode(invoice_tlv) + self.assertEqual(invoice.serialize(with_signature=False), data) + self.assertEqual(invoice.invoice_blindedpay[0].fee_base_msat, data['invoice_blindedpay']['payinfo'][0]['fee_base_msat']) + + def test_subtype_encode_decode(self): + invreq_bech32 = "lnr1qqp6hn00zcssxr0juddeytv7nwawhk9nq9us0arnk8j8wnsq8r2e86vzgtfneupe2gp9yzzcyypymkt4c0n6rhcdw9a7ay2ptuje2gvehscwcchlvgntump3x7e7tc0sgp9k43qeu892gfnz2hrr7akh2x8erh7zm2tv52884vyl462dm5tfcahgtuzt7j0npy7getf4trv5d4g78a9fkwu3kke6hcxdr6t2n7vz" + invreq = BOLT12InvoiceRequest.decode(invreq_bech32) + invreq_pl_tlv = invreq.encode(signing_key=bfh('4141414141414141414141414141414141414141414141414141414141414141'), as_bech32=False) + + ohds = OnionHopsDataSingle( + tlv_stream_name='onionmsg_tlv', + payload={ + 'invoice_request': {'invoice_request': invreq_pl_tlv}, + 'reply_path': { + 'path': { + 'first_node_id': bfh('0309d14e515e8ef4ea022787dcda8550edfbd7da6052208d2fc0cc4f7d949558e5'), + 'first_path_key': bfh('0309d14e515e8ef4ea022787dcda8550edfbd7da6052208d2fc0cc4f7d949558e5'), + 'num_hops': 2, + 'path': [ + { + 'blinded_node_id': bfh('0309d14e515e8ef4ea022787dcda8550edfbd7da6052208d2fc0cc4f7d949558e5'), + 'enclen': 5, + 'encrypted_recipient_data': bfh('0000000000'), + }, + { + 'blinded_node_id': bfh('0309d14e515e8ef4ea022787dcda8550edfbd7da6052208d2fc0cc4f7d949558e5'), + 'enclen': 6, + 'encrypted_recipient_data': bfh('001111222233'), + } + ] + } + }, + }, + blind_fields={ + 'padding': {'padding': b''}, + } + ) + + ohds_b = ohds.to_bytes() + self.assertEqual(ohds_b, bfh('fd012902940309d14e515e8ef4ea022787dcda8550edfbd7da6052208d2fc0cc4f7d949558e50309d14e515e8ef4ea022787dcda8550edfbd7da6052208d2fc0cc4f7d949558e5020309d14e515e8ef4ea022787dcda8550edfbd7da6052208d2fc0cc4f7d949558e5000500000000000309d14e515e8ef4ea022787dcda8550edfbd7da6052208d2fc0cc4f7d949558e5000600111122223340910003abcdef1621030df2e35b922d9e9bbaebd8b3017907f473b1e4774e0038d593e98242d33cf039520252085821024dd975c3e7a1df0d717bee91415f25952199bc30ec62ff6226be6c3137b3e5e1f040dc8f84d6ba1a027766c3412e5c9f44d8a265818e584e5220b509d387dd127d27975dbb93c2f3a072af735b5ed4000ae32dda9075f34894bb58eaf25cb1aeab630000000000000000000000000000000000000000000000000000000000000000')) + + with io.BytesIO(ohds_b) as fd: + ohds2 = OnionHopsDataSingle.from_fd(fd, tlv_stream_name='onionmsg_tlv') + self.assertEqual(ohds2.payload['invoice_request']['invoice_request'], invreq_pl_tlv) + self.assertTrue('reply_path' in ohds2.payload) + + # test nested complex types with count > 1 + offer_data = { + "offer_absolute_expiry": {"seconds_from_epoch": 1763136094}, + "offer_amount": {"amount": 1000}, + "offer_description": {"description": "ABCD"}, + "offer_issuer_id": {"id": bfh("0325c5bc9c9b4fe688e82784f17bc14e81e3f786e9a8c663e9bbec2412af0c0339")}, + "offer_paths": {"paths": [ + { + "first_node_id": bfh("02d4ad66692f3e39773a5917d55db2c8b81839425c4489532fd5d166466fce56d4"), + "first_path_key": bfh("02b4d2e30315f7a6322fb57ed420ef8f9c541d7331a8b3a086c2692c49209be811"), + "num_hops": bytes([2]), # num_hops is defined as byte, not int + "path": [ + { + "blinded_node_id": bfh("034b1da9c0afa084c604f74f839de006d550422facc3b4be83323702892f7f5949"), + "enclen": 51, + "encrypted_recipient_data": bfh("42f0018dcfe5185602618b718f7aa72b1b97d8e85b97f88b8fdad95b80fd93a21d9a975cf544e8c4b5c2f519bc83bab84bda6b") + }, + { + "blinded_node_id": bfh("021a4900c95fcb5ef59284203e005b505d17cdaa066b13134d98930fb4ff1425f4"), + "enclen": 50, + "encrypted_recipient_data": bfh("9b66a56801a3da6b3149d8b5df0ce9f25df7605b689dd662c40fc5782cbee2786903e83f6827fa52c93af2acdb8e123c72e0") + } + ] + }, + { + "first_node_id": bfh("031a10cc4d1aea5a59e7888f3eb2f0509e3fc58dae63deff87ba34f217ae419cf7"), + "first_path_key": bfh("029a5a12f3b9c0132176ab5347f49486f3d2572aa9d9c3d8ebf622e80a4131f268"), + "num_hops": bytes([2]), # num_hops is defined as byte, not int + "path": [ + { + "blinded_node_id": bfh("0250fce42de743a914b821de93c0033713e9b27c8ada26424c0e75c461c1337e1a"), + "enclen": 51, + "encrypted_recipient_data": bfh("c2e291a9bcf57b57e115d161f49bd8682044bcd11db3adb96ba4d8d99827650aa5691d48c78822c9ae26c446ffa03a41fbc1de") + }, + { + "blinded_node_id": bfh("02718474dc3bd8fb42af40c27ff98da911008f4020b90835d4a39ffea084406614"), + "enclen": 50, + "encrypted_recipient_data": bfh("9b0fc9045ff50ea82babad699c610e14607343dd70ca12dc5575edb28b5673e3660a3eb1b62fd5b6b7d14fd651d5bbee3ad3") + } + ] + } + ]} + } + + offer = BOLT12Offer.deserialize(offer_data).encode(as_bech32=True) + decoded = BOLT12Offer.decode(offer).serialize() + self.assertEqual(offer_data, decoded) + + def test_invoice_request_schnorr_signature(self): + invreq = 'lnr1qqp6hn00zcssxr0juddeytv7nwawhk9nq9us0arnk8j8wnsq8r2e86vzgtfneupe2gp9yzzcyypymkt4c0n6rhcdw9a7ay2ptuje2gvehscwcchlvgntump3x7e7tc0sgp9k43qeu892gfnz2hrr7akh2x8erh7zm2tv52884vyl462dm5tfcahgtuzt7j0npy7getf4trv5d4g78a9fkwu3kke6hcxdr6t2n7vz' + data = BOLT12InvoiceRequest.decode(invreq) + + payer_key = bfh('4242424242424242424242424242424242424242424242424242424242424242') + invreq_pl_tlv = data.encode(signing_key=payer_key, as_bech32=False) + + self.assertEqual(invreq_pl_tlv, bfh('0003abcdef1621030df2e35b922d9e9bbaebd8b3017907f473b1e4774e0038d593e98242d33cf039520252085821024dd975c3e7a1df0d717bee91415f25952199bc30ec62ff6226be6c3137b3e5e1f0406f02f053bc4186dc980ece06c57e6fa867d61839700fa0f58fc383bfd8e40c428b942a7c157dc77b49a2172fa44aeb0a6e77194fe87df4a7575b71011bbe0332')) + + def test_schnorr_signature(self): + """encode+decode invoice to test signature validation""" + # the signing key is different from the encoded node_id, so the signature is invalid + signing_key = bfh('4242424242424242424242424242424242424242424242424242424242424242') + with self.assertRaises(MsgInvalidSignature): + invoice = BOLT12Invoice.decode('lni1qqzdatd7auzqwqgzqvzq2ps8pqqszzsnw3jhxazlv4hxxmmyv40kjmnkda5kxegkyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvx2cyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxdqdvpwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxgzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvszqhwcuj966ma9n9nqwqtl032xeyv6755yeflt235pmww58egx6rxryqq2vfjxv6rtgsaqqqqqeqqqqp7sqxgqqqqqqqqqqqqzqqqqqqqqr6zgqqqzq9yq35cmzpm5cppcg9gyr9tzrp2zpr86lwy2y4fzpfsau6azq5xv2m9ez3sv4sndlu403jcn2sz2gytqggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvlqsq2smesfhwpr27j0kpgk7prlvewkk639e2c080wyc43epy04hegwgv8kwm04v8ey9t6lxkp5rv65dz9w0xly26mu8rl42hheq0h98y0z') + encoded = invoice.encode(signing_key=signing_key, as_bech32=False) + BOLT12Invoice.decode(encoded) + + # now use the same key as used inside the Invoice payload + signing_key = bfh('4141414141414141414141414141414141414141414141414141414141414141') + invoice = BOLT12Invoice.decode('lni1qqzdatd7auzqwqgzqvzq2ps8pqqszzsnw3jhxazlv4hxxmmyv40kjmnkda5kxegkyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvx2cyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxdqdvpwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxgzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvszqhwcuj966ma9n9nqwqtl032xeyv6755yeflt235pmww58egx6rxryqq2vfjxv6rtgsaqqqqqeqqqqp7sqxgqqqqqqqqqqqqzqqqqqqqqr6zgqqqzq9yq35cmzpm5cppcg9gyr9tzrp2zpr86lwy2y4fzpfsau6azq5xv2m9ez3sv4sndlu403jcn2sz2gytqggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvlqsq2smesfhwpr27j0kpgk7prlvewkk639e2c080wyc43epy04hegwgv8kwm04v8ey9t6lxkp5rv65dz9w0xly26mu8rl42hheq0h98y0z') + encoded = invoice.encode(signing_key=signing_key, as_bech32=False) + BOLT12Invoice.decode(encoded) + + def test_extract_shared_fields(self): + invoice = BOLT12Invoice.decode('lni1qqzdatd7auzqwqgzqvzq2ps8pqqszzsnw3jhxazlv4hxxmmyv40kjmnkda5kxegkyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvx2cyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxdqdvpwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxgzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvszqhwcuj966ma9n9nqwqtl032xeyv6755yeflt235pmww58egx6rxryqq2vfjxv6rtgsaqqqqqeqqqqp7sqxgqqqqqqqqqqqqzqqqqqqqqr6zgqqqzq9yq35cmzpm5cppcg9gyr9tzrp2zpr86lwy2y4fzpfsau6azq5xv2m9ez3sv4sndlu403jcn2sz2gytqggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvlqsq2smesfhwpr27j0kpgk7prlvewkk639e2c080wyc43epy04hegwgv8kwm04v8ey9t6lxkp5rv65dz9w0xly26mu8rl42hheq0h98y0z') + invoice_fields = [f.name for f in fields(invoice)] + self.assertTrue(any(f.startswith('invoice_') for f in invoice_fields)) + extracted_offer = extract_shared_fields(invoice, BOLT12Offer) + self.assertEqual(type(extracted_offer), BOLT12Offer) + offer_fields = [f.name for f in fields(extracted_offer)] + self.assertFalse(any(f.startswith('invreq_') or f.startswith('invoice_') for f in offer_fields)) + self.assertTrue(invoice.offer_issuer_id) + self.assertEqual(invoice.offer_issuer_id, extracted_offer.offer_issuer_id) + + extracted_invreq = extract_shared_fields(invoice, BOLT12InvoiceRequest) + invreq_fields = [f.name for f in fields(extracted_invreq)] + self.assertTrue(any(f.startswith('invreq_') for f in invreq_fields)) + self.assertTrue(invoice.invreq_metadata) + self.assertEqual(invoice.invreq_metadata, extracted_invreq.invreq_metadata) + self.assertEqual(invoice.offer_issuer_id, extracted_invreq.offer_issuer_id) + + def test_bolt12_invoice_path_id_payload(self): + amount_msat = 1000 + created_at = int(time.time()) + relative_expiry = created_at + 100 + payment_preimage = os.urandom(32) + min_final_cltv_expiry_delta = 322 + invoice_features = LNWALLET_FEATURES.for_bolt12_invoice() + invreq_payer_id = os.urandom(33) + offer_metadata_digest = os.urandom(32) + invreq_quantity = 5 + invreq_payer_note = "Thanks for the Pizza, it was delicious!" + offer_description = "Pizza Salami - 30 cm - Old Italy" + path_id_payload = BOLT12InvoicePathIDPayload( + amount_msat=amount_msat, + created_at=created_at, + relative_expiry=relative_expiry, + payment_preimage=payment_preimage, + min_final_cltv_expiry_delta=min_final_cltv_expiry_delta, + invoice_features=invoice_features, + payer_id=invreq_payer_id, + offer_metadata_digest=offer_metadata_digest, + quantity=invreq_quantity, + payer_note=invreq_payer_note, + description=offer_description, + ) + encoded = path_id_payload.encode() + decoded = BOLT12InvoicePathIDPayload.decode(encoded) + self.assertEqual(decoded.amount_msat, amount_msat) + self.assertEqual(decoded.created_at, created_at) + self.assertEqual(decoded.relative_expiry, relative_expiry) + self.assertEqual(decoded.payment_preimage, payment_preimage) + self.assertEqual(decoded.min_final_cltv_expiry_delta, min_final_cltv_expiry_delta) + self.assertEqual(decoded.invoice_features, invoice_features) + self.assertEqual(decoded.payer_id, invreq_payer_id) + self.assertEqual(decoded.offer_metadata_digest, offer_metadata_digest) + self.assertEqual(decoded.quantity, invreq_quantity) + self.assertEqual(decoded.payer_note, invreq_payer_note) + self.assertEqual(decoded.description, offer_description) + + def test_bolt12_invoice_error(self): + invoice_error_tlv = bfh("01015203086465616462656566050e616d6f756e7420746f6f206c6f77") + invoice_error = Bolt12InvoiceError.from_tlv(invoice_error_tlv) + invoice_error = invoice_error.to_tlv() + self.assertEqual(invoice_error, invoice_error_tlv) + invoice_error = Bolt12InvoiceError( + msg="amount too low", + erroneous_field=82, + suggested_value=b"deadbeef" + ) + self.assertEqual(invoice_error.to_tlv(), invoice_error_tlv) + invoice_error = Bolt12InvoiceError.from_tlv(b"deadbeef") + self.assertEqual(invoice_error.message, "malformed invoice error") + + def test_fallback_address(self): + # invoice without fallback address + invoice = BOLT12Invoice.decode('lni1qqzdatd7auzqwqgzqvzq2ps8pqqszzsnw3jhxazlv4hxxmmyv40kjmnkda5kxegkyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvx2cyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxdqdvpwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxgzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvszqhwcuj966ma9n9nqwqtl032xeyv6755yeflt235pmww58egx6rxryqq2vfjxv6rtgsaqqqqqeqqqqp7sqxgqqqqqqqqqqqqzqqqqqqqqr6zgqqqzq9yq35cmzpm5cppcg9gyr9tzrp2zpr86lwy2y4fzpfsau6azq5xv2m9ez3sv4sndlu403jcn2sz2gytqggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvlqsq2smesfhwpr27j0kpgk7prlvewkk639e2c080wyc43epy04hegwgv8kwm04v8ey9t6lxkp5rv65dz9w0xly26mu8rl42hheq0h98y0z') + self.assertIsNone(invoice.fallback_address) + + # invoice with fallback address + invoice = BOLT12Invoice.decode('lni1qqzdatd7auzqwqgzqvzq2ps8pqqszzsnw3jhxazlv4hxxmmyv40kjmnkda5kxegkyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvx2cyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxdqdvpwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxgzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvszqhwcuj966ma9n9nqwqtl032xeyv6755yeflt235pmww58egx6rxryqq2vfjxv6rtgsaqqqqqeqqqqp7sqxgqqqqqqqqqqqqzqqqqqqqqr6zgqqqzq9yq35cmzpm5cppcg9gyr9tzrp2zpr86lwy2y4fzpfsau6azq5xv2m9ez3sv4sndlu403jcn2sz2gy2c9cqqq29vsj0npht2n230kazsz9ymxypzhzn04umqggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvlqsqp9r3ynq3f88wwcc5hsy6as87txrnse8tmhay4dkkz36rcncj2dleazl4pg7j2tzzqazk37ztmztm75fspm4fwkvut2uzehsjd750jw') + fallback_address = { + 'version': bytes([0]), + 'address': bfh('56424f986eb54d517dba2808a4d988115c537d79'), + 'len': len(bfh('56424f986eb54d517dba2808a4d988115c537d79')), + } + self.assertEqual(invoice.invoice_fallbacks[0], fallback_address) + self.assertEqual(invoice.fallback_address, 'bc1q2epylxrwk4x4zld69qy2fkvgz9w9xltef9uv0h') + + def test_is_expired(self): + offer_expired = BOLT12Offer.decode('lno1qsqszzqzq05q5ptyv4ekxuswq9u3ypr5v4ehg93pq02w80w8hqqpleka0d5j3usclz8mhtgmm6228k9t63hccc9snhrnw') + self.assertTrue(offer_expired.is_expired) + offer_no_expiry = BOLT12Offer.decode('lno1qsqszzqzq05q5ptyv4ekxusjq36x2um5zcssxjvgvuq4xn8frm3alch8h0lwfeh78expwcy4h2zvdzq2d57d2n3u') + self.assertFalse(offer_no_expiry.is_expired) + invreq_doesnt_expire = BOLT12InvoiceRequest.decode("lnr1qqp6hn00zcssxr0juddeytv7nwawhk9nq9us0arnk8j8wnsq8r2e86vzgtfneupe2gp9yzzcyypymkt4c0n6rhcdw9a7ay2ptuje2gvehscwcchlvgntump3x7e7tc0sgp9k43qeu892gfnz2hrr7akh2x8erh7zm2tv52884vyl462dm5tfcahgtuzt7j0npy7getf4trv5d4g78a9fkwu3kke6hcxdr6t2n7vz") + self.assertFalse(invreq_doesnt_expire.is_expired) + invoice_expired = BOLT12Invoice.decode('lni1qqzdatd7auzqwqgzqvzq2ps8pqqszzsnw3jhxazlv4hxxmmyv40kjmnkda5kxegkyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvx2cyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxdqdvpwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxgzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvszqhwcuj966ma9n9nqwqtl032xeyv6755yeflt235pmww58egx6rxryqq2vfjxv6rtgsaqqqqqeqqqqp7sqxgqqqqqqqqqqqqzqqqqqqqqr6zgqqqzq9yq35cmzpm5cppcg9gyr9tzrp2zpr86lwy2y4fzpfsau6azq5xv2m9ez3sv4sndlu403jcn2sz2gytqggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvlqsq2smesfhwpr27j0kpgk7prlvewkk639e2c080wyc43epy04hegwgv8kwm04v8ey9t6lxkp5rv65dz9w0xly26mu8rl42hheq0h98y0z') + self.assertTrue(invoice_expired.is_expired) + invoice_not_expired = BOLT12Invoice.decode('lni1qqzdatd7auzqwqgzqvzq2ps8pqqszzsnw3jhxazlv4hxxmmyv40kjmnkda5kxegkyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvx2cyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxdqdvpwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxgzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvszqhwcuj966ma9n9nqwqtl032xeyv6755yeflt235pmww58egx6rxryqq2vfjxv6rtgsaqqqqqeqqqqp7sqxgqqqqqqqqqqqqzqqqqqqqqr6zgqqqzq9yq35cmzpm5czrhxkfl75zpj43ps4pq3na0hz9z253q5cw7dw3q2rx9dju3gcx2cfkl72hcevf4gp9yz9syypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvx0sgpp90znnmygs2r9ynxdz63etw3xp28lh2lhf3wpn9g86l57w5cl3evgltx0xjl6yal4taxn4p6z3kzxy6qm2s46qtaauf6y8yvdm035p') + self.assertFalse(invoice_not_expired.is_expired) + with self.assertRaises(ValueError): + _invoice_created_in_the_future = BOLT12Invoice.decode('lni1qqzdatd7auzqwqgzqvzq2ps8pqqszzsnw3jhxazlv4hxxmmyv40kjmnkda5kxegkyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvx2cyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxdqdvpwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxgzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvszqhwcuj966ma9n9nqwqtl032xeyv6755yeflt235pmww58egx6rxryqq2vfjxv6rtgsaqqqqqeqqqqp7sqxgqqqqqqqqqqqqzqqqqqqqqr6zgqqqzq9yqj3q26275cppcg9gyr9tzrp2zpr86lwy2y4fzpfsau6azq5xv2m9ez3sv4sndlu403jcn2sz2gytqggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvlqsqp09v7ll0jmwe52hf45calr0e2wyzyxljautwshuazc7z60shrtz8nk5vuez686cnp5aqpmk39c6k8u8ptg9hxn8jlmlcqkx4grq7hv') + + def test_serde_complex_fields(self): + payer_key = bfh('4141414141414141414141414141414141414141414141414141414141414141') + + invreq_bech32 = "lnr1qqp6hn00zcssxr0juddeytv7nwawhk9nq9us0arnk8j8wnsq8r2e86vzgtfneupe2gp9yzzcyypymkt4c0n6rhcdw9a7ay2ptuje2gvehscwcchlvgntump3x7e7tc0sgp9k43qeu892gfnz2hrr7akh2x8erh7zm2tv52884vyl462dm5tfcahgtuzt7j0npy7getf4trv5d4g78a9fkwu3kke6hcxdr6t2n7vz" + invreq = BOLT12InvoiceRequest.decode(invreq_bech32).serialize(with_signature=False) + dummy_path = { + "blinded_node_id": bfh("034b1da9c0afa084c604f74f839de006d550422facc3b4be83323702892f7f5949"), + "enclen": 51, + "encrypted_recipient_data": bfh( + "42f0018dcfe5185602618b718f7aa72b1b97d8e85b97f88b8fdad95b80fd93a21d9a975cf544e8c4b5c2f519bc83bab84bda6b") + } + + # test complex field cardinality + invreq['offer_paths'] = { + 'paths': [ + {'first_node_id': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'first_path_key': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'num_hops': bytes([1]), + 'path': [dummy_path]}, + {'first_node_id': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'first_path_key': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'num_hops': bytes([1]), + 'path': [dummy_path]}, + {'first_node_id': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'first_path_key': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'num_hops': bytes([1]), + 'path': [dummy_path]} + ] + } + + invreq_pl_tlv = BOLT12InvoiceRequest.deserialize(invreq).encode(signing_key=payer_key, as_bech32=False) + + with io.BytesIO(invreq_pl_tlv) as f: + deser = OnionWireSerializer.read_tlv_stream(fd=f, tlv_stream_name='invoice_request') + self.assertEqual(len(deser['offer_paths']['paths']), 3) + + # test complex field all members required + invreq = {'offer_paths': {'paths': [ + {'first_node_id': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'first_path_key': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'num_hops': bytes([1])} + ]}} + + # assertRaises on generic Exception used in lnmsg encode/write_tlv_stream makes flake8 complain + # so work around this for now (TODO: refactor lnmsg generic exceptions) + # with self.assertRaises(Exception): + try: + with io.BytesIO() as fd: + OnionWireSerializer.write_tlv_stream( + fd=fd, + tlv_stream_name='invoice_request', + signing_key=payer_key, + **invreq, + ) + except Exception as e: + pass + else: + raise Exception('Exception expected') + + # test complex field count matches parameters + invreq = { + 'offer_paths': {'paths': [ + {'first_node_id': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'first_path_key': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'num_hops': bytes([1]), + 'path': []} + ]} + } + + with self.assertRaises(AssertionError): + with io.BytesIO() as fd: + OnionWireSerializer.write_tlv_stream( + fd=fd, + tlv_stream_name='invoice_request', + signing_key=payer_key, + **invreq, + ) diff --git a/tests/test_commands.py b/tests/test_commands.py index 81801464e107..130f11f81b3f 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,6 +1,7 @@ import asyncio import binascii import datetime +import json import os.path import unittest from unittest import mock @@ -10,7 +11,8 @@ import electrum from electrum.commands import Commands, eval_bool -from electrum import storage, wallet +from electrum import storage, wallet, constants +from electrum.constants import BitcoinMainnet from electrum.lnutil import RECEIVED, channel_id_from_funding_tx from electrum.lnworker import RecvMPPResolution from electrum.wallet import Abstract_Wallet @@ -21,6 +23,7 @@ from electrum.util import UserFacingException, NotEnoughFunds from electrum.crypto import sha256 from electrum.bolt11 import decode_bolt11_invoice +from electrum.bolt12 import BOLT12Offer from electrum.daemon import Daemon from electrum import json_db @@ -860,3 +863,80 @@ async def test_delete_channel_backup(self): result = await cmds.delete_channel_backup(self._CHANNEL_POINT, wallet=w) self.assertIsNone(result) w.lnworker.remove_channel_backup.assert_called_once_with(chan_id) + + async def test_decode_bolt12(self, *mock_args): + cmds = Commands(config=self.config) + + # set net to mainnet as the offers below contain the mainnet chain + prev_net = constants.net + constants.net = BitcoinMainnet + + # offer + offer = 'lno1pggxv6tjwd6zqar9wd6zqmmxvejhy93pq02rpdcl6l20pakl2ad70k0n8v862jwp2twq8a8uz0hz5wfafg495' + decoded = json.loads(await cmds.decode_bolt12(bech32=offer)) + self.assertEqual(decoded['offer_description']['description'], 'first test offer') + self.assertEqual( + decoded['offer_issuer_id']['id'], + '03d430b71fd7d4f0f6df575be7d9f33b0fa549c152dc03f4fc13ee2a393d4a2a5a', + ) + + # invoice_request + invoice_request = ('lnr1qqp6hn00zcssxr0juddeytv7nwawhk9nq9us0arnk8j8wnsq8r2e86vzgtfneupe2g' + 'p9yzzcyypymkt4c0n6rhcdw9a7ay2ptuje2gvehscwcchlvgntump3x7e7tc0sgp9k43q' + 'eu892gfnz2hrr7akh2x8erh7zm2tv52884vyl462dm5tfcahgtuzt7j0npy7getf4trv5' + 'd4g78a9fkwu3kke6hcxdr6t2n7vz') + decoded = json.loads(await cmds.decode_bolt12(bech32=invoice_request)) + self.assertEqual(decoded['invreq_amount']['msat'], 21_000) + self.assertEqual(decoded['invreq_metadata']['blob'], 'abcdef') + self.assertIn('invreq_payer_id', decoded) + self.assertIn('signature', decoded) + + # invoice + invoice = ('lni1qqzdatd7auzqwqgzqvzq2ps8pqqszzsnw3jhxazlv4hxxmmyv40kjmnkda5kxegkyypwa3e' + 'yt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvx2cyypwa3eyt44h6txtxquqh7lz5' + 'djge4afgfjn7k4rgrkuag0jsd5xvxdqdvpwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrk' + 'uag0jsd5xvxgzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad2xs8de6sl9qmgvcvszqhwcuj96' + '6ma9n9nqwqtl032xeyv6755yeflt235pmww58egx6rxryqq2vfjxv6rtgsaqqqqqeqqqqp7sqx' + 'gqqqqqqqqqqqqzqqqqqqqqr6zgqqqzq9yq35cmzpm5cppcg9gyr9tzrp2zpr86lwy2y4fzpfsa' + 'u6azq5xv2m9ez3sv4sndlu403jcn2sz2gytqggzamrjghtt05kvkvpcp0a79gmy3nt6jsn98ad' + '2xs8de6sl9qmgvcvlqsq2smesfhwpr27j0kpgk7prlvewkk639e2c080wyc43epy04hegwgv8k' + 'wm04v8ey9t6lxkp5rv65dz9w0xly26mu8rl42hheq0h98y0z') + decoded = json.loads(await cmds.decode_bolt12(bech32=invoice)) + self.assertIn('invoice_amount', decoded) + self.assertIn('invoice_payment_hash', decoded) + self.assertIn('invoice_node_id', decoded) + self.assertIn('invoice_created_at', decoded) + self.assertIn('invoice_paths', decoded) + self.assertIn('signature', decoded) + + # decoding an invalid bech32 string raises + with self.assertRaises(UserFacingException): + await cmds.decode_bolt12(bech32='invalid_string') + + # set back to Testnet + constants.net = prev_net + + @mock.patch.object(wallet.Abstract_Wallet, 'save_db') + async def test_add_lightning_offer(self, *mock_args): + wallet: Abstract_Wallet = restore_wallet_from_text__for_unittest( + 'disagree rug lemon bean unaware square alone beach tennis exhibit fix mimic', + path='if_this_exists_mocking_failed_648151893', + config=self.config)['wallet'] + cmds = Commands(config=self.config) + + bolt12_offer = (await cmds.add_lightning_offer( + amount=Decimal(0.001), + description="test cli", + relative_expiry=None, + issuer_name="me", + allow_unblinded=True, + wallet=wallet, + ))['offer'] + decoded_offer = BOLT12Offer.decode(bolt12_offer) + self.assertEqual(len(decoded_offer.offer_metadata), 40) + self.assertEqual(decoded_offer.offer_amount, 100000000) + self.assertEqual(decoded_offer.offer_description, "test cli") + self.assertEqual(decoded_offer.offer_absolute_expiry, None) + self.assertEqual(decoded_offer.offer_issuer, "me") + self.assertEqual(decoded_offer.offer_issuer_id, wallet.lnworker.node_keypair.pubkey) + self.assertEqual(decoded_offer.offer_paths, None) diff --git a/tests/test_lnpeer.py b/tests/test_lnpeer.py index 19e023115521..ba387cdca0f5 100644 --- a/tests/test_lnpeer.py +++ b/tests/test_lnpeer.py @@ -831,7 +831,7 @@ async def try_pay_with_too_low_final_cltv_delta(lnaddr, w1=w1, w2=w2): lnaddr.tags = [tag for tag in lnaddr.tags if tag[0] != 'c'] + [['c', 144]] b11 = encode_bolt11_invoice(lnaddr, w2.node_keypair.privkey) pay_req = Invoice.from_bech32(b11) - assert pay_req._lnaddr.get_min_final_cltv_delta() == 144 # what w1 will use to pay + assert pay_req.bolt11_invoice.get_min_final_cltv_delta() == 144 # what w1 will use to pay result, log = await w1.pay_invoice(pay_req) if not result: raise PaymentFailure() @@ -960,7 +960,7 @@ async def try_pay_invoice_twice(pay_req: Invoice, w1=w1): result, log = await w1.pay_invoice(pay_req) assert result is True # now pay the same invoice again, the payment should be rejected by w2 - w1.set_payment_status(pay_req._lnaddr.paymenthash, PR_UNPAID, direction=lnutil.SENT) + w1.set_payment_status(pay_req.bolt11_invoice.paymenthash, PR_UNPAID, direction=lnutil.SENT) result, log = await w1.pay_invoice(pay_req) if not result: # w1.pay_invoice returned a payment failure as the payment got rejected by w2 @@ -1011,11 +1011,12 @@ async def pay(): # alice sends htlc BUT NOT COMMITMENT_SIGNED p1.maybe_send_commitment = lambda x: None route1 = (await w1.create_routes_from_invoice(lnaddr2.get_amount_msat(), decoded_invoice=lnaddr2))[0][0].route - paysession1 = w1._paysessions[lnaddr2.paymenthash + lnaddr2.payment_secret] + paysession1 = w1._paysessions[lnaddr2.paymenthash + pay_req2.get_routing_info().id] shi1 = SentHtlcInfo( route=route1, - payment_secret_orig=lnaddr2.payment_secret, - payment_secret_bucket=lnaddr2.payment_secret, + payment_key=paysession1.payment_key, + per_trampoline_payment_secret=None, + blinded_path=None, amount_msat=lnaddr2.get_amount_msat(), bucket_msat=lnaddr2.get_amount_msat(), amount_receiver_msat=lnaddr2.get_amount_msat(), @@ -1031,11 +1032,12 @@ async def pay(): # bob sends htlc BUT NOT COMMITMENT_SIGNED p2.maybe_send_commitment = lambda x: None route2 = (await w2.create_routes_from_invoice(lnaddr1.get_amount_msat(), decoded_invoice=lnaddr1))[0][0].route - paysession2 = w2._paysessions[lnaddr1.paymenthash + lnaddr1.payment_secret] + paysession2 = w2._paysessions[lnaddr1.paymenthash + pay_req1.get_routing_info().id] shi2 = SentHtlcInfo( route=route2, - payment_secret_orig=lnaddr1.payment_secret, - payment_secret_bucket=lnaddr1.payment_secret, + payment_key=paysession2.payment_key, + per_trampoline_payment_secret=None, + blinded_path=None, amount_msat=lnaddr1.get_amount_msat(), bucket_msat=lnaddr1.get_amount_msat(), amount_receiver_msat=lnaddr1.get_amount_msat(), @@ -1635,17 +1637,18 @@ async def test_channel_usage_after_closing(self): # AssertionError is ok since we shouldn't use old routes, and the # route finding should fail when channel is closed async def f(): + paysession = w1._paysessions[lnaddr.paymenthash + pay_req.get_routing_info().id] shi = SentHtlcInfo( route=route, - payment_secret_orig=lnaddr.payment_secret, - payment_secret_bucket=lnaddr.payment_secret, + payment_key=paysession.payment_key, + per_trampoline_payment_secret=None, + blinded_path=None, amount_msat=amount_msat, bucket_msat=amount_msat, amount_receiver_msat=amount_msat, trampoline_fee_level=None, trampoline_route=None, ) - paysession = w1._paysessions[lnaddr.paymenthash + lnaddr.payment_secret] pay = w1.pay_to_route( sent_htlc_info=shi, paysession=paysession, @@ -1928,13 +1931,14 @@ async def wait_for_htlcs(): await asyncio.sleep(0.25) # give w2 some time to do mistakes self.assertEqual(w2.received_mpp_htlcs[payment_key.hex()].resolution, RecvMPPResolution.COMPLETE) if test_expiry: - # we set an expiry delta of 20 blocks before expiry, htlc expiry should be +144 current height + # we set an expiry delta of 20 blocks before expiry, htlc expiry should be current height + 144 + random delay [72;143] # so adding some blocks should get the htlcs failed - w2.network.blockchain()._height += 50 + blocks_remaining = next(iter(w2.received_mpp_htlcs[payment_key.hex()].htlcs)).htlc.cltv_abs - w2.network.blockchain()._height + w2.network.blockchain()._height += (blocks_remaining - 20) await asyncio.sleep(0.1) - # the htlcs should not get failed yet as 144-50 > 20 + # the htlcs should not get failed yet as they still have 1 block left self.assertEqual(w2.received_mpp_htlcs[payment_key.hex()].resolution, RecvMPPResolution.COMPLETE) - w2.network.blockchain()._height += 75 + w2.network.blockchain()._height += 1 return # the htlcs should get failed and pay should return PaymentFailure # saving the preimage should let the htlcs get fulfilled diff --git a/tests/test_lnrouter.py b/tests/test_lnrouter.py index 3ad775cf691d..d24b35f5ad36 100644 --- a/tests/test_lnrouter.py +++ b/tests/test_lnrouter.py @@ -1,3 +1,5 @@ +import dataclasses +import os import random import unittest from math import inf @@ -7,20 +9,22 @@ from electrum import util from electrum.channel_db import NodeInfo from electrum.onion_message import is_onion_message_node -from electrum.trampoline import (create_trampoline_onion, _allocate_fee_budget_among_route, PLACEHOLDER_FEE, - get_trampoline_budget) +from electrum.trampoline import (create_trampoline_onion, _allocate_fee_budget_among_route, PLACEHOLDER_FEE, get_trampoline_budget, + TrampolineRoute, create_trampoline_route_and_onion, DEFAULT_TRAMPOLINE_CLTV_DELTA) from electrum.util import bfh -from electrum.lnutil import ShortChannelID, LnFeatures, PaymentFeeBudget +from electrum.lnutil import (ShortChannelID, LnFeatures, UnblindedRoutingInfo, PaymentFeeBudget, BlindedRoutingInfo, + FeeBudgetExceeded, MIN_FINAL_CLTV_DELTA_ACCEPTED, NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE) from electrum.lnonion import (OnionHopsDataSingle, new_onion_packet, process_onion_packet, _decode_onion_error, decode_onion_error, - OnionFailureCode) + OnionFailureCode, BlindedPathInfo) from electrum import bitcoin, lnrouter from electrum.constants import BitcoinTestnet from electrum.simple_config import SimpleConfig from electrum.lnrouter import (PathEdge, LiquidityHintMgr, DEFAULT_PENALTY_PROPORTIONAL_MILLIONTH, - DEFAULT_PENALTY_BASE_MSAT, fee_for_edge_msat, LNPaymentTRoute, TrampolineEdge) + DEFAULT_PENALTY_BASE_MSAT, fee_for_edge_msat, LNPaymentTRoute, TrampolineEdge, + FinalForwardFees) -from . import ElectrumTestCase +from . import ElectrumTestCase, lnhelpers from .test_bitcoin import needs_test_with_all_chacha20_implementations @@ -459,44 +463,211 @@ def test_process_onion_packet(self): def test_create_legacy_trampoline_onion_multiple_rtags(self): """Test to verify we don't overfill the trampoline onion with r_tags if there are more tags than available space""" - dummy_route: LNPaymentTRoute = [ - TrampolineEdge( - invoice_routing_info=[ - bfh("010305061295fa30847df41ae6ee809b560e78d65c2a7337a41c725ea3920b65e08a03b62b00003a0002000003e8000000010050"), - bfh("01037414fe3dcfedc4a0a0e153205d9a973af5096d1cd1c8c53d07ed12d7dd966f19f424000000000020000003e8000008ca0050"), - bfh("01038550162fa86287884a6a052471934abb5cb261c5a2b15386df8104d3c7bcb85dddd92ee1898ee15c000003e8000000010090"), - bfh("010244bb7ba2392ab2d493ad04ad4afcd482ca44a2bfe5b42bcc830bfe00e5b08082f424000000000029000003e8000008ca0050") - ], - invoice_features=LnFeatures.VAR_ONION_REQ | LnFeatures.PAYMENT_SECRET_REQ | LnFeatures.BASIC_MPP_OPT, - short_channel_id=ShortChannelID.from_str("0x0x0"), - start_node=node('a'), - end_node=node('b'), - fee_base_msat=0, - fee_proportional_millionths=0, - cltv_delta=0, - node_features=0 - ), - TrampolineEdge( - invoice_routing_info=[], - invoice_features=None, - short_channel_id=ShortChannelID.from_str("0x0x0"), - start_node=node('b'), - end_node=node('c'), - fee_base_msat=0, - fee_proportional_millionths=0, - cltv_delta=0, - node_features=0 - ), - ] + troute = TrampolineRoute( + edges=[ + TrampolineEdge( + short_channel_id=ShortChannelID.from_str("0x0x0"), + start_node=node('a'), + end_node=node('b'), + fee_base_msat=0, + fee_proportional_millionths=0, + cltv_delta=0, + node_features=0, + ), + TrampolineEdge( + invoice_routing_info=[ + bfh("010305061295fa30847df41ae6ee809b560e78d65c2a7337a41c725ea3920b65e08a03b62b00003a0002000003e8000000010050"), + bfh("01037414fe3dcfedc4a0a0e153205d9a973af5096d1cd1c8c53d07ed12d7dd966f19f424000000000020000003e8000008ca0050"), + bfh("01038550162fa86287884a6a052471934abb5cb261c5a2b15386df8104d3c7bcb85dddd92ee1898ee15c000003e8000000010090"), + bfh("010244bb7ba2392ab2d493ad04ad4afcd482ca44a2bfe5b42bcc830bfe00e5b08082f424000000000029000003e8000008ca0050") + ], + invoice_features=LnFeatures.VAR_ONION_REQ | LnFeatures.PAYMENT_SECRET_REQ | LnFeatures.BASIC_MPP_OPT, + short_channel_id=ShortChannelID.from_str("0x0x0"), + start_node=node('b'), + end_node=node('c'), + fee_base_msat=0, + fee_proportional_millionths=0, + cltv_delta=0, + node_features=0 + ), + ], + is_legacy=True, + final_forward_fees=FinalForwardFees(fee_base_msat=0, fee_proportional_millionths=0, forwarder_cltv_delta=576), + ) # create a trampoline onion, this shouldn't raise InvalidPayloadSize create_trampoline_onion( - route=dummy_route, + trampoline_route=troute, + routing_info=UnblindedRoutingInfo( + node_pubkey=node('d'), + payment_secret=urandom(32), + final_cltv_delta=0, + r_tags=[], + invoice_features=LnFeatures.VAR_ONION_REQ | LnFeatures.PAYMENT_SECRET_REQ | LnFeatures.BASIC_MPP_OPT, + ), + blinded_path=None, amount_msat=0, - final_cltv_abs=0, + local_height=0, total_msat=0, payment_hash=urandom(32), + ) + + def test_create_trampoline_route_and_onion_unblinded_legacy(self): + routing_info = UnblindedRoutingInfo( + node_pubkey=node('c'), payment_secret=urandom(32), + final_cltv_delta=144, + r_tags=[], + invoice_features=LnFeatures(0), # no trampoline support + ) + r = create_trampoline_route_and_onion( + amount_msat=10_000_000, + total_msat=10_000_000, + routing_info=routing_info, + my_pubkey=node('a'), + my_trampoline=node('b'), + payment_hash=os.urandom(32), + local_height=100_000, + trampoline_fee_level=1, + next_trampolines={}, + failed_routes=[], + budget=PaymentFeeBudget(fee_msat=210_000, cltv=2100), + blinded_path=None, ) + route, onion, amount_with_fees, bucket_cltv_delta = r + self.assertEqual(route.edges[0].start_node, node('a')) + self.assertEqual(route.edges[0].end_node, node('b')) + self.assertEqual(len(route.edges), 1) + + # recipient is in next_trampolines + r = create_trampoline_route_and_onion( + amount_msat=10_000_000, + total_msat=10_000_000, + routing_info=routing_info, + my_pubkey=node('a'), + my_trampoline=node('b'), + payment_hash=os.urandom(32), + local_height=100_000, + trampoline_fee_level=1, + next_trampolines={ + node('c'): None, # recipient shouldn't be used + node('d'): None, + }, + failed_routes=[], + budget=PaymentFeeBudget(fee_msat=210_000, cltv=2100), + blinded_path=None, + ) + route, onion, amount_with_fees, bucket_cltv_delta = r + self.assertEqual(route.edges[0].start_node, node('a')) + self.assertEqual(route.edges[0].end_node, node('b')) + self.assertEqual(route.edges[1].start_node, node('b')) + self.assertEqual(route.edges[1].end_node, node('d')) + self.assertEqual(len(route.edges), 2) + + def test_create_trampoline_route_and_onion_blinded_legacy(self): + # random IP + paths = lnhelpers.get_dummy_paths() + routing_info = BlindedRoutingInfo( + paths=paths, + invoice_features=LnFeatures(0), + final_cltv_delta=50, + ) + r = create_trampoline_route_and_onion( + amount_msat=10_000_000, + total_msat=10_000_000, + routing_info=routing_info, + my_pubkey=node('a'), + my_trampoline=node('b'), + payment_hash=os.urandom(32), + local_height=100_000, + trampoline_fee_level=1, + next_trampolines={}, + failed_routes=[], + budget=PaymentFeeBudget(fee_msat=210_000, cltv=2100), + blinded_path=paths[0], + ) + route, onion, amount_with_fees, bucket_cltv_delta = r + self.assertEqual(route.edges[0].start_node, node('a')) + self.assertEqual(route.edges[0].end_node, node('b')) + self.assertEqual(len(route.edges), 1) + + paths = lnhelpers.get_dummy_paths(first_node_id=node('c')) + routing_info = BlindedRoutingInfo( + paths=paths, + invoice_features=LnFeatures(0), + final_cltv_delta=50, + ) + r = create_trampoline_route_and_onion( + amount_msat=10_000_000, + total_msat=10_000_000, + routing_info=routing_info, + my_pubkey=node('a'), + my_trampoline=node('b'), + payment_hash=os.urandom(32), + local_height=100_000, + trampoline_fee_level=1, + next_trampolines={ + node('c'): None, # IP is in next_trampolines + node('d'): None, + }, + failed_routes=[], + budget=PaymentFeeBudget(fee_msat=210_000, cltv=2100), + blinded_path=paths[0], + ) + route, onion, amount_with_fees, bucket_cltv_delta = r + self.assertEqual(route.edges[0].start_node, node('a')) + self.assertEqual(route.edges[0].end_node, node('b')) + self.assertEqual(route.edges[1].start_node, node('b')) + self.assertEqual(route.edges[1].end_node, node('d')) + self.assertEqual(len(route.edges), 2) + + def test_create_trampoline_route_and_onion_blinded_budget(self): + """The destinations final CLTV delta is not deducted from the budget, see PaymentFeeBudget comment""" + paths = lnhelpers.get_dummy_paths() + blinded_cltv = paths[0].payinfo.cltv_expiry_delta + routing_info = BlindedRoutingInfo(paths=paths, invoice_features=LnFeatures(0), final_cltv_delta=50) + + def build(budget_cltv: int, routing_info, blinded_path = None): + return create_trampoline_route_and_onion( + amount_msat=10_000_000, + total_msat=10_000_000, + routing_info=routing_info, + my_pubkey=node('a'), + my_trampoline=node('b'), + payment_hash=os.urandom(32), + local_height=100_000, + trampoline_fee_level=1, + next_trampolines={}, + failed_routes=[], + budget=PaymentFeeBudget(fee_msat=210_000, cltv=budget_cltv), + blinded_path=blinded_path, + ) + + # check the blinded paths cltv cost is not accounted into the budget, similar to unblinded min_final_cltv_delta + route, onion, amount_with_fees, bucket_cltv_delta = build(DEFAULT_TRAMPOLINE_CLTV_DELTA, routing_info, paths[0]) + self.assertEqual(bucket_cltv_delta, DEFAULT_TRAMPOLINE_CLTV_DELTA + blinded_cltv + 50) + + # the check raises if there is not enough cltv budget for the hops excluding the IP (node b) + with self.assertRaises(FeeBudgetExceeded): + build(DEFAULT_TRAMPOLINE_CLTV_DELTA - 1, routing_info, paths[0]) + + # check unblinded behavior + routing_info = UnblindedRoutingInfo( + node_pubkey=node('c'), + payment_secret=urandom(32), + final_cltv_delta=NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE - DEFAULT_TRAMPOLINE_CLTV_DELTA, + r_tags=[], + invoice_features=LnFeatures(0), + ) + route, onion, amount_with_fees, bucket_cltv_delta = build(DEFAULT_TRAMPOLINE_CLTV_DELTA, routing_info) + self.assertEqual(bucket_cltv_delta, NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE) + + with self.assertRaises(FeeBudgetExceeded): + build(DEFAULT_TRAMPOLINE_CLTV_DELTA - 1, routing_info) + + # check the sanity check (cltv <= NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE) + with self.assertRaises(FeeBudgetExceeded): + routing_info = dataclasses.replace(routing_info, final_cltv_delta=routing_info.final_cltv_delta + 1) + build(DEFAULT_TRAMPOLINE_CLTV_DELTA, routing_info) @needs_test_with_all_chacha20_implementations def test_decode_onion_error(self): diff --git a/tests/test_lnutil.py b/tests/test_lnutil.py index 5df8755ce524..fe3439c08ad0 100644 --- a/tests/test_lnutil.py +++ b/tests/test_lnutil.py @@ -10,8 +10,9 @@ derive_privkey, derive_pubkey, make_htlc_tx, extract_ctn_from_tx, get_compressed_pubkey_from_bech32, ScriptHtlc, calc_fees_for_commitment_tx, UpdateAddHtlc, LnFeatures, ln_compare_features, IncompatibleLightningFeatures, ChannelType, offered_htlc_trim_threshold_sat, received_htlc_trim_threshold_sat, - ImportedChannelBackupStorage, list_enabled_ln_feature_bits, PaymentFeeBudget, LnFeatureContexts + ImportedChannelBackupStorage, list_enabled_ln_feature_bits, PaymentFeeBudget, LnFeatureContexts, FeeBudgetExceeded ) +from electrum.lnonion import BlindedPayInfo from electrum.util import bfh, MyEncoder from electrum.transaction import Transaction, PartialTransaction, Sighash from electrum.lnworker import LNWallet @@ -1190,4 +1191,22 @@ async def test_payment_fee_budget(self): self.assertEqual(budget.fee_msat, config.LIGHTNING_PAYMENT_FEE_CUTOFF_MSAT) self.assertEqual(reversed_fee_msat, budget.fee_msat) - + # test subtract_blinded_path_fees + budget = PaymentFeeBudget(fee_msat=10_000, cltv=500) + payinfo = BlindedPayInfo( + fee_base_msat=1000, + fee_proportional_millionths=100, + cltv_expiry_delta=144, + htlc_minimum_msat=0, + htlc_maximum_msat=2**32, + features=LnFeatures(0), + ) + # blinded fee: 1000 + (100 * 100_000) // 1_000_000 = 1010 + remaining = budget.subtract_blinded_path_fees(payinfo, amount_msat=100_000) + self.assertEqual(remaining.fee_msat, 10_000 - 1010) + self.assertEqual(remaining.cltv, 500 - 144) + + # test that exceeding the budget raises + small_budget = PaymentFeeBudget(fee_msat=500, cltv=500) + with self.assertRaises(FeeBudgetExceeded): + small_budget.subtract_blinded_path_fees(payinfo, amount_msat=100_000) diff --git a/tests/test_lnwallet.py b/tests/test_lnwallet.py index 4c3919f07740..622161790d3f 100644 --- a/tests/test_lnwallet.py +++ b/tests/test_lnwallet.py @@ -1,13 +1,23 @@ +import asyncio +import dataclasses import logging import os -import asyncio from unittest import mock from decimal import Decimal +import time from typing import Optional +from unittest.mock import patch + +from electrum_ecc import ECPrivkey from electrum.address_synchronizer import TX_HEIGHT_LOCAL -from electrum import bitcoin import electrum.trampoline +from electrum.onion_message import NoOnionMessagePeers +from .test_wallet_vertical import UNICODE_HORROR + +from electrum import constants +from electrum.bolt12 import BOLT12Offer, BOLT12InvoiceRequest, BOLT12Invoice, BOLT12InvoicePathIDPayload, Bolt12InvoiceError +from electrum.crypto import hmac_oneshot from electrum.lnutil import RECEIVED, MIN_FINAL_CLTV_DELTA_ACCEPTED, serialize_htlc_key, LnFeatures, HTLCOwner from electrum.logging import console_stderr_handler from electrum.lntransport import LNPeerAddr @@ -17,9 +27,10 @@ from electrum.lnonion import OnionPacket, OnionRoutingFailure from electrum.crypto import sha256 from electrum.simple_config import SimpleConfig +from electrum.lnworker import LNWALLET_FEATURES from . import ElectrumTestCase, lnhelpers -from .lnhelpers import create_test_channels +from .lnhelpers import create_test_channels, get_dummy_paths class TestLNWallet(ElectrumTestCase): @@ -68,6 +79,37 @@ def test_create_payment_info__amount_must_not_be_zero(self): exp_delay=exp_delay, ) + def test_save_blinded_payment_info(self): + amount_msat = 1000 + created_at = int(time.time()) + relative_expiry = created_at + 100 + payment_preimage = os.urandom(32) + min_final_cltv_expiry_delta = 322 + invoice_features = LNWALLET_FEATURES.for_bolt12_invoice() + invreq_payer_id = os.urandom(33) + path_id_payload = BOLT12InvoicePathIDPayload( + amount_msat=amount_msat, + created_at=created_at, + relative_expiry=relative_expiry, + payment_preimage=payment_preimage, + min_final_cltv_expiry_delta=min_final_cltv_expiry_delta, + invoice_features=invoice_features, + payer_id=invreq_payer_id, + ) + wallet = self.lnwallet_anchors + wallet.save_blinded_payment_info(path_id_payload) + payment_hash = sha256(payment_preimage) + payment_info = wallet.get_payment_info(payment_hash, direction=RECEIVED) + self.assertIsNotNone(payment_info) + self.assertEqual(wallet.get_preimage(payment_hash), payment_preimage) + self.assertEqual(payment_info.payment_hash, payment_hash) + self.assertEqual(payment_info.amount_msat, amount_msat) + self.assertEqual(payment_info.status, PR_UNPAID) + self.assertEqual(payment_info.min_final_cltv_delta, min_final_cltv_expiry_delta) + self.assertEqual(payment_info.expiry_delay, relative_expiry) + self.assertEqual(payment_info.creation_ts, created_at) + self.assertEqual(payment_info.invoice_features, invoice_features) + async def test_trampoline_invoice_features_and_routing_hints(self): """ When the invoice_features signal trampoline support, routing hints must only @@ -474,3 +516,588 @@ async def test_rebalance_channels(self): chan1.set_frozen_for_sending(True) # shouldn't matter, this channel will receive success, log = await alice.rebalance_channels(chan0, chan1, amount_msat=150_000_000) self.assertTrue(success, msg=log) + + async def test_request_bolt12_invoice(self): + wallet = self.lnwallet_anchors + + offer_issuer_id = ECPrivkey.generate_random_key().get_public_key_bytes() + offer = BOLT12Offer( + offer_chains=[constants.net.rev_genesis_bytes()], + offer_description="test", + offer_issuer_id=offer_issuer_id, + ) + + submit_send_calls = [] + def fake_submit_send(*, payload, node_id_or_blinded_paths, key=None): + submit_send_calls.append((payload, node_id_or_blinded_paths)) + return asyncio.Future() + + with patch('electrum.lnworker.get_blinded_reply_paths', return_value=get_dummy_paths()), \ + patch.object(wallet.onion_message_manager, 'submit_send', side_effect=fake_submit_send): + task = asyncio.create_task( + wallet.request_bolt12_invoice(bolt12_offer=offer, amount_msat=21_000) + ) + + start = time.time() + while not wallet._pending_bolt12_invoice_requests: + await asyncio.sleep(0.05) + if time.time() - start > 2: + task.cancel() + self.fail(f"invreq future wasn't registered") + + self.assertEqual(len(submit_send_calls), 1) + payload, destination = submit_send_calls[0] + self.assertEqual(destination, offer_issuer_id) + self.assertIn('invoice_request', payload) + self.assertIn('reply_path', payload) + + self.assertEqual(len(wallet._pending_bolt12_invoice_requests), 1) + path_id, fut = next(iter(wallet._pending_bolt12_invoice_requests.items())) + fut.set_result("invoice") + + self.assertIs(await task, "invoice") + + self.assertNotIn(path_id, wallet._pending_bolt12_invoice_requests) + + def test_create_bolt12_invoice_request_with_offer(self): + wallet = self.lnwallet_anchors + + amount_msat = 10_000 + offer_issuer_id = ECPrivkey.generate_random_key().get_public_key_bytes() + offer = BOLT12Offer( + offer_chains=[constants.net.rev_genesis_bytes()], + offer_amount=amount_msat, + offer_description="test offer", + offer_issuer_id=offer_issuer_id, + ) + + # raises if amount is much higher than offer_amount + with self.assertRaises(ValueError): + _ = wallet.create_bolt12_invoice_request(offer=offer, amount_msat=40000) + + unsigned_invreq, signing_key = wallet.create_bolt12_invoice_request( + offer=offer, + amount_msat=amount_msat, + payer_note="pls send invoice", + ) + + self.assertIsInstance(unsigned_invreq, BOLT12InvoiceRequest) + self.assertIsInstance(signing_key, ECPrivkey) + + # offer fields propagated into the invreq + self.assertEqual(unsigned_invreq.offer_issuer_id, offer_issuer_id) + self.assertEqual(unsigned_invreq.offer_amount, 10_000) + self.assertEqual(unsigned_invreq.offer_description, "test offer") + self.assertEqual(unsigned_invreq.offer_chains, [constants.net.rev_genesis_bytes()]) + + # invreq fields set from our parameters + self.assertEqual(unsigned_invreq.invreq_amount, amount_msat) + self.assertEqual(unsigned_invreq.invreq_payer_note, "pls send invoice") + self.assertEqual(unsigned_invreq.invreq_payer_id, signing_key.get_public_key_bytes()) + self.assertEqual(unsigned_invreq.invreq_chain, constants.net.rev_genesis_bytes()) + # invreq_metadata is the 16-byte entropy concatenated with sha256(signed_invreq_tlv) + self.assertEqual(len(unsigned_invreq.invreq_metadata), 16 + 32) + + # standalone-only fields are absent + self.assertIsNone(unsigned_invreq.invreq_paths) + + # derived signing key is not the node key + self.assertNotEqual(signing_key.get_secret_bytes(), wallet.node_keypair.privkey) + + # test the stateless authenticity scheme + entropy, invreq_sig_digest = unsigned_invreq.invreq_metadata[:16], unsigned_invreq.invreq_metadata[-32:] + derived_privkey = hmac_oneshot( + key=wallet.bolt12_secret_key, + msg=b'invreq_key' + entropy, + digest='sha-256', + ) + self.assertEqual(derived_privkey, signing_key.get_secret_bytes()) + + signable_invreq = dataclasses.replace(unsigned_invreq, invreq_metadata=entropy) + resigned = signable_invreq.encode(signing_key=signing_key.get_secret_bytes(), as_bech32=False) + self.assertEqual(sha256(resigned), invreq_sig_digest) + + def test_create_bolt12_invoice_request_without_offer(self): + wallet = self.lnwallet_anchors + + reply_paths = get_dummy_paths() + amount_msat = 42_000 + with patch('electrum.lnworker.get_blinded_reply_paths', return_value=reply_paths): + unsigned_invreq, signing_key = wallet.create_bolt12_invoice_request( + offer=None, + amount_msat=amount_msat, + payer_note="standalone invreq", + allow_unblinded=False, + ) + + self.assertIsInstance(unsigned_invreq, BOLT12InvoiceRequest) + self.assertIsInstance(signing_key, ECPrivkey) + + # not a response to an offer: offer identity fields must be empty + self.assertIsNone(unsigned_invreq.offer_issuer_id) + self.assertIsNone(unsigned_invreq.offer_paths) + self.assertIsNone(unsigned_invreq.offer_amount) + self.assertIsNone(unsigned_invreq.offer_chains) + # payer_note is stored in offer_description for standalone invreqs + self.assertEqual(unsigned_invreq.offer_description, "standalone invreq") + + # invreq fields set from our parameters + self.assertEqual(unsigned_invreq.invreq_amount, amount_msat) + self.assertEqual(unsigned_invreq.invreq_payer_id, signing_key.get_public_key_bytes()) + self.assertEqual(unsigned_invreq.invreq_chain, constants.net.rev_genesis_bytes()) + self.assertEqual(len(unsigned_invreq.invreq_metadata), 16 + 32) + + # blinded reply paths are attached so the payee can respond + self.assertEqual(len(unsigned_invreq.invreq_paths), 1) + self.assertEqual(unsigned_invreq.invreq_paths[0], reply_paths[0].path) + + # with reply paths available, the derived signing key is used (not node key) + self.assertNotEqual(signing_key.get_secret_bytes(), wallet.node_keypair.privkey) + + # authenticity scheme (same as offer-based invreqs) + entropy, stored_digest = unsigned_invreq.invreq_metadata[:16], unsigned_invreq.invreq_metadata[-32:] + derived_privkey = hmac_oneshot( + key=wallet.bolt12_secret_key, + msg=b'invreq_key' + entropy, + digest='sha-256', + ) + self.assertEqual(derived_privkey, signing_key.get_secret_bytes()) + + signable_invreq = dataclasses.replace(unsigned_invreq, invreq_metadata=entropy) + resigned = signable_invreq.encode(signing_key=signing_key.get_secret_bytes(), as_bech32=False) + self.assertEqual(sha256(resigned), stored_digest) + + def test_create_offer(self): + wallet = self.lnwallet_anchors + + amount_msat = 10_000 + memo = "test" + expiry = 4000 + issuer = "pizza italy" + with self.assertRaises(NoOnionMessagePeers): + wallet.create_offer( + amount_msat=amount_msat, + description=memo, + relative_expiry=expiry, + issuer_name=issuer, + allow_unblinded=False, + ) + + offer = wallet.create_offer( + amount_msat=amount_msat, + description=memo, + relative_expiry=expiry, + issuer_name=issuer, + allow_unblinded=True, + ) + + self.assertEqual(offer.offer_amount, amount_msat) + self.assertEqual(offer.offer_description, memo) + self.assertEqual(offer.offer_issuer, issuer) + self.assertEqual(offer.offer_issuer_id, wallet.node_keypair.pubkey) + self.assertIsNone(offer.offer_paths) + absolute_expiry = int(time.time()) + expiry + self.assertLess(offer.offer_absolute_expiry - absolute_expiry, 2) + + # test that offer metadata is a mac of encoded offer + mac, remaining_metadata = offer.offer_metadata[-32:], offer.offer_metadata[:-32] + offer = dataclasses.replace(offer, offer_metadata=remaining_metadata) + encoded_offer = offer.encode(as_bech32=False) + our_mac = hmac_oneshot(key=wallet.bolt12_secret_key, msg=b'offer' + encoded_offer, digest='sha-256') + self.assertEqual(our_mac, mac) + + # assert two offers signed with node keypair are not equal + # this allows for example to blacklist/revoke certain offers + offer1 = wallet.create_offer(allow_unblinded=True).encode() + offer2 = wallet.create_offer(allow_unblinded=True).encode() + self.assertNotEqual(offer1, offer2) + + def test__verify_bolt12_offer_created_by_us(self): + wallet = self.lnwallet_anchors + + offer_created_by_us = wallet.create_offer( + allow_unblinded=True, + amount_msat=1000, + description="best pizza place", + ) + self.assertTrue(wallet._verify_bolt_12_offer_created_by_us(offer_created_by_us)) + + offer_modified = dataclasses.replace(offer_created_by_us, offer_description="pizza place stinks") + self.assertFalse(wallet._verify_bolt_12_offer_created_by_us(offer_modified)) + + offer_modified = dataclasses.replace(offer_created_by_us, offer_amount=1001) + self.assertFalse(wallet._verify_bolt_12_offer_created_by_us(offer_modified)) + + async def test_on_bolt12_invoice_request(self): + wallet = self.lnwallet_anchors + + reply_paths = get_dummy_paths() + with patch('electrum.lnworker.get_blinded_reply_paths', return_value=reply_paths) as mock: + offer = wallet.create_offer( + amount_msat=1000, + description="test", + relative_expiry=None, + issuer_name="tester", + allow_unblinded=False, + ) + # invoice needs to get signed with the offer_issuer_id's secret from offer paths path_id + offer_encrypted_recipient_data_path_id = mock.call_args.args[1] + + # create invoice request for our own offer + unsigned_invreq, signing_key = wallet.create_bolt12_invoice_request( + offer=offer, + amount_msat=1000, + payer_note="pls send invoice", + ) + invreq_tlv = unsigned_invreq.encode(signing_key=signing_key.get_secret_bytes(), as_bech32=False) + + # create payload that we receive when someone (we as well) requests the invoice for our offer + incoming_payload = { + 'invoice_request': {'invoice_request': invreq_tlv}, + 'reply_path': {'path': dataclasses.asdict(get_dummy_paths()[0].path)}, + 'encrypted_recipient_data': {'encrypted_recipient_data': offer.offer_paths[0].path[-1].encrypted_recipient_data}, + } + # decrypted encrypted recipient data + recipient_data = { + 'path_id': {'data': offer_encrypted_recipient_data_path_id} + } + + with patch.object(wallet.onion_message_manager, 'submit_send') as mock_submit_send: + wallet.on_bolt12_invoice_request(payload=incoming_payload, recipient_data=recipient_data) + self.assertIn( + b'no active channels', + mock_submit_send.call_args.kwargs['payload']['invoice_error']['invoice_error'], + ) + + # add a channel so the active channel check passes + regular_peer = self.create_mock_lnwallet(name='regular_peer') + chan_r, _ = create_test_channels(alice_lnwallet=wallet, bob_lnwallet=regular_peer) + wallet._add_channel(chan_r) + invoice_paths = get_dummy_paths() + with patch('electrum.lnworker.get_blinded_paths_to_me', return_value=invoice_paths), \ + patch.object(wallet.onion_message_manager, 'submit_send') as mock_submit_send: + wallet.on_bolt12_invoice_request(payload=incoming_payload, recipient_data=recipient_data) + sent_invoice_tlv = mock_submit_send.call_args.kwargs['payload']['invoice']['invoice'] + sent_invoice = BOLT12Invoice.decode(sent_invoice_tlv) + + self.assertEqual(offer.offer_issuer_id, sent_invoice.invoice_node_id) + + # invreq for an offer that was not created by us (MAC verification fails) + foreign_offer = dataclasses.replace(offer, offer_description="tampered description") + foreign_invreq, foreign_signing_key = wallet.create_bolt12_invoice_request( + offer=foreign_offer, + amount_msat=1000, + payer_note="pls send invoice", + ) + foreign_invreq_tlv = foreign_invreq.encode( + signing_key=foreign_signing_key.get_secret_bytes(), as_bech32=False) + foreign_payload = { + 'invoice_request': {'invoice_request': foreign_invreq_tlv}, + 'reply_path': {'path': dataclasses.asdict(get_dummy_paths()[0].path)}, + 'encrypted_recipient_data': {'encrypted_recipient_data': foreign_offer.offer_paths[0].path[-1].encrypted_recipient_data}, + } + with patch('electrum.lnworker.get_blinded_paths_to_me', return_value=invoice_paths), \ + patch.object(wallet.onion_message_manager, 'submit_send') as mock_submit_send: + wallet.on_bolt12_invoice_request(payload=foreign_payload, recipient_data=recipient_data) + self.assertIn( + b'no matching offer for this invoice request', + mock_submit_send.call_args.kwargs['payload']['invoice_error']['invoice_error'], + ) + + # invreq arrived with encrypted_recipient_data that doesn't belong to any of the offer paths + wrong_path_payload = { + 'invoice_request': {'invoice_request': invreq_tlv}, + 'reply_path': {'path': dataclasses.asdict(get_dummy_paths()[0].path)}, + 'encrypted_recipient_data': {'encrypted_recipient_data': b'not_in_offer_paths'}, + } + with patch.object(wallet.onion_message_manager, 'submit_send') as mock_submit_send: + wallet.on_bolt12_invoice_request(payload=wrong_path_payload, recipient_data=recipient_data) + self.assertFalse(mock_submit_send.called) + + # invreq arrives for an offer that is already expired + with patch('electrum.lnworker.get_blinded_reply_paths', return_value=get_dummy_paths()): + expiring_offer = wallet.create_offer( + amount_msat=1000, + description="expiring offer", + relative_expiry=3600, + allow_unblinded=False, + ) + expiring_invreq, expiring_signing_key = wallet.create_bolt12_invoice_request( + offer=expiring_offer, + amount_msat=1000, + payer_note="pls send invoice", + ) + expiring_invreq_tlv = expiring_invreq.encode( + signing_key=expiring_signing_key.get_secret_bytes(), as_bech32=False) + expiring_payload = { + 'invoice_request': {'invoice_request': expiring_invreq_tlv}, + 'reply_path': {'path': dataclasses.asdict(get_dummy_paths()[0].path)}, + 'encrypted_recipient_data': {'encrypted_recipient_data': expiring_offer.offer_paths[0].path[-1].encrypted_recipient_data}, + } + with patch('electrum.lnworker.time.time', return_value=expiring_offer.offer_absolute_expiry + 1), \ + patch('electrum.lnworker.get_blinded_paths_to_me', return_value=invoice_paths), \ + patch.object(wallet.onion_message_manager, 'submit_send') as mock_submit_send: + wallet.on_bolt12_invoice_request(payload=expiring_payload, recipient_data=recipient_data) + self.assertIn( + b'offer already expired', + mock_submit_send.call_args.kwargs['payload']['invoice_error']['invoice_error'], + ) + + async def test_on_bolt12_offerless_invoice_request(self): + wallet = self.lnwallet_anchors + + # add a channel so for receive capacity + regular_peer = self.create_mock_lnwallet(name='regular_peer') + chan_r, _ = create_test_channels(alice_lnwallet=wallet, bob_lnwallet=regular_peer) + wallet._add_channel(chan_r) + + # offerless invoice_request, with invreq_paths (blinded) + with patch('electrum.lnworker.get_blinded_reply_paths', return_value=get_dummy_paths()): + unsigned_invreq, signing_key = wallet.create_bolt12_invoice_request( + offer=None, + amount_msat=1000, + payer_note="sats for you", + ) + + self.assertIsNone(unsigned_invreq.offer_issuer_id) + self.assertIsNone(unsigned_invreq.offer_paths) + self.assertIsNotNone(unsigned_invreq.invreq_paths) + self.assertEqual(unsigned_invreq.offer_description, "sats for you") + + invreq_tlv = unsigned_invreq.encode(signing_key=signing_key.get_secret_bytes(), as_bech32=False) + incoming_payload = { + 'invoice_request': {'invoice_request': invreq_tlv}, + 'reply_path': {'path': dataclasses.asdict(get_dummy_paths()[0].path)}, + 'encrypted_recipient_data': {'encrypted_recipient_data': b'is_unused'}, + } + + with patch('electrum.lnworker.get_blinded_paths_to_me', return_value=get_dummy_paths()), \ + patch.object(wallet.onion_message_manager, 'submit_send') as mock_submit_send: + wallet.on_bolt12_invoice_request(payload=incoming_payload, recipient_data={}) + + sent_kwargs = mock_submit_send.call_args.kwargs + self.assertEqual( + sent_kwargs['node_id_or_blinded_paths'], + unsigned_invreq.invreq_paths, + ) + sent_invoice = BOLT12Invoice.decode(sent_kwargs['payload']['invoice']['invoice']) + self.assertEqual(sent_invoice.invoice_amount, 1000) + self.assertEqual(sent_invoice.offer_description, "sats for you") + + async def test__verify_bolt12_invoice_requested_by_us(self): + wallet = self.lnwallet_anchors + + # pretend we're paying a peer's offer + peer_offer_key = ECPrivkey.generate_random_key() + peer_offer = BOLT12Offer( + offer_chains=[constants.net.rev_genesis_bytes()], + offer_amount=1000, + offer_description="peer offer", + offer_issuer_id=peer_offer_key.get_public_key_bytes(), + ) + # invoice request for this offer + unsigned_invreq, _ = wallet.create_bolt12_invoice_request(offer=peer_offer, amount_msat=1000) + + def build_invoice(invreq: BOLT12InvoiceRequest) -> BOLT12Invoice: + dummy_path = get_dummy_paths() + return BOLT12Invoice( + **invreq.__dict__, + invoice_amount=invreq.invreq_amount, + invoice_created_at=int(time.time()), + invoice_payment_hash=os.urandom(32), + invoice_node_id=peer_offer_key.get_public_key_bytes(), + invoice_paths=tuple(p.path for p in dummy_path), + invoice_blindedpay=tuple(p.payinfo for p in dummy_path), + ) + + invoice = build_invoice(unsigned_invreq) + self.assertTrue(wallet._verify_bolt12_invoice_requested_by_us(invoice)) + + # tampering with any invreq field invalidates the stored metadata digest + self.assertFalse(wallet._verify_bolt12_invoice_requested_by_us( + build_invoice(dataclasses.replace(unsigned_invreq, invreq_amount=1001)) + )) + self.assertFalse(wallet._verify_bolt12_invoice_requested_by_us( + dataclasses.replace(invoice, invreq_payer_note="tampered") + )) + self.assertFalse(wallet._verify_bolt12_invoice_requested_by_us( + dataclasses.replace(invoice, offer_description="tampered") + )) + + self.assertFalse(wallet._verify_bolt12_invoice_requested_by_us( + dataclasses.replace(invoice, invreq_metadata=b'\x00' * 10) + )) + + # metadata with wrong entropy yields a different derived key: verification fails + wrong_entropy_meta = os.urandom(16) + invoice.invreq_metadata[-32:] + self.assertFalse(wallet._verify_bolt12_invoice_requested_by_us( + dataclasses.replace(invoice, invreq_metadata=wrong_entropy_meta) + )) + + # invoice carrying an invreq created by a different wallet: verification fails + other_wallet = self.create_mock_lnwallet(name='other_wallet') + other_invreq, _ = other_wallet.create_bolt12_invoice_request( + offer=peer_offer, + amount_msat=1000, + ) + self.assertFalse(wallet._verify_bolt12_invoice_requested_by_us(build_invoice(other_invreq))) + + # offerless unblinded invreq is signed with the node keypair + with patch('electrum.lnworker.get_blinded_reply_paths', side_effect=NoOnionMessagePeers("no peers")): + unblinded_invreq, node_signing_key = wallet.create_bolt12_invoice_request( + offer=None, + amount_msat=2000, + allow_unblinded=True, + ) + self.assertEqual(node_signing_key.get_secret_bytes(), wallet.node_keypair.privkey) + self.assertEqual(unblinded_invreq.invreq_payer_id, wallet.node_keypair.pubkey) + + unblinded_invoice = build_invoice(unblinded_invreq) + self.assertTrue(wallet._verify_bolt12_invoice_requested_by_us(unblinded_invoice)) + + async def test_on_bolt12_invoice(self): + wallet = self.lnwallet_anchors + + offer_issuer_key = ECPrivkey.generate_random_key() + offer = BOLT12Offer( + offer_chains=[constants.net.rev_genesis_bytes()], + offer_amount=1000, + offer_description=UNICODE_HORROR, + offer_issuer_id=offer_issuer_key.get_public_key_bytes(), + ) + unsigned_invreq, invreq_signing_key = wallet.create_bolt12_invoice_request( + offer=offer, + amount_msat=1000, + payer_note=UNICODE_HORROR, + ) + path_id = invreq_signing_key.get_secret_bytes() + recipient_data = {'path_id': {'data': path_id}} + + def register_pending_invoice() -> asyncio.Future: + fut = asyncio.Future() + wallet._pending_bolt12_invoice_requests[path_id] = fut + return fut + + def build_invoice_tlv( + *, invreq: BOLT12InvoiceRequest = unsigned_invreq, + amount: int = unsigned_invreq.offer_amount, + created_at: Optional[int] = None, + relative_expiry: int = 3600, + ) -> bytes: + dummy_paths = get_dummy_paths() + invoice = BOLT12Invoice( + **invreq.__dict__, + invoice_amount=amount, + invoice_created_at=created_at if created_at is not None else int(time.time()), + invoice_relative_expiry=relative_expiry, + invoice_payment_hash=os.urandom(32), + invoice_node_id=offer_issuer_key.get_public_key_bytes(), + invoice_paths=tuple(p.path for p in dummy_paths), + invoice_blindedpay=tuple(p.payinfo for p in dummy_paths), + ) + return invoice.encode(signing_key=offer_issuer_key.get_secret_bytes(), as_bech32=False) + + # happy path: a valid invoice resolves the pending future + fut = register_pending_invoice() + wallet.on_bolt12_invoice( + recipient_data=recipient_data, + payload={'invoice': {'invoice': build_invoice_tlv()}}, + ) + received, _ = await fut + self.assertIsInstance(received, BOLT12Invoice) + self.assertEqual(received.invoice_amount, 1000) + self.assertEqual(received.offer_issuer_id, offer.offer_issuer_id) + + # invoice_error payload sets the exception on the pending future + fut = register_pending_invoice() + err_tlv = Bolt12InvoiceError("something went wrong").to_tlv() + wallet.on_bolt12_invoice( + recipient_data=recipient_data, + payload={'invoice_error': {'invoice_error': err_tlv}}, + ) + with self.assertRaises(Bolt12InvoiceError) as ctx: + await fut + self.assertIn("something went wrong", str(ctx.exception)) + + # malformed invoice TLV: decode error is surfaced on the future + fut = register_pending_invoice() + wallet.on_bolt12_invoice( + recipient_data=recipient_data, + payload={'invoice': {'invoice': os.urandom(32)}}, + ) + with self.assertRaises(Bolt12InvoiceError) as ctx: + await fut + self.assertIn("received invalid invoice", str(ctx.exception)) + + # invoice with modified invreq content + fut = register_pending_invoice() + tampered_invreq = dataclasses.replace(unsigned_invreq, invreq_payer_note="hello") + wallet.on_bolt12_invoice( + recipient_data=recipient_data, + payload={'invoice': {'invoice': build_invoice_tlv(invreq=tampered_invreq)}}, + ) + with self.assertRaises(Bolt12InvoiceError) as ctx: + await fut + self.assertIn("unable to verify invoice content", str(ctx.exception)) + + # expired invoice: exception on the future + fut = register_pending_invoice() + wallet.on_bolt12_invoice( + recipient_data=recipient_data, + payload={'invoice': {'invoice': build_invoice_tlv(created_at=1000, relative_expiry=1)}}, + ) + with self.assertRaises(Bolt12InvoiceError) as ctx: + await fut + self.assertIn("received expired invoice", str(ctx.exception)) + + # unknown path_id and not an offerless invreq: silently dropped + del wallet._pending_bolt12_invoice_requests[path_id] + wallet.on_bolt12_invoice( + recipient_data={'path_id': {'data': os.urandom(32)}}, + payload={'invoice': {'invoice': build_invoice_tlv()}}, + ) + self.assertEqual(wallet._pending_bolt12_invoice_requests, {}) + + # offerless invreq: invoice must arrive on a reply path we sent in invreq_paths + with patch('electrum.lnworker.get_blinded_reply_paths', return_value=get_dummy_paths()): + offerless_invreq, offerless_signing_key = wallet.create_bolt12_invoice_request( + offer=None, + amount_msat=2000, + ) + + def build_offerless_invoice_tlv() -> bytes: + invoice_node_key = ECPrivkey.generate_random_key() + dummy_paths = get_dummy_paths() + invoice = BOLT12Invoice( + **offerless_invreq.__dict__, + invoice_amount=offerless_invreq.invreq_amount, + invoice_created_at=int(time.time()), + invoice_payment_hash=os.urandom(32), + invoice_node_id=invoice_node_key.get_public_key_bytes(), + invoice_paths=tuple(p.path for p in dummy_paths), + invoice_blindedpay=tuple(p.payinfo for p in dummy_paths), + ) + return invoice.encode(signing_key=invoice_node_key.get_secret_bytes(), as_bech32=False) + + expected_encrypted_recipient_data = offerless_invreq.invreq_paths[0].path[-1].encrypted_recipient_data + + # offerless happy path: encrypted_recipient_data matches one of invreq_paths + with self.assertRaises(NotImplementedError): + wallet.on_bolt12_invoice( + recipient_data={}, + payload={ + 'invoice': {'invoice': build_offerless_invoice_tlv()}, + 'encrypted_recipient_data': {'encrypted_recipient_data': expected_encrypted_recipient_data}, + }, + ) + + # offerless on incorrect reply path: invoice is silently dropped, future stays pending + with self.assertLogs('electrum', level='WARN') as logs: + wallet.on_bolt12_invoice( + recipient_data={}, + payload={ + 'invoice': {'invoice': build_offerless_invoice_tlv()}, + 'encrypted_recipient_data': {'encrypted_recipient_data': os.urandom(32)}, + }, + ) + self.assertTrue(any('received invoice for offerless invreq on incorrect path' in msg for msg in logs.output)) diff --git a/tests/test_onion_message.py b/tests/test_onion_message.py index 8595fae36a3c..53ef5f735cf3 100644 --- a/tests/test_onion_message.py +++ b/tests/test_onion_message.py @@ -16,9 +16,9 @@ from electrum.lnonion import ( OnionHopsDataSingle, OnionPacket, process_onion_packet, get_bolt04_onion_key, encrypt_onionmsg_data_tlv, get_shared_secrets_along_route, new_onion_packet, ONION_MESSAGE_LARGE_SIZE, HOPS_DATA_SIZE, InvalidPayloadSize, - encrypt_hops_recipient_data, blinding_privkey, decrypt_onionmsg_data_tlv) + encrypt_hops_recipient_data, blinding_privkey, decrypt_onionmsg_data_tlv, BlindedPath) from electrum.crypto import get_ecdh, privkey_to_pubkey -from electrum.lntransport import LNPeerAddr +from electrum.lntransport import LNPeerAddr, extract_nodeid from electrum.lnutil import (LnFeatures, Keypair, MIN_FINAL_CLTV_DELTA_ACCEPTED, REMOTE, MIN_FINAL_CLTV_DELTA_BUFFER_INVOICE) from electrum.onion_message import ( @@ -164,17 +164,9 @@ def test_decode_onion_message(self): def test_decrypt_onion_message(self): o = OnionPacket.from_bytes(ONION_MESSAGE_PACKET) our_privkey = bfh(test_vectors['decrypt']['hops'][0]['privkey']) - blinding = bfh(test_vectors['route']['first_path_key']) + path_key = bfh(test_vectors['route']['first_path_key']) - shared_secret = get_ecdh(our_privkey, blinding) - b_hmac = get_bolt04_onion_key(b'blinded_node_id', shared_secret) - b_hmac_int = int.from_bytes(b_hmac, byteorder="big") - - our_privkey_int = int.from_bytes(our_privkey, byteorder="big") - our_privkey_int = our_privkey_int * b_hmac_int % ecc.CURVE_ORDER - our_privkey = our_privkey_int.to_bytes(32, byteorder="big") - - p = process_onion_packet(o, our_privkey, tlv_stream_name='onionmsg_tlv') + p = process_onion_packet(o, our_privkey, tlv_stream_name='onionmsg_tlv', current_path_key=path_key) self.assertEqual(p.hop_data.blind_fields, {}) self.assertEqual(p.hop_data.hmac, bfh('a5296325ba478ba1e1a9d1f30a2d5052b2e2889bbd64f72c72bc71d8817288a2')) @@ -201,14 +193,14 @@ def test_create_blinded_path(self): final_recipient_data = {'path_id': {'data': bfh('0102')}} rp = create_blinded_path(session_key, [pubkey], final_recipient_data) - self.assertEqual(pubkey, rp['first_node_id']) - self.assertEqual(bfh('022ed557f5ad336b31a49857e4e9664954ac33385aa20a93e2d64bfe7f08f51277'), rp['first_path_key']) - self.assertEqual(b"\x01", rp['num_hops']) + self.assertEqual(pubkey, rp.first_node_id) + self.assertEqual(bfh('022ed557f5ad336b31a49857e4e9664954ac33385aa20a93e2d64bfe7f08f51277'), rp.first_path_key) + self.assertEqual(b"\x01", rp.num_hops) self.assertEqual([{ 'blinded_node_id': bfh('031e5d91e6c417f6e8c16d1086db1887edef7be9334f5e744d04edb8da7507481e'), 'enclen': 20, 'encrypted_recipient_data': bfh('2dbaa54a819775aa0548ab85db68c5099e7b1180') - }], rp['path']) + }], [dataclasses.asdict(p) for p in rp.path]) # TODO: serialization test to test_lnmsg.py with io.BytesIO() as blinded_path_fd: @@ -216,7 +208,7 @@ def test_create_blinded_path(self): fd=blinded_path_fd, field_type='blinded_path', count=1, - value=rp) + value=dataclasses.asdict(rp)) blinded_path = blinded_path_fd.getvalue() self.assertEqual(blinded_path, bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619022ed557f5ad336b31a49857e4e9664954ac33385aa20a93e2d64bfe7f08f5127701031e5d91e6c417f6e8c16d1086db1887edef7be9334f5e744d04edb8da7507481e00142dbaa54a819775aa0548ab85db68c5099e7b1180')) @@ -248,10 +240,10 @@ def test_create_onionmessage_to_blinded_path_via_alice(self): encrypt_hops_recipient_data(hops_data, hop_shared_secrets) blinded_path_blinded_ids = [] - for i, x in enumerate(blinded_path_to_dave.get('path')): - blinded_path_blinded_ids.append(x.get('blinded_node_id')) - payload = {'encrypted_recipient_data': {'encrypted_recipient_data': x.get('encrypted_recipient_data')}} - if i == len(blinded_path_to_dave.get('path')) - 1: + for i, x in enumerate(blinded_path_to_dave.path): + blinded_path_blinded_ids.append(x.blinded_node_id) + payload = {'encrypted_recipient_data': {'encrypted_recipient_data': x.encrypted_recipient_data}} + if i == len(blinded_path_to_dave.path) - 1: # add final recipient payload payload['message'] = {'text': bfh(test_vectors['onionmessage']['unknown_tag_1'])} hops_data.append( @@ -286,7 +278,7 @@ async def wait_one_htlc_switch_iteration(self, *args): def is_initialized(self): return True - def send_message(self, *args, **kwargs): + def send_onion_message(self, *args, **kwargs): if self.on_send_message: self.on_send_message(*args, **kwargs) @@ -311,6 +303,16 @@ def keypair(privkey: ECPrivkey): self.dave = keypair(ECPrivkey(privkey_bytes=b'\x44'*32)) self.eve = keypair(ECPrivkey(privkey_bytes=b'\x45'*32)) self.fred = keypair(ECPrivkey(privkey_bytes=b'\x46'*32)) + self.gerald = keypair(ECPrivkey(privkey_bytes=b'\x47'*32)) + self.harry = keypair(ECPrivkey(privkey_bytes=b'\x48'*32)) + + async def run_test_exception(self, t): + t1 = t.submit_send( + payload={'message': {'text': 'no_onionmsg_peers'.encode('utf-8')}}, + node_id_or_blinded_paths=self.harry.pubkey) + + with self.assertRaises(NoOnionMessagePeers): + await t1 async def run_test1(self, t): t1 = t.submit_send( @@ -347,13 +349,38 @@ async def run_test4(self, t, rkey): self.assertEqual(t4_result, ({'path_id': {'data': b'electrum' + rkey}}, {})) async def run_test5(self, t): - t5 = t.submit_send( - payload={'message': {'text': 'no_peer'.encode('utf-8')}}, + lnw = t.lnwallet + self.assertFalse(self.eve.pubkey in lnw.lnpeermgr.peers) + + t.send_direct_connect_fallback = True + t5_1 = t.submit_send( + payload={'message': {'text': 'no_route_peer_address_known'.encode('utf-8')}}, node_id_or_blinded_paths=self.eve.pubkey) + with self.assertRaises(Timeout) as c: + await t5_1 + + self.assertTrue(self.eve.pubkey in lnw.lnpeermgr._peers) + del lnw.lnpeermgr._peers[self.eve.pubkey] + + t5_2 = t.submit_send( + payload={'message': {'text': 'no_route_no_peer_address'.encode('utf-8')}}, + node_id_or_blinded_paths=self.gerald.pubkey) + + # will not find route to gerald, and doesn't have gerald's address + with self.assertRaises(NoRouteFound) as c: + await t5_2 + + self.assertIsNone(c.exception.peer_address) + + t.send_direct_connect_fallback = False + t5_3 = t.submit_send( + payload={'message': {'text': 'no_route_peer_address_known_but_ignored'.encode('utf-8')}}, + node_id_or_blinded_paths=self.eve.pubkey) # will not find route to eve, but has eve's address, but we are configured to not direct connect with self.assertRaises(NoRouteFound) as c: - await t5 + await t5_3 + self.assertEqual(c.exception.peer_address, LNPeerAddr('localhost', 1234, self.eve.pubkey)) async def run_test6(self, t, rkey): @@ -374,7 +401,7 @@ async def test_request_and_reply(self): # mock add_peer for direct connection fallback async def mock__add_peer(host, port, node_id): mock_peer = MockPeer(pubkey=node_id) - # lnw.lnpeermgr._peers[node_id] = mock_peer + lnw.lnpeermgr._peers[node_id] = mock_peer return mock_peer lnw.lnpeermgr._add_peer = mock__add_peer @@ -388,21 +415,27 @@ def slowwithreply(key, *args, **kwargs): time.sleep(2*TIME_STEP) t.on_onion_message_received({'path_id': {'data': b'electrum' + key}}, {}) - rkey1 = bfh('0102030405060708') - rkey2 = bfh('0102030405060709') - rkey3 = bfh('010203040506070a') - - lnw.lnpeermgr._peers[self.alice.pubkey] = MockPeer(self.alice.pubkey) - lnw.lnpeermgr._peers[self.bob.pubkey] = MockPeer(self.bob.pubkey, on_send_message=slow) - lnw.lnpeermgr._peers[self.carol.pubkey] = MockPeer(self.carol.pubkey, on_send_message=partial(withreply, rkey1)) - lnw.lnpeermgr._peers[self.dave.pubkey] = MockPeer(self.dave.pubkey, on_send_message=partial(slowwithreply, rkey2)) - lnw.channel_db._addresses[self.eve.pubkey] = {NetAddress('localhost', '1234'): int(time.time())} - lnw.lnpeermgr._peers[self.fred.pubkey] = MockPeer(self.fred.pubkey, on_send_message=partial(withreply, rkey3)) t = OnionMessageManager(lnw) t.start_network(network=n) try: await asyncio.sleep(TIME_STEP) + + await self.run_test_exception(t) + lnw.lnpeermgr._peers[self.harry.pubkey] = MockPeer(self.harry.pubkey, their_features=LnFeatures(0)) + await self.run_test_exception(t) + + rkey1 = bfh('0102030405060708') + rkey2 = bfh('0102030405060709') + rkey3 = bfh('010203040506070a') + + lnw.lnpeermgr._peers[self.alice.pubkey] = MockPeer(self.alice.pubkey) + lnw.lnpeermgr._peers[self.bob.pubkey] = MockPeer(self.bob.pubkey, on_send_message=slow) + lnw.lnpeermgr._peers[self.carol.pubkey] = MockPeer(self.carol.pubkey, on_send_message=partial(withreply, rkey1)) + lnw.lnpeermgr._peers[self.dave.pubkey] = MockPeer(self.dave.pubkey, on_send_message=partial(slowwithreply, rkey2)) + lnw.channel_db._addresses[self.eve.pubkey] = {NetAddress('localhost', '1234'): int(time.time())} + lnw.lnpeermgr._peers[self.fred.pubkey] = MockPeer(self.fred.pubkey, on_send_message=partial(withreply, rkey3)) + self.logger.debug('tests in sequence') await self.run_test1(t) await self.run_test2(t) @@ -506,12 +539,11 @@ async def test_get_blinded_paths_to_me_payment(self): alice.lnpeermgr.get_peer_by_pubkey(bob.node_keypair.pubkey).their_features |= LnFeatures.OPTION_ROUTE_BLINDING_OPT final_recipient_data = {'path_id': {'data': os.urandom(32)}} - paths, payinfos = get_blinded_paths_to_me(alice, final_recipient_data, onion_message=False) + blinded_path_infos = get_blinded_paths_to_me(alice, final_recipient_data, onion_message=False) - self.assertEqual(len(paths), 1) - self.assertEqual(len(payinfos), 1) + self.assertEqual(len(blinded_path_infos), 1) - self.assertEqual(payinfos[0], { + self.assertEqual(blinded_path_infos[0].payinfo.to_dict(), { 'fee_base_msat': bob_chan.forwarding_fee_base_msat, 'fee_proportional_millionths': bob_chan.forwarding_fee_proportional_millionths, 'cltv_expiry_delta': bob_chan.forwarding_cltv_delta + MIN_FINAL_CLTV_DELTA_ACCEPTED + MIN_FINAL_CLTV_DELTA_BUFFER_INVOICE, @@ -521,13 +553,13 @@ async def test_get_blinded_paths_to_me_payment(self): 'features': bytes(0), }) - blinded_path = paths[0] - self.assertEqual(len(blinded_path['path']), 2) - self.assertEqual(blinded_path['first_node_id'], bob.node_keypair.pubkey) - self.assertEqual(len(blinded_path['first_path_key']), 33) - self.assertEqual(blinded_path['num_hops'], len(blinded_path['path']).to_bytes(length=1, byteorder='big')) - self.assertIn('blinded_node_id', blinded_path['path'][0]) - self.assertIn('encrypted_recipient_data', blinded_path['path'][0]) + blinded_path = blinded_path_infos[0].path + self.assertEqual(len(blinded_path.path), 2) + self.assertEqual(blinded_path.first_node_id, bob.node_keypair.pubkey) + self.assertEqual(len(blinded_path.first_path_key), 33) + self.assertEqual(blinded_path.num_hops, len(blinded_path.path).to_bytes(length=1, byteorder='big')) + self.assertIsNotNone(blinded_path.path[0].blinded_node_id) + self.assertIsNotNone(blinded_path.path[0].encrypted_recipient_data) async def test_create_route_to_introduction_point(self): # A -- B -- C -- D -- E @@ -539,9 +571,13 @@ async def test_create_route_to_introduction_point(self): session_key = os.urandom(32) introduction_point = edward.node_keypair.pubkey first_path_key = ecc.ECPrivkey.generate_random_key().get_public_key_bytes() - blinded_path = { - 'first_path_key': first_path_key, - } + BlindedPath.__post_init__ = lambda _: None # disable sanity checks + blinded_path = BlindedPath( + first_path_key=first_path_key, + first_node_id=None, + num_hops=None, + path=None, + ) with self.assertRaises(NoRouteFound): create_route_to_introduction_point(alice, blinded_path, introduction_point, session_key) diff --git a/tests/test_payment_identifier.py b/tests/test_payment_identifier.py index f19fb6baaeaf..6cc4e52010d5 100644 --- a/tests/test_payment_identifier.py +++ b/tests/test_payment_identifier.py @@ -37,15 +37,21 @@ def setUp(self): def test_maybe_extract_bech32_lightning_payment_identifier(self): bolt11 = "lnbc1ps9zprzpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygsdqq9qypqszpyrpe4tym8d3q87d43cgdhhlsrt78epu7u99mkzttmt2wtsx0304rrw50addkryfrd3vn3zy467vxwlmf4uz7yvntuwjr2hqjl9lw5cqwtp2dy" + bolt12 = "lno1pqpzacq2qqgwuquxfmcztl0gldv8mxy3sm8x5jscdz27u39fy6luxu8zcdn9j73l3uprukkghkdufdz6adxl0ejhy0lmzfykj08u6df9v4v2c93qknz8eggzq2jyyszrrt35mmkyl7efrv5x8a3wspk07pghey4a5kcm4ef76p0ksqpnh7fqgmq9eaf7ntqspcksqkqk8ngvjtjp585mqw3qata3xe8aycgkpprk87yqcxhh705dxauxkghsc9xywqpez7lt5gw67kqwejl83unmuc7r44h32durffs4rmpcgrhxa8x8y9gqxgy9w2tqgpxqk0tl487a9ssuchyh5p9t3le3n5ylhevggk6ly8wzxvds0jawct4spe2tzqfp5d34kah9ss" lnurl = "lnurl1dp68gurn8ghj7um9wfmxjcm99e5k7telwy7nxenrxvmrgdtzxsenjcm98pjnwxq96s9" self.assertEqual(bolt11, maybe_extract_bech32_lightning_payment_identifier(f"{bolt11}".upper())) self.assertEqual(bolt11, maybe_extract_bech32_lightning_payment_identifier(f"lightning:{bolt11}")) self.assertEqual(bolt11, maybe_extract_bech32_lightning_payment_identifier(f" lightning:{bolt11} ".upper())) + self.assertEqual(bolt12, maybe_extract_bech32_lightning_payment_identifier(f"{bolt12}".upper())) + self.assertEqual(bolt12, maybe_extract_bech32_lightning_payment_identifier(f"lightning:{bolt12}")) + self.assertEqual(bolt12, maybe_extract_bech32_lightning_payment_identifier(f" lightning:{bolt12} ".upper())) self.assertEqual(lnurl, maybe_extract_bech32_lightning_payment_identifier(lnurl)) self.assertEqual(lnurl, maybe_extract_bech32_lightning_payment_identifier(f" lightning:{lnurl} ".upper())) self.assertEqual(None, maybe_extract_bech32_lightning_payment_identifier(f"bitcoin:{bolt11}")) + self.assertEqual(None, maybe_extract_bech32_lightning_payment_identifier(f"bitcoin:{bolt12}")) self.assertEqual(None, maybe_extract_bech32_lightning_payment_identifier(f":{bolt11}")) + self.assertEqual(None, maybe_extract_bech32_lightning_payment_identifier(f":{bolt12}")) self.assertEqual(None, maybe_extract_bech32_lightning_payment_identifier(f"garbage text")) def test_remove_uri_prefix(self): @@ -85,7 +91,7 @@ def test_bolt11(self): self.assertEqual(PaymentIdentifierType.BOLT11, pi.type) self.assertFalse(pi.is_amount_locked()) self.assertFalse(pi.is_error()) - self.assertIsNotNone(pi.bolt11) + self.assertIsNotNone(pi.lightning_invoice) for pi_str in [ f'lightning: {bolt11}', @@ -99,7 +105,7 @@ def test_bolt11(self): pi = PaymentIdentifier(None, bolt_11_w_fallback) self.assertTrue(pi.is_valid()) self.assertEqual(PaymentIdentifierType.BOLT11, pi.type) - self.assertIsNotNone(pi.bolt11) + self.assertIsNotNone(pi.lightning_invoice) self.assertTrue(pi.is_lightning()) self.assertTrue(pi.is_onchain()) self.assertTrue(pi.is_amount_locked()) @@ -109,6 +115,38 @@ def test_bolt11(self): self.assertFalse(pi.need_finalize()) self.assertFalse(pi.is_multiline()) + def test_bolt12(self): + offers = [ + ('lno1pqpzacq2qqgwuquxfmcztl0gldv8mxy3sm8x5jscdz27u39fy6luxu8zcdn9j73l3up5nwlwchur9zukwx743mvm0rvftrhskna22pcvtkyhufn5rc97j3gzqffs859lkadpfasgwxj47xvml7jgekez0lpfuwzhegyxsn2lzdx86qpny7xrmgwj6lphxcfauu22kenqnty4tqdlgnh8tyg87lamqe84nmh2vn0a2n908l7z7cfjghjsuusv7k079upfw0x7dpzavqpwj8swx9ee9q9cumg07fk4gvlajyhy6lfjv0cfe9gqxg0gykehtgjkxwzz24rqdssj4fjcm8xhv2rwel04ed4up2h5sf8n6y7scr0q5rt65k06s6u3mvefzer7qq', True), + ('lno1pgqppmsrse80qf0aara4slvcjxrvu6j2rp5ftmjy4yntlsmsutpkvkt6878s9h02lqjy3hxc0x67pvwmu3evl6nsnvyy6adl2vn0dym4m2hdtrlnqgprp7jxwvnz5zj7xhz0fwel78cj0u90zgzpwr6we8j0nwzuv5tx9egqxdn72n27tdyers8ffdc2n75cydcl4tkd5lee0trwaekj9luzz5ydqh6cz07448ldts3yzkdk09ekl9t53ryq9lvvpuq90cmylys5saumem93wtvfd77z4alynefyj7ua7kr69dnfqqet7nsydwqa9ghdfy8udkc7x86ydl5l4nrsctfl8d3w4ejcceh9zqh0acy4cc4rcv6wv7zr6gh7fwsjzu8q', False), + ] + for bolt12, amount_locked in offers: + for valid_pi_str in [ + f'{bolt12}', + f' {bolt12}', + f'{bolt12} ', + f'lightning:{bolt12}', + f' lightning:{bolt12}', + f'lightning:{bolt12} ', + f'lightning:{bolt12.upper()}', + f'lightning:{bolt12}'.upper(), + ]: + pi = PaymentIdentifier(None, valid_pi_str) + self.assertTrue(pi.is_valid()) + self.assertEqual(PaymentIdentifierType.BOLT12_OFFER, pi.type) + self.assertEqual(pi.is_amount_locked(), amount_locked) + self.assertFalse(pi.is_error()) + self.assertIsNotNone(pi.bolt12_offer) + self.assertTrue(pi.need_finalize()) + self.assertFalse(pi.is_multiline()) + + for invalid_pi_str in [ + f'lightning: {bolt12}', + f'bitcoin:{bolt12}' + ]: + pi = PaymentIdentifier(None, invalid_pi_str) + self.assertFalse(pi.is_valid()) + def test_bip21(self): bip21 = 'bitcoin:bc1qj3zx2zc4rpv3npzmznxhdxzn0wm7pzqp8p2293?message=unit_test' for pi_str in [ @@ -143,7 +181,7 @@ def test_bip21(self): self.assertTrue(pi.is_lightning()) self.assertTrue(pi.is_onchain()) self.assertIsNotNone(pi.bip21) - self.assertIsNotNone(pi.bolt11) + self.assertIsNotNone(pi.lightning_invoice) self.assertTrue(pi.has_expired()) self.assertEqual('unit_test', pi.bip21.get('message')) diff --git a/tests/test_route_blinding.py b/tests/test_route_blinding.py new file mode 100644 index 000000000000..008b86f4becc --- /dev/null +++ b/tests/test_route_blinding.py @@ -0,0 +1,117 @@ +import os + +from electrum.lnonion import get_shared_secrets_along_route, OnionHopsDataSingle, encrypt_hops_recipient_data +from electrum.lnutil import LnFeatures +from electrum.util import read_json_file, bfh + +from tests import ElectrumTestCase + +# test vectors https://github.com/lightning/bolts/pull/765/files +path = os.path.join(os.path.dirname(__file__), 'route-blinding-test.json') +test_vectors = read_json_file(path) +HOPS = test_vectors['generate']['hops'] +BOB = HOPS[0] +CAROL = HOPS[1] +DAVE = HOPS[2] +EVE = HOPS[3] + +BOB_TLVS = BOB['tlvs'] +CAROL_TLVS = CAROL['tlvs'] +DAVE_TLVS = DAVE['tlvs'] +EVE_TLVS = EVE['tlvs'] + +BOB_PUBKEY = bfh(test_vectors['route']['first_node_id']) +CAROL_PUBKEY = bfh(CAROL['node_id']) +DAVE_PUBKEY = bfh(DAVE['node_id']) +EVE_PUBKEY = bfh(EVE['node_id']) + + +class TestPaymentRouteBlinding(ElectrumTestCase): + + def test_blinded_path_payload_tlv_concat(self): + + hop_shared_secrets1, blinded_node_ids1 = get_shared_secrets_along_route([BOB_PUBKEY, CAROL_PUBKEY], bfh(BOB['session_key'])) + hop_shared_secrets2, blinded_node_ids2 = get_shared_secrets_along_route([DAVE_PUBKEY, EVE_PUBKEY], bfh(DAVE['session_key'])) + hop_shared_secrets = hop_shared_secrets1 + hop_shared_secrets2 + blinded_node_ids = blinded_node_ids1 + blinded_node_ids2 + + for i, ss in enumerate(hop_shared_secrets): + self.assertEqual(ss, bfh(HOPS[i]['shared_secret'])) + for i, ss in enumerate(blinded_node_ids): + self.assertEqual(ss, bfh(HOPS[i]['blinded_node_id'])) + + hops_data = [ + OnionHopsDataSingle( + tlv_stream_name='payload', + blind_fields={ + 'padding': {'padding': bfh(BOB_TLVS['padding'])}, + 'short_channel_id': {'short_channel_id': 1729}, # FIXME scid from "0x0x1729" testvector repr + 'payment_relay': { + 'cltv_expiry_delta': BOB_TLVS['payment_relay']['cltv_expiry_delta'], + 'fee_proportional_millionths': BOB_TLVS['payment_relay']['fee_proportional_millionths'], + 'fee_base_msat': BOB_TLVS['payment_relay']['fee_base_msat'], + }, + 'payment_constraints': { + 'max_cltv_expiry': BOB_TLVS['payment_constraints']['max_cltv_expiry'], + 'htlc_minimum_msat': BOB_TLVS['payment_constraints']['htlc_minimum_msat'], + }, + 'allowed_features': {'features': b''}, + 'unknown_tag_561': {'data': bfh(BOB_TLVS['unknown_tag_561'])}, + } + ), + OnionHopsDataSingle( + tlv_stream_name='payload', + blind_fields={ + 'short_channel_id': {'short_channel_id': 1105}, + 'next_path_key_override': {'path_key': bfh(CAROL_TLVS['next_path_key_override'])}, + 'payment_relay': { + 'cltv_expiry_delta': CAROL_TLVS['payment_relay']['cltv_expiry_delta'], + 'fee_proportional_millionths': CAROL_TLVS['payment_relay']['fee_proportional_millionths'], + 'fee_base_msat': CAROL_TLVS['payment_relay']['fee_base_msat'], + }, + 'payment_constraints': { + 'max_cltv_expiry': CAROL_TLVS['payment_constraints']['max_cltv_expiry'], + 'htlc_minimum_msat': CAROL_TLVS['payment_constraints']['htlc_minimum_msat'], + }, + 'allowed_features': {'features': b''}, + } + ), + OnionHopsDataSingle( + tlv_stream_name='payload', + blind_fields={ + 'padding': {'padding': bfh(DAVE_TLVS['padding'])}, + 'short_channel_id': {'short_channel_id': 561}, + 'payment_relay': { + 'cltv_expiry_delta': DAVE_TLVS['payment_relay']['cltv_expiry_delta'], + 'fee_proportional_millionths': DAVE_TLVS['payment_relay']['fee_proportional_millionths'], + # 'fee_base_msat': DAVE_TLVS['payment_relay']['fee_base_msat'], + # FIXME: mandatory but not in test vectors ? + 'fee_base_msat': 0 + }, + 'payment_constraints': { + 'max_cltv_expiry': DAVE_TLVS['payment_constraints']['max_cltv_expiry'], + 'htlc_minimum_msat': DAVE_TLVS['payment_constraints']['htlc_minimum_msat'], + }, + 'allowed_features': {'features': b''}, + } + ), + OnionHopsDataSingle( + tlv_stream_name='payload', + blind_fields={ + 'padding': {'padding': bfh(EVE_TLVS['padding'])}, + 'path_id': {'data': bfh(EVE_TLVS['path_id'])}, + 'payment_constraints': { + 'max_cltv_expiry': EVE_TLVS['payment_constraints']['max_cltv_expiry'], + 'htlc_minimum_msat': EVE_TLVS['payment_constraints']['htlc_minimum_msat'], + }, + 'allowed_features': {'features': LnFeatures(1 << EVE_TLVS['allowed_features']['features'][0]).to_tlv_bytes()}, + 'unknown_tag_65535': {'data': bfh(EVE_TLVS['unknown_tag_65535'])}, + } + ), + ] + + encrypt_hops_recipient_data(hops_data, hop_shared_secrets) + + for i, hop in enumerate(hops_data): + self.assertEqual(hop.payload['encrypted_recipient_data']['encrypted_recipient_data'], + bfh(HOPS[i]['encrypted_data']), f'hop {i} not matching')