diff --git a/electrum/bolt12.py b/electrum/bolt12.py new file mode 100644 index 000000000000..cdbb10915b64 --- /dev/null +++ b/electrum/bolt12.py @@ -0,0 +1,743 @@ +# -*- 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 not 'invreq_chain' 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("offer_quantity_max is given but no invreq_quantity") + # if offer_quantity_max is non-zero + if self.offer_quantity_max and not self.invreq_quantity or self.invreq_quantity > self.offer_quantity_max: + # MUST reject the invoice request if invreq_quantity is zero, OR greater than offer_quantity_max + raise ValueError("invreq_quantity > 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': + with io.BytesIO(tlv) as fd: + invoice_error = OnionWireSerializer.read_tlv_stream(fd=fd, tlv_stream_name='invoice_error') + return cls( + msg=invoice_error.get('error', {}).get('msg'), + 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 ea57c7808ff9..6797f34ab809 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, @@ -2229,6 +2300,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')} @@ -2272,11 +2345,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 Exception('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..ca8ba641f590 --- /dev/null +++ b/electrum/gui/qml/components/Bolt12OfferDialog.qml @@ -0,0 +1,146 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import QtQuick.Controls.Material + +import org.electrum 1.0 + +import "controls" + +ElDialog { + id: dialog + + title: qsTr('BOLT12 Offer') + iconSource: '../../../icons/bolt12.png' + + property InvoiceParser invoiceParser + + padding: 0 + + property bool commentValid: true // TODO? + property bool amountValid: amountBtc.textAsSats.satsInt > 0 + 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 + text: qsTr('Issuer') + color: Material.accentColor + visible: 'issuer' in invoiceParser.offerData + } + TextHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + visible: 'issuer' in invoiceParser.offerData + Label { + width: parent.width + wrapMode: Text.Wrap + text: invoiceParser.offerData['issuer'] + } + } + Label { + Layout.columnSpan: 2 + Layout.fillWidth: true + text: qsTr('Description') + color: Material.accentColor + } + TextHighlightPane { + Layout.columnSpan: 2 + Layout.fillWidth: true + Label { + width: parent.width + text: invoiceParser.offerData['description'] + wrapMode: Text.Wrap + } + } + Label { + Layout.columnSpan: 2 + text: qsTr('Amount') + color: Material.accentColor + } + + RowLayout { + Layout.columnSpan: 2 + Layout.fillWidth: true + BtcField { + id: amountBtc + Layout.preferredWidth: rootLayout.width /3 + text: 'amount' in invoiceParser.offerData + ? Config.formatSatsForEditing(invoiceParser.offerData['amount']/1000) + : '' + readOnly: 'amount' in invoiceParser.offerData + color: Material.foreground // override gray-out on disabled + fiatfield: amountFiat + onTextAsSatsChanged: { + invoiceParser.amountOverride = textAsSats + } + } + Label { + text: Config.baseUnit + color: Material.accentColor + } + } + + RowLayout { + Layout.columnSpan: 2 + 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 + } + } + + Label { + Layout.columnSpan: 2 + text: qsTr('Note') + color: Material.accentColor + } + ElTextArea { + id: note + Layout.columnSpan: 2 + Layout.fillWidth: true + Layout.minimumHeight: 100 + wrapMode: TextEdit.Wrap + placeholderText: qsTr('Enter an (optional) message for the receiver') + // TODO: max 100 chars is arbitrary, not sure what the max size is + color: text.length > 100 ? constants.colorError : Material.foreground + } + } + + FlatButton { + Layout.topMargin: constants.paddingLarge + Layout.fillWidth: true + text: qsTr('Pay') + 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..2230220e9f70 100644 --- a/electrum/gui/qml/components/InvoiceDialog.qml +++ b/electrum/gui/qml/components/InvoiceDialog.qml @@ -17,7 +17,11 @@ 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') + : invoice.lnprops.is_bolt12 + ? qsTr('BOLT12 Invoice') + : qsTr('Lightning Invoice') iconSource: Qt.resolvedUrl('../../icons/tab_send.png') padding: 0 @@ -107,6 +111,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 +451,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 + + TextHighlightPane { + 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/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index 55f73519db45..b2d4e59c8b6c 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) { @@ -438,6 +438,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 { @@ -748,6 +762,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/components/controls/InvoiceDelegate.qml b/electrum/gui/qml/components/controls/InvoiceDelegate.qml index bc053b07ba8c..e4fc082e2b3a 100644 --- a/electrum/gui/qml/components/controls/InvoiceDelegate.qml +++ b/electrum/gui/qml/components/controls/InvoiceDelegate.qml @@ -40,7 +40,9 @@ ItemDelegate { Layout.preferredWidth: constants.iconSizeLarge Layout.preferredHeight: constants.iconSizeLarge source: model.is_lightning - ? "../../../icons/lightning.png" + ? model.is_bolt12 + ? "../../../icons/bolt12.png" + : "../../../icons/lightning.png" : "../../../icons/bitcoin.png" Image { diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index 7069663f3441..e139421ac27a 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -8,24 +8,26 @@ from electrum.i18n import _ from electrum.logging import get_logger +from electrum.util import InvoiceError, bfh 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.payment_identifier import ( + PaymentIdentifier, PaymentIdentifierState, PaymentIdentifierType, invoice_from_payment_identifier +) from electrum.network import Network from electrum.util import event_listener - from electrum.gui.common_qt.util import QtEventListener +from electrum.bolt12 import BOLT12Invoice 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 +37,7 @@ class Type(IntEnum): OnchainInvoice = 0 LightningInvoice = 1 LNURLPayRequest = 2 + Bolt12Offer = 3 @pyqtEnum class Status(IntEnum): @@ -144,11 +147,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,19 +251,38 @@ 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 + issuer = b12i.offer_issuer + lnprops.update({ + 'blinded_paths': [{ + 'first_node': self.name_for_node_id(x.first_node_id), + 'path_length': x.hop_count, + } for x in paths], + '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() @@ -277,6 +299,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 +390,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 +471,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 +482,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 +493,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 +504,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 +519,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 +546,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 +570,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 +593,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 +605,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, PaymentIdentifierType.BOLT12_OFFER]: + 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 +617,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 +655,15 @@ def on_lnurl_pay(self, lnurldata: LNURL6Data): self.setValidLNURLPayRequest() self.lnurlRetrieved.emit() + def on_bolt12_offer(self, bolt12_offer): + self._logger.debug(f'on_bolt12_offer: {bolt12_offer!r}') + self._offerData = {} + self._offerData.update(bolt12_offer.get('offer_description', {})) + self._offerData.update(bolt12_offer.get('offer_amount', {})) + self._offerData.update(bolt12_offer.get('offer_issuer', {})) + self.setValidBolt12Offer() + self.bolt12Offer.emit() + @pyqtSlot() @pyqtSlot(str) def lnurlGetInvoice(self, comment=None): @@ -628,7 +687,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 +707,42 @@ 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 + assert self._pi.need_finalize() + self._logger.debug(f'{self._offerData!r}') + + amount = self.amountOverride.satsInt + + def on_finished(pi): + 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(self.amountOverride.satsInt, pi.bolt12_invoice) + + self._busy = True + self.busyChanged.emit() + + self._pi.finalize(amount_sat=amount, comment=note, on_finished=on_finished) + + def on_bolt12_invoice(self, orig_amount, bolt12_invoice: Invoice): + self._logger.debug(f'on_bolt12_invoice {bolt12_invoice!r}') + + # # assure no shenanigans with the invoice we get back + if orig_amount * 1000 != bolt12_invoice.amount_msat: # TODO msat precision can cause trouble here + raise Exception('Unexpected amount in invoice, differs from invoice_request specified amount') + + 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/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 6e1c35d3e797..7238ede2f800 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 7d6a8bbbbef5..6282a79b84fd 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 +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,100 @@ 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), + ) + else: + b11 = self.bolt11_invoice + return UnblindedRoutingInfo( + node_pubkey=b11.pubkey.serialize(), + r_tags=b11.get_routing_info('r'), + payment_secret=b11.payment_secret, + min_final_cltv_delta=b11.get_min_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/lnmsg.py b/electrum/lnmsg.py index e8ad41d24fa9..02a33e45e4d3 100644 --- a/electrum/lnmsg.py +++ b/electrum/lnmsg.py @@ -5,17 +5,21 @@ from types import MappingProxyType from collections import OrderedDict +import electrum_ecc as ecc + +from . import bitcoin from .lnutil import OnionFailureCodeMetaFlag +from .util import chunks class FailedToParseMsg(Exception): msg_type_int: Optional[int] = None msg_type_name: Optional[str] = None + class UnknownMsgType(FailedToParseMsg): pass class UnknownOptionalMsgType(UnknownMsgType): pass class UnknownMandatoryMsgType(UnknownMsgType): pass - class MalformedMsg(FailedToParseMsg): pass class UnknownMsgFieldType(MalformedMsg): pass class UnexpectedEndOfStream(MalformedMsg): pass @@ -24,6 +28,7 @@ class UnknownMandatoryTLVRecordType(MalformedMsg): pass class MsgTrailingGarbage(MalformedMsg): pass class MsgInvalidFieldOrder(MalformedMsg): pass class UnexpectedFieldSizeForEncoder(MalformedMsg): pass +class MsgInvalidSignature(MalformedMsg): pass def _num_remaining_bytes_to_read(fd: io.BytesIO) -> int: @@ -94,7 +99,7 @@ def _read_primitive_field( fd: io.BytesIO, field_type: str, count: Union[int, str] -) -> Union[bytes, int]: +) -> Union[bytes, int, str]: if not fd: raise Exception() if isinstance(count, int): @@ -150,6 +155,8 @@ def _read_primitive_field( type_len = 32 elif field_type == 'signature': type_len = 64 + elif field_type == 'bip340sig': + type_len = 64 elif field_type == 'point': type_len = 33 elif field_type == 'short_channel_id': @@ -166,6 +173,9 @@ def _read_primitive_field( if len(buf) != type_len: raise UnexpectedEndOfStream() return buf + elif field_type == 'utf8': + if count != '...': + raise Exception(f"utf8 fields can only have unbounded count") if count == "...": total_len = -1 # read all @@ -177,6 +187,20 @@ def _read_primitive_field( buf = fd.read(total_len) if total_len >= 0 and len(buf) != total_len: raise UnexpectedEndOfStream() + + if field_type == 'utf8': + try: + return buf.decode('utf-8') + except UnicodeDecodeError as e: + raise MalformedMsg(f'invalid utf-8: {buf.hex()}') from e + + if field_type == 'point': + for point in chunks(buf, type_len): + try: + ecc.ECPubkey(b=point) + except Exception as e: + raise MalformedMsg(f"invalid point: {point.hex()}") from e + return buf @@ -186,7 +210,7 @@ def _write_primitive_field( fd: io.BytesIO, field_type: str, count: Union[int, str], - value: Union[bytes, int] + value: Union[bytes, int, str] ) -> None: if not fd: raise Exception() @@ -246,6 +270,8 @@ def _write_primitive_field( type_len = 32 elif field_type == 'signature': type_len = 64 + elif field_type == 'bip340sig': + type_len = 64 elif field_type == 'point': type_len = 33 elif field_type == 'short_channel_id': @@ -258,6 +284,10 @@ def _write_primitive_field( type_len = 33 # point else: raise Exception(f"invalid sciddir_or_pubkey, prefix byte not in range 0-3") + elif field_type == 'utf8': + if count != '...': + raise Exception(f"utf8 fields can only have unbounded count") + value = value.encode('utf-8') total_len = -1 if count != "...": if type_len is None: @@ -274,12 +304,16 @@ def _write_primitive_field( raise Exception(f"tried to write {len(value)} bytes, but only wrote {nbytes_written}!?") -def _read_tlv_record(*, fd: io.BytesIO) -> Tuple[int, bytes]: +def _read_tlv_record(*, fd: io.BytesIO) -> Tuple[int, bytes, bytes]: if not fd: raise Exception() + pos_start = fd.tell() tlv_type = _read_primitive_field(fd=fd, field_type="bigsize", count=1) tlv_len = _read_primitive_field(fd=fd, field_type="bigsize", count=1) tlv_val = _read_primitive_field(fd=fd, field_type="byte", count=tlv_len) - return tlv_type, tlv_val + pos_end = fd.tell() + fd.seek(pos_start) + rawbytes = fd.read(pos_end - pos_start) + return tlv_type, tlv_val, rawbytes def _write_tlv_record(*, fd: io.BytesIO, tlv_type: int, tlv_val: bytes) -> None: @@ -321,6 +355,46 @@ def _parse_msgtype_intvalue_for_onion_wire(value: str) -> int: return msg_type_int +def _tlv_merkle_root(tlvs: List[Sequence[bytes]]) -> bytes: + first_tlv = None + tlv_merkle_nodes = [] + + for tlvt, tlv in tlvs: + if first_tlv is None: + first_tlv = tlv + tlv_val = tlv + tlv_record_type = write_bigsize_int(tlvt) + merkle_leaf_hash = bitcoin.bip340_tagged_hash(b'LnLeaf', tlv_val) + merkle_nonce = bitcoin.bip340_tagged_hash(b'LnNonce' + first_tlv, tlv_record_type) + + # ascending order + msg = merkle_leaf_hash + merkle_nonce if merkle_leaf_hash < merkle_nonce else merkle_nonce + merkle_leaf_hash + merkle_node_hash = bitcoin.bip340_tagged_hash(b'LnBranch', msg) + + tlv_merkle_nodes.append(merkle_node_hash) + + while len(tlv_merkle_nodes) > 1: + target = [] + for chunk in chunks(tlv_merkle_nodes, 2): + if len(chunk) == 1: + target.append(chunk[0]) + else: + msg = chunk[0] + chunk[1] if chunk[0] < chunk[1] else chunk[1] + chunk[0] + merkle_node_hash = bitcoin.bip340_tagged_hash(b'LnBranch', msg) + target.append(merkle_node_hash) + tlv_merkle_nodes = target + + return tlv_merkle_nodes[0] + + +def _is_bolt12_signature_tlv_type(tlv_type: int) -> bool: + """ + Each form is signed using one or more *signature TLV elements*: TLV + types 240 through 1000 (inclusive) + """ + return tlv_type in range(240, 1001) + + class LNSerializer: def __init__(self, *, name: str = 'peer_wire'): @@ -331,6 +405,7 @@ def __init__(self, *, name: str = 'peer_wire'): self.in_tlv_stream_get_tlv_record_scheme_from_type = {} # type: Dict[str, Dict[int, List[Sequence[str]]]] self.in_tlv_stream_get_record_type_from_name = {} # type: Dict[str, Dict[str, int]] self.in_tlv_stream_get_record_name_from_type = {} # type: Dict[str, Dict[int, str]] + self.in_tlv_stream_signature_tlv_records = {} # type: Dict[str, Dict[int, str]] self.subtypes = {} # type: Dict[str, Dict[str, Sequence[str]]] @@ -393,7 +468,17 @@ def __init__(self, *, name: str = 'peer_wire'): assert fieldname not in self.subtypes[subtypename], f"duplicate field definition for {fieldname} for subtype {subtypename}" self.subtypes[subtypename][fieldname] = tuple(row) else: - pass # TODO + pass # TODO: raise? + + for stream_name, scheme_map in self.in_tlv_stream_get_tlv_record_scheme_from_type.items(): + sig_records = {} + for tlv_type, scheme in scheme_map.items(): + for row in scheme: + if row[0] == 'tlvdata' and row[4] == 'bip340sig': + assert _is_bolt12_signature_tlv_type(tlv_type), f"bip340sig field outside bolt 12 range: {stream_name=} {tlv_type=}" + sig_records[tlv_type] = row[3] + break + self.in_tlv_stream_signature_tlv_records[stream_name] = sig_records def write_field( self, @@ -495,14 +580,42 @@ def read_field( count=subtype_field_count) parsedlist.append(parsed) + # fd might contain more bytes, but we got passed a count. break when we have 'count' items. + # (e.g. nested complex types) + if isinstance(count, int) and len(parsedlist) == count: + break + return parsedlist if count == '...' or count > 1 else parsedlist[0] - def write_tlv_stream(self, *, fd: io.BytesIO, tlv_stream_name: str, **kwargs) -> None: + def write_tlv_stream(self, *, fd: io.BytesIO, tlv_stream_name: str, signing_key: Optional[bytes] = None, **kwargs) -> None: + sign_over_tlvs = [] + sig_tlv_type, sig_tlv_record_name = None, None + sig_records = self.in_tlv_stream_signature_tlv_records.get(tlv_stream_name, {}) + if signing_key is not None: + sig_tlv_types = list(sig_records.keys()) + if len(sig_tlv_types) != 1: + raise NotImplementedError + sig_tlv_type = sig_tlv_types[0] + sig_tlv_record_name = self.in_tlv_stream_get_record_name_from_type[tlv_stream_name][sig_tlv_type] + assert sig_tlv_record_name not in kwargs, f"pass either existing {sig_tlv_record_name} or signing_key" + scheme_map = self.in_tlv_stream_get_tlv_record_scheme_from_type[tlv_stream_name] for tlv_record_type, scheme in scheme_map.items(): # note: tlv_record_type is monotonically increasing tlv_record_name = self.in_tlv_stream_get_record_name_from_type[tlv_stream_name][tlv_record_type] + + is_signature_record = tlv_record_type in sig_records if tlv_record_name not in kwargs: - continue + # skip record_name if not in kwargs, unless we need to generate it + if not is_signature_record or signing_key is None: + continue + # calculate signature over previously serialized tlv records + # and store in kwargs for inclusion in tlv stream + merkle_root = _tlv_merkle_root(sign_over_tlvs) + priv = ecc.ECPrivkey(signing_key) + tag = b'lightning' + tlv_stream_name.encode('ascii') + sig_tlv_record_name.encode('ascii') + signature = priv.schnorr_sign(bitcoin.bip340_tagged_hash(tag, merkle_root)) + kwargs[tlv_record_name] = {sig_records[tlv_record_type]: signature} + with io.BytesIO() as tlv_record_fd: for row in scheme: if row[0] == "tlvtype": @@ -525,14 +638,30 @@ def write_tlv_stream(self, *, fd: io.BytesIO, tlv_stream_name: str, **kwargs) -> value=field_value) else: raise Exception(f"unexpected row in scheme: {row!r}") - _write_tlv_record(fd=fd, tlv_type=tlv_record_type, tlv_val=tlv_record_fd.getvalue()) - def read_tlv_stream(self, *, fd: io.BytesIO, tlv_stream_name: str) -> Dict[str, Dict[str, Any]]: + tlv_val = tlv_record_fd.getvalue() + + with io.BytesIO() as tlvfd: + _write_tlv_record(fd=tlvfd, tlv_type=tlv_record_type, tlv_val=tlv_val) + raw_tlv = tlvfd.getvalue() + fd.write(raw_tlv) + + # signature TLVs are excluded from the bolt 12 merkle root + if signing_key is not None and not is_signature_record: + sign_over_tlvs.append((tlv_record_type, raw_tlv)) + + def read_tlv_stream(self, *, + fd: io.BytesIO, + tlv_stream_name: str, + signing_key_path: Optional[Sequence[str]] = None) -> Dict[str, Dict[str, Any]]: + sign_over_tlvs = [] + signature_record = None # type: Optional[tuple[int, str]] # (tlv_type, tlv_record_name) parsed = {} # type: Dict[str, Dict[str, Any]] scheme_map = self.in_tlv_stream_get_tlv_record_scheme_from_type[tlv_stream_name] + sig_records = self.in_tlv_stream_signature_tlv_records.get(tlv_stream_name, {}) last_seen_tlv_record_type = -1 # type: int while _num_remaining_bytes_to_read(fd) > 0: - tlv_record_type, tlv_record_val = _read_tlv_record(fd=fd) + tlv_record_type, tlv_record_val, rawbytes = _read_tlv_record(fd=fd) if not (tlv_record_type > last_seen_tlv_record_type): raise MsgInvalidFieldOrder(f"TLV records must be monotonically increasing by type. " f"cur: {tlv_record_type}. prev: {last_seen_tlv_record_type}") @@ -545,8 +674,20 @@ def read_tlv_stream(self, *, fd: io.BytesIO, tlv_stream_name: str) -> Dict[str, raise UnknownMandatoryTLVRecordType(f"{tlv_stream_name}/{tlv_record_type}") from None else: # unknown "odd" type: skip it + if signing_key_path and not _is_bolt12_signature_tlv_type(tlv_record_type): + sign_over_tlvs.append((tlv_record_type, rawbytes)) continue tlv_record_name = self.in_tlv_stream_get_record_name_from_type[tlv_stream_name][tlv_record_type] + + # collect tlvs for deferred signature check + if signing_key_path: + if tlv_record_type in sig_records: + if signature_record is not None: + raise MalformedMsg(f"multiple signatures in {tlv_stream_name=} not supported") + signature_record = (tlv_record_type, tlv_record_name) + else: + sign_over_tlvs.append((tlv_record_type, rawbytes)) + parsed[tlv_record_name] = {} with io.BytesIO(tlv_record_val) as tlv_record_fd: for row in scheme: @@ -573,6 +714,25 @@ def read_tlv_stream(self, *, fd: io.BytesIO, tlv_stream_name: str) -> Dict[str, raise Exception(f"unexpected row in scheme: {row!r}") if _num_remaining_bytes_to_read(tlv_record_fd) > 0: raise MsgTrailingGarbage(f"TLV record ({tlv_stream_name}/{tlv_record_name}) has extra trailing garbage") + + if signing_key_path: + if signature_record is None: + raise MalformedMsg(f"expected signature in {tlv_stream_name}") + sig_tlv_type, sig_tlv_record_name = signature_record + merkle_root = _tlv_merkle_root(sign_over_tlvs) + signing_pubkey = parsed + for key in signing_key_path: # walk signing_key_path + signing_pubkey = signing_pubkey[key] + assert isinstance(signing_pubkey, bytes) + sig_field_name = sig_records[sig_tlv_type] + sig_bytes = parsed[sig_tlv_record_name][sig_field_name] + if not isinstance(sig_bytes, bytes) or len(sig_bytes) != 64: + raise MsgInvalidSignature(f"invalid signature data in {tlv_stream_name}/{sig_tlv_record_name}: {sig_bytes=}") + tag = b'lightning' + tlv_stream_name.encode('ascii') + sig_tlv_record_name.encode('ascii') + tagh = bitcoin.bip340_tagged_hash(tag, merkle_root) + if not ecc.ECPubkey(signing_pubkey).schnorr_verify(sig_bytes, tagh): + raise MsgInvalidSignature(f"invalid signature in {'.'.join(signing_key_path)}") + return parsed def encode_msg(self, msg_type: str, **kwargs) -> bytes: diff --git a/electrum/lnonion.py b/electrum/lnonion.py index e3deb45ba175..233b4ca7a410 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 +_logger = get_logger(__name__) HOPS_DATA_SIZE = 1300 # also sometimes called routingInfoSize in bolt-04 PER_HOP_HMAC_SIZE = 32 @@ -158,6 +161,115 @@ 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_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 +334,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 +426,7 @@ def calc_hops_data_for_payment( *, final_cltv_abs: int, total_msat: int, - payment_secret: bytes, + payment_secret: Optional[bytes], ) -> 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 +438,19 @@ 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)] # 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 +464,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, + *, + local_height: 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': local_height}, + '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 = local_height + 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) @@ -390,6 +577,8 @@ class ProcessedOnionPacket(NamedTuple): hop_data: OnionHopsDataSingle next_packet: OnionPacket trampoline_onion_packet: OnionPacket + blinded_path_recipient_data: Optional[Mapping] + next_path_key: Optional[bytes] = None @property def amt_to_forward(self) -> Optional[int]: @@ -404,19 +593,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 +631,7 @@ def process_onion_packet( *, associated_data: bytes = b'', is_trampoline=False, + 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 +639,11 @@ 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 + blinded_path_recipient_data = {} + if path_key: + recipient_data_shared_secret = get_ecdh(our_onion_private_key, path_key) + our_onion_private_key = blinding_privkey(our_onion_private_key, 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 +662,19 @@ 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) + + 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 path_key: + # we are the introduction point + path_key = hop_data.payload.get('current_path_key', {}).get('path_key') + recipient_data_shared_secret = get_ecdh(our_onion_private_key, path_key) + 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 +688,20 @@ def process_onion_packet( public_key=next_public_key, hops_data=next_hops_data_fd.read(data_size), hmac=hop_data.hmac) + + next_path_key = None 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 are_we_final = False - return ProcessedOnionPacket(are_we_final, hop_data, next_onion_packet, trampoline_onion_packet) + + if path_key: + next_path_key = next_blinding_from_shared_secret(path_key, recipient_data_shared_secret) + + return ProcessedOnionPacket(are_we_final, hop_data, next_onion_packet, trampoline_onion_packet, + blinded_path_recipient_data, next_path_key) def compare_trampoline_onions( diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index d4551cc5cc7d..bbdb5ffb3827 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, @@ -65,6 +65,7 @@ LN_P2P_NETWORK_TIMEOUT = 20 +ONION_MESSAGE_SEND_DELAY = 0.25 class Peer(Logger, EventListener): @@ -141,6 +142,9 @@ def __init__( self._processed_onion_cache = LRUCache(maxsize=100) # type: LRUCache[bytes, ProcessedOnionPacket] self._last_commitsig_sent_time = 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 @@ -152,6 +156,42 @@ def send_message(self, message_name: str, **kwargs): self._store_raw_msg_if_local_update(raw_msg, message_name=message_name, channel_id=kwargs.get("channel_id")) self.transport.send_bytes(raw_msg) + 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): @@ -552,6 +592,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()) @@ -1920,6 +1961,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())) @@ -1927,6 +1969,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, @@ -1934,7 +1980,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 @@ -1944,8 +1991,9 @@ def pay(self, *, amount_msat: int, total_msat: int, payment_hash: bytes, - min_final_cltv_delta: int, - payment_secret: bytes, + min_final_cltv_delta: Optional[int], + payment_secret: Optional[bytes], + blinded_path_info: Optional['BlindedPathInfo'] = None, trampoline_onion: Optional[OnionPacket] = None, ) -> UpdateAddHtlc: @@ -1960,7 +2008,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, @@ -2041,12 +2090,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}") @@ -2062,14 +2113,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). @@ -2090,7 +2141,39 @@ 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 + + if sha256(path_id_payload.payment_preimage) != htlc.payment_hash: + log_fail_reason(f"sender misused our blinded path for unrelated payment? {path_id_payload}") + raise exc_incorrect_or_unknown_pd + + # we could even go as far and only persist the information once we have received all htlcs + self.lnworker.save_blinded_payment_info(path_id_payload) + 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 @@ -2110,11 +2193,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, *, @@ -2151,7 +2234,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, @@ -2159,11 +2242,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: @@ -2194,6 +2278,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, @@ -2222,10 +2307,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: @@ -2309,6 +2395,7 @@ def _fail_htlc_set( processed_onion_packet = self._process_incoming_onion_packet( onion_packet, payment_hash=payment_hash, + path_key=mpp_htlc.htlc.path_key, is_trampoline=False, ) if raw_error: @@ -2830,6 +2917,7 @@ def _run_htlc_switch_iteration(self): processed_onion_packet = self._process_incoming_onion_packet( onion_packet, payment_hash=htlc.payment_hash, + path_key=htlc.path_key, is_trampoline=False, ) payment_key: str = self._check_unfulfilled_htlc( @@ -2937,6 +3025,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, + path_key=mpp_htlc.htlc.path_key, is_trampoline=False, ) onion_payload = processed_onion.hop_data.payload @@ -2989,6 +3078,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, + path_key=mpp_htlc.htlc.path_key, is_trampoline=False, # this is always the outer onion ) processed_onions[mpp_htlc] = (processed_onion, None) @@ -3245,17 +3335,20 @@ def _process_incoming_onion_packet( self, onion_packet: OnionPacket, *, payment_hash: bytes, + 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]) + (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, + path_key=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 4322dc692b6b..ca5930703fcd 100644 --- a/electrum/lnrouter.py +++ b/electrum/lnrouter.py @@ -33,6 +33,7 @@ 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 +42,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 +117,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[dict], 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)) @@ -133,27 +136,35 @@ 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 + amount_msat_for_recipient: int, # that final receiver gets + cltv_delta_for_recipient: Optional[int], # that final receiver gets, not given for blinded paths + additional_blinded_path_fees: Optional['BlindedPayInfo'] = 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 + amt = amount_msat_for_recipient cltv_cost_of_route = 0 # excluding cltv_delta_for_dest + if additional_blinded_path_fees is not None: + amt += fee_for_edge_msat( + forwarded_amount_msat=amt, + fee_base_msat=additional_blinded_path_fees.fee_base_msat, + fee_proportional_millionths=additional_blinded_path_fees.fee_proportional_millionths, + ) + cltv_cost_of_route += additional_blinded_path_fees.cltv_expiry_delta for route_edge in reversed(route[1:]): amt += route_edge.fee_for_edge(amt) cltv_cost_of_route += route_edge.cltv_delta - fee_cost = amt - amount_msat_for_dest + fee_cost = amt - amount_msat_for_recipient # check against budget if cltv_cost_of_route > budget.cltv: return False if fee_cost > budget.fee_msat: return False # sanity check - total_cltv_delta = cltv_cost_of_route + cltv_delta_for_dest + total_cltv_delta = cltv_cost_of_route + (cltv_delta_for_recipient or 0) if total_cltv_delta > NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE: return False return True @@ -452,6 +463,7 @@ def _edge_cost( end_node: bytes, payment_amt_msat: int, ignore_costs=False, + ignore_amount_constraints: bool = False, is_mine=False, my_channels: Dict[ShortChannelID, 'Channel'] = None, private_route_edges: Dict[ShortChannelID, RouteEdge] = None, @@ -481,14 +493,15 @@ def _edge_cost( return float('inf'), 0 if channel_policy.is_disabled(): return float('inf'), 0 - if payment_amt_msat < channel_policy.htlc_minimum_msat: - return float('inf'), 0 # payment amount too little - if channel_info.capacity_sat is not None and \ - payment_amt_msat // 1000 > channel_info.capacity_sat: - return float('inf'), 0 # payment amount too large - if channel_policy.htlc_maximum_msat is not None and \ - payment_amt_msat > channel_policy.htlc_maximum_msat: - return float('inf'), 0 # payment amount too large + if not ignore_amount_constraints: + if payment_amt_msat < channel_policy.htlc_minimum_msat: + return float('inf'), 0 # payment amount too little + if channel_info.capacity_sat is not None and \ + payment_amt_msat // 1000 > channel_info.capacity_sat: + return float('inf'), 0 # payment amount too large + if channel_policy.htlc_maximum_msat is not None and \ + payment_amt_msat > channel_policy.htlc_maximum_msat: + return float('inf'), 0 # payment amount too large route_edge = private_route_edges.get(short_channel_id, None) if route_edge is None: node_info = self.channel_db.get_node_info_for_node_id(node_id=end_node) @@ -513,7 +526,7 @@ def _edge_cost( # - The larger the payment amount, and the longer the CLTV, # the more irritating it is if the HTLC gets stuck. # - Paying lower fees is better. :) - if ignore_costs: + if ignore_costs or ignore_amount_constraints: return DEFAULT_PENALTY_BASE_MSAT, 0 fee_msat = route_edge.fee_for_edge(payment_amt_msat) cltv_cost = route_edge.cltv_delta * payment_amt_msat * 15 / 1_000_000_000 @@ -528,10 +541,10 @@ def get_shortest_path_hops( *, nodeA: bytes, # nodeA is expected to be our node id if channels are passed in my_sending_channels nodeB: bytes, - invoice_amount_msat: int, + invoice_amount_msat: Optional[int], my_sending_channels: Dict[ShortChannelID, 'Channel'] = None, private_route_edges: Dict[ShortChannelID, RouteEdge] = None, - node_filter: Optional[Callable[[bytes, NodeInfo], bool]] = None + node_filter: Optional[Callable[[bytes, Optional[NodeInfo]], bool]] = None, ) -> Dict[bytes, PathEdge]: # note: we don't lock self.channel_db, so while the path finding runs, # the underlying graph could potentially change... (not good but maybe ~OK?) @@ -545,11 +558,12 @@ def get_shortest_path_hops( # run Dijkstra # The search is run in the REVERSE direction, from nodeB to nodeA, # to properly calculate compound routing fees. + ignore_amount_constraints = invoice_amount_msat is None # e.g. onion messages distance_from_start = defaultdict(lambda: float('inf')) distance_from_start[nodeB] = 0 previous_hops = {} # type: Dict[bytes, PathEdge] nodes_to_explore = queue.PriorityQueue() - nodes_to_explore.put((0, invoice_amount_msat, nodeB)) # order of fields (in tuple) matters! + nodes_to_explore.put((0, invoice_amount_msat or 0, nodeB)) # order of fields (in tuple) matters! now = int(time.time()) # main loop of search @@ -592,7 +606,8 @@ def get_shortest_path_hops( if edge_startnode == nodeA and my_sending_channels: # payment outgoing, on our channel if edge_channel_id not in my_sending_channels: continue - if not my_sending_channels[edge_channel_id].can_pay(amount_msat, check_frozen=True): + if not ignore_amount_constraints \ + and not my_sending_channels[edge_channel_id].can_pay(amount_msat, check_frozen=True): continue edge_cost, fee_for_edge_msat = self._edge_cost( short_channel_id=edge_channel_id, @@ -600,6 +615,7 @@ def get_shortest_path_hops( end_node=edge_endnode, payment_amt_msat=amount_msat, ignore_costs=(edge_startnode == nodeA), + ignore_amount_constraints=ignore_amount_constraints, is_mine=is_mine, my_channels=my_sending_channels, private_route_edges=private_route_edges, @@ -626,15 +642,15 @@ def find_path_for_payment( *, nodeA: bytes, nodeB: bytes, - invoice_amount_msat: int, + invoice_amount_msat: Optional[int], my_sending_channels: Dict[ShortChannelID, 'Channel'] = None, private_route_edges: Dict[ShortChannelID, RouteEdge] = None, - node_filter: Optional[Callable[[bytes, NodeInfo], bool]] = None + node_filter: Optional[Callable[[bytes, Optional[NodeInfo]], bool]] = None ) -> Optional[LNPaymentPath]: """Return a path from nodeA to nodeB.""" assert type(nodeA) is bytes assert type(nodeB) is bytes - assert type(invoice_amount_msat) is int + assert type(invoice_amount_msat) is int or invoice_amount_msat is None if my_sending_channels is None: my_sending_channels = {} @@ -659,6 +675,28 @@ def find_path_for_payment( edge_startnode = edge.node_id return path + def find_path_for_onion_message( + self, + *, + nodeA: bytes, + nodeB: bytes, + my_sending_channels: Dict[ShortChannelID, 'Channel'] = None, + private_route_edges: Dict[ShortChannelID, RouteEdge] = None, + ) -> Optional[LNPaymentPath]: + from .onion_message import is_onion_message_node + def _node_filter(edge_startnode, node_info): + if edge_startnode == nodeA: + return True # assume the sending node does support onion messages + return is_onion_message_node(edge_startnode, node_info) + return self.find_path_for_payment( + nodeA=nodeA, + nodeB=nodeB, + my_sending_channels=my_sending_channels, + private_route_edges=private_route_edges, + node_filter=_node_filter, + invoice_amount_msat=None, + ) + def create_route_from_path( self, path: Optional[LNPaymentPath], diff --git a/electrum/lnutil.py b/electrum/lnutil.py index 9287744b8f63..7a670d4c003a 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 @@ -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 @@ -1713,6 +1716,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 +1909,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 +1937,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('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 +2142,53 @@ 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 substract_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. + """ + blinded_fee_msat = ( + blinded_payinfo.fee_base_msat + + (blinded_payinfo.fee_proportional_millionths * amount_msat) // 1_000_000 + ) + 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 + min_final_cltv_delta: int + 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', ...] + 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 135312017059..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 @@ -116,3 +118,144 @@ subtype,blinded_path_hop subtypedata,blinded_path_hop,blinded_node_id,point, subtypedata,blinded_path_hop,enclen,u16, subtypedata,blinded_path_hop,encrypted_recipient_data,byte,enclen +tlvtype,offer,offer_chains,2 +tlvdata,offer,offer_chains,chains,chain_hash,... +tlvtype,offer,offer_metadata,4 +tlvdata,offer,offer_metadata,data,byte,... +tlvtype,offer,offer_currency,6 +tlvdata,offer,offer_currency,iso4217,utf8,... +tlvtype,offer,offer_amount,8 +tlvdata,offer,offer_amount,amount,tu64, +tlvtype,offer,offer_description,10 +tlvdata,offer,offer_description,description,utf8,... +tlvtype,offer,offer_features,12 +tlvdata,offer,offer_features,features,byte,... +tlvtype,offer,offer_absolute_expiry,14 +tlvdata,offer,offer_absolute_expiry,seconds_from_epoch,tu64, +tlvtype,offer,offer_paths,16 +tlvdata,offer,offer_paths,paths,blinded_path,... +tlvtype,offer,offer_issuer,18 +tlvdata,offer,offer_issuer,issuer,utf8,... +tlvtype,offer,offer_quantity_max,20 +tlvdata,offer,offer_quantity_max,max,tu64, +tlvtype,offer,offer_issuer_id,22 +tlvdata,offer,offer_issuer_id,id,point, +tlvtype,invoice_request,invreq_metadata,0 +tlvdata,invoice_request,invreq_metadata,blob,byte,... +tlvtype,invoice_request,offer_chains,2 +tlvdata,invoice_request,offer_chains,chains,chain_hash,... +tlvtype,invoice_request,offer_metadata,4 +tlvdata,invoice_request,offer_metadata,data,byte,... +tlvtype,invoice_request,offer_currency,6 +tlvdata,invoice_request,offer_currency,iso4217,utf8,... +tlvtype,invoice_request,offer_amount,8 +tlvdata,invoice_request,offer_amount,amount,tu64, +tlvtype,invoice_request,offer_description,10 +tlvdata,invoice_request,offer_description,description,utf8,... +tlvtype,invoice_request,offer_features,12 +tlvdata,invoice_request,offer_features,features,byte,... +tlvtype,invoice_request,offer_absolute_expiry,14 +tlvdata,invoice_request,offer_absolute_expiry,seconds_from_epoch,tu64, +tlvtype,invoice_request,offer_paths,16 +tlvdata,invoice_request,offer_paths,paths,blinded_path,... +tlvtype,invoice_request,offer_issuer,18 +tlvdata,invoice_request,offer_issuer,issuer,utf8,... +tlvtype,invoice_request,offer_quantity_max,20 +tlvdata,invoice_request,offer_quantity_max,max,tu64, +tlvtype,invoice_request,offer_issuer_id,22 +tlvdata,invoice_request,offer_issuer_id,id,point, +tlvtype,invoice_request,invreq_chain,80 +tlvdata,invoice_request,invreq_chain,chain,chain_hash, +tlvtype,invoice_request,invreq_amount,82 +tlvdata,invoice_request,invreq_amount,msat,tu64, +tlvtype,invoice_request,invreq_features,84 +tlvdata,invoice_request,invreq_features,features,byte,... +tlvtype,invoice_request,invreq_quantity,86 +tlvdata,invoice_request,invreq_quantity,quantity,tu64, +tlvtype,invoice_request,invreq_payer_id,88 +tlvdata,invoice_request,invreq_payer_id,key,point, +tlvtype,invoice_request,invreq_payer_note,89 +tlvdata,invoice_request,invreq_payer_note,note,utf8,... +tlvtype,invoice_request,invreq_paths,90 +tlvdata,invoice_request,invreq_paths,paths,blinded_path,... +tlvtype,invoice_request,signature,240 +tlvdata,invoice_request,signature,sig,bip340sig, +tlvtype,invoice,invreq_metadata,0 +tlvdata,invoice,invreq_metadata,blob,byte,... +tlvtype,invoice,offer_chains,2 +tlvdata,invoice,offer_chains,chains,chain_hash,... +tlvtype,invoice,offer_metadata,4 +tlvdata,invoice,offer_metadata,data,byte,... +tlvtype,invoice,offer_currency,6 +tlvdata,invoice,offer_currency,iso4217,utf8,... +tlvtype,invoice,offer_amount,8 +tlvdata,invoice,offer_amount,amount,tu64, +tlvtype,invoice,offer_description,10 +tlvdata,invoice,offer_description,description,utf8,... +tlvtype,invoice,offer_features,12 +tlvdata,invoice,offer_features,features,byte,... +tlvtype,invoice,offer_absolute_expiry,14 +tlvdata,invoice,offer_absolute_expiry,seconds_from_epoch,tu64, +tlvtype,invoice,offer_paths,16 +tlvdata,invoice,offer_paths,paths,blinded_path,... +tlvtype,invoice,offer_issuer,18 +tlvdata,invoice,offer_issuer,issuer,utf8,... +tlvtype,invoice,offer_quantity_max,20 +tlvdata,invoice,offer_quantity_max,max,tu64, +tlvtype,invoice,offer_issuer_id,22 +tlvdata,invoice,offer_issuer_id,id,point, +tlvtype,invoice,invreq_chain,80 +tlvdata,invoice,invreq_chain,chain,chain_hash, +tlvtype,invoice,invreq_amount,82 +tlvdata,invoice,invreq_amount,msat,tu64, +tlvtype,invoice,invreq_features,84 +tlvdata,invoice,invreq_features,features,byte,... +tlvtype,invoice,invreq_quantity,86 +tlvdata,invoice,invreq_quantity,quantity,tu64, +tlvtype,invoice,invreq_payer_id,88 +tlvdata,invoice,invreq_payer_id,key,point, +tlvtype,invoice,invreq_payer_note,89 +tlvdata,invoice,invreq_payer_note,note,utf8,... +tlvtype,invoice,invreq_paths,90 +tlvdata,invoice,invreq_paths,paths,blinded_path,... +tlvtype,invoice,invoice_paths,160 +tlvdata,invoice,invoice_paths,paths,blinded_path,... +tlvtype,invoice,invoice_blindedpay,162 +tlvdata,invoice,invoice_blindedpay,payinfo,blinded_payinfo,... +tlvtype,invoice,invoice_created_at,164 +tlvdata,invoice,invoice_created_at,timestamp,tu64, +tlvtype,invoice,invoice_relative_expiry,166 +tlvdata,invoice,invoice_relative_expiry,seconds_from_creation,tu32, +tlvtype,invoice,invoice_payment_hash,168 +tlvdata,invoice,invoice_payment_hash,payment_hash,sha256, +tlvtype,invoice,invoice_amount,170 +tlvdata,invoice,invoice_amount,msat,tu64, +tlvtype,invoice,invoice_fallbacks,172 +tlvdata,invoice,invoice_fallbacks,fallbacks,fallback_address,... +tlvtype,invoice,invoice_features,174 +tlvdata,invoice,invoice_features,features,byte,... +tlvtype,invoice,invoice_node_id,176 +tlvdata,invoice,invoice_node_id,node_id,point, +tlvtype,invoice,signature,240 +tlvdata,invoice,signature,sig,bip340sig, +subtype,blinded_payinfo +subtypedata,blinded_payinfo,fee_base_msat,u32, +subtypedata,blinded_payinfo,fee_proportional_millionths,u32, +subtypedata,blinded_payinfo,cltv_expiry_delta,u16, +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, +subtypedata,fallback_address,address,byte,len +tlvtype,invoice_error,erroneous_field,1 +tlvdata,invoice_error,erroneous_field,tlv_fieldnum,tu64, +tlvtype,invoice_error,suggested_value,3 +tlvdata,invoice_error,suggested_value,value,byte,... +tlvtype,invoice_error,error,5 +tlvdata,invoice_error,error,msg,utf8,... 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 c93a644a9f43..2a65f7c921e3 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,13 +80,14 @@ 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, @@ -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 if TYPE_CHECKING: from .network import Network @@ -131,7 +140,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 @@ -147,7 +156,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) @@ -169,13 +183,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 @@ -209,6 +224,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 = ( @@ -855,28 +872,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() @@ -959,9 +969,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] @@ -971,8 +982,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 @@ -987,6 +999,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: @@ -1015,6 +1035,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: @@ -1087,6 +1108,10 @@ 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, bytes] + # 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 @@ -1876,16 +1901,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")) @@ -1898,12 +1925,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) @@ -1913,13 +1940,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, @@ -1946,12 +1970,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, @@ -1969,17 +1990,14 @@ 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) @@ -1988,7 +2006,8 @@ async def pay_to_node( 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={"r_tags:" + str(BOLT11Addr.format_bolt11_routing_info_as_human_readable(routing_info.r_tags)) \ + if not isinstance(routing_info, BlindedRoutingInfo) else str(len(routing_info.paths)) + " blinded path(s)"}. " f"{amount_to_pay=} msat. {budget=}") if not self.uses_trampoline(): self.logger.info( @@ -2117,7 +2136,7 @@ async def pay_to_route( self, *, paysession: PaySession, sent_htlc_info: SentHtlcInfo, - min_final_cltv_delta: int, + min_final_cltv_delta: Optional[int], trampoline_onion: Optional[OnionPacket] = None, fw_payment_key: str = None, ) -> None: @@ -2131,15 +2150,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 @@ -2303,6 +2334,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): @@ -2326,8 +2368,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)) @@ -2335,19 +2376,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: @@ -2376,7 +2431,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. @@ -2384,6 +2438,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 @@ -2398,8 +2454,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 @@ -2413,7 +2468,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 + 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() @@ -2429,19 +2486,16 @@ 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) @@ -2469,13 +2523,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, + 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: @@ -2488,39 +2544,40 @@ 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, )) 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_recipient=amount_msat, + cltv_delta_for_recipient=None if blinded_path else routing_info.min_final_cltv_delta, + additional_blinded_path_fees=blinded_path.payinfo if blinded_path else None + ): 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)) + min_final_cltv_delta = None if blinded_path else routing_info.min_final_cltv_delta + routes.append((shi, min_final_cltv_delta, fwd_trampoline_onion)) except NoPathFound: continue except FeeBudgetExceeded as e: @@ -2535,7 +2592,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()}') @@ -2562,23 +2618,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 @@ -2605,34 +2697,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) \ @@ -3149,8 +3224,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, @@ -3159,7 +3233,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 @@ -3195,8 +3269,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 @@ -3231,7 +3304,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 @@ -3957,10 +4030,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) @@ -4034,6 +4118,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()}") @@ -4051,7 +4136,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): @@ -4067,7 +4152,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)}') @@ -4133,6 +4218,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, @@ -4148,14 +4234,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, + min_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, @@ -4205,25 +4295,48 @@ def create_onion_for_route( amount_msat: int, total_msat: int, payment_hash: bytes, - min_final_cltv_delta: int, - payment_secret: bytes, + min_final_cltv_delta: Optional[int], # for blinded payments we don't know the final cltv delta + 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" + assert bool(min_final_cltv_delta) ^ bool(blinded_path_info) + # 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 + 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) + assert final_cltv_abs <= cltv_abs, (final_cltv_abs, cltv_abs) + 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, + local_height=local_height, + total_msat=total_msat, + invoice_blinded_path_info=blinded_path_info, + ) + assert cltv_abs > local_height, (cltv_abs, local_height) + payment_path_pubkeys = [x.node_id for x in route] + blinded_node_ids + 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') @@ -4238,8 +4351,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 @@ -4263,3 +4376,434 @@ 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): + 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) + 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) + + if invreq.offer_issuer_id and (cached_invoice := self._bolt12_invoice_cache.get(invreq_metadata_digest)) is not None: + # 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 = cached_invoice + else: + serialized_invoice = self._create_bolt12_invoice_for_invoice_request( + invreq=invreq, + invoice_signing_key=invoice_signing_key, + ) + self._bolt12_invoice_cache[invreq_metadata_digest] = serialized_invoice + 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.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, + ) -> bytes: + """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 + 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=sha256(path_id_payload.payment_preimage), + 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 + + 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) + # TODO: persist description(s) for GUI? diff --git a/electrum/onion_message.py b/electrum/onion_message.py index 167b8976a4f3..f35aada70ec0 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -27,21 +27,23 @@ import os import threading import time - -from typing import TYPE_CHECKING, Optional, Sequence, NamedTuple, Tuple, Union +import dataclasses +import random +from typing import TYPE_CHECKING, Optional, Sequence, NamedTuple, Tuple, Union, Mapping import electrum_ecc as ecc from electrum.channel_db import get_mychannel_policy -from electrum.lnrouter import PathEdge +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) -from electrum.lnutil import LnFeatures, MIN_FINAL_CLTV_DELTA_ACCEPTED, MAXIMUM_REMOTE_TO_SELF_DELAY_ACCEPTED + 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, + validate_features, IncompatibleOrInsaneFeatures) from electrum.util import OldTaskGroup, log_exceptions, random_shuffled_copy @@ -64,6 +66,10 @@ def now() -> float: PAYMENT_PATHS_MAX = 3 +class NoOnionMessagePeers(Exception): pass +class NoRouteBlindingChannelPeers(Exception): pass + + class NoRouteFound(Exception): def __init__(self, *args, peer_address: 'LNPeerAddr' = None): Exception.__init__(self, *args) @@ -78,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: @@ -89,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 @@ -116,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: @@ -150,45 +144,56 @@ def is_onion_message_node(node_id: bytes, node_info: Optional['NodeInfo']) -> bo def create_onion_message_route_to(lnwallet: 'LNWallet', node_id: bytes) -> Sequence[PathEdge]: - """Constructs a route to the destination node_id, first by starting with peers with existing channels, - and if no route found, opening a direct peer connection if node_id is found with an address in - channel_db.""" - # TODO: is this the proper way to set up my_sending_channels? - my_active_channels = [ - chan for chan in lnwallet.channels.values() if - chan.is_active() and not chan.is_frozen_for_sending()] - my_sending_channels = {chan.short_channel_id: chan for chan in my_active_channels - if chan.short_channel_id is not None} - # find route to introduction point over existing channel mesh - # NOTE: nodes that are in channel_db but are offline are not removed from the set - if lnwallet.network.path_finder: - if path := lnwallet.network.path_finder.find_path_for_payment( + """ + Constructs a route for sending an onion message to node_id. + With Gossip: Tries to find a route through the graph. If no route can be found, raise NoRouteFound. + If a connection address is known for node_id it is added to NoRouteFound for potential direct connection. + With Trampoline: Construct a route: us -> random trampoline peer -> node_id. + We hope the trampoline peer will open a direct connection to node_id and forward the message. + NOTE: Eclair only does this with the relay-policy config set to relay-all! + """ + # destination is existing peer, use direct route + if lnwallet.lnpeermgr.get_peer_by_pubkey(node_id): + return [PathEdge(short_channel_id=None, start_node=None, end_node=node_id)] + + if not lnwallet.uses_trampoline(): + # find route to node over existing channel mesh + # NOTE: nodes that are in channel_db but are offline are not removed from the set + my_active_channels = [chan for chan in lnwallet.channels.values() if chan.is_active()] + my_sending_channels = { + chan.short_channel_id: chan for chan in my_active_channels + if chan.short_channel_id is not None + } + + # TODO: if this blocks the event loop too long it might needs to go on a thread + if path := lnwallet.network.path_finder.find_path_for_onion_message( nodeA=lnwallet.node_keypair.pubkey, nodeB=node_id, - invoice_amount_msat=10000, # TODO: do this without amount constraints - node_filter=lambda x, y: True if x == lnwallet.node_keypair.pubkey else is_onion_message_node(x, y), my_sending_channels=my_sending_channels ): # first edge must be to our peer peer = lnwallet.lnpeermgr.get_peer_by_pubkey(path[0].end_node) assert peer, 'first hop not a peer' return path - - # alt: dest is existing peer? - if lnwallet.lnpeermgr.get_peer_by_pubkey(node_id): - return [PathEdge(short_channel_id=None, start_node=None, end_node=node_id)] - - # if we have an address, pass it. - if lnwallet.channel_db: - if peer_addr := lnwallet.channel_db.get_last_good_address(node_id): + elif peer_addr := lnwallet.channel_db.get_last_good_address(node_id): + # if we have an address, pass it. raise NoRouteFound('no path found, peer_addr available', peer_address=peer_addr) + else: + # if we use trampoline just assume the trampoline will open a direct connection to the next_node_id + trampoline_peers = [p for p in lnwallet.lnpeermgr.peers.values() if lnwallet.is_trampoline_peer(p.pubkey)] + if trampoline_peers: + random_trampoline_peer = random.choice(trampoline_peers) + return [ + PathEdge(short_channel_id=None, start_node=None, end_node=random_trampoline_peer.pubkey), + PathEdge(short_channel_id=None, start_node=random_trampoline_peer.pubkey, end_node=node_id), + ] raise NoRouteFound('no path found') def create_route_to_introduction_point( lnwallet: 'LNWallet', - blinded_path: dict, + blinded_path: BlindedPath, introduction_point: bytes, session_key: bytes ): @@ -199,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) @@ -233,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) @@ -246,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) - - path_key = next_path_key - + 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, + ) + + 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: @@ -364,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, ) @@ -382,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 @@ -397,90 +386,115 @@ 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 channels peers if not onion_message - - uses current onion_message capable channel peers if exist and if onion_message - - otherwise, uses current onion_message capable peers if onion_message - - reply_path introduction points are direct peers only (TODO: longer paths)""" + - uses active channel peers if my_channels not provided + - if onion_message, filters channels for onion_message feature + - if not onion_message, filters channels for route_blinding feature + - if onion_message and no suitable channel peers, tries onion_message capable peers + - raises if no blinded path could be generated + - 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_active_channels = [chan for chan in lnwallet.channels.values() if chan.is_active()] - my_channels = my_active_channels + my_channels = [chan for chan in lnwallet.channels.values() if chan.is_active()] - if onion_message: - my_channels = [chan for chan in my_channels if lnwallet.lnpeermgr.get_peer_by_pubkey(chan.node_id) and - lnwallet.lnpeermgr.get_peer_by_pubkey(chan.node_id).their_features.supports(LnFeatures.OPTION_ONION_MESSAGE_OPT)] + required_features = LnFeatures.OPTION_ONION_MESSAGE_OPT if onion_message else LnFeatures.OPTION_ROUTE_BLINDING_OPT + my_channels = [chan for chan in my_channels if lnwallet.lnpeermgr.get_peer_by_pubkey(chan.node_id) and + lnwallet.lnpeermgr.get_peer_by_pubkey(chan.node_id).their_features.supports(required_features)] result = [] - payinfo = [] mynodeid = lnwallet.node_keypair.pubkey - local_height = lnwallet.network.get_local_height() - - if len(my_channels): + 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) - # get policy - cp = get_mychannel_policy(chan.short_channel_id, chan.node_id, {chan.short_channel_id: chan}) - - dest_max_cltv_expiry = local_height + MAXIMUM_REMOTE_TO_SELF_DELAY_ACCEPTED - - # TODO: for longer paths (>2), reverse traverse and calculate max_cltv_expiry at each intermediate hop - # and determine the cltv delta sums and fee sums of the hops for the payinfo struct. - # current assumption is len(blinded_path) == 2 (us and peer) - sum_cltv_expiry_delta = cp.cltv_delta - sum_fee_base_msat = cp.fee_base_msat - sum_fee_proportional_millionths = cp.fee_proportional_millionths - # path htlc limits - blinded_path_min_htlc_msat = cp.htlc_minimum_msat - blinded_path_max_htlc_msat = cp.htlc_maximum_msat - - hop_extras = [{ - # spec: MUST include encrypted_data_tlv.payment_relay for each non-final node. - 'payment_relay': { - 'cltv_expiry_delta': cp.cltv_delta, - 'fee_base_msat': cp.fee_base_msat, - 'fee_proportional_millionths': cp.fee_proportional_millionths, - }, - # spec: MUST set encrypted_data_tlv.payment_constraints for each non-final node and MAY set it for the final node: - # - # max_cltv_expiry to the largest block height at which the route is allowed to be used, starting - # from the final node's chosen max_cltv_expiry height at which the route should expire, adding - # the final node's min_final_cltv_expiry_delta and then adding - # encrypted_data_tlv.payment_relay.cltv_expiry_delta at each hop. - # - # htlc_minimum_msat to the largest minimum HTLC value the nodes will allow. - 'payment_constraints': { - 'max_cltv_expiry': dest_max_cltv_expiry + cp.cltv_delta, - 'htlc_minimum_msat': blinded_path_min_htlc_msat - } - }] - payinfo.append({ - '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, - 'htlc_minimum_msat': blinded_path_min_htlc_msat, - 'htlc_maximum_msat': blinded_path_max_htlc_msat, - 'flen': 0, - 'features': bytes(0) - }) - blinded_path = create_blinded_path(os.urandom(32), [chan.node_id, mynodeid], final_recipient_data, - hop_extras=hop_extras, channels=[chan] if not onion_message else None) - result.append(blinded_path) - elif onion_message: - # we can use peers even without channels for onion messages - my_onionmsg_peers = [peer for peer in lnwallet.lnpeermgr.peers.values() if - peer.their_features.supports(LnFeatures.OPTION_ONION_MESSAGE_OPT)] - if len(my_onionmsg_peers): + 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 + blinded_path = create_blinded_path( + session_key=os.urandom(32), + path=[chan.node_id, mynodeid], + final_recipient_data=final_recipient_data, + hop_extras=hop_extras, + channels=[chan] if not onion_message else None, + ) + result.append(BlindedPathInfo( + path=blinded_path, + payinfo=payinfo, + )) + + if not result: + if not onion_message: + raise NoRouteBlindingChannelPeers('no OPTION_ROUTE_BLINDING capable channel peers') + else: + # fall back to peers without channels for onion messages + my_onionmsg_peers = [peer for peer in lnwallet.lnpeermgr.peers.values() if + peer.their_features.supports(LnFeatures.OPTION_ONION_MESSAGE_OPT)] + if not my_onionmsg_peers: + raise NoOnionMessagePeers('no ONION_MESSAGE capable peers') 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) - - return result, payinfo + result.append(BlindedPathInfo( + path=blinded_path, + payinfo=None, + )) + + assert result + return result + + +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) + sum_cltv_expiry_delta = cp.cltv_delta + sum_fee_base_msat = cp.fee_base_msat + sum_fee_proportional_millionths = cp.fee_proportional_millionths + # path htlc limits + blinded_path_min_htlc_msat = cp.htlc_minimum_msat + blinded_path_max_htlc_msat = cp.htlc_maximum_msat + + # TODO: for longer paths (>2), reverse traverse and calculate max_cltv_expiry at each intermediate hop + # and determine the cltv delta sums and fee sums of the hops for the payinfo struct. + # current assumption is len(blinded_path) == 2 (us and peer) + local_height = lnwallet.network.get_local_height() + dest_max_cltv_expiry = local_height + MAXIMUM_REMOTE_TO_SELF_DELAY_ACCEPTED + hop_extras = [{ + # spec: MUST include encrypted_data_tlv.payment_relay for each non-final node. + 'payment_relay': { + 'cltv_expiry_delta': cp.cltv_delta, + 'fee_base_msat': cp.fee_base_msat, + 'fee_proportional_millionths': cp.fee_proportional_millionths, + }, + # spec: MUST set encrypted_data_tlv.payment_constraints for each non-final node and MAY set it for the final node: + # + # max_cltv_expiry to the largest block height at which the route is allowed to be used, starting + # from the final node's chosen max_cltv_expiry height at which the route should expire, adding + # the final node's min_final_cltv_expiry_delta and then adding + # encrypted_data_tlv.payment_relay.cltv_expiry_delta at each hop. + # + # htlc_minimum_msat to the largest minimum HTLC value the nodes will allow. + 'payment_constraints': { + 'max_cltv_expiry': dest_max_cltv_expiry + cp.cltv_delta, + 'htlc_minimum_msat': blinded_path_min_htlc_msat + } + }] + 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, + htlc_minimum_msat=blinded_path_min_htlc_msat, + htlc_maximum_msat=blinded_path_max_htlc_msat, + features=LnFeatures(0), + ) + return payinfo, hop_extras class Timeout(Exception): pass @@ -506,26 +520,27 @@ 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) - self.network = None # type: Optional['Network'] + self.network = None # type: Optional[Network] self.taskgroup = None # type: OldTaskGroup self.lnwallet = lnwallet self.pending = {} # type: dict[bytes, OnionMessageManager.Request] @@ -533,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" @@ -572,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}') @@ -616,13 +631,11 @@ 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)) @@ -635,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 @@ -669,11 +682,11 @@ async def _wait_task(self, key: bytes, future: asyncio.Future): finally: 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=}') @@ -683,15 +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) - if not reply_paths: - raise Exception(f'Could not create a reply_path for {key=}. No active peers?') + final_payload['reply_path'] = {'path': [dataclasses.asdict(x.path) for x in reply_paths]} - final_payload['reply_path'] = {'path': 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? @@ -748,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 @@ -830,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, 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}') @@ -843,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'] @@ -854,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..de0e70e7a65d 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,61 @@ 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')) + try: + invoice, invoice_tlv = await self.wallet.lnworker.request_bolt12_invoice( + self.bolt12_offer, + amount_msat=amount_sat * 1000, + 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) + 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 +584,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 +627,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 +643,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 +656,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 4258cbe125a8..f4000fc6e7b1 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 08d0cf28ce00..c6c5f3d4ff0e 100644 --- a/electrum/trampoline.py +++ b/electrum/trampoline.py @@ -4,15 +4,15 @@ 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 from .lnonion import ( - calc_hops_data_for_payment, new_onion_packet, OnionPacket, PER_HOP_HMAC_SIZE + calc_hops_data_for_payment, new_onion_packet, OnionPacket, PER_HOP_HMAC_SIZE, + calc_hops_data_for_blinded_payment, BlindedPathInfo ) -from .lnrouter import TrampolineEdge, is_route_within_budget, LNPaymentTRoute +from .lnrouter import TrampolineEdge, is_route_within_budget, LNPaymentTRoute, fee_for_edge_msat from .lnutil import NoPathFound from .lntransport import LNPeerAddr from . import constants @@ -20,6 +20,7 @@ from .util import random_shuffled_copy if TYPE_CHECKING: + from .lnutil import RoutingInfo from .lnchannel import Channel @@ -46,6 +47,7 @@ _TRAMPOLINE_NODES_UNITTESTS = {} # used in unit tests + TRAMPOLINE_HOPS_MAX_DATA_SIZE = 500 LAZY_TRAMPOLINE_MAGIC = b'lazy' LAZY_TRAMPOLINE_VERSION = b'\x00' @@ -88,6 +90,7 @@ def decode_next_trampolines(data: bytes) -> dict: return next_trampolines + def hardcoded_trampoline_nodes() -> Mapping[str, LNPeerAddr]: if _TRAMPOLINE_NODES_UNITTESTS: return _TRAMPOLINE_NODES_UNITTESTS @@ -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: @@ -291,25 +299,36 @@ 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'], +) -> tuple[LNPaymentTRoute, bool]: # 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 + assert final_node_pubkey != my_trampoline, "is_direct_path should be true?" # our first trampoline hop is decided by the channel we use _extend_trampoline_route( @@ -317,21 +336,31 @@ def create_trampoline_route( ) 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()) 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": dataclasses.asdict(blinded_path.path), + "payment_info": blinded_path.payinfo.to_dict(), + }] + 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 else: next_trampoline = my_trampoline # maybe add second trampoline @@ -343,77 +372,140 @@ def create_trampoline_route( 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) + # Add final edge. Needed for fees for last TF as fees are allocated for the start_node. + # Last edge end_node is recipient or IP. + if route[-1].end_node != final_node_pubkey: + _extend_trampoline_route(route, end_node=final_node_pubkey) - # replace placeholder fees in route - usable_budget_msat = get_trampoline_budget(trampoline_fee_level, budget.fee_msat) + # 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.substract_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=}") - _allocate_fee_budget_among_route( + # 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 isinstance(routing_info, BlindedRoutingInfo) else amount_msat + _allocate_fee_budget_among_route( # replaces the placeholder fee values of the route edges route, 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) ) # check that we can pay amount and fees + min_final_cltv_delta = routing_info.min_final_cltv_delta if isinstance(routing_info, UnblindedRoutingInfo) else 0 if not is_route_within_budget( - route=route, - budget=budget, - amount_msat_for_dest=amount_msat, - cltv_delta_for_dest=min_final_cltv_delta, + route=route, # unblinded route + budget=budget, # whole budget + amount_msat_for_recipient=amount_msat, + cltv_delta_for_recipient=min_final_cltv_delta, + additional_blinded_path_fees=blinded_path.payinfo if blinded_path else None, ): - raise FeeBudgetExceeded(f"route exceeds budget: budget: {budget}") - return route + raise FeeBudgetExceeded(f"route exceeds budget: budget: {budget=} {unblinded_path_budget=}") + + # extend the last trampolines budget so it can pay for the blinded path + if blinded_path and is_legacy: + last_trampoline, payinfo = route[-1], blinded_path.payinfo + last_trampoline.fee_base_msat += payinfo.fee_base_msat + last_trampoline.fee_proportional_millionths += payinfo.fee_proportional_millionths + last_trampoline.cltv_delta += payinfo.cltv_expiry_delta + + return route, is_legacy def create_trampoline_onion( *, route: LNPaymentTRoute, + routing_info: 'BlindedRoutingInfo | UnblindedRoutingInfo', + blinded_path: Optional['BlindedPathInfo'], amount_msat: int, - final_cltv_abs: int, + local_height: int, + is_legacy: bool, 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. + 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, + local_height=local_height, + total_msat=total_msat, + invoice_blinded_path_info=blinded_path, + ) + else: + # either non-blinded payment or blinded legacy trampoline payment (last trampoline forwards to IP) + final_cltv_abs = local_height + if isinstance(routing_info, UnblindedRoutingInfo): + final_cltv_abs += routing_info.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=routing_info.payment_secret if not blinded_path else None, + ) + if is_legacy: + # The last TrampolineEdge was only added to the route to allocate fees for the last trampoline forwarder. + # No onion layer is needed for the recipient/IP as they don't understand Trampoline. + del hops_data[-1]; del route[-1] + assert hops_data and route, (hops_data, route) + + # detect trampoline hops. mutate hops_data payloads to make them trampoline payloads. payment_path_pubkeys = [x.node_id for x in route] - num_hops = len(payment_path_pubkeys) + 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: + # include blinded paths so the trampoline can route to the recipient + # invoice_routing_info is set on route[-1] by create_trampoline_route + assert route[-1].invoice_routing_info is not None, route + del payload["short_channel_id"] + payload["outgoing_blinded_paths"] = {"paths": route[-1].invoice_routing_info} + payload["invoice_features"] = {"invoice_features": LnFeatures(route[-1].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 + del payload["short_channel_id"] + 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: + assert not blinded_path # 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 @@ -448,42 +540,40 @@ 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, + blinded_path: Optional['BlindedPathInfo'], ) -> Tuple[LNPaymentTRoute, OnionPacket, int, int]: # create route for the trampoline_onion - trampoline_route = create_trampoline_route( + # blinded path hops are not appended to this route + trampoline_route, is_legacy = 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, + route=trampoline_route, # last edge might get truncated + routing_info=routing_info, + blinded_path=blinded_path, amount_msat=amount_msat, - final_cltv_abs=final_cltv_abs, + local_height=local_height, + is_legacy=is_legacy, 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/util.py b/electrum/util.py index afbf95df1a1b..aedcc82a0017 100644 --- a/electrum/util.py +++ b/electrum/util.py @@ -2503,3 +2503,15 @@ class ChoiceItem: key: Any label: str # user facing string extra_data: Any = None + + +def convert_fiat_to_satoshi(currency: str, amount: Decimal) -> Optional[int]: + """Convert amount of given currency into satoshi if exchange rate is available""" + from .network import Network + network = Network.get_instance() + if not network or not network.daemon or not network.daemon.fx: + _logger.warning(f"cannot convert fiat to bitcoin: {network}") + return None + fx = network.daemon.fx + return 0 + diff --git a/electrum/wallet.py b/electrum/wallet.py index 0de4221029a8..40585af67e99 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -73,7 +73,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/electrum/wallet_db.py b/electrum/wallet_db.py index c53c355b0753..10773f599768 100644 --- a/electrum/wallet_db.py +++ b/electrum/wallet_db.py @@ -71,7 +71,7 @@ def __init__(self, wallet_db: 'WalletDB'): # seed_version is now used for the version of the wallet file OLD_SEED_VERSION = 4 # electrum versions < 2.0 NEW_SEED_VERSION = 11 # electrum versions >= 2.0 -FINAL_SEED_VERSION = 71 # electrum >= 2.7 will set this to prevent +FINAL_SEED_VERSION = 72 # electrum >= 2.7 will set this to prevent # old versions from overwriting new format @@ -250,6 +250,7 @@ def upgrade(self): self._convert_version_69() self._convert_version_70() self._convert_version_71() + self._convert_version_72() self.put('seed_version', FINAL_SEED_VERSION) # just to be sure def _convert_wallet_type(self): @@ -1426,6 +1427,17 @@ def _convert_version_71(self): self.data['genesis_blockhash'] = constants.net.GENESIS self.data['seed_version'] = 71 + def _convert_version_72(self): + """UpdateAddHtlc storage tuple gained a trailing 'path_key' field for BOLT12 route blinding. + Existing 5-tuples remain readable thanks to the default arg in UpdateAddHtlc.from_tuple, but + older clients call from_tuple(*tuple) positionally and would raise TypeError on a 6-tuple + entry. The version bump ensures they refuse to open wallets that may now contain such + entries instead of failing mid-deserialization. + """ + if not self._is_upgrade_method_needed(71, 71): + return + self.data['seed_version'] = 72 + def _convert_imported(self): if not self._is_upgrade_method_needed(0, 13): return 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-signature-test.json b/tests/bolt12-signature-test.json new file mode 100644 index 000000000000..00d327071e66 --- /dev/null +++ b/tests/bolt12-signature-test.json @@ -0,0 +1,137 @@ +[ + { + "comment": "Simple n1 test, tlv1 = 1000", + "tlv": "n1", + "first-tlv": "010203e8", + "leaves": [ + { + "H(`LnLeaf`,010203e8)": "67a2a995433890d8fe0c18a1765ad19e98f1fcfeff14c13a45bbc80964a78cf7", + "H(`LnNonce`|first-tlv,tlv1-type)": "255a95f5b6b3c6997e2838dc4d9348807fb6da8eb7bbc02d30662d144718b6aa", + "H(`LnBranch`,leaf+nonce)": "b013756c8fee86503a0b4abdab4cddeb1af5d344ca6fc2fa8b6c08938caa6f93" + } + ], + "branches": [], + "merkle": "b013756c8fee86503a0b4abdab4cddeb1af5d344ca6fc2fa8b6c08938caa6f93" + }, + { + "comment": "n1 test, tlv1 = 1000, tlv2 = 1x2x3", + "tlv": "n1", + "first-tlv": "010203e8", + "leaves": [ + { + "H(`LnLeaf`,010203e8)": "67a2a995433890d8fe0c18a1765ad19e98f1fcfeff14c13a45bbc80964a78cf7", + "H(`LnNonce`|first-tlv,tlv1-type)": "255a95f5b6b3c6997e2838dc4d9348807fb6da8eb7bbc02d30662d144718b6aa", + "H(`LnBranch`,leaf+nonce)": "b013756c8fee86503a0b4abdab4cddeb1af5d344ca6fc2fa8b6c08938caa6f93" + }, + { + "H(`LnLeaf`,02080000010000020003)": "cc04567fcbff60d4de87afe5142de16b7401531300554838b2d1117341a4ea8d", + "H(`LnNonce`|first-tlv,tlv2-type)": "12bc15565410d8e3251a6fb1c53a2d360f39a9f65afb8403ef875016e34ff678", + "H(`LnBranch`,leaf+nonce)": "19d6ecfa3be88d29c30e56167f58526d7695dfac9cb95e1256deb222c92db4d0" + } + ], + "branches": [ + { + "desc": "1: tlv1+nonce and tlv2+nonce", + "H(`LnBranch`,19d6ecfa3be88d29c30e56167f58526d7695dfac9cb95e1256deb222c92db4d0b013756c8fee86503a0b4abdab4cddeb1af5d344ca6fc2fa8b6c08938caa6f93)": "c3774abbf4815aa54ccaa026bff6581f01f3be5fe814c620a252534f434bc0d1" + } + ], + "merkle": "c3774abbf4815aa54ccaa026bff6581f01f3be5fe814c620a252534f434bc0d1" + }, + { + "comment": "n1 test, tlv1 = 1000, tlv2 = 1x2x3, tlv3 = 0266e4598d1d3c415f572a8488830b60f7e744ed9235eb0b1ba93283b315c03518, 1, 2", + "tlv": "n1", + "first-tlv": "010203e8", + "leaves": [ + { + "H(`LnLeaf`,010203e8)": "67a2a995433890d8fe0c18a1765ad19e98f1fcfeff14c13a45bbc80964a78cf7", + "H(`LnNonce`|first-tlv,1)": "255a95f5b6b3c6997e2838dc4d9348807fb6da8eb7bbc02d30662d144718b6aa", + "H(`LnBranch`,leaf+nonce)": "b013756c8fee86503a0b4abdab4cddeb1af5d344ca6fc2fa8b6c08938caa6f93" + }, + { + "H(`LnLeaf`,02080000010000020003)": "cc04567fcbff60d4de87afe5142de16b7401531300554838b2d1117341a4ea8d", + "H(`LnNonce`|first-tlv,2)": "12bc15565410d8e3251a6fb1c53a2d360f39a9f65afb8403ef875016e34ff678", + "H(`LnBranch`,leaf+nonce)": "19d6ecfa3be88d29c30e56167f58526d7695dfac9cb95e1256deb222c92db4d0" + }, + { + "H(`LnLeaf`,03310266e4598d1d3c415f572a8488830b60f7e744ed9235eb0b1ba93283b315c0351800000000000000010000000000000002)": "47da319b36d61a006e0dbcf6642fe4c822c33a6131af67dfa9293b089c5cbd27", + "H(`LnNonce`|first-tlv,3)": "068cf6e9d2db9258a6c1d3304a8f2e9d4d046ea711664c9a96960234f707a084", + "H(`LnBranch`,leaf+nonce)": "7c879819c09f1525e7bc69b84f7928180de584f92c846e01fa2daf5b17e32967" + } + ], + "branches": [ + { + "desc": "1: tlv1+nonce and tlv2+nonce", + "H(`LnBranch`,19d6ecfa3be88d29c30e56167f58526d7695dfac9cb95e1256deb222c92db4d0b013756c8fee86503a0b4abdab4cddeb1af5d344ca6fc2fa8b6c08938caa6f93)": "c3774abbf4815aa54ccaa026bff6581f01f3be5fe814c620a252534f434bc0d1" + }, + { + "desc": "1 and tlv3+nonce", + "H(`LnBranch`,7c879819c09f1525e7bc69b84f7928180de584f92c846e01fa2daf5b17e32967c3774abbf4815aa54ccaa026bff6581f01f3be5fe814c620a252534f434bc0d1)": "ab2e79b1283b0b31e0b035258de23782df6b89a38cfa7237bde69aed1a658c5d" + } + ], + "merkle": "ab2e79b1283b0b31e0b035258de23782df6b89a38cfa7237bde69aed1a658c5d" + }, + { + "comment": "invoice_request test: offer_issuer_id = Alice (privkey 0x414141...), offer_description = 'A Mathematical Treatise', offer_amount = 100, offer_currency = 'USD', invreq_payer_id = Bob (privkey 0x424242...), invreq_metadata = 0x0000000000000000", + "bolt12": "lnr1qqyqqqqqqqqqqqqqqcp4256ypqqkgzshgysy6ct5dpjk6ct5d93kzmpq23ex2ct5d9ek293pqthvwfzadd7jejes8q9lhc4rvjxd022zv5l44g6qah82ru5rdpnpjkppqvjx204vgdzgsqpvcp4mldl3plscny0rt707gvpdh6ndydfacz43euzqhrurageg3n7kafgsek6gz3e9w52parv8gs2hlxzk95tzeswywffxlkeyhml0hh46kndmwf4m6xma3tkq2lu04qz3slje2rfthc89vss", + "tlv": "invoice_request", + "first-tlv": "00080000000000000000", + "leaves": [ + { + "H(`LnLeaf`,00080000000000000000)": "cd45d50b8dbb73ba995f92aa48be7c2909331998cb070572f5499bae338a03c6", + "H(`LnNonce`|first-tlv,0)": "edc13c82e89b213a5641b27f0c06c5f31ea948a0cc2fd6495120cc8590cac3f5", + "H(`LnBranch`,leaf+nonce)": "5ced451fad76ab7edc8084b84c8b5086df195b2a503c25b371e6850a280c94ab" + }, + { + "H(`LnLeaf`,0603555344)": "ae61bfe63f8fc81b7a02a962182a5b5e01501365806481d52fbdfbca915266fa", + "H(`LnNonce`|first-tlv,6)": "cc9fc57ce5e82252b6cc8908a93f012b13294a82132768e36dd767b3c3c289e8", + "H(`LnBranch`,leaf+nonce)": "a2ea87a666c1524d25132ff59883c96a118728ff76595d239f5806143e3e9c9e" + }, + { + "H(`LnLeaf`,080164)": "b4f3adb8ca4f4a4c0e7cd9e0b1cafe8634cf8a864e1a730868bdda39fbd3e336", + "H(`LnNonce`|first-tlv,8)": "376180f1ef3b7973ba4989f9391502bd78a1a8a54929fe9adcaec1dd2bfec648", + "H(`LnBranch`,leaf+nonce)": "fa0bb4f0fa2f2625c63eec9bf3a29c9aa304e64d5aa44d38e050a6bd7d6fc5c0" + }, + { + "H(`LnLeaf`,0a1741204d617468656d61746963616c205472656174697365)": "7007775409456c33c47bddd7ce946ecd5a82035f1d5a529cc90e84d146f75a6e", + "H(`LnNonce`|first-tlv,10)": "01926a0c38b4ec71d76b116eeb81ea7999706fdce24a7f5b9d67bf867fd0c4d8", + "H(`LnBranch`,leaf+nonce)": "349379beebd68fd72296e76cb2ae28554b35fa9234853956b81b24c008783230" + }, + { + "H(`LnLeaf`,162102eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619)": "bdde38b7b58fa74acee1e943bbc32c04306368cb2aa513856f53f45be461051b", + "H(`LnNonce`|first-tlv,22)": "2e571571c7dd0739dbc4180bb96b7652b055f9e97f80d37337c96689990fdbaa", + "H(`LnBranch`,leaf+nonce)": "384853c9811863028876088ce34e75d784ac027fd564f103ea972cdf96236e47" + }, + { + "H(`LnLeaf`,58210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c)": "f3b92382531e261e16a0f35d65f314ae622306bbb1b206fee00d80153b76eea3", + "H(`LnNonce`|first-tlv,88)": "c31a695332d176217470b705cde5c8cd71cdb611e1f26c5a98f14c0d935c97bd", + "H(`LnBranch`,leaf+nonce)": "73e067757513706491e0da4e8077112e606da55c04239ad13ab609bc82907600" + } + ], + "branches": [ + { + "desc": "1: metadata+nonce and currency+nonce", + "H(`LnBranch`,5ced451fad76ab7edc8084b84c8b5086df195b2a503c25b371e6850a280c94aba2ea87a666c1524d25132ff59883c96a118728ff76595d239f5806143e3e9c9e)": "f0aa4611039a3a8a90dc8331fa75c9acf433be7285cac0983902aaaa8f66aaa9" + }, + { + "desc": "2: amount+nonce and descripton+nonce", + "H(`LnBranch`,349379beebd68fd72296e76cb2ae28554b35fa9234853956b81b24c008783230fa0bb4f0fa2f2625c63eec9bf3a29c9aa304e64d5aa44d38e050a6bd7d6fc5c0)": "92e6478159d6763b19c5d03a8a834e179116f89e0cec700049e5ce921f8c400e" + }, + { + "desc": "3: 1 and 2", + "H(`LnBranch`,92e6478159d6763b19c5d03a8a834e179116f89e0cec700049e5ce921f8c400ef0aa4611039a3a8a90dc8331fa75c9acf433be7285cac0983902aaaa8f66aaa9)": "432097bd1a848ab41eee3695a2c5932c4aea987b27b1a61e58ac950ecce1214a" + }, + { + "desc": "4: node_id+nonce and payer_id+nonce", + "H(`LnBranch`,384853c9811863028876088ce34e75d784ac027fd564f103ea972cdf96236e4773e067757513706491e0da4e8077112e606da55c04239ad13ab609bc82907600)": "2ac9b0261d644027939d9a7bd055cb2468b79d92c6811d56a300c6b8ff97c14d" + }, + { + "desc": "5: 3 and 4", + "H(`LnBranch`,2ac9b0261d644027939d9a7bd055cb2468b79d92c6811d56a300c6b8ff97c14d432097bd1a848ab41eee3695a2c5932c4aea987b27b1a61e58ac950ecce1214a)": "608407c18ad9a94d9ea2bcdbe170b6c20c462a7833a197621c916f78cf18e624" + } + ], + "merkle": "608407c18ad9a94d9ea2bcdbe170b6c20c462a7833a197621c916f78cf18e624", + "signature_tag": "lightninginvoice_requestsignature", + "H(signature_tag,merkle)": "aefe3aa88a69772c246dcaef75ed3e7566c08ecc4e9f995233526a5651fc34cd", + "signature": "b8f83ea3288cfd6ea510cdb481472575141e8d8744157f98562d162cc1c472526fdb24befefbdebab4dbb726bbd1b7d8aec057f8fa805187e5950d2bbe0e5642" + } +] 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/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 0dcadfed9b1d..202b32df0ace 100755 --- a/tests/regtest/regtest.sh +++ b/tests/regtest/regtest.sh @@ -770,6 +770,24 @@ if [[ $1 == "watchtower" ]]; then wait_until_spent $ctx_id $output_index # alice's to_local gets punished fi +if [[ $1 == "bolt12" ]]; then + 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) 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..6226e85ee8db --- /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=int.to_bytes(len(blinded_path_hops), signed=False, byteorder='big') +) + +# 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, + local_height=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..a8084b10a227 --- /dev/null +++ b/tests/test_bolt12.py @@ -0,0 +1,551 @@ +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 +from electrum.lnonion import OnionHopsDataSingle +from electrum.lnutil import LnFeatures +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: + with self.assertRaises(Exception, 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_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': int.to_bytes(1, byteorder="big", signed=False), + '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) + + 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': int.to_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': int.to_bytes(1), + 'path': [dummy_path]}, + {'first_node_id': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'first_path_key': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'num_hops': int.to_bytes(1), + 'path': [dummy_path]}, + {'first_node_id': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'first_path_key': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'num_hops': int.to_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': int.to_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': int.to_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 f294c6da5b58..801c4f21e7e4 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 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 @@ -784,3 +787,80 @@ async def lnpeermgr_add_peer(*args, **kwargs): result = await cmds.add_peer(connection_string=connection_string, wallet=w) assert called == 2 self.assertTrue(result) + + 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(Exception): + 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_lnmsg.py b/tests/test_lnmsg.py index b7ca81798444..c01b54c2340f 100644 --- a/tests/test_lnmsg.py +++ b/tests/test_lnmsg.py @@ -1,12 +1,13 @@ import io +import os from electrum.lnmsg import (read_bigsize_int, write_bigsize_int, FieldEncodingNotMinimal, UnexpectedEndOfStream, LNSerializer, UnknownMandatoryTLVRecordType, MalformedMsg, MsgTrailingGarbage, MsgInvalidFieldOrder, encode_msg, decode_msg, UnexpectedFieldSizeForEncoder, OnionWireSerializer, - UnknownMsgType) + UnknownMsgType, _tlv_merkle_root, _read_tlv_record) from electrum.lnonion import OnionRoutingFailure -from electrum.util import bfh +from electrum.util import bfh, read_json_file from electrum.lnutil import ShortChannelID, LnFeatures from electrum.channel_db import NodeInfo from electrum import constants @@ -116,9 +117,8 @@ def test_read_tlv_stream_tests1(self): lnser.read_tlv_stream(fd=io.BytesIO(bfh("0329023da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb0000000000000001")), tlv_stream_name="n1") with self.assertRaises(UnexpectedEndOfStream): lnser.read_tlv_stream(fd=io.BytesIO(bfh("0330023da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb000000000000000100000000000001")), tlv_stream_name="n1") - # check if ECC point is valid?... skip for now. - #with self.assertRaises(Exception): - # lnser.read_tlv_stream(fd=io.BytesIO(bfh("0331043da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb00000000000000010000000000000002")), tlv_stream_name="n1") + with self.assertRaises(MalformedMsg): # check if ECC point is valid + lnser.read_tlv_stream(fd=io.BytesIO(bfh("0331043da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb00000000000000010000000000000002")), tlv_stream_name="n1") with self.assertRaises(MsgTrailingGarbage): lnser.read_tlv_stream(fd=io.BytesIO(bfh("0332023da092f6980e58d2c037173180e9a465476026ee50f96695963e8efe436f54eb0000000000000001000000000000000001")), tlv_stream_name="n1") with self.assertRaises(UnexpectedEndOfStream): @@ -413,3 +413,25 @@ def test_address_parsing_and_serialization(self): self.assertEqual(paf(taf(host, port)), [(host, port)]) for input_, output in valid_inputs_with_defined_output: self.assertEqual(paf(taf(*input_)), output) + + def test_bolt12_signature_test_vectors(self): + test_vector_file = os.path.join(os.path.dirname(__file__), "bolt12-signature-test.json") + vectors = read_json_file(test_vector_file) + leaf_key_prefix = "H(`LnLeaf`," + + def _extract_tlvs(vector): + tlvs = [] + for leaf in vector["leaves"]: + leaf_tlv_hex = next( # "H(`LnLeaf`,010203e8)" <- extract the tlv hex + k[len(leaf_key_prefix):-1] for k in leaf if k.startswith(leaf_key_prefix) + ) + tlv_bytes = bfh(leaf_tlv_hex) + tlv_type, _, _ = _read_tlv_record(fd=io.BytesIO(tlv_bytes)) + tlvs.append((tlv_type, tlv_bytes)) + return tlvs + + for vector in vectors: + with self.subTest(comment=vector["comment"]): + tlvs = _extract_tlvs(vector) + merkle_root = _tlv_merkle_root(tlvs) + self.assertEqual(vector["merkle"], merkle_root.hex()) diff --git a/tests/test_lnpeer.py b/tests/test_lnpeer.py index 19e023115521..7e5ec7206b50 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, diff --git a/tests/test_lnrouter.py b/tests/test_lnrouter.py index acc8f40b6db5..08adcda1bce9 100644 --- a/tests/test_lnrouter.py +++ b/tests/test_lnrouter.py @@ -1,3 +1,4 @@ +import os import random import unittest from math import inf @@ -8,9 +9,9 @@ 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) + get_trampoline_budget, create_trampoline_route_and_onion) from electrum.util import bfh -from electrum.lnutil import ShortChannelID, LnFeatures, PaymentFeeBudget +from electrum.lnutil import ShortChannelID, LnFeatures, UnblindedRoutingInfo, PaymentFeeBudget from electrum.lnonion import (OnionHopsDataSingle, new_onion_packet, process_onion_packet, _decode_onion_error, decode_onion_error, OnionFailureCode) @@ -488,15 +489,76 @@ def test_create_legacy_trampoline_onion_multiple_rtags(self): node_features=0 ), ] + routing_info = UnblindedRoutingInfo( + node_pubkey=node('c'), + payment_secret=urandom(32), + min_final_cltv_delta=0, + r_tags=[], # create_trampoline_onion uses r_tags from route + invoice_features=LnFeatures(0), + ) # create a trampoline onion, this shouldn't raise InvalidPayloadSize create_trampoline_onion( route=dummy_route, + routing_info=routing_info, amount_msat=0, - final_cltv_abs=0, + local_height=0, total_msat=0, payment_hash=urandom(32), + is_legacy=True, + blinded_path=None, + ) + + def test_create_trampoline_route_and_onion_unblinded_legacy(self): + routing_info = UnblindedRoutingInfo( + node_pubkey=node('c'), payment_secret=urandom(32), + min_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[0].start_node, node('a')) + self.assertEqual(route[0].end_node, node('b')) + self.assertEqual(len(route), 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[0].start_node, node('a')) + self.assertEqual(route[0].end_node, node('b')) + self.assertEqual(route[1].start_node, node('b')) + self.assertEqual(route[1].end_node, node('d')) + self.assertEqual(len(route), 2) @needs_test_with_all_chacha20_implementations def test_decode_onion_error(self): @@ -523,33 +585,40 @@ def test_decode_onion_error(self): async def test_find_path_for_onion_message(self): self.prepare_graph() - amount_to_send = 1000 # we route along channels, and we use find_path_for_payment, so dummy this. - path = self.path_finder.find_path_for_payment( - nodeA=node('a'), - nodeB=node('c'), - invoice_amount_msat=amount_to_send, - node_filter=is_onion_message_node) + path = self.path_finder.find_path_for_onion_message(nodeA=node('a'), nodeB=node('c')) self.assertEqual([ PathEdge(start_node=node('a'), end_node=node('d'), short_channel_id=channel(6)), PathEdge(start_node=node('d'), end_node=node('c'), short_channel_id=channel(4)), ], path) - # impossible routes - path = self.path_finder.find_path_for_payment( - nodeA=node('e'), - nodeB=node('a'), - invoice_amount_msat=amount_to_send, - node_filter=is_onion_message_node) + # node e doesn't support onion messages + path = self.path_finder.find_path_for_onion_message(nodeA=node('a'), nodeB=node('e')) self.assertIsNone(path) + async def test_find_path_for_onion_message_ignores_amount_constraints(self): + self.prepare_graph() + + # bump htlc_minimum_msat on channel(4) d->c direction + key = (node('d'), channel(4)) + self.cdb._policies[key] = self.cdb._policies[key]._replace(htlc_minimum_msat=10_000_000) + + # a small payment can no longer be routed path = self.path_finder.find_path_for_payment( nodeA=node('a'), - nodeB=node('e'), - invoice_amount_msat=amount_to_send, - node_filter=is_onion_message_node) + nodeB=node('c'), + invoice_amount_msat=1000, + node_filter=is_onion_message_node, + ) self.assertIsNone(path) + # but an onion message still routes through the same hop + path = self.path_finder.find_path_for_onion_message(nodeA=node('a'), nodeB=node('c')) + self.assertEqual([ + PathEdge(start_node=node('a'), end_node=node('d'), short_channel_id=channel(6)), + PathEdge(start_node=node('d'), end_node=node('c'), short_channel_id=channel(4)), + ], path) + def _tramp_edge(start: str, end: str, *, fee_base=PLACEHOLDER_FEE, fee_prop=PLACEHOLDER_FEE, cltv=576) -> TrampolineEdge: return TrampolineEdge( diff --git a/tests/test_lnutil.py b/tests/test_lnutil.py index 5df8755ce524..8d74f47a075f 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 substract_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.substract_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.substract_blinded_path_fees(payinfo, amount_msat=100_000) diff --git a/tests/test_lnwallet.py b/tests/test_lnwallet.py index 4c3919f07740..43f4ae46812d 100644 --- a/tests/test_lnwallet.py +++ b/tests/test_lnwallet.py @@ -1,13 +1,29 @@ +import asyncio +import dataclasses import logging import os -import asyncio from unittest import mock from decimal import Decimal from typing import Optional +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.gui.qt.wizard import wallet +from electrum.onion_message import NoOnionMessagePeers +from . import ElectrumTestCase +from .test_lnchannel import create_test_channels +from .test_wallet_vertical import UNICODE_HORROR + +from electrum import constants +from electrum.bolt12 import BOLT12Offer, BOLT12InvoiceRequest, BOLT12Invoice, BOLT12InvoicePathIDPayload, Bolt12InvoiceError +from electrum.crypto import sha256, hmac_oneshot +from electrum.lnonion import BlindedPath, BlindedPathHop, BlindedPathInfo, BlindedPayInfo 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,11 +33,37 @@ 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 +def get_dummy_paths(): + fake_pubkey = ECPrivkey.generate_random_key().get_public_key_bytes() + reply_paths = [BlindedPathInfo( + path=BlindedPath( + first_node_id=fake_pubkey, + first_path_key=fake_pubkey, + num_hops=(1).to_bytes(1, 'big'), + path=[BlindedPathHop( + blinded_node_id=fake_pubkey, + 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 reply_paths + + class TestLNWallet(ElectrumTestCase): TESTNET = True TEST_ANCHOR_CHANNELS = True @@ -68,6 +110,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 +547,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 8b02d239c939..85b9d3d5885a 100644 --- a/tests/test_onion_message.py +++ b/tests/test_onion_message.py @@ -16,13 +16,13 @@ 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 from electrum.onion_message import ( create_blinded_path, OnionMessageManager, NoRouteFound, Timeout, - create_route_to_introduction_point, get_blinded_paths_to_me + create_route_to_introduction_point, get_blinded_paths_to_me, NoOnionMessagePeers ) from electrum.util import bfh, read_json_file, OldTaskGroup, get_asyncio_loop from electrum.logging import console_stderr_handler @@ -163,17 +163,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', path_key=path_key) self.assertEqual(p.hop_data.blind_fields, {}) self.assertEqual(p.hop_data.hmac, bfh('a5296325ba478ba1e1a9d1f30a2d5052b2e2889bbd64f72c72bc71d8817288a2')) @@ -200,14 +192,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: @@ -215,7 +207,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')) @@ -247,10 +239,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( @@ -271,12 +263,13 @@ def __init__(self): self.config.EXPERIMENTAL_LN_FORWARD_PAYMENTS = True -class MockPeer: - their_features = LnFeatures(LnFeatures.OPTION_ONION_MESSAGE_OPT) +ONION_MESSAGE_CAPABLE_PEER_FEATURES = LnFeatures(LnFeatures.OPTION_ONION_MESSAGE_OPT) - def __init__(self, pubkey, on_send_message=None): +class MockPeer: + def __init__(self, pubkey, on_send_message=None, their_features=ONION_MESSAGE_CAPABLE_PEER_FEATURES): self.pubkey = pubkey self.on_send_message = on_send_message + self.their_features = their_features async def wait_one_htlc_switch_iteration(self, *args): pass @@ -284,7 +277,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) @@ -309,6 +302,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( @@ -345,13 +348,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): @@ -372,7 +400,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 @@ -386,21 +414,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) @@ -501,14 +535,14 @@ async def test_get_blinded_paths_to_me_payment(self): bob_update = decode_msg(bob_update_raw)[1] bob_update['raw'] = bob_update_raw alice_chan.set_remote_update(bob_update) + 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, @@ -518,13 +552,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 @@ -536,9 +570,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')