diff --git a/electrum/bolt12.py b/electrum/bolt12.py new file mode 100644 index 000000000000..6b6cbaa5afd4 --- /dev/null +++ b/electrum/bolt12.py @@ -0,0 +1,479 @@ +# -*- 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 os +import time +import attr +from decimal import Decimal +from typing import TYPE_CHECKING, Union, Optional, List, Tuple, Sequence + +import electrum_ecc as ecc + +from . import constants +from .bitcoin import COIN +from .json_db import stored_in, StoredObject +from .lnaddr import LnAddr +from .lnmsg import OnionWireSerializer, batched +from .lnutil import LnFeatures, hex_to_bytes, bytes_to_hex +from .onion_message import Timeout, get_blinded_paths_to_me, BlindedPathInfo +from .segwit_addr import bech32_decode, DecodedBech32, convertbits, bech32_encode, Encoding + +if TYPE_CHECKING: + from .lnworker import LNWallet + + +DEFAULT_INVOICE_EXPIRY = 3600 + + +def is_offer(data: str) -> bool: + d = bech32_decode(data, ignore_long_length=True, with_checksum=False) + if d == DecodedBech32(None, None, None): + return False + return d.hrp == 'lno' + + +def matches_our_chain(chains: 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 + chains = list(batched(chains, 32)) + chain_hash = constants.net.rev_genesis_bytes() + return tuple(chain_hash) in chains + + +def bolt12_bech32_to_bytes(data: str) -> bytes: + d = bech32_decode(data, ignore_long_length=True, with_checksum=False) + d = bytes(convertbits(d.data, 5, 8)) + # we bomb on trailing 0, remove + while d[-1] == 0: + d = d[:-1] + return d + + +def decode_offer(data: Union[str, bytes]) -> dict: + d = bolt12_bech32_to_bytes(data) if isinstance(data, str) else data + with io.BytesIO(d) as f: + result = OnionWireSerializer.read_tlv_stream(fd=f, tlv_stream_name='offer') + offer_chains = result.get('offer_chains', {}).get('chains') + if not matches_our_chain(offer_chains): + raise Exception('no matching chain') + return result + + +def decode_invoice_request(data: Union[str, bytes]) -> dict: + d = bolt12_bech32_to_bytes(data) if isinstance(data, str) else data + with io.BytesIO(d) as f: + result = OnionWireSerializer.read_tlv_stream(fd=f, tlv_stream_name='invoice_request', signing_key_path=('invreq_payer_id', 'key')) + invreq_chain = result.get('invreq_chain', {}).get('chain') + if not matches_our_chain(invreq_chain): + raise Exception('no matching chain') + return result + + +def decode_invoice(data: Union[str, bytes]) -> dict: + d = bolt12_bech32_to_bytes(data) if isinstance(data, str) else data + with io.BytesIO(d) as f: + return OnionWireSerializer.read_tlv_stream(fd=f, tlv_stream_name='invoice', signing_key_path=('invoice_node_id', 'node_id')) + + +def encode_offer(data: dict, *, as_bech32=False) -> Union[bytes, str]: + with io.BytesIO() as fd: + OnionWireSerializer.write_tlv_stream(fd=fd, tlv_stream_name='offer', **data) + if not as_bech32: + return fd.getvalue() + bech32_data = convertbits(list(fd.getvalue()), 8, 5, True) + return bech32_encode(Encoding.BECH32, 'lno', bech32_data, with_checksum=False) + + +def encode_invoice_request(data: dict, payer_key: bytes) -> bytes: + with io.BytesIO() as fd: + OnionWireSerializer.write_tlv_stream(fd=fd, tlv_stream_name='invoice_request', signing_key=payer_key, **data) + return fd.getvalue() + + +def encode_invoice(data: dict, signing_key: bytes) -> bytes: + with io.BytesIO() as fd: + OnionWireSerializer.write_tlv_stream(fd=fd, tlv_stream_name='invoice', signing_key=signing_key, **data) + return fd.getvalue() + + +def create_offer( + *, + offer_paths: Optional[Sequence[BlindedPathInfo]] = None, + node_id: Optional[bytes] = None, + amount_msat: Optional[int] = None, + memo: Optional[str] = None, + expiry: Optional[int] = None, + issuer: Optional[str] = None, +): + offer_id = os.urandom(16) + offer = { + 'offer_metadata': {'data': offer_id}, + 'offer_description': {'description': memo}, + } + + if constants.net != constants.BitcoinMainnet: + offer.update({'offer_chains': {'chains': constants.net.rev_genesis_bytes()}}) + + if not offer_paths: + offer.update({'offer_issuer_id': {'id': node_id}}) + else: + # TODO: remove adding of offer_issuer_id, once we can sign invoices properly based on invreq used blinded path + offer.update({'offer_issuer_id': {'id': node_id}}) + offer.update({'offer_paths': {'paths': [x.path for x in offer_paths]}}) + + if issuer: + offer.update({'offer_issuer': {'issuer': issuer}}) + + if amount_msat: + offer['offer_amount'] = {'amount': amount_msat} + + if expiry: + now = int(time.time()) + offer['offer_absolute_expiry'] = {'seconds_from_epoch': now + expiry} + + return offer_id, offer + + +@stored_in('offers') +@attr.s +class Offer(StoredObject): + offer_id = attr.ib(kw_only=True, type=bytes, converter=hex_to_bytes, repr=bytes_to_hex) + offer_bech32 = attr.ib(kw_only=True, type=str) + + +# @stored_in('path_ids') +# @attr.s +# class PathId(StoredObject): +# # payment_hash = attr.ib(kw_only=True, type=bytes, converter=hex_to_bytes, repr=bytes_to_hex) +# path_id = attr.ib(kw_only=True, type=bytes, converter=hex_to_bytes, repr=bytes_to_hex) + + +def to_lnaddr(data: dict) -> LnAddr: + # FIXME: abusing BOLT11 oriented LnAddr for BOLT12 fields + net = constants.net + addr = LnAddr() + + # NOTE: CLN puts the real node_id here, which is defeats the whole purpose of blinded paths + # also, this should not be used as routing destination in payments (introduction point in set of blinded paths + # must be used instead + pubkey = data.get('invoice_node_id').get('node_id') + + class WrappedBytesKey: + serialize = lambda: pubkey + addr.pubkey = WrappedBytesKey + addr.net = net + addr.date = data.get('invoice_created_at').get('timestamp') + addr.paymenthash = data.get('invoice_payment_hash').get('payment_hash') + addr.payment_secret = b'\x00' * 32 # Note: payment secret is not needed, recipient can use path_id in encrypted_recipient_data + msat = data.get('invoice_amount', {}).get('msat', None) + if msat is not None: + addr.amount = Decimal(msat) / COIN / 1000 + fallbacks = data.get('invoice_fallbacks', []) + fallbacks = list(filter(lambda x: x['version'] <= 16 and 2 <= len(x['address'] <= 40), fallbacks)) + if fallbacks: + addr.tags.append(('f', fallbacks[0])) + exp = data.get('invoice_relative_expiry', {}).get('seconds_from_creation', 0) + if exp: + addr.tags.append(('x', int(exp))) + description = data.get('offer_description', {}).get('description') + if description: + addr.tags.append(('d', description)) + features = data.get('invoice_features', {}).get('features') + if features: + # CLN (v25.09) doesn't add the assumed (see BOLT9) features to BOLT12 invoices, we add them here + addr.tags.append(('9', LnFeatures(int.from_bytes(features, byteorder="big", signed=False)).with_assumed().for_invoice())) + return addr + + +async def request_invoice( + lnwallet: 'LNWallet', + bolt12_offer: dict, + amount_msat: int, + *, + note: Optional[str] = None, +) -> Tuple[dict, bytes]: + # NOTE: offer_chains isn't checked here, bolt12.decode_offer already raises on invalid chains. + + # - if it chooses to send an `invoice_request`, it sends an onion message: + # - if `offer_paths` is set: + # - MUST send the onion message via any path in `offer_paths` to the final `onion_msg_hop`.`blinded_node_id` in that path + # - otherwise: + # - MUST send the onion message to `offer_issuer_id` + # - MAY send more than one `invoice_request` onion message at once. + + offer_paths = bolt12_offer.get('offer_paths') + if offer_paths: + paths = offer_paths.get('paths') # type? + assert len(paths) + node_id_or_blinded_paths = [] + for path in paths: + with io.BytesIO() as fd: + OnionWireSerializer.write_field(fd=fd, field_type='blinded_path', count=1, value=path) + node_id_or_blinded_paths.append(fd.getvalue()) + else: + node_id_or_blinded_paths = bolt12_offer['offer_issuer_id']['id'] + + # spec: MUST set invreq_payer_id to a transient public key. + # spec: MUST remember the secret key corresponding to invreq_payer_id. + session_key = os.urandom(32) + blinding = ecc.ECPrivkey(session_key).get_public_key_bytes() + + # One is a response to an offer; this contains the `offer_issuer_id` or `offer_paths` and + # all other offer details, and is generally received over an onion + # message: if it's valid and refers to a known offer, the response is + # generally to reply with an `invoice` using the `reply_path` field of + # the onion message. + invreq_data = copy.deepcopy(bolt12_offer) # include all fields of the offer + invreq_data.update({ + 'invreq_payer_id': {'key': blinding}, + 'invreq_metadata': {'blob': os.urandom(8)}, # TODO: fill invreq_metadata unique, and store for association + 'invreq_amount': {'msat': amount_msat}, + }) + + if note: + invreq_data['invreq_payer_note'] = {'note': note} + + if constants.net != constants.BitcoinMainnet: + invreq_data['invreq_chain'] = {'chain': constants.net.rev_genesis_bytes()} + + invreq_tlv = encode_invoice_request(invreq_data, session_key) + req_payload = { + 'invoice_request': {'invoice_request': invreq_tlv} + } + + try: + lnwallet.logger.info(f'requesting bolt12 invoice') + rcpt_data, payload = await lnwallet.onion_message_manager.submit_send( + payload=req_payload, node_id_or_blinded_paths=node_id_or_blinded_paths + ) + lnwallet.logger.debug(f'{rcpt_data=} {payload=}') + if 'invoice_error' in payload: + return _raise_invoice_error(payload) + if 'invoice' not in payload: + raise Exception('reply is not an invoice') + invoice_tlv = payload['invoice']['invoice'] + invoice_data = decode_invoice(invoice_tlv) + lnwallet.logger.info('received bolt12 invoice') + lnwallet.logger.debug(f'invoice_data: {invoice_data!r}') + bech32_data = convertbits(list(invoice_tlv), 8, 5, True) + invoice_bech32 = bech32_encode(Encoding.BECH32, 'lni', bech32_data, with_checksum=False) + lnwallet.logger.debug(f'invoice bech32: {invoice_bech32}') + except Timeout: + lnwallet.logger.info('timeout waiting for bolt12 invoice') + raise + except Exception as e: + lnwallet.logger.error(f'error requesting bolt12 invoice: {e!r}') + raise + + # validation https://github.com/lightning/bolts/blob/master/12-offer-encoding.md#requirements-1 + # NOTE: assumed scenario: invoice in response to invoice_request + if any(invoice_data.get(x) is None for x in [ + 'invoice_amount', 'invoice_created_at', 'invoice_payment_hash', + 'invoice_node_id', 'invoice_paths', 'invoice_blindedpay' + ]): + raise Exception('invalid bolt12 invoice') + + # - MUST reject the invoice if num_hops is 0 in any blinded_path in invoice_paths. + invoice_paths = invoice_data.get('invoice_paths').get('paths') + for invoice_path in invoice_paths: + if len(invoice_path.get('path', [])) == 0: + raise Exception('invalid bolt12 invoice, zero-length invoice_path present') + + # - MUST reject the invoice if invoice_blindedpay does not contain exactly one blinded_payinfo per invoice_paths.blinded_path. + if len(invoice_paths) != len(invoice_data.get('invoice_blindedpay').get('payinfo', [])): + raise Exception('invalid bolt12 invoice, incorrect number of invoice_blindedpay.payinfo found') + + # - MUST reject the invoice if all fields in ranges 0 to 159 and 1000000000 to 2999999999 (inclusive) do not exactly match the invoice request. + invreq_keys = filter(lambda key: 0 <= key[0] <= 159 or 1_000_000_000 <= key[0] <= 2_999_999_999, + OnionWireSerializer.in_tlv_stream_get_record_name_from_type['invoice_request'].items()) + for ftype, fkey in invreq_keys: + if not invoice_data.get(fkey) == invreq_data.get(fkey): + raise Exception(f'invalid bolt12 invoice, non-matching invreq {fkey=}') + # - MUST reject the invoice if invoice_node_id is not equal to offer_issuer_id if offer_issuer_id is present + if offer_issuer_id := bolt12_offer.get('offer_issuer_id', {}).get('id'): + if not invoice_data.get('invoice_node_id', {}).get('node_id') == offer_issuer_id: + raise Exception(f'invalid bolt12 invoice, invoice_node_id does not match offer_issuer_id') + # TODO: otherwise MUST reject the invoice if invoice_node_id is not equal to the final blinded_node_id it sent the invoice request to. + + # - MUST reject the invoice if invoice_amount is not equal to invreq_amount if invreq_amount is present + # - otherwise SHOULD confirm authorization if invoice_amount.msat is not within the amount range authorized. + if invoice_amount := invoice_data.get('invoice_amount', {}).get('msat'): + if invoice_amount != amount_msat: + raise Exception('invoice bolt12 invoice, invoice_amount != invreq_amount') + + # TODO: + # - invoice_features checks + # - invoice_blindedpay.payinfo matches invoice_paths.blinded_path and features + # - fallback address checks + # - MUST reject the invoice if it did not arrive via one of the paths in invreq_paths + + return invoice_data, invoice_tlv + + +def verify_request_and_create_invoice( + lnwallet: 'LNWallet', + bolt12_offer: dict, + bolt12_invreq: dict, + invoice_expiry: int = 0, +) -> dict: + now = int(time.time()) + + # - MUST reject the invoice request if the offer fields do not exactly match a valid, unexpired offer. + offer_keys = filter(lambda key: 0 <= key[0] <= 159 or 1_000_000_000 <= key[0] <= 2_999_999_999, + OnionWireSerializer.in_tlv_stream_get_record_name_from_type['offer'].items()) + for ftype, fkey in offer_keys: + if not bolt12_offer.get(fkey) == bolt12_invreq.get(fkey): + raise InvoiceRequestException(f'invalid bolt12 invoice_request, non-matching offer {fkey=}') + + # TODO check constraints, like expiry, offer_amount etc + if offer_expiry := bolt12_offer.get('offer_absolute_expiry', {}).get('seconds_from_epoch'): + if now > offer_expiry: + raise Bolt12InvoiceError('offer expired') + + # spec: MUST reject the invoice request if invreq_payer_id or invreq_metadata are not present. + # NOTE: invreq_payer_id already checked as part of signature verification + if not bolt12_invreq.get('invreq_metadata', {}).get('blob'): + raise InvoiceRequestException('invreq_metadata missing') + + # TODO: store invreq_metadata in lnwallet (no need for persistence) + # spec: if offer_issuer_id is present, and invreq_metadata is identical to a previous invoice_request: + # MAY simply reply with the previous invoice. + # otherwise: + # MUST NOT reply with a previous invoice. + + # copy the invreq and offer fields + invoice = copy.deepcopy(bolt12_invreq) + del invoice['signature'] # remove the signature from the invreq + + # spec: if invreq_amount is present: MUST set invoice_amount to invreq_amount + # otherwise: 'expected' amount (or amount == 0 invoice? or min_htlc_msat from channel set?) + amount_msat = 0 + if bolt12_invreq.get('invreq_amount'): + amount_msat = bolt12_invreq['invreq_amount']['msat'] + elif bolt12_invreq.get('offer_amount'): + amount_msat = bolt12_invreq['offer_amount']['amount'] + else: # TODO: raise if neither offer nor invreq specify amount? + pass + + invoice_payment_hash = lnwallet.create_payment_info(amount_msat=amount_msat) # TODO cltv, expiry + + if invoice_expiry <= 0: + invoice_expiry = DEFAULT_INVOICE_EXPIRY + + # determine invoice features + # TODO: not yet supporting jit channels. see lnwallet.get_bolt11_invoice() + invoice_features = lnwallet.features.for_invoice() + if not lnwallet.uses_trampoline(): + invoice_features &= ~ LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT_ELECTRUM + + invoice.update({ + 'invoice_amount': {'msat': amount_msat}, + 'invoice_created_at': {'timestamp': now}, + 'invoice_relative_expiry': {'seconds_from_creation': invoice_expiry}, + 'invoice_payment_hash': {'payment_hash': invoice_payment_hash}, + 'invoice_features': {'features': invoice_features.to_tlv_bytes()} + }) + + # spec: if offer_issuer_id is present: MUST set invoice_node_id to the offer_issuer_id + # spec: otherwise, if offer_paths is present: MUST set invoice_node_id to the final blinded_node_id + # on the path it received the invoice request + if bolt12_offer.get('offer_issuer_id'): + invoice.update({ + 'invoice_node_id': {'node_id': bolt12_offer['offer_issuer_id']['id']} + }) + # TODO for non-blinded path, where to store payment secret? + else: + # NOTE: requires knowledge of invreq incoming path and its final blinded_node_id + # and corresponding secret for signing invoice + + # if offer_paths := bolt12_offer.get('offer_paths', {}).get('paths'): + # # TODO match path, assuming path[0] for now + # path_last_blinded_node_id = offer_paths[0].get('path')[-1].get('blinded_node_id') + # invoice.update({ + # 'invoice_node_id': {'node_id': path_last_blinded_node_id} + # }) + + # we don't have invreq used path available here atm. see also request_invoice() + raise Exception('branch not implemented, electrum should set offer_issuer_id') + + recipient_data = {} + + # collect suitable channels for payment + invoice_channels = [ + chan for chan in lnwallet.channels.values() + if chan.is_active() and chan.can_receive(amount_msat=amount_msat, check_frozen=True) + ] + if not invoice_channels: + raise InvoiceRequestException('no active channels with sufficient receive capacity, ignoring invoice_request.') + + invoice_paths = get_blinded_paths_to_me( + lnwallet, final_recipient_data=recipient_data, my_channels=invoice_channels) + + invoice.update({ + 'invoice_paths': {'paths': [x.path for x in invoice_paths]}, + 'invoice_blindedpay': {'payinfo': [x.payinfo for x in invoice_paths]} + }) + + lnwallet.add_path_ids_for_payment_hash(invoice_payment_hash, invoice_paths) + + return invoice + + +class InvoiceRequestException(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 + + 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 _raise_invoice_error(payload): + invoice_error_tlv = payload['invoice_error']['invoice_error'] + with io.BytesIO(invoice_error_tlv) as fd: + invoice_error = OnionWireSerializer.read_tlv_stream(fd=fd, tlv_stream_name='invoice_error') + raise Bolt12InvoiceError(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')) diff --git a/electrum/commands.py b/electrum/commands.py index 4a60e8b3e050..1bf98ca550da 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -43,15 +43,16 @@ import electrum_ecc as ecc -from . import util +from . import util, bolt12 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 .segwit_addr import bech32_encode, Encoding, convertbits, 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 @@ -1375,6 +1376,93 @@ 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, + 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 + """ + amount_msat = satoshis(amount) * 1000 if amount else None + bolt12_offer = bolt12.decode_offer(offer) + offer_amount = bolt12_offer.get('offer_amount') + offer_amount_msat = offer_amount.get('amount') + if amount_msat and offer_amount_msat: + assert amount_msat == offer_amount_msat + lnworker = wallet.lnworker + bolt12_invoice, bolt12_invoice_tlv = await bolt12.request_invoice(lnworker, bolt12_offer, amount_msat or offer_amount_msat) + invoice = Invoice.from_bolt12_invoice_tlv(bolt12_invoice_tlv) + success, log = await lnworker.pay_invoice(invoice) + return { + 'success': success, + 'log': [x.formatted_tuple() for x in log] + } + + @command('wnl') + async def add_offer( + self, + amount: Optional[Decimal] = None, + memo: Optional[str] = '', + expiry: Optional[int] = 3600, + issuer: Optional[str] = None, + wallet: Abstract_Wallet = None + ): + """Create a bolt12 offer. + + arg:decimal:amount:Requested amount (in btc) + arg:str:memo:Description of the request + arg:int:expiry:Time in seconds. + arg:str:issuer:Issuer string + """ + amount_msat = satoshis(amount) * 1000 if amount else None + expiry = int(expiry) if expiry else None + key = wallet.lnworker.create_offer(amount_msat=amount_msat, memo=memo, expiry=expiry, issuer=issuer) + offer = wallet.lnworker.get_offer(key) + + return { + 'id': key.hex(), + 'offer': offer.offer_bech32 + } + + @command('wl') + async def get_offer(self, offer_id, wallet: Abstract_Wallet = None): + """ + retrieve bolt12 offer + arg:str:offer_id:the offer id + """ + id_ = bfh(offer_id) + offer = wallet.lnworker.get_offer(id_) + return { + 'id': offer_id, + 'offer': offer.offer_bech32 + } if offer else {} + + @command('wl') + async def delete_offer(self, offer_id, wallet: Abstract_Wallet = None): + """ + delete bolt12 offer + arg:str:offer_id:the offer id + """ + wallet.lnworker.delete_offer(bfh(offer_id)) + + @command('wl') + async def list_offers(self, wallet: Abstract_Wallet = None): + """ + list bolt12 offers + """ + result = [] + for offer_id, offer in wallet.lnworker.offers.items(): + result.append({ + 'id': offer_id.hex(), + 'offer': offer.offer_bech32 + }) + return result + @command('wnl') async def add_hold_invoice( self, @@ -2214,6 +2302,22 @@ async def get_blinded_path_via(self, node_id: str, dummy_hops: int = 0, wallet: return encoded_blinded_path.hex() + @command('') + async def decode_bolt12(self, bech32: str): + """Decode bolt12 object + + arg:str:bech32:invoice or offer + """ + dec = bolt12.bech32_decode(bech32, ignore_long_length=True, with_checksum=False) + if dec == INVALID_BECH32: + raise Exception('invalid bech32') + d = { + 'lni': bolt12.decode_invoice, + 'lno': bolt12.decode_offer, + 'lnr': bolt12.decode_invoice_request, + }[dec.hrp](bech32) + return json_encode(d) + 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 51f41de5a745..4d2bb2dc9594 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 @@ -106,6 +110,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 @@ -420,6 +448,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 a9755ac81390..4b91f0322f52 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) { @@ -482,6 +482,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 { @@ -795,6 +809,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 c3a9a8f33f21..0e085db7da8f 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -8,22 +8,25 @@ 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, BOLT12_INVOICE_PREFIX ) 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.paymentrequest import PaymentRequest -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.bolt12 import decode_invoice from .qetypes import QEAmount from .qewallet import QEWallet from .util import status_update_timer_interval, QtEventListener, event_listener -from ...util import InvoiceError class QEInvoice(QObject, QtEventListener): @@ -33,6 +36,7 @@ class Type(IntEnum): OnchainInvoice = 0 LightningInvoice = 1 LNURLPayRequest = 2 + Bolt12Offer = 3 @pyqtEnum class Status(IntEnum): @@ -140,11 +144,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 @@ -152,7 +156,7 @@ def amount(self): return self._amount @pyqtProperty(QEAmount, notify=amountOverrideChanged) - def amountOverride(self): + def amountOverride(self) -> QEAmount: return self._amountOverride @amountOverride.setter @@ -244,18 +248,39 @@ def set_lnprops(self): return lnaddr = self._effectiveInvoice._lnaddr - ln_routing_info = lnaddr.get_routing_info('r') - self._logger.debug(str(ln_routing_info)) - self._lnprops = { + invoice_str = self._effectiveInvoice.lightning_invoice + is_bolt12 = invoice_str.startswith(BOLT12_INVOICE_PREFIX) + + lnprops = { + 'is_bolt12': is_bolt12, '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 [] } + if is_bolt12: + b12i = decode_invoice(bfh(invoice_str[len(BOLT12_INVOICE_PREFIX):])) + paths = b12i.get('invoice_paths', {}).get('paths') + issuer = b12i.get('offer_issuer', {}).get('issuer', '') + lnprops.update({ + 'blinded_paths': [{ + 'first_node': self.name_for_node_id(x.get('first_node_id')), + 'path_length': int.from_bytes(x.get('num_hops'), "big") + } for x in paths], + 'issuer': issuer + }) + else: + ln_routing_info = lnaddr.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.get_node_alias(node_id) if lnworker else None) or node_id.hex() @@ -271,6 +296,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() @@ -438,6 +465,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): @@ -445,6 +476,7 @@ def __init__(self, parent=None): self._pi = None self._lnurlData = None + self._offerData = None self._busy = False self.clear() @@ -455,6 +487,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) @@ -465,6 +498,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 @@ -472,7 +513,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 = '' @@ -497,6 +540,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, payment_request, uri): return self._wallet.wallet.create_invoice( outputs=outputs, @@ -530,7 +579,7 @@ def validateRecipient(self, pi: PaymentIdentifier): PaymentIdentifierType.BIP70, PaymentIdentifierType.BOLT11, PaymentIdentifierType.LNADDR, PaymentIdentifierType.LNURLP, PaymentIdentifierType.EMAILLIKE, PaymentIdentifierType.DOMAINLIKE, - PaymentIdentifierType.OPENALIAS, + PaymentIdentifierType.OPENALIAS, PaymentIdentifierType.BOLT12_OFFER ]: self.validationError.emit('unknown', _('Unknown invoice')) return @@ -557,6 +606,10 @@ def _update_from_payment_identifier(self): self._bip70_payment_request_resolved(self._pi.bip70_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)] @@ -565,8 +618,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.')) @@ -615,6 +668,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): @@ -657,6 +719,43 @@ 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): + self._logger.debug(f'on_bolt12_invoice {bolt12_invoice!r}') + + invoice = Invoice.from_bolt12_invoice_tlv(self._pi.bolt12_invoice_tlv) + # # assure no shenanigans with the invoice we get back + if orig_amount * 1000 != 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(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 9c3442da4308..2bc551dc66d3 100644 --- a/electrum/gui/qml/qeinvoicelistmodel.py +++ b/electrum/gui/qml/qeinvoicelistmodel.py @@ -6,7 +6,7 @@ from electrum.logging import get_logger from electrum.util import Satoshis, format_time -from electrum.invoices import BaseInvoice, PR_EXPIRED, LN_EXPIRY_NEVER, Invoice, Request, PR_PAID +from electrum.invoices import BaseInvoice, PR_EXPIRED, LN_EXPIRY_NEVER, Invoice, Request, PR_PAID, BOLT12_INVOICE_PREFIX from .util import QtEventListener, qt_event_listener, status_update_timer_interval from .qetypes import QEAmount @@ -21,7 +21,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)) @@ -133,7 +133,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): @@ -193,9 +193,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'] = invoice.lightning_invoice and invoice.lightning_invoice.startswith(BOLT12_INVOICE_PREFIX) return item @@ -228,7 +229,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 0a56d9376909..336004b580c9 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_tlv(): + icon_name = 'bolt12.png' + else: + icon_name = 'lightning.png' else: icon_name = 'bitcoin.png' if item.bip70: diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 482a4bba0612..62a7fe564e75 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, - paymentrequest, lnutil) + paymentrequest, lnutil, bolt12) from electrum.bitcoin import COIN, is_address, DummyAddress from electrum.plugin import run_hook from electrum.i18n import _ @@ -1711,7 +1711,13 @@ def do_export(): def show_lightning_invoice(self, invoice: Invoice): from electrum.util import format_short_id - lnaddr = lndecode(invoice.lightning_invoice) + if bolt12_invoice_tlv := invoice.bolt12_invoice_tlv(): + bolt12_inv = bolt12.decode_invoice(bolt12_invoice_tlv) + lnaddr = bolt12.to_lnaddr(bolt12_inv) + elif invoice.lightning_invoice: + lnaddr = lndecode(invoice.lightning_invoice) # FIXME: assumes BOLT11, should be abstracted by Invoice + else: + raise Exception() d = WindowModalDialog(self, _("Lightning Invoice")) vbox = QVBoxLayout(d) grid = QGridLayout() diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index b3bdbd60ed71..9c13501275e3 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -456,7 +456,8 @@ def update_fields(self): lock_recipient = pi.type in [PaymentIdentifierType.LNURL, PaymentIdentifierType.LNURLW, PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR, PaymentIdentifierType.OPENALIAS, PaymentIdentifierType.BIP70, - 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] @@ -498,8 +499,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 '') @@ -586,7 +588,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 eb55dfe01c10..c66bb074cb2f 100644 --- a/electrum/invoices.py +++ b/electrum/invoices.py @@ -6,7 +6,7 @@ from .json_db import StoredObject, stored_in from .i18n import _ -from .util import age, InvoiceError, format_satoshis +from .util import age, InvoiceError, format_satoshis, bfh from .bip21 import create_bip21_uri from .lnutil import hex_to_bytes from .lnaddr import lndecode, LnAddr @@ -94,6 +94,9 @@ def _decode_outputs(outputs) -> Optional[List[PartialTxOutput]]: LN_EXPIRY_NEVER = 100 * 365 * 24 * 60 * 60 # 100 years +BOLT12_INVOICE_PREFIX = 'bolt12_invoice_tlv:' + + @attr.s class BaseInvoice(StoredObject): """ @@ -232,6 +235,33 @@ def from_bech32(cls, invoice: str) -> 'Invoice': lightning_invoice=invoice, ) + @classmethod + def from_bolt12_invoice_tlv(cls, bolt12_invoice_tlv: bytes) -> 'Invoice': + # FIXME: due to + # 1) the codebase assumption of Invoice to be serialized to WalletDB and + # 2) asymmetry of storing bytes fields in WalletDB (returns as hex str) we cannot store bolt12_invoice + # in WalletDB directly without explicit conversion of each bytes field occurrence. Instead, store whole + # invoice TLV as hex str and re-parse when needed. + from .bolt12 import decode_invoice + bolt12_invoice = decode_invoice(bolt12_invoice_tlv) + amount_msat = bolt12_invoice.get('invoice_amount').get('msat') + timestamp = bolt12_invoice.get('invoice_created_at').get('timestamp') + exp_delay = bolt12_invoice.get('invoice_relative_expiry', {}).get('seconds_from_creation', 0) + message = bolt12_invoice.get('offer_description', {}).get('description', '') + + # TODO: check payer id? + # check htlc minmax from invoice_blindedpay? + return Invoice( + message=message, + amount_msat=amount_msat, + time=timestamp, + exp=exp_delay, + outputs=None, + bip70=None, + height=0, + lightning_invoice=BOLT12_INVOICE_PREFIX + bolt12_invoice_tlv.hex() + ) + @classmethod def from_bip70_payreq(cls, pr: 'PaymentRequest', *, height: int = 0) -> 'Invoice': return Invoice( @@ -289,10 +319,24 @@ def get_address(self) -> Optional[str]: address = self._lnaddr.get_fallback_address() or None return address + def _is_bolt12_invoice(self) -> bool: + return bool(self.bolt12_invoice_tlv()) + + def bolt12_invoice_tlv(self) -> Optional[bytes]: + if self.lightning_invoice is None or not self.lightning_invoice.startswith(BOLT12_INVOICE_PREFIX): + return None + return bfh(self.lightning_invoice[len(BOLT12_INVOICE_PREFIX):]) + @property def _lnaddr(self) -> LnAddr: if self.__lnaddr is None: - self.__lnaddr = lndecode(self.lightning_invoice) + if self._is_bolt12_invoice(): + from .bolt12 import decode_invoice, to_lnaddr + invoice_tlv = bfh(self.lightning_invoice[len(BOLT12_INVOICE_PREFIX):]) + bolt12_invoice = decode_invoice(invoice_tlv) + self.__lnaddr = to_lnaddr(bolt12_invoice) + else: + self.__lnaddr = lndecode(self.lightning_invoice) return self.__lnaddr @property @@ -303,8 +347,14 @@ def rhash(self) -> str: @lightning_invoice.validator def _validate_invoice_str(self, attribute, value): if value is not None: - lnaddr = lndecode(value) # this checks the str can be decoded - self.__lnaddr = lnaddr # save it, just to avoid having to recompute later + if value.startswith(BOLT12_INVOICE_PREFIX): + from .bolt12 import decode_invoice, to_lnaddr + invoice_tlv = bfh(value[len(BOLT12_INVOICE_PREFIX):]) + bolt12_invoice = decode_invoice(invoice_tlv) + self.__lnaddr = to_lnaddr(bolt12_invoice) + else: + lnaddr = lndecode(value) # this checks the str can be decoded + self.__lnaddr = lnaddr # save it, just to avoid having to recompute later def can_be_paid_onchain(self) -> bool: if self.is_lightning(): diff --git a/electrum/lnmsg.py b/electrum/lnmsg.py index eeb83af503c1..1f85f8e1436a 100644 --- a/electrum/lnmsg.py +++ b/electrum/lnmsg.py @@ -1,3 +1,4 @@ +import itertools import os import csv import io @@ -5,6 +6,9 @@ from types import MappingProxyType from collections import OrderedDict +import electrum_ecc as ecc + +from . import bitcoin from .lnutil import OnionFailureCodeMetaFlag @@ -12,10 +16,10 @@ 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,10 @@ def _read_primitive_field( buf = fd.read(total_len) if total_len >= 0 and len(buf) != total_len: raise UnexpectedEndOfStream() + + if field_type == 'utf8': + return buf.decode('utf-8') + return buf @@ -186,7 +200,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 +260,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 +274,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 +294,17 @@ 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.seek(0, 1) 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.seek(0, 1) + rawlen = pos_end - pos_start + fd.seek(-rawlen, 1) + rawbytes = fd.read(rawlen) + return tlv_type, tlv_val, rawbytes def _write_tlv_record(*, fd: io.BytesIO, tlv_type: int, tlv_val: bytes) -> None: @@ -321,6 +346,47 @@ def _parse_msgtype_intvalue_for_onion_wire(value: str) -> int: return msg_type_int +def batched(iterable, n): # itertools.batched available from python >=3.12 + # batched('ABCDEFG', 3) --> ABC DEF G + if n < 1: + raise ValueError('n must be at least one') + it = iter(iterable) + while batch := tuple(itertools.islice(it, n)): + yield batch + + +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 batch in batched(tlv_merkle_nodes, 2): + if len(batch) == 1: + target.append(batch[0]) + else: + msg = batch[0] + batch[1] if batch[0] < batch[1] else batch[1] + batch[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] + + class LNSerializer: def __init__(self, *, for_onion_wire: bool = False): @@ -396,7 +462,7 @@ def __init__(self, *, for_onion_wire: bool = False): 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? def write_field( self, @@ -498,14 +564,31 @@ 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: bytes = None, **kwargs) -> None: + sign_over_tlvs = [] + 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] if tlv_record_name not in kwargs: - continue + # skip record_name if not in kwargs, unless we need to generate it + if tlv_record_name != 'signature' or signing_key is None: + continue + else: + # 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') + b'signature' + signature = priv.schnorr_sign(bitcoin.bip340_tagged_hash(tag, merkle_root)) + kwargs[tlv_record_name] = {'sig': signature} with io.BytesIO() as tlv_record_fd: for row in scheme: if row[0] == "tlvtype": @@ -528,14 +611,29 @@ 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() + _write_tlv_record(fd=fd, tlv_type=tlv_record_type, tlv_val=tlv_val) + + # if we need to sign the tlvs, we need the entire TLV, so serialize again + # and collect in `sign_over_tlvs` + # NOTE: assumption: there are no fields after 'signature' (240) + if signing_key and tlv_record_name != 'signature': + with io.BytesIO() as tlvfd: + _write_tlv_record(fd=tlvfd, tlv_type=tlv_record_type, tlv_val=tlv_val) + sign_over_tlvs.append((tlv_record_type, tlvfd.getvalue())) + + 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_seen = False parsed = {} # type: Dict[str, Dict[str, Any]] scheme_map = self.in_tlv_stream_get_tlv_record_scheme_from_type[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}") @@ -550,6 +648,24 @@ def read_tlv_stream(self, *, fd: io.BytesIO, tlv_stream_name: str) -> Dict[str, # unknown "odd" type: skip it continue tlv_record_name = self.in_tlv_stream_get_record_name_from_type[tlv_stream_name][tlv_record_type] + # collect tlvs for signature check + if signing_key_path: + if tlv_record_name == 'signature': + signature_seen = True + # verify + merkle_root = _tlv_merkle_root(sign_over_tlvs) + signature = tlv_record_val + tag = b'lightning' + tlv_stream_name.encode('ascii') + b'signature' + tagh = bitcoin.bip340_tagged_hash(tag, merkle_root) + signing_key = parsed + for key in signing_key_path: # walk signing_key_path + signing_key = signing_key[key] + assert isinstance(signing_key, bytes) + correct = ecc.ECPubkey(signing_key).schnorr_verify(signature, tagh) + if not correct: + raise MsgInvalidSignature(f"invalid signature in {'.'.join(signing_key_path)}") + 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: @@ -576,6 +692,8 @@ 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 and not signature_seen: + raise MalformedMsg(f"signature expected but missing") return parsed def encode_msg(self, msg_type: str, **kwargs) -> bytes: diff --git a/electrum/lnonion.py b/electrum/lnonion.py index 7760fd4459c0..4d1a6f9579a8 100644 --- a/electrum/lnonion.py +++ b/electrum/lnonion.py @@ -42,6 +42,10 @@ from . import lnmsg from . import util +from .logging import get_logger +_logger = get_logger(__name__) + + if TYPE_CHECKING: from .lnrouter import LNPaymentRoute @@ -51,6 +55,7 @@ PER_HOP_HMAC_SIZE = 32 ONION_MESSAGE_LARGE_SIZE = 32768 + class UnsupportedOnionPacketVersion(Exception): pass class InvalidOnionMac(Exception): pass class InvalidOnionPubkey(Exception): pass @@ -199,6 +204,25 @@ def get_blinded_node_id(node_id: bytes, shared_secret: bytes): return blinded_node_id.get_public_key_bytes() +def blinding_privkey(privkey: bytes, blinding: bytes) -> bytes: + shared_secret = get_ecdh(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(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") + return our_privkey + + +def next_blinding_from_shared_secret(pubkey: bytes, shared_secret: bytes) -> bytes: + # E_i+1=SHA256(E_i||ss_i) * E_i + blinding_factor = sha256(pubkey + shared_secret) + blinding_factor_int = int.from_bytes(blinding_factor, byteorder="big") + next_public_key_int = ecc.ECPubkey(pubkey) * blinding_factor_int + return next_public_key_int.get_public_key_bytes() + + def new_onion_packet( payment_path_pubkeys: Sequence[bytes], session_key: bytes, @@ -351,6 +375,79 @@ def calc_hops_data_for_payment( return hops_data, amt, cltv_abs +def calc_hops_data_for_blinded_payment( + route: 'LNPaymentRoute', + amount_msat: int, # that final recipient receives + *, + final_cltv_abs: int, + total_msat: int, + bolt12_invoice: dict, +) -> 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. + """ + if len(route) > NUM_MAX_EDGES_IN_PAYMENT_PATH: + raise PaymentFailure(f"too long route ({len(route)} edges)") + + amt = amount_msat + cltv_abs = final_cltv_abs + inv_path = bolt12_invoice.get('invoice_paths').get('paths')[0] + inv_blindedpay_info = bolt12_invoice.get('invoice_blindedpay').get('payinfo')[0] + # htlc_maximum_msat for blinded path + if htlc_max := inv_blindedpay_info.get('htlc_maximum_msat'): + if htlc_max < amt: + raise Exception(f'blinded path htlc_maximum_msat {htlc_max} too low for {amt=}') + + inv_hops = inv_path.get('path') + if not isinstance(inv_hops, list): + inv_hops = [inv_hops] + num_hops = len(inv_hops) + + hops_data = [] + _logger.info('inv_hops: ' + repr(inv_hops)) + hops_pubkeys = [x.get('blinded_node_id') for x in inv_hops] + # build reversed + for i, inv_hop in enumerate(reversed(inv_hops)): + payload = {} + if i == 0: # sender intended amount for recipient + payload = { # ? + 'amt_to_forward': {'amt_to_forward': amount_msat}, + 'outgoing_cltv_value': {'outgoing_cltv_value': cltv_abs}, + 'total_amount_msat': {'total_msat': total_msat}, + } + if i == num_hops - 1: # introduction point + payload['current_blinding_point'] = {'blinding': inv_path.get('first_path_key')} + payload['encrypted_recipient_data'] = {'encrypted_data': inv_hop.get('encrypted_recipient_data')} + + _logger.info(f'inv_hop[{num_hops - 1 - i}].payload: ' + repr(payload)) + hops_data.append(OnionHopsDataSingle(payload=payload)) + + # calc amount from aggregate blinded path info to send to introduction point + amt = amount_msat + inv_blindedpay_info.get('fee_base_msat') + \ + (inv_blindedpay_info.get('fee_proportional_millionths') * amount_msat) // 1000000 + cltv_abs += inv_blindedpay_info.get('cltv_expiry_delta') + _logger.info(f'blinded payment introduction point {amt=} for {amount_msat=}, {cltv_abs=}') + + # payloads, backwards from last hop (but excluding the first edges): + for i, route_edge in enumerate(reversed(route[0:])): + 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.info(f'route_edge[{len(route) - 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() + return hops_data, hops_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 +487,8 @@ class ProcessedOnionPacket(NamedTuple): hop_data: OnionHopsDataSingle next_packet: OnionPacket trampoline_onion_packet: OnionPacket + blinded_path_recipient_data: Optional[dict] + next_blinding: bytes = None @property def amt_to_forward(self) -> Optional[int]: @@ -403,16 +502,27 @@ 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: + k1 = k2 = 'short_channel_id' + return self._get_from_payload(k1, k2, ShortChannelID) + + scid = self.blinded_path_recipient_data.get('short_channel_id', {}).get('short_channel_id') + return ShortChannelID(scid) if scid else None @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) + + payment_secret_from_recipient_data = self.blinded_path_recipient_data.get('path_id', {}).get('data') + return payment_secret_from_recipient_data def _get_from_payload(self, k1: str, k2: str, res_type: type): try: @@ -429,13 +539,19 @@ def process_onion_packet( *, associated_data: bytes = b'', is_trampoline=False, - is_onion_message=False, + blinding: bytes = None, tlv_stream_name='payload') -> ProcessedOnionPacket: # TODO: check Onion features ( PERM|NODE|3 (required_node_feature_missing ) if onion_packet.version != 0: raise UnsupportedOnionPacketVersion() 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 blinding: + recipient_data_shared_secret = get_ecdh(our_onion_private_key, blinding) + our_onion_private_key = blinding_privkey(our_onion_private_key, blinding) 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 +570,20 @@ 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) + + erd = (hop_data.payload.get('encrypted_recipient_data', {}) + .get('encrypted_recipient_data' if is_onion_message else 'encrypted_data')) + if erd: + # we are part of a blinded path + if not blinding: + # we are the introduction point + blinding = hop_data.payload.get('current_blinding_point', {}).get('blinding') + recipient_data_shared_secret = get_ecdh(our_onion_private_key, blinding) + blinded_path_recipient_data = decrypt_onionmsg_data_tlv( + shared_secret=recipient_data_shared_secret, + encrypted_recipient_data=erd + ) + # trampoline trampoline_onion_packet = hop_data.payload.get('trampoline_onion_packet') if trampoline_onion_packet: @@ -469,21 +599,25 @@ def process_onion_packet( hops_data=top_hops_data_fd.read(TRAMPOLINE_HOPS_DATA_SIZE), hmac=top_hmac) # calc next ephemeral key - blinding_factor = sha256(onion_packet.public_key + shared_secret) - blinding_factor_int = int.from_bytes(blinding_factor, byteorder="big") - next_public_key_int = ecc.ECPubkey(onion_packet.public_key) * blinding_factor_int - next_public_key = next_public_key_int.get_public_key_bytes() + next_public_key = next_blinding_from_shared_secret(onion_packet.public_key, shared_secret) next_onion_packet = OnionPacket( public_key=next_public_key, hops_data=next_hops_data_fd.read(data_size), hmac=hop_data.hmac) + + next_blinding = 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 blinding: + next_blinding = next_blinding_from_shared_secret(blinding, recipient_data_shared_secret) + + return ProcessedOnionPacket(are_we_final, hop_data, next_onion_packet, trampoline_onion_packet, + blinded_path_recipient_data, next_blinding) def compare_trampoline_onions( diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index 55a591c4647b..0e82269f5792 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -21,7 +21,7 @@ from aiorpcx import ignore_after from .lrucache import LRUCache -from .crypto import sha256, sha256d, privkey_to_pubkey +from .crypto import sha256, sha256d, privkey_to_pubkey, get_ecdh from . import bitcoin, util from . import constants from .util import (log_exceptions, ignore_exceptions, chunks, OldTaskGroup, @@ -35,7 +35,7 @@ from .lnonion import (OnionFailureCode, OnionPacket, obfuscate_onion_error, OnionRoutingFailure, ProcessedOnionPacket, UnsupportedOnionPacketVersion, InvalidOnionMac, InvalidOnionPubkey, OnionFailureCodeMetaFlag, - OnionParsingError) + OnionParsingError, decrypt_onionmsg_data_tlv) from .lnchannel import Channel, RevokeAndAck, ChannelState, PeerState, ChanCloseOption, CF_ANNOUNCE_CHANNEL from . import lnutil from .lnutil import (Outpoint, LocalConfig, RECEIVED, UpdateAddHtlc, ChannelConfig, @@ -1941,6 +1941,7 @@ def send_htlc( cltv_abs: int, onion: OnionPacket, session_key: Optional[bytes] = None, + blinding: 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())) @@ -1948,6 +1949,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 blinding: + extra = {'update_add_htlc_tlvs': {'blinded_path': {'path_key': blinding}}} + self.send_message( "update_add_htlc", channel_id=chan.channel_id, @@ -1955,7 +1960,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 @@ -1968,6 +1974,7 @@ def pay(self, *, min_final_cltv_delta: int, payment_secret: bytes, trampoline_onion: Optional[OnionPacket] = None, + bolt12_invoice: Optional[dict] = None, ) -> UpdateAddHtlc: assert amount_msat > 0, "amount_msat is not greater zero" @@ -1981,7 +1988,8 @@ def pay(self, *, payment_hash=payment_hash, min_final_cltv_delta=min_final_cltv_delta, payment_secret=payment_secret, - trampoline_onion=trampoline_onion + trampoline_onion=trampoline_onion, + bolt12_invoice=bolt12_invoice, ) htlc = self.send_htlc( chan=chan, @@ -2065,12 +2073,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"] + blinding = 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, + blinding=blinding) 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}") @@ -2086,8 +2096,8 @@ 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, @@ -2114,10 +2124,33 @@ 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 - if (total_msat := processed_onion.total_msat) is None: - log_fail_reason(f"'total_msat' missing from onion") - raise exc_incorrect_or_unknown_pd + data=amt_to_forward.to_bytes(8, byteorder="big")) # height will be added later + + if htlc.blinding: # payment over blinded path + # spec: MUST return an error if the payload contains other tlv fields than encrypted_recipient_data, + # current_path_key, amt_to_forward, outgoing_cltv_value and total_amount_msat. + assert all(x in ['encrypted_recipient_data', 'current_blinding_point', 'amt_to_forward', 'outgoing_cltv_value', 'total_amount_msat'] + for x in processed_onion.hop_data.payload.keys()) + recipient_data = processed_onion.blinded_path_recipient_data + 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 + + if path_id not in self.lnworker._pathids[htlc.payment_hash]: + log_fail_reason(f"unknown path_id for payment_hash") + raise exc_incorrect_or_unknown_pd + + payment_secret_from_onion = self.lnworker.get_payment_secret(htlc.payment_hash) + + if (total_msat := processed_onion.hop_data.payload.get('total_amount_msat', {}).get('total_msat')) is None: + log_fail_reason(f"'total_msat' missing from onion") + raise exc_incorrect_or_unknown_pd + 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 if chan.jit_opening_fee: channel_opening_fee = chan.jit_opening_fee @@ -2134,7 +2167,7 @@ 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: log_fail_reason(f"'payment_secret' missing from onion") raise exc_incorrect_or_unknown_pd @@ -2333,6 +2366,7 @@ def _fail_htlc_set( processed_onion_packet = self._process_incoming_onion_packet( onion_packet, payment_hash=payment_hash, + blinding=mpp_htlc.htlc.blinding, is_trampoline=False, ) if raw_error: @@ -2851,6 +2885,7 @@ def _run_htlc_switch_iteration(self): processed_onion_packet = self._process_incoming_onion_packet( onion_packet, payment_hash=htlc.payment_hash, + blinding=htlc.blinding, is_trampoline=False, ) payment_key: str = self._check_unfulfilled_htlc( @@ -2960,6 +2995,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, + blinding=mpp_htlc.htlc.blinding, is_trampoline=False, ) onion_payload = processed_onion.hop_data.payload @@ -3012,6 +3048,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, + blinding=mpp_htlc.htlc.blinding, is_trampoline=False, # this is always the outer onion ) processed_onions[mpp_htlc] = (processed_onion, None) @@ -3264,17 +3301,20 @@ def _process_incoming_onion_packet( self, onion_packet: OnionPacket, *, payment_hash: bytes, + blinding: 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 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, + blinding=blinding) self._processed_onion_cache[cache_key] = processed_onion except UnsupportedOnionPacketVersion: raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_VERSION, data=onion_hash) diff --git a/electrum/lnutil.py b/electrum/lnutil.py index e938cf3d2598..a577153cb867 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -1495,6 +1495,13 @@ class LnFeatures(IntFlag): _ln_feature_contexts[OPTION_TRAMPOLINE_ROUTING_REQ_ELECTRUM] = (LNFC.INIT | LNFC.NODE_ANN | LNFC.INVOICE) _ln_feature_contexts[OPTION_TRAMPOLINE_ROUTING_OPT_ELECTRUM] = (LNFC.INIT | LNFC.NODE_ANN | LNFC.INVOICE) + OPTION_ROUTE_BLINDING_REQ = 1 << 24 + OPTION_ROUTE_BLINDING_OPT = 1 << 25 + + _ln_feature_direct_dependencies[OPTION_ROUTE_BLINDING_OPT] = {VAR_ONION_OPT} + _ln_feature_contexts[OPTION_ROUTE_BLINDING_REQ] = (LNFC.INIT | LNFC.NODE_ANN) + _ln_feature_contexts[OPTION_ROUTE_BLINDING_OPT] = (LNFC.INIT | LNFC.NODE_ANN) + OPTION_SHUTDOWN_ANYSEGWIT_REQ = 1 << 26 OPTION_SHUTDOWN_ANYSEGWIT_OPT = 1 << 27 @@ -1578,6 +1585,11 @@ def for_channel_announcement(self) -> 'LnFeatures': features |= (1 << flag) return features + def with_assumed(self) -> 'LnFeatures': + features = self + features |= LN_FEATURES_ASSUMED + return features + def min_len(self) -> int: b = int.bit_length(self) return b // 8 + int(bool(b % 8)) @@ -1606,6 +1618,12 @@ def get_names(self) -> Sequence[str]: r.append(feature_name or f"bit_{flag}") return r + def to_tlv_bytes(self) -> bytes: + a = hex(int(self))[2:] + b = (len(a) % 2) * '0' + a + d = bytes.fromhex(b) + return d + if hasattr(IntFlag, "_numeric_repr_"): # python 3.11+ # performance improvement (avoid base2<->base10), see #8403 _numeric_repr_ = hex @@ -1695,6 +1713,17 @@ def name_minimal(self): ) +# some features are assumed (@20251118) https://github.com/lightning/bolts/blob/master/09-features.md +LN_FEATURES_ASSUMED = ( + LnFeatures(0) + | LnFeatures.OPTION_DATA_LOSS_PROTECT_REQ + | LnFeatures.VAR_ONION_REQ + | LnFeatures.OPTION_STATIC_REMOTEKEY_REQ + | LnFeatures.PAYMENT_SECRET_REQ + | LnFeatures.OPTION_CHANNEL_TYPE_REQ +) + + def get_ln_flag_pair_of_bit(flag_bit: int) -> int: """Ln Feature flags are assigned in pairs, one even, one odd. See BOLT-09. Return the other flag from the pair. @@ -1910,16 +1939,18 @@ class UpdateAddHtlc: cltv_abs: int htlc_id: Optional[int] = dataclasses.field(default=None) timestamp: int = dataclasses.field(default_factory=lambda: int(time.time())) + blinding: bytes = None @staticmethod @stored_in('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, blinding = 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, + blinding=None if blinding is None else bytes.fromhex(blinding)) def to_json(self): self._validate() diff --git a/electrum/lnwire/onion_wire.csv b/electrum/lnwire/onion_wire.csv index 08e40399ab0f..28e63c5123dd 100644 --- a/electrum/lnwire/onion_wire.csv +++ b/electrum/lnwire/onion_wire.csv @@ -119,3 +119,141 @@ 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,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 9fefeb16b39c..d13dde22d2f1 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -29,12 +29,13 @@ import dns.exception from aiorpcx import run_in_thread, NetAddress, ignore_after +from .bolt12 import encode_invoice, Bolt12InvoiceError, Offer, decode_offer from .logging import Logger from .i18n import _ from .json_db import stored_in from .channel_db import UpdateStatus, ChannelDBNotLoaded, get_mychannel_info, get_mychannel_policy -from . import constants, util, lnutil +from . import constants, util, lnutil, 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, @@ -57,7 +58,9 @@ sha256, chacha20_encrypt, chacha20_decrypt, pw_encode_with_version_and_mac, pw_decode_with_version_and_mac ) -from .onion_message import OnionMessageManager +from .onion_message import ( + OnionMessageManager, send_onion_message_to, encode_blinded_path, get_blinded_reply_paths, NoOnionMessagePeers +) from .lntransport import ( LNTransport, LNResponderTransport, LNTransportBase, LNPeerAddr, split_host_port, extract_nodeid, ConnStringFormatError @@ -77,7 +80,7 @@ ) from .lnonion import ( decode_onion_error, OnionFailureCode, OnionRoutingFailure, OnionPacket, - ProcessedOnionPacket, calc_hops_data_for_payment, new_onion_packet, + ProcessedOnionPacket, calc_hops_data_for_payment, new_onion_packet, calc_hops_data_for_blinded_payment, ) from .lnmsg import decode_msg from .lnrouter import ( @@ -203,6 +206,8 @@ class ErrorAddingPeer(Exception): pass | LnFeatures.OPTION_SHUTDOWN_ANYSEGWIT_OPT | LnFeatures.OPTION_SCID_ALIAS_OPT | LnFeatures.OPTION_SUPPORT_LARGE_CHANNEL_OPT + | LnFeatures.OPTION_ONION_MESSAGE_OPT + | LnFeatures.OPTION_ROUTE_BLINDING_OPT ) LNGOSSIP_FEATURES = ( @@ -748,6 +753,7 @@ def __init__( invoice_pubkey: bytes, uses_trampoline: bool, # whether sender uses trampoline or gossip use_two_trampolines: bool, # whether legacy payments will try to use two trampolines + bolt12_invoice: Optional[dict] = None, ): assert payment_hash assert payment_secret @@ -756,6 +762,7 @@ def __init__( self.payment_key = payment_hash + payment_secret Logger.__init__(self) + self.bolt12_invoice = bolt12_invoice self.invoice_features = LnFeatures(invoice_features) self.r_tags = r_tags self.min_final_cltv_delta = min_final_cltv_delta @@ -930,6 +937,16 @@ def __init__(self, wallet: 'Abstract_Wallet', xprv): self._channel_backups[bfh(channel_id)] = cb = ChannelBackup(storage, lnworker=self) self.wallet.set_reserved_addresses_for_chan(cb, reserved=True) + self._offers = {} # type: Dict[bytes, Offer] + offers = self.db.get_dict("offers") + for offer_id, offer in offers.items(): + self._offers[bfh(offer_id)] = offer + + self._pathids = {} # type: Dict[bytes, Sequence[bytes]] + pathids = self.db.get_dict("path_ids") + for payment_hash, path_ids in pathids.items(): + self._pathids[bfh(payment_hash)] = path_ids + self._paysessions = dict() # type: Dict[bytes, PaySession] self.sent_htlcs_info = dict() # type: Dict[SentHtlcKey, SentHtlcInfo] self.received_mpp_htlcs = self.db.get_dict('received_mpp_htlcs') # type: Dict[str, ReceivedMPPStatus] # payment_key -> ReceivedMPPStatus @@ -1606,9 +1623,15 @@ async def pay_invoice( channels: Optional[Sequence[Channel]] = None, budget: Optional[PaymentFeeBudget] = None, ) -> Tuple[bool, List[HtlcLog]]: - bolt11 = invoice.lightning_invoice - lnaddr = self._check_bolt11_invoice(bolt11, amount_msat=amount_msat) - min_final_cltv_delta = lnaddr.get_min_final_cltv_delta() + bolt12_invoice = None + if bolt12_invoice_tlv := invoice.bolt12_invoice_tlv(): + bolt12_invoice = bolt12.decode_invoice(bolt12_invoice_tlv) + lnaddr = self._check_bolt12_invoice(bolt12_invoice, amount_msat=amount_msat) + min_final_cltv_delta = bolt12_invoice.get('invoice_blindedpay').get('payinfo')[0].get('cltv_expiry_delta') + elif 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 @@ -1654,6 +1677,7 @@ async def pay_invoice( full_path=full_path, channels=channels, budget=budget, + bolt12_invoice=bolt12_invoice, ) success = True except PaymentFailure as e: @@ -1688,6 +1712,7 @@ async def pay_to_node( budget: PaymentFeeBudget, channels: Optional[Sequence[Channel]] = None, fw_payment_key: str = None, # for forwarding + bolt12_invoice: Optional[dict] = None, # TODO: lots of unnecessary data included here, split off later ) -> None: """ Can raise PaymentFailure, ChannelDBNotLoaded, @@ -1716,6 +1741,7 @@ async def pay_to_node( # TODO: if you read this, the year is 2027 and there is no use for the second trampoline # hop code anymore remove the code completely. use_two_trampolines=False, + bolt12_invoice=bolt12_invoice, ) self.logs[payment_hash.hex()] = log = [] # TODO incl payment_secret in key (re trampoline forwarding) @@ -1871,7 +1897,8 @@ async def pay_to_route( payment_hash=paysession.payment_hash, min_final_cltv_delta=min_final_cltv_delta, payment_secret=shi.payment_secret_bucket, - trampoline_onion=trampoline_onion) + trampoline_onion=trampoline_onion, + bolt12_invoice=paysession.bolt12_invoice) key = (paysession.payment_hash, short_channel_id, htlc.htlc_id) self.sent_htlcs_info[key] = shi @@ -2035,6 +2062,35 @@ 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, invoice: dict, *, amount_msat: int = None) -> LnAddr: + """Parses and validates a bolt12 invoice dict into a LnAddr. + Includes pre-payment checks external to the parser. + """ + addr = bolt12.to_lnaddr(invoice) + + # # blind copy below, unchecked + # if addr.is_expired(): + # raise InvoiceError(_("This invoice has expired")) + # # check amount + # if amount_msat: # replace amt in invoice. main usecase is paying zero amt invoices + # existing_amt_msat = addr.get_amount_msat() + # if existing_amt_msat and amount_msat < existing_amt_msat: + # raise Exception("cannot pay lower amt than what is originally in LN invoice") + # addr.amount = Decimal(amount_msat) / COIN / 1000 + # if addr.amount is None: + # raise InvoiceError(_("Missing amount")) + # # check cltv + # if addr.get_min_final_cltv_delta() > NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE: + # raise InvoiceError("{}\n{}".format( + # _("Invoice wants us to risk locking funds for unreasonably long."), + # f"min_final_cltv_delta: {addr.get_min_final_cltv_delta()}")) + + # check features + if addr.get_features() != LnFeatures(0): + addr.validate_and_compare_features(self.features) + + return addr + def is_trampoline_peer(self, node_id: bytes) -> bool: # until trampoline is advertised in lnfeatures, check against hardcoded list if is_hardcoded_trampoline(node_id): @@ -2130,7 +2186,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, + r_tags=paysession.r_tags, # bolt12 TODO: r_tags only used in trampoline case receiver_pubkey=paysession.invoice_pubkey, ) for sc in split_configurations: @@ -2228,6 +2284,7 @@ async def create_routes_for_payment( my_sending_channels=[channel] if is_multichan_mpp else my_active_channels, full_path=full_path, budget=budget._replace(fee_msat=budget.fee_msat // sc.config.number_parts()), + bolt12_invoice=paysession.bolt12_invoice, ) ) shi = SentHtlcInfo( @@ -2264,6 +2321,7 @@ def create_route_for_single_htlc( my_sending_channels: List[Channel], full_path: Optional[LNPaymentPath], budget: PaymentFeeBudget, + bolt12_invoice: Optional[dict], ) -> LNPaymentRoute: my_sending_aliases = set(chan.get_local_scid_alias() for chan in my_sending_channels) @@ -2311,10 +2369,14 @@ def create_route_for_single_htlc( 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 + dest_node = invoice_pubkey + if bolt12_invoice: + paths = bolt12_invoice.get('invoice_paths').get('paths') + dest_node = paths[0].get('first_node_id') try: route = self.network.path_finder.find_route( nodeA=self.node_keypair.pubkey, - nodeB=invoice_pubkey, + nodeB=dest_node, invoice_amount_msat=amount_msat, path=full_path, my_sending_channels=my_sending_channels, @@ -2329,8 +2391,11 @@ def create_route_for_single_htlc( self.logger.info(f"rejecting route (exceeds budget): {route=}. {budget=}") raise FeeBudgetExceeded() assert len(route) > 0 - if route[-1].end_node != invoice_pubkey: - raise LNPathInconsistent("last node_id != invoice pubkey") + if route[-1].end_node != dest_node: + if bolt12_invoice: + raise LNPathInconsistent("last node_id != blinded path introduction point") + else: + raise LNPathInconsistent("last node_id != invoice pubkey") # add features from invoice route[-1].node_features |= invoice_features return route @@ -3621,10 +3686,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) @@ -3698,6 +3774,7 @@ def log_fail_reason(reason: str): amount_msat=next_amount_msat_htlc, cltv_abs=next_cltv_abs, onion=processed_onion.next_packet, + blinding=processed_onion.next_blinding, ) except BaseException as e: log_fail_reason(f"error sending message to next_peer={next_chan.node_id.hex()}") @@ -3857,17 +3934,29 @@ def create_onion_for_route( min_final_cltv_delta: int, payment_secret: bytes, trampoline_onion: Optional[OnionPacket] = None, + bolt12_invoice: Optional[dict] = None, ): # 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 bolt12_invoice: + 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) + else: + hops_data, blinded_node_ids, amount_msat, cltv_abs = calc_hops_data_for_blinded_payment( + route, + amount_msat, + final_cltv_abs=final_cltv_abs, + total_msat=total_msat, + bolt12_invoice=bolt12_invoice) + hops_data = hops_data[1:] + blinded_node_ids = blinded_node_ids[1:] + 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}") @@ -3891,7 +3980,10 @@ def create_onion_for_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] + if bolt12_invoice: + payment_path_pubkeys = [x.node_id for x in route] + blinded_node_ids + else: + payment_path_pubkeys = [x.node_id for x in route] 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 @@ -3915,3 +4007,126 @@ 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 + + def create_offer( + self, + *, + amount_msat: Optional[int] = None, + memo: Optional[str] = None, + expiry: int, + issuer: Optional[str] = None, + allow_unblinded: bool = True + ) -> bytes: + """ Create an offer + allow_unblinded only makes sense if node_id is public, or for testing with direct electrum peer + """ + reply_paths = None + try: + reply_paths = get_blinded_reply_paths(self) + except NoOnionMessagePeers: + if not allow_unblinded: + raise + + offer_id, offer = bolt12.create_offer( + offer_paths=reply_paths, node_id=self.node_keypair.pubkey, amount_msat=amount_msat, + memo=memo, expiry=expiry, issuer=issuer) + with self.lock: + o = self._offers[offer_id] = Offer(offer_id=offer_id, offer_bech32=bolt12.encode_offer(offer, as_bech32=True)) + self.db.get('offers')[offer_id.hex()] = o + return offer_id + + def get_offer(self, offer_id: bytes) -> Optional[Offer]: + return self._offers.get(offer_id) + + def delete_offer(self, offer_id: bytes): + with self.lock: + offer = self._offers.pop(offer_id, None) + if offer is None: + return + self.db.get('offers').pop(offer_id.hex()) + + @property + def offers(self) -> Mapping[bytes, Offer]: + """Returns a read-only copy of offers.""" + with self.lock: + return self._offers.copy() + + def add_path_ids_for_payment_hash(self, payment_hash, invoice_paths): + """Store payment hash -> [path_id] association """ + with self.lock: + path_ids = [x.path_id for x in invoice_paths] + self._pathids[payment_hash] = path_ids + self.db.get('path_ids')[payment_hash.hex()] = path_ids + + def on_bolt12_invoice_request(self, recipient_data: dict, payload: dict): + # match to offer + self.logger.debug(f'on_bolt12_invoice_request: {recipient_data=} {payload=}') + + invreq_tlv = payload['invoice_request']['invoice_request'] + invreq = bolt12.decode_invoice_request(invreq_tlv) + self.logger.info(f'invoice_request: {invreq=}') + + offer_id = invreq.get('offer_metadata', {}).get('data') + offer = self.get_offer(offer_id) + if offer is None: + self.logger.warning('no matching offer for invoice_request') + return + self.logger.debug(f'invoice_request for offer_id={offer_id.hex()}') + offer = decode_offer(offer.offer_bech32) + + # two scenarios: + # 1) not in response to offer (no offer_issuer_id or offer_paths) + # MUST reject the invoice request if any of the following are present: + # offer_chains, offer_features or offer_quantity_max. + # MUST reject the invoice request if invreq_amount is not present. + # MAY use offer_amount (or offer_currency) for informational display to user. + # if it sends an invoice in response: + # MUST use invreq_paths if present, otherwise MUST use invreq_payer_id as the node id to send to. + + # 2) response to offer. + # + # MUST reject the invoice request if the offer fields do not exactly match a valid, unexpired offer. + # if offer_paths is present: + # MUST ignore the invoice_request if it did not arrive via one of those paths. + # otherwise: + # MUST ignore any invoice_request if it arrived via a blinded path. + # if offer_quantity_max is present: + # MUST reject the invoice request if there is no invreq_quantity field. + # if offer_quantity_max is non-zero: + # MUST reject the invoice request if invreq_quantity is zero, OR greater than offer_quantity_max. + # otherwise: + # MUST reject the invoice request if there is an invreq_quantity field. + # if offer_amount is present: + # MUST calculate the expected amount using the offer_amount: + # if offer_currency is not the invreq_chain currency, convert to the invreq_chain currency. + # if invreq_quantity is present, multiply by invreq_quantity.quantity. + # if invreq_amount is present: + # MUST reject the invoice request if invreq_amount.msat is less than the expected amount. + # MAY reject the invoice request if invreq_amount.msat greatly exceeds the expected amount. + # otherwise (no offer_amount): + # MUST reject the invoice request if it does not contain invreq_amount. + # SHOULD send an invoice in response using the onionmsg_tlv reply_path. + + is_response_to_offer = True # always assume scenario 2 for now + + if is_response_to_offer: + node_id_or_blinded_path = encode_blinded_path(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. + if 'invreq_paths' in invreq: + node_id_or_blinded_path = invreq['invreq_paths']['paths'][0] # take first + else: + node_id_or_blinded_path = invreq['invreq_payer_id'] + + try: + invoice = bolt12.verify_request_and_create_invoice(self, offer, invreq) + except Bolt12InvoiceError as e: + error_payload = {'invoice_error': {'invoice_error': e.to_tlv()}} + send_onion_message_to(self, node_id_or_blinded_path, error_payload) + return + + destination_payload = { + 'invoice': {'invoice': encode_invoice(invoice, self.node_keypair.privkey)} + } + + send_onion_message_to(self, node_id_or_blinded_path, destination_payload) diff --git a/electrum/onion_message.py b/electrum/onion_message.py index 1212353449a9..519f4090b708 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -31,18 +31,20 @@ from random import random from types import MappingProxyType -from typing import TYPE_CHECKING, Optional, Sequence, NamedTuple, List +from typing import TYPE_CHECKING, Optional, Sequence, NamedTuple, Tuple, Union import electrum_ecc as ecc +from electrum.channel_db import get_mychannel_policy from electrum.lnrouter import PathEdge 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, +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) -from electrum.lnutil import LnFeatures + 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 from electrum.util import OldTaskGroup, log_exceptions @@ -55,12 +57,18 @@ def now(): from electrum.network import Network from electrum.lnrouter import NodeInfo from electrum.lntransport import LNPeerAddr + from electrum.lnchannel import Channel from asyncio import Task logger = get_logger(__name__) REQUEST_REPLY_PATHS_MAX = 3 +PAYMENT_PATHS_MAX = 3 + + +class NoOnionMessagePeers(Exception): pass +class NoRouteBlindingChannelPeers(Exception): pass class NoRouteFound(Exception): @@ -75,7 +83,8 @@ def create_blinded_path( final_recipient_data: dict, *, hop_extras: Optional[Sequence[dict]] = None, - dummy_hops: Optional[int] = 0 + dummy_hops: Optional[int] = 0, + channels: Optional[Sequence['Channel']] = None, ) -> dict: # 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. @@ -93,10 +102,18 @@ def create_blinded_path( is_non_final_node = i < len(path) - 1 if is_non_final_node: - recipient_data = { - # TODO: SHOULD add padding data to ensure all encrypted_data_tlv(i) have the same length - 'next_node_id': {'node_id': path[i+1]} - } + # spec: alt: short_channel_id instead of next_node_id + if channels: # use short_channel_id for payments + recipient_data = { + # TODO: SHOULD add padding data to ensure all encrypted_data_tlv(i) have the same length + 'short_channel_id': {'short_channel_id': channels[i].short_channel_id} + } + else: + recipient_data = { + # TODO: SHOULD add padding data to ensure all encrypted_data_tlv(i) have the same length + 'next_node_id': {'node_id': path[i+1]} + } + if hop_extras and i < len(hop_extras): # extra hop data for debugging for now recipient_data.update(hop_extras[i]) else: @@ -122,16 +139,14 @@ def create_blinded_path( return blinded_path -def blinding_privkey(privkey: bytes, blinding: bytes) -> bytes: - shared_secret = get_ecdh(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(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") - - return our_privkey +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() def is_onion_message_node(node_id: bytes, node_info: Optional['NodeInfo']) -> bool: @@ -142,21 +157,21 @@ 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} + and if no route found, raise a NoRouteFound with a node network address hint if node_id is found with + an address in channel_db. + """ + 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 + } # 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( nodeA=lnwallet.node_keypair.pubkey, nodeB=node_id, - invoice_amount_msat=10000, # TODO: do this without amount constraints + invoice_amount_msat=10000, # TODO: do this without amount constraints (generalize to edge_filter) 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 ): return path @@ -227,12 +242,7 @@ def send_onion_message_to( if next_path_key_override: next_path_key = next_path_key_override.get('path_key') else: - # E_i+1=SHA256(E_i||ss_i) * E_i - blinding_factor = sha256(our_blinding + shared_secret) - blinding_factor_int = int.from_bytes(blinding_factor, byteorder="big") - next_public_key_int = ecc.ECPubkey(our_blinding) * blinding_factor_int - next_path_key = next_public_key_int.get_public_key_bytes() - + next_path_key = next_blinding_from_shared_secret(our_blinding, shared_secret) path_key = next_path_key else: @@ -364,44 +374,142 @@ def send_onion_message_to( ) +class BlindedPathInfo(NamedTuple): + path: dict + path_id: bytes + payinfo: Optional[dict] + + def get_blinded_reply_paths( lnwallet: 'LNWallet', - path_id: bytes, + path_id: Optional[bytes] = None, *, max_paths: int = REQUEST_REPLY_PATHS_MAX, - preferred_node_id: bytes = None -) -> Sequence[dict]: - """construct a list of blinded reply_paths. +) -> Sequence[BlindedPathInfo]: + """construct a list of blinded reply-paths for onion message. + """ + mydata = {'path_id': {'data': path_id}} if path_id else {} + paths = get_blinded_paths_to_me(lnwallet, mydata, max_paths=max_paths, onion_message=True) + return paths + + +def get_blinded_paths_to_me( + lnwallet: 'LNWallet', + final_recipient_data: dict, + *, + max_paths: int = PAYMENT_PATHS_MAX, + my_channels: Optional[Sequence['Channel']] = None, + onion_message: bool = False +) -> Sequence[BlindedPathInfo]: + """construct a list of blinded paths. current logic: - - uses current onion_message capable channel peers if exist - - otherwise, uses current onion_message capable peers - - prefers preferred_node_id if given - - reply_path introduction points are direct peers only (TODO: longer reply 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 - my_active_channels = [chan for chan in lnwallet.channels.values() if chan.is_active()] - my_onionmsg_channels = [chan for chan in my_active_channels if lnwallet.peers.get(chan.node_id) and - lnwallet.peers.get(chan.node_id).their_features.supports(LnFeatures.OPTION_ONION_MESSAGE_OPT)] - my_onionmsg_peers = [peer for peer in lnwallet.peers.values() if peer.their_features.supports(LnFeatures.OPTION_ONION_MESSAGE_OPT)] + if not my_channels: + my_active_channels = [chan for chan in lnwallet.channels.values() if chan.is_active()] + my_channels = my_active_channels + + if onion_message: + my_channels = [chan for chan in my_channels if lnwallet.peers.get(chan.node_id) and + lnwallet.peers.get(chan.node_id).their_features.supports(LnFeatures.OPTION_ONION_MESSAGE_OPT)] + else: + my_channels = [chan for chan in my_channels if lnwallet.peers.get(chan.node_id) and + lnwallet.peers.get(chan.node_id).their_features.supports(LnFeatures.OPTION_ROUTE_BLINDING_OPT)] result = [] mynodeid = lnwallet.node_keypair.pubkey - mydata = {'path_id': {'data': path_id}} # same path_id used in every reply path - if len(my_onionmsg_channels): - # randomize list, but prefer preferred_node_id - rchans = sorted(my_onionmsg_channels, key=lambda x: random() if x.node_id != preferred_node_id else 0) + local_height = lnwallet.network.get_local_height() + + if len(my_channels): + # randomize list + rchans = sorted(my_channels, key=lambda x: random()) for chan in rchans[:max_paths]: - blinded_path = create_blinded_path(os.urandom(32), [chan.node_id, mynodeid], mydata) - result.append(blinded_path) - elif len(my_onionmsg_peers): - # randomize list, but prefer preferred_node_id - rpeers = sorted(my_onionmsg_peers, key=lambda x: random() if x.pubkey != preferred_node_id else 0) - for peer in rpeers[:max_paths]: - blinded_path = create_blinded_path(os.urandom(32), [peer.pubkey, mynodeid], mydata) - result.append(blinded_path) + hop_extras = None + payinfo = 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_ctlv_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_ctlv_expiry + cp.cltv_delta, + 'htlc_minimum_msat': blinded_path_min_htlc_msat + } + }] + payinfo = { + 'fee_base_msat': sum_fee_base_msat, + 'fee_proportional_millionths': sum_fee_proportional_millionths, + 'cltv_expiry_delta': sum_cltv_expiry_delta + MIN_FINAL_CLTV_DELTA_ACCEPTED, + 'htlc_minimum_msat': blinded_path_min_htlc_msat, + 'htlc_maximum_msat': blinded_path_max_htlc_msat, + 'flen': 0, + 'features': bytes(0) + } + recipient_data, path_id = ensure_path_id(final_recipient_data) + blinded_path = create_blinded_path(os.urandom(32), [chan.node_id, mynodeid], recipient_data, + hop_extras=hop_extras, channels=[chan] if not onion_message else None) + result.append(BlindedPathInfo(blinded_path, path_id, payinfo)) + elif onion_message: + # we can use peers even without channels for onion messages + my_onionmsg_peers = [peer for peer in lnwallet.peers.values() if + peer.their_features.supports(LnFeatures.OPTION_ONION_MESSAGE_OPT)] + if len(my_onionmsg_peers): + # randomize list + rpeers = sorted(my_onionmsg_peers, key=lambda x: random()) + for peer in rpeers[:max_paths]: + recipient_data, path_id = ensure_path_id(final_recipient_data) + blinded_path = create_blinded_path(os.urandom(32), [peer.pubkey, mynodeid], recipient_data) + result.append(BlindedPathInfo(blinded_path, path_id, None)) + else: + raise NoOnionMessagePeers('no ONION_MESSAGE capable peers') + else: + raise NoRouteBlindingChannelPeers('no OPTION_ROUTE_BLINDING capable channel peers') return result +def ensure_path_id(recipient_data: dict): + if not recipient_data.get('path_id', {}).get('data'): + result = recipient_data.copy() + path_id = os.urandom(32) + result.update({'path_id': {'data': path_id}}) + return result, path_id + else: + return recipient_data, recipient_data.get('path_id', {}).get('data') + + class Timeout(Exception): pass @@ -415,8 +523,7 @@ class OnionMessageManager(Logger): - forwards are best-effort. They should not need retrying, but a queue is used to limit the pacing of forwarding, and limiting the number of outstanding forwards. Any onion message forwards arriving when the forward queue is full will be dropped. - - TODO: iterate through routes for each request""" + """ SLEEP_DELAY = 1 REQUEST_REPLY_TIMEOUT = 30 @@ -425,10 +532,17 @@ class OnionMessageManager(Logger): FORWARD_RETRY_DELAY = 2 FORWARD_MAX_QUEUE = 3 - class Request(NamedTuple): - future: asyncio.Future - payload: dict - node_id_or_blinded_path: bytes + class Request: + def __init__(self, *, payload: dict, node_id_or_blinded_paths: Union[bytes, Sequence[bytes]]): + 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, 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 __init__(self, lnwallet: 'LNWallet'): Logger.__init__(self) @@ -440,6 +554,9 @@ def __init__(self, lnwallet: 'LNWallet'): self.send_queue = asyncio.PriorityQueue() self.forward_queue = asyncio.PriorityQueue() + # TODO: expose as config option + self.send_direct_connect_fallback = True + def start_network(self, *, network: 'Network') -> None: assert network assert self.network is None, "already started" @@ -522,13 +639,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=} {e!r}') 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.add_peer(str(e.peer_address)) + # NOTE: above, when passing the caught exception instance e directly it leads to GeneratorExit() else: self.logger.debug(f'resubmit {key=}') self.send_queue.put_nowait((now() + self.REQUEST_REPLY_RETRY_DELAY, expires, key)) @@ -541,8 +656,8 @@ def _remove_pending_message(self, key: bytes) -> None: def submit_send( self, *, payload: dict, - node_id_or_blinded_path: bytes, - key: bytes = None) -> 'Task': + node_id_or_blinded_paths: Union[bytes, Sequence[bytes]], + 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 corresponds with arriving replies. @@ -554,13 +669,9 @@ def submit_send( key = os.urandom(8) assert type(key) is bytes and len(key) >= 8 - self.logger.debug(f'submit_send {key=} {payload=} {node_id_or_blinded_path=}') + self.logger.debug(f'submit_send {key=} {payload=} {node_id_or_blinded_paths=}') - req = OnionMessageManager.Request( - future=asyncio.Future(), - payload=payload, - node_id_or_blinded_path=node_id_or_blinded_path - ) + req = OnionMessageManager.Request(payload=payload, node_id_or_blinded_paths=node_id_or_blinded_paths) with self.pending_lock: if key in self.pending: raise Exception(f'{key=} already exists!') @@ -579,12 +690,18 @@ 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.get(key) payload = req.payload - node_id_or_blinded_path = req.node_id_or_blinded_path - self.logger.debug(f'send_pending_message {key=} {payload=} {node_id_or_blinded_path=}') + current_index = req.current_index + + # get next path (round robin) + dests = req.node_id_or_blinded_paths + dest = dests[current_index] + req.current_index = (current_index + 1) % len(dests) + + self.logger.debug(f'send_pending_message {key=} {payload=} {dest=}') final_payload = copy.deepcopy(payload) @@ -592,14 +709,21 @@ 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': [x.path for x in reply_paths]} - final_payload['reply_path'] = {'path': reply_paths} - - # TODO: we should try alternate paths when retrying, this is currently not done. - # (send_onion_message_to decides path, without knowledge of prev attempts) - send_onion_message_to(self.lnwallet, node_id_or_blinded_path, 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[current_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 + self.logger.info(f'No route to destination, attempting direct peer connection to {str(e.peer_address)}') + await self.lnwallet.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? @@ -656,10 +780,15 @@ 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: + if 'message' not in payload and 'invoice_request' not in payload: self.logger.error('Unsupported onion message payload') return + if 'invoice_request' in payload: + self.lnwallet.on_bolt12_invoice_request(recipient_data, payload) + return + + # log 'message' payload if 'text' not in payload['message'] or not isinstance(payload['message']['text'], bytes): self.logger.error('Malformed \'message\' payload') return @@ -739,8 +868,8 @@ def on_onion_message(self, payload: dict) -> None: 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, is_onion_message=True, tlv_stream_name='onionmsg_tlv') + processed_onion_packet = process_onion_packet( + onion_packet, self.lnwallet.node_keypair.privkey, blinding=blinding, tlv_stream_name='onionmsg_tlv') payload = processed_onion_packet.hop_data.payload self.logger.debug(f'onion peeled: {processed_onion_packet!r}') diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index 725dc44f76a5..4e4d7355c556 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -6,11 +6,12 @@ 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 .onion_message import Timeout from .util import parse_max_spend, InvoiceError from .util import get_asyncio_loop, log_exceptions from .transaction import PartialTxOutput @@ -47,6 +48,10 @@ def is_uri(data: str) -> bool: return False +def now(): + return int(time.time()) + + RE_ALIAS = r'(.*?)\s*\<([0-9A-Za-z]{1,})\>' RE_EMAIL = r'\b[A-Za-z0-9._%+-]+@([A-Za-z0-9-]+\.)+[A-Z|a-z]{2,7}\b' RE_DOMAIN = r'\b([A-Za-z0-9-]+\.)+[A-Z|a-z]{2,7}\b' @@ -66,6 +71,7 @@ class PaymentIdentifierState(IntEnum): # the merchant payment processor of the tx after on-chain broadcast, # and supply a refund address (bip70) MERCHANT_ACK = 7 # PI notified merchant. nothing to be done. + BOLT12_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 @@ -86,6 +92,7 @@ class PaymentIdentifierType(IntEnum): OPENALIAS = 10 LNADDR = 11 DOMAINLIKE = 12 + BOLT12_OFFER = 13 class FieldsForGUI(NamedTuple): @@ -106,6 +113,7 @@ class PaymentIdentifier(Logger): * bip21 URI * lightning-URI (containing bolt11 or lnurl) * bolt11 invoice + * bolt12 offer * lnurl * lightning address """ @@ -139,6 +147,9 @@ 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 + self.bolt12_invoice = None self.parse(text) @@ -158,7 +169,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_FINALIZE] def need_merchant_notify(self): return self._state == PaymentIdentifierState.MERCHANT_NOTIFY @@ -170,7 +181,7 @@ 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.bolt11) or bool(self.bolt12_offer) def is_onchain(self): if self._type in [PaymentIdentifierType.SPK, PaymentIdentifierType.MULTILINE, PaymentIdentifierType.BIP70, @@ -194,6 +205,9 @@ def is_amount_locked(self): return not self.need_resolve() # always fixed after resolve? elif self._type == PaymentIdentifierType.BOLT11: return bool(self.bolt11.get_amount_sat()) + elif self._type == PaymentIdentifierType.BOLT12_OFFER: + # if we received an invoice already, amount is locked + return bool(self.bolt12_offer.get('offer_amount')) if not self.is_available() else True 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(): @@ -235,6 +249,16 @@ def parse(self, text: str): self.error = _("Error parsing LNURL") + f":\n{e}" self.set_state(PaymentIdentifierState.INVALID) return + elif bolt12.is_offer(invoice_or_lnurl): + self.logger.debug(f'BOLT12 offer') + try: + self.bolt12_offer = bolt12.decode_offer(invoice_or_lnurl) + self._type = PaymentIdentifierType.BOLT12_OFFER + self.set_state(PaymentIdentifierState.BOLT12_FINALIZE) + except Exception as e: + self.error = _("Error parsing BOLT12 offer") + f":\n{e}" + self.set_state(PaymentIdentifierState.INVALID) + return else: self._type = PaymentIdentifierType.BOLT11 try: @@ -308,7 +332,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()) @@ -380,7 +404,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()) @@ -394,35 +418,55 @@ async def _do_finalize( ): from .invoices import Invoice try: - if not self.lnurl_data: - raise Exception("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 self._state == PaymentIdentifierState.LNURLP_FINALIZE: + if not self.lnurl_data: + raise Exception("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 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.bolt11 = invoice + self.set_state(PaymentIdentifierState.AVAILABLE) + elif self._state == PaymentIdentifierState.BOLT12_FINALIZE: + assert self.bolt12_offer + try: + if not self.wallet.lnworker: + raise Exception('wallet is not lightning-enabled') + invoice, invoice_tlv = await bolt12.request_invoice( + self.wallet.lnworker, self.bolt12_offer, amount_sat * 1000, note=comment) + self.bolt12_invoice = invoice + # expose TLV as well, due to WalletDB serialization limitations. + # see Invoice.from_bolt12_invoice_tlv for more information + self.bolt12_invoice_tlv = invoice_tlv + self.set_state(PaymentIdentifierState.AVAILABLE) + self.logger.debug(f'BOLT12 invoice_request reply: {invoice!r}') + except Timeout: + self.error = _('Timeout requesting invoice') + self.set_state(PaymentIdentifierState.NOT_FOUND) + except Exception as e: + self.error = str(e) + self.set_state(PaymentIdentifierState.ERROR) except Exception as e: self.error = str(e) self.logger.error(f"_do_finalize() got error: {e!r}") @@ -594,6 +638,17 @@ def get_fields_for_GUI(self) -> FieldsForGUI: elif self.bolt11: recipient, amount, description = self._get_bolt11_fields() + elif self.bolt12_offer: + offer_amount = self.bolt12_offer.get('offer_amount') + if offer_amount: + amount = Decimal(offer_amount.get('amount')) / 1000 # msat->sat + offer_description = self.bolt12_offer.get('offer_description') + if offer_description: + description = offer_description.get('description') + offer_issuer = self.bolt12_offer.get('offer_issuer') + if offer_issuer: + recipient = offer_issuer.get('issuer') + elif self.lnurl and self.lnurl_data: assert isinstance(self.lnurl_data, LNURL6Data), f"{self.lnurl_data=}" domain = urllib.parse.urlparse(self.lnurl).netloc @@ -667,6 +722,16 @@ def has_expired(self): return self.bip70_data.has_expired() elif self.bolt11: return self.bolt11.has_expired() + elif self.bolt12_offer or self.bolt12_invoice: + # TODO: invoice not from offer, or original offer unavailable (e.g. saved resolved invoice from offer) + if self.bolt12_offer: + offer_absolute_expiry = self.bolt12_offer.get('offer_absolute_expiry', {}).get('seconds_from_epoch', 0) + if offer_absolute_expiry: + return now() > offer_absolute_expiry + if self.bolt12_invoice: + invoice_relative_expiry = self.bolt12_invoice.get('invoice_relative_expiry', {}).get('seconds_from_creation', 0) + if invoice_relative_expiry: + return now() > self.bolt12_invoice.get('invoice_created_at').get('timestamp') + invoice_relative_expiry 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() @@ -676,17 +741,17 @@ 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, PaymentIdentifierState.MERCHANT_NOTIFY] 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 + invoice = pi.bolt11 if pi.bolt11 else Invoice.from_bolt12_invoice_tlv(pi.bolt12_invoice_tlv) if not invoice: return - if invoice._lnaddr.get_amount_msat() is None: + if invoice._lnaddr.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 22494708699a..42666f97f4bb 100644 --- a/electrum/segwit_addr.py +++ b/electrum/segwit_addr.py @@ -43,6 +43,9 @@ class DecodedBech32(NamedTuple): data: Optional[Sequence[int]] # 5-bit ints +INVALID_BECH32 = DecodedBech32(None, None, None) + + def bech32_polymod(values): """Internal function that computes the Bech32 checksum.""" generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] @@ -71,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 @@ -79,32 +82,34 @@ 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]) -def bech32_decode(bech: str, *, ignore_long_length=False) -> DecodedBech32: +def bech32_decode(bech: str, *, ignore_long_length=False, with_checksum=True) -> DecodedBech32: """Validate a Bech32/Bech32m string, and determine HRP and data.""" bech_lower = bech.lower() if bech_lower != bech and bech.upper() != bech: - return DecodedBech32(None, None, None) + return INVALID_BECH32 pos = bech.rfind('1') if pos < 1 or pos + 7 > len(bech) or (not ignore_long_length and len(bech) > 90): - return DecodedBech32(None, None, None) + return INVALID_BECH32 # check that HRP only consists of sane ASCII chars if any(ord(x) < 33 or ord(x) > 126 for x in bech[:pos+1]): - return DecodedBech32(None, None, None) + return INVALID_BECH32 bech = bech_lower hrp = bech[:pos] try: data = [CHARSET_INVERSE[x] for x in bech[pos + 1:]] except KeyError: - return DecodedBech32(None, None, None) + return INVALID_BECH32 + if not with_checksum: + return DecodedBech32(encoding=None, hrp=hrp, data=data) encoding = bech32_verify_checksum(hrp, data) if encoding is None: - return DecodedBech32(None, None, None) + return INVALID_BECH32 return DecodedBech32(encoding=encoding, hrp=hrp, data=data[:-6]) diff --git a/electrum/wallet.py b/electrum/wallet.py index 88002e96af76..e8919e30d8bd 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -45,16 +45,17 @@ import electrum_ecc as ecc from aiorpcx import ignore_after, run_in_thread -from . import util, keystore, transaction, bitcoin, coinchooser, bip32, descriptor +from . import util, keystore, transaction, bitcoin, coinchooser, bip32, descriptor, constants from .i18n import _ from .bip32 import BIP32Node, convert_bip32_intpath_to_strpath, convert_bip32_strpath_to_intpath from .logging import get_logger, Logger +from .segwit_addr import bech32_encode, Encoding, convertbits from .util import ( NotEnoughFunds, UserCancelled, profiler, OldTaskGroup, format_fee_satoshis, WalletFileException, BitcoinException, InvalidPassword, format_time, timestamp_to_datetime, Satoshis, Fiat, TxMinedInfo, quantize_feerate, OrderedDictWithIndex, multisig_type, parse_max_spend, OnchainHistoryItem, read_json_file, write_json_file, UserFacingException, FileImportFailed, EventListener, - event_listener + event_listener, bfh ) from .bitcoin import COIN, is_address, is_minikey, relayfee, dust_threshold, DummyAddress, DummyAddressUsedInTxException from .keystore import ( @@ -72,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, BOLT12_INVOICE_PREFIX +) from .contacts import Contacts from .mnemonic import Mnemonic from .lnworker import LNWallet @@ -2954,7 +2957,13 @@ def export_invoice(self, x: Invoice) -> Dict[str, Any]: d = x.as_dict(status) d['invoice_id'] = d.pop('id') if x.is_lightning(): - d['lightning_invoice'] = x.lightning_invoice + invoice = x.lightning_invoice + if invoice.startswith(BOLT12_INVOICE_PREFIX): + bolt12_invoice = bfh(invoice[len(BOLT12_INVOICE_PREFIX):]) + bech32_data = convertbits(list(bolt12_invoice), 8, 5, True) + d['lightning_invoice'] = bech32_encode(Encoding.BECH32, 'lni', bech32_data, with_checksum=False) + else: + d['lightning_invoice'] = x.lightning_invoice if self.lnworker and status == PR_UNPAID: d['can_pay'] = self.lnworker.can_pay_invoice(x) if self.lnworker and status == PR_PAID: 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/regtest.py b/tests/regtest.py index 6c0953ec2f05..10123e9fc11a 100644 --- a/tests/regtest.py +++ b/tests/regtest.py @@ -144,6 +144,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 e51af808c6fa..205a01569d62 100755 --- a/tests/regtest/regtest.sh +++ b/tests/regtest/regtest.sh @@ -725,6 +725,25 @@ if [[ $1 == "watchtower" ]]; then wait_until_spent $ctx_id $output_index # alice's to_local gets punished fi +if [[ $1 == "bolt12" ]]; then +# $carol enable_htlc_settle false + bob_node=$($bob nodeid) + wait_for_balance carol 1 + echo "alice and carol open channels with bob" + chan_id1=$($alice open_channel $bob_node 0.15 --password='' --push_amount=0.075) + chan_id2=$($carol open_channel $bob_node 0.15 --password='' --push_amount=0.075) + new_blocks 3 + wait_until_channel_open alice + wait_until_channel_open carol + echo "alice pays carol" + offer=$($carol add_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..1ce0b38a3cb6 --- /dev/null +++ b/tests/test_blinded_payment_onion.py @@ -0,0 +1,90 @@ +import os + +from electrum.lnonion import new_onion_packet, calc_hops_data_for_blinded_payment, calc_hops_data_for_payment, OnionPacket +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 = [ + { + 'blinded_node_id': bfh(hop['blinded_node_id']), + 'encrypted_recipient_data': bfh(hop['encrypted_data']) + } for hop in blinded_route_hops +] +blinded_payinfo = generate['blinded_payinfo'] + +ONION_MESSAGE_PACKET = bfh(generate['onion']) +session_key = bfh(generate['session_key']) +associated_data = bfh(generate['associated_data']) + +bolt12_invoice = { + 'invoice_paths': { + 'paths': [ + { + 'path': blinded_path, + 'first_path_key': first_path_key + } + ] + }, + 'invoice_blindedpay': { + 'payinfo': [blinded_payinfo] + } +} + + +class TestPaymentRouteBlinding(ElectrumTestCase): + + def test_blinded_payment_onion(self): + # route contains only the non-blinded hop + alice_outgoing_channel_id = ShortChannelID.from_str(alice_hop["tlvs"]["outgoing_channel_id"]) + route = [ + RouteEdge( + start_node=bytes(33), # our pubkey, not used + end_node=bfh(alice_hop['pubkey']), + 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, hops_pubkeys, amt, cltv_abs = calc_hops_data_for_blinded_payment( + route=route, + amount_msat=amount_msat, + final_cltv_abs=final_cltv, + total_msat=total_msat, + bolt12_invoice=bolt12_invoice, + ) + + # bob pubkey is not blinded + payment_path_pubkeys = [x.node_id for x in route] + [first_node_id] + hops_pubkeys[1:] + + # 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..ea19e1fd4250 --- /dev/null +++ b/tests/test_bolt12.py @@ -0,0 +1,530 @@ +import asyncio +import copy +import io +import time + +from electrum_ecc import ECPrivkey + +from electrum import segwit_addr, lnutil +from electrum import bolt12 +from electrum.bolt12 import ( + is_offer, decode_offer, encode_invoice_request, decode_invoice_request, encode_invoice, decode_invoice, + encode_offer, verify_request_and_create_invoice, InvoiceRequestException +) +from electrum.crypto import privkey_to_pubkey +from electrum.invoices import LN_EXPIRY_NEVER +from electrum.lnchannel import Channel +from electrum.lnmsg import UnknownMandatoryTLVRecordType, _tlv_merkle_root, OnionWireSerializer +from electrum.lnonion import OnionHopsDataSingle +from electrum.lnutil import LnFeatures +from electrum.onion_message import NoRouteBlindingChannelPeers +from electrum.segwit_addr import INVALID_BECH32, bech32_encode, Encoding, convertbits +from electrum.util import bfh + +from . import ElectrumTestCase, test_lnpeer + + +def bech32_decode(x): + return segwit_addr.bech32_decode(x, ignore_long_length=True, with_checksum=False) + + +class MockLNWallet(test_lnpeer.MockLNWallet): + def __init__(self): + lnkey = bfh('4141414141414141414141414141414141414141414141414141414141414141') + kp = lnutil.Keypair(privkey_to_pubkey(lnkey), lnkey) + q = asyncio.Queue() + super().__init__(local_keypair=kp, chans=[], tx_queue=q, name='test', has_anchors=False) + + def create_payment_info( + self, *, + amount_msat, + min_final_cltv_delta=None, + exp_delay: int = LN_EXPIRY_NEVER, + write_to_disk=True + ) -> bytes: + return b'' + + def add_path_ids_for_payment_hash(self, payment_hash, invoice_paths): + pass + + +class MockChannel: + def __init__(self, node_id, rcv_capacity: int = 0): + self.short_channel_id = lnutil.ShortChannelID.from_str('0x0x0') + self.node_id = node_id + self.rcv_capacity = rcv_capacity + + def is_active(self): + return True + + def can_receive(self, *, amount_msat, check_frozen=False): + return True if self.rcv_capacity == 0 else amount_msat <= self.rcv_capacity + + def get_remote_update(self): + return bfh('0102beb6d231566566e014c6f417f247a5e8e882fd6b44ff4526ee230ace401d6ae57205b5c5dd2de21b9ceecbd8676d99a4588266b38b8af59305103c956127122843497fd7f826957108f4a30fd9cec3aeba79972084e90ead01ea3309000000002fe34de423b66e0a6510eb91030200900000000000000001000003e8000000640000000012088038') + + +ROUTE_BLINDING_CAPABLE_PEER_FEATURES = LnFeatures(LnFeatures.OPTION_ROUTE_BLINDING_OPT) + + +class MockPeer: + def __init__(self, pubkey, on_send_message=None, their_features=ROUTE_BLINDING_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 + + +class TestBolt12(ElectrumTestCase): + 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 = decode_offer(offer) + self.assertEqual(od, {'offer_description': {'description': "Offer by rusty's node"}, + 'offer_issuer_id': + {'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 = decode_offer(offer) + self.assertEqual(od, {'offer_amount': {'amount': 50}, + 'offer_description': {'description': '50msat multi-quantity offer'}, + 'offer_issuer': {'issuer': 'rustcorp.com.au'}, + 'offer_quantity_max': {'max': 0}, + 'offer_issuer_id': + {'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") + # contains TLV record type 26 which is not defined (yet) in 12-offer-encoding.md + with self.assertRaises(UnknownMandatoryTLVRecordType): + od = bolt12.decode_offer(offer) + + offer = 'lno1pqqkgz38xycrqmtnv96zqetkv4e8jgrdd9h82ar99ss82upqw3hjqargwfjk2gr5d9kk2ucjzpe82um50yhx77nvv938xtn0wfn3vggz8uepnksrac56zt0yzplxchpkfas88qhsvhetlmv3mhttjyreh77p5qsq8s0qzqs' + d = bech32_decode(offer) + self.assertNotEqual(d, INVALID_BECH32, "bech32 decode error") + self.assertEqual(d.hrp, 'lno', "wrong hrp") + # contains TLV record type 26 which is not defined (yet) in 12-offer-encoding.md + with self.assertRaises(UnknownMandatoryTLVRecordType): + od = bolt12.decode_offer(offer) + + offer = 'lno1pqqkgz3zxycrqmtnv96zqetkv4e8jgryv9ujcgrxwfhk6gp3949xzm3dxgcryvgjzpe82um50yhx77nvv938xtn0wfn3vggz8uepnksrac56zt0yzplxchpkfas88qhsvhetlmv3mhttjyreh77p5qspqysq2q2laenqq' + d = bech32_decode(offer) + self.assertNotEqual(d, INVALID_BECH32, "bech32 decode error") + self.assertEqual(d.hrp, 'lno', "wrong hrp") + # contains TLV record type 26 which is not defined (yet) in 12-offer-encoding.md + with self.assertRaises(UnknownMandatoryTLVRecordType): + od = bolt12.decode_offer(offer) + + offer = 'lno1pqpq86q2fgcnqvpsd4ekzapqv4mx2uneyqcnqgryv9uhxtpqveex7mfqxyk55ctw95erqv339ss8qcteyqcksu3qvfjkvmmjv5s8gmeqxcczqum9vdhkuernypkxzar9zgg8yatnw3ujumm6d3skyuewdaexw93pqglnyxw6q0hzngfdusg8umzuxe8kquuz7pjl90ldj8wadwgs0xlmcxszqy9pcpsqqq8pqqpuyqzszhlwvcqq' + d = bech32_decode(offer) + self.assertNotEqual(d, INVALID_BECH32, "bech32 decode error") + self.assertEqual(d.hrp, 'lno', "wrong hrp") + # contains TLV record type 26 which is not defined (yet) in 12-offer-encoding.md + with self.assertRaises(UnknownMandatoryTLVRecordType): + od = bolt12.decode_offer(offer) + + offer = 'lno1pqpq86q2xycnqvpsd4ekzapqv4mx2uneyqcnqgryv9uhxtpqveex7mfqxyk55ctw95erqv339ss8qun094exzarpzgg8yatnw3ujumm6d3skyuewdaexw93pqglnyxw6q0hzngfdusg8umzuxe8kquuz7pjl90ldj8wadwgs0xlmcxszqy9pczqqp5hsqqgd9uqzqpgptlhxvqq' + d = bech32_decode(offer) + self.assertNotEqual(d, INVALID_BECH32, "bech32 decode error") + self.assertEqual(d.hrp, 'lno', "wrong hrp") + # contains TLV record type 26 which is not defined (yet) in 12-offer-encoding.md + with self.assertRaises(UnknownMandatoryTLVRecordType): + od = bolt12.decode_offer(offer) + + offer = 'lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0yfpqun4wd68jtn00fkxzcnn9ehhyeckyypr7vsemgp7u2dp9hjpqlnvtsmy7crnstcxtu4lakgam44ezpuml0q6qgqsz' + d = bech32_decode(offer) + self.assertNotEqual(d, INVALID_BECH32, "bech32 decode error") + self.assertEqual(d.hrp, 'lno', "wrong hrp") + # contains TLV record type 26 which is not defined (yet) in 12-offer-encoding.md + with self.assertRaises(UnknownMandatoryTLVRecordType): + od = bolt12.decode_offer(offer) + + def test_decode_offer(self): + 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 = decode_offer(offer) + self.assertEqual(od['offer_description']['description'], 'first test offer') + self.assertEqual(od['offer_issuer_id']['id'], bfh('03d430b71fd7d4f0f6df575be7d9f33b0fa549c152dc03f4fc13ee2a393d4a2a5a')) + + def test_decode_invreq(self): + payer_key = bfh('4242424242424242424242424242424242424242424242424242424242424242') + kp = lnutil.Keypair(privkey_to_pubkey(payer_key), payer_key) + + invreq_tlv = bfh('0a1066697273742074657374206f66666572162103d430b71fd7d4f0f6df575be7d9f33b0fa549c152dc03f4fc13ee2a393d4a2a5a520215b358210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c591b696e7672657120666f722066697273742074657374206f66666572f04080ffce74cb03393cd47307de79d7a6782e0d7b84b76d29958f9f732369a6620e0ed6bbeee66c99d077eb62f8cec8e2fe06ab15943961b2c009e41781aca3f34e') + invreq = decode_invoice_request(invreq_tlv) + + 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}, + } + del invreq['signature'] + self.assertEqual(invreq, data) + + def test_decode_invoice(self): + signing_key = bfh('4141414141414141414141414141414141414141414141414141414141414141') + kp = lnutil.Keypair(privkey_to_pubkey(signing_key), signing_key) + + invoice_tlv = bfh('0407010203040506070801010a13746573745f656e636f64655f696e766f696365b02102eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619f04088d62f8db0c4033f7c4700e050f298d93f2d28a67e015f675c0835e22bfae2ec82fae6f404b24a92dc4779b85ff1fd63ff0521284e78f24969039ad98c0204ab') + invoice = decode_invoice(invoice_tlv) + data = {'offer_metadata': {'data': bfh('01020304050607')}, + 'offer_amount': {'amount': 1}, + 'offer_description': {'description': 'test_encode_invoice'}, + 'invoice_node_id': {'node_id': kp.pubkey}, + } + del invoice['signature'] + self.assertEqual(invoice, data) + + def test_encode_offer(self): + data = { + 'offer_description': {'description': 'first test offer'}, + 'offer_issuer_id': {'id': bfh('03d430b71fd7d4f0f6df575be7d9f33b0fa549c152dc03f4fc13ee2a393d4a2a5a')} + } + offer_tlv = encode_offer(data) + 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') + + 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_tlv = encode_invoice_request(data, payer_key) + self.assertEqual(invreq_tlv, bfh('0a1066697273742074657374206f66666572162103d430b71fd7d4f0f6df575be7d9f33b0fa549c152dc03f4fc13ee2a393d4a2a5a520215b358210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c591b696e7672657120666f722066697273742074657374206f66666572f04080ffce74cb03393cd47307de79d7a6782e0d7b84b76d29958f9f732369a6620e0ed6bbeee66c99d077eb62f8cec8e2fe06ab15943961b2c009e41781aca3f34e')) + + 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'}, + 'invoice_node_id': {'node_id': kp.pubkey} + } + invoice_tlv = encode_invoice(data, signing_key=kp.privkey) + self.assertEqual(invoice_tlv, bfh('0407010203040506070801010a13746573745f656e636f64655f696e766f696365b02102eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619f04088d62f8db0c4033f7c4700e050f298d93f2d28a67e015f675c0835e22bfae2ec82fae6f404b24a92dc4779b85ff1fd63ff0521284e78f24969039ad98c0204ab')) + + def test_subtype_encode_decode(self): + offer = 'lno1pggxv6tjwd6zqar9wd6zqmmxvejhy93pq02rpdcl6l20pakl2ad70k0n8v862jwp2twq8a8uz0hz5wfafg495' + od = decode_offer(offer) + data = {'offer_issuer_id': od['offer_issuer_id']} + invreq_pl_tlv = encode_invoice_request(data, payer_key=bfh('4141414141414141414141414141414141414141414141414141414141414141')) + + 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''}, + #'path_id': {'data': bfh('deadbeefbadc0ffeedeadbeefbadc0ffeedeadbeefbadc0ffeedeadbeefbadc0')} + } + ) + + ohds_b = ohds.to_bytes() + + self.assertEqual(ohds_b, bfh('fd00fd02940309d14e515e8ef4ea022787dcda8550edfbd7da6052208d2fc0cc4f7d949558e50309d14e515e8ef4ea022787dcda8550edfbd7da6052208d2fc0cc4f7d949558e5020309d14e515e8ef4ea022787dcda8550edfbd7da6052208d2fc0cc4f7d949558e5000500000000000309d14e515e8ef4ea022787dcda8550edfbd7da6052208d2fc0cc4f7d949558e500060011112222334065162103d430b71fd7d4f0f6df575be7d9f33b0fa549c152dc03f4fc13ee2a393d4a2a5af04078205e3d9d3cf87b743bcad5bca89f12f868ce638fbb4051d9570bac1a79d90ae3650ebcf15603b9349697edf71bf78ecb802aafe146d9118fe387bdb36ed26e0000000000000000000000000000000000000000000000000000000000000000')) + + with io.BytesIO(ohds_b) as fd: + ohds2 = OnionHopsDataSingle.from_fd(fd, tlv_stream_name='onionmsg_tlv') + self.assertTrue('invoice_request' in ohds2.payload) # TODO + self.assertTrue('reply_path' in ohds2.payload) # TODO + + # 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 = encode_offer(offer_data) + decoded = decode_offer(offer) + self.assertEqual(offer_data, decoded) + + def test_merkle_root(self): + # test vectors in https://github.com/lightning/bolts/pull/798 + tlvs = [ + (1, bfh('010203e8')), + (2, bfh('02080000010000020003')), + (3, bfh('03310266e4598d1d3c415f572a8488830b60f7e744ed9235eb0b1ba93283b315c0351800000000000000010000000000000002')) + ] + + self.assertEqual(_tlv_merkle_root(tlvs[:1]), bfh('b013756c8fee86503a0b4abdab4cddeb1af5d344ca6fc2fa8b6c08938caa6f93')) + self.assertEqual(_tlv_merkle_root(tlvs[:2]), bfh('c3774abbf4815aa54ccaa026bff6581f01f3be5fe814c620a252534f434bc0d1')) + self.assertEqual(_tlv_merkle_root(tlvs[:3]), bfh('ab2e79b1283b0b31e0b035258de23782df6b89a38cfa7237bde69aed1a658c5d')) + + def test_invoice_request_schnorr_signature(self): + # use invoice request in https://github.com/lightning/bolts/pull/798 to match test vectors + invreq = 'lnr1qqyqqqqqqqqqqqqqqcp4256ypqqkgzshgysy6ct5dpjk6ct5d93kzmpq23ex2ct5d9ek293pqthvwfzadd7jejes8q9lhc4rvjxd022zv5l44g6qah82ru5rdpnpjkppqvjx204vgdzgsqpvcp4mldl3plscny0rt707gvpdh6ndydfacz43euzqhrurageg3n7kafgsek6gz3e9w52parv8gs2hlxzk95tzeswywffxlkeyhml0hh46kndmwf4m6xma3tkq2lu04qz3slje2rfthc89vss' + data = decode_invoice_request(invreq) + del data['signature'] # remove signature, we regenerate it + + payer_key = bfh('4242424242424242424242424242424242424242424242424242424242424242') + invreq_pl_tlv = encode_invoice_request(data, payer_key) + + self.assertEqual(invreq_pl_tlv, bfh('0008000000000000000006035553440801640a1741204d617468656d61746963616c205472656174697365162102eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f28368661958210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1cf040b8f83ea3288cfd6ea510cdb481472575141e8d8744157f98562d162cc1c472526fdb24befefbdebab4dbb726bbd1b7d8aec057f8fa805187e5950d2bbe0e5642')) + + def test_schnorr_signature(self): + # encode+decode invoice to test signature + signing_key = bfh('4242424242424242424242424242424242424242424242424242424242424242') + signing_pubkey = ECPrivkey(signing_key).get_public_key_bytes() + invoice_tlv = encode_invoice({ + 'offer_amount': {'amount': 1}, + 'offer_description': {'description': 'test'}, + 'invoice_node_id': {'node_id': signing_pubkey} + }, signing_key) + decode_invoice(invoice_tlv) + + def test_serde_complex_fields(self): + payer_key = bfh('4141414141414141414141414141414141414141414141414141414141414141') + + # test complex field cardinality without explicit count + invreq = { + 'offer_paths': {'paths': [ + {'first_node_id': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'first_path_key': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'num_hops': 0, + 'path': []}, + {'first_node_id': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'first_path_key': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'num_hops': 0, + 'path': []}, + {'first_node_id': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'first_path_key': bfh('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + 'num_hops': 0, + 'path': []} + ]} + } + + invreq_pl_tlv = encode_invoice_request(invreq, payer_key=payer_key) + + with io.BytesIO() as fd: + f = io.BytesIO(invreq_pl_tlv) + 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': 0} + ]} + } + + # 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: + invreq_pl_tlv = encode_invoice_request(invreq, payer_key=payer_key) + 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': 1, + 'path': []} + ]} + } + + with self.assertRaises(AssertionError): + invreq_pl_tlv = encode_invoice_request(invreq, payer_key=payer_key) + + def gen_base_offer_and_invreq(self, wkp, pkp, *, offer_extra: dict = None, invreq_extra: dict = None): + offer_data = { + 'offer_metadata': {'data': bfh('01')}, + 'offer_amount': {'amount': 1000}, + 'offer_description': {'description': 'descr'}, + 'offer_issuer': {'issuer': 'test'}, + 'offer_issuer_id': {'id': wkp.pubkey} + } + if offer_extra: + offer_data.update(offer_extra) + + invreq_data = copy.deepcopy(offer_data) + invreq_data.update({ + 'invreq_metadata': {'blob': bfh('ff')}, + 'invreq_payer_id': {'key': pkp.pubkey}, + 'signature': {'sig': bfh('00')} # bogus + }) + if invreq_extra: + invreq_data.update(invreq_extra) + + return offer_data, invreq_data + + async def test_invoice_request(self): + wallet_key = bfh('4141414141414141414141414141414141414141414141414141414141414141') + wkp = lnutil.Keypair(privkey_to_pubkey(wallet_key), wallet_key) + chan_key = bfh('4242424242424242424242424242424242424242424242424242424242424242') + ckp = lnutil.Keypair(privkey_to_pubkey(chan_key), chan_key) + payer_key = bfh('4343434343434343434343434343434343434343434343434343434343434343') + pkp = lnutil.Keypair(privkey_to_pubkey(payer_key), payer_key) + + lnwallet = MockLNWallet() + try: + chan = MockChannel(ckp.pubkey, 1_000_000) + lnwallet.channels[ckp.pubkey] = chan + peer = MockPeer(ckp.pubkey) + + # base case but without ROUTE_BLINDING capable peers + with self.assertRaises(NoRouteBlindingChannelPeers): + offer_data, invreq_data = self.gen_base_offer_and_invreq(wkp, pkp) + invoice_data = verify_request_and_create_invoice(lnwallet, offer_data, invreq_data) + + lnwallet.peers[ckp.pubkey] = peer + + # base case + offer_data, invreq_data = self.gen_base_offer_and_invreq(wkp, pkp) + invoice_data = verify_request_and_create_invoice(lnwallet, offer_data, invreq_data) + del invreq_data['signature'] + for key in invreq_data: + self.assertEqual(invoice_data.get(key), invreq_data.get(key)) + + # non matching offer fields in invreq + offer_data, invreq_data = self.gen_base_offer_and_invreq(wkp, pkp) + del invreq_data['offer_metadata'] + with self.assertRaises(InvoiceRequestException): + verify_request_and_create_invoice(lnwallet, offer_data, invreq_data) + + offer_data, invreq_data = self.gen_base_offer_and_invreq(wkp, pkp) + invreq_data['offer_metadata'] = {'data': bfh('02')} + with self.assertRaises(InvoiceRequestException): + verify_request_and_create_invoice(lnwallet, offer_data, invreq_data) + + offer_data, invreq_data = self.gen_base_offer_and_invreq(wkp, pkp) + invreq_data['offer_amount'] = {'amount': 1001} + with self.assertRaises(InvoiceRequestException): + verify_request_and_create_invoice(lnwallet, offer_data, invreq_data) + + offer_data, invreq_data = self.gen_base_offer_and_invreq(wkp, pkp) + invreq_data['offer_issuer_id'] = {'id': ckp.pubkey} + with self.assertRaises(InvoiceRequestException): + verify_request_and_create_invoice(lnwallet, offer_data, invreq_data) + + # invreq_metadata mandatory + offer_data, invreq_data = self.gen_base_offer_and_invreq(wkp, pkp) + del invreq_data['invreq_metadata'] + with self.assertRaises(InvoiceRequestException): + verify_request_and_create_invoice(lnwallet, offer_data, invreq_data) + + # expiry + offer_data, invreq_data = self.gen_base_offer_and_invreq(wkp, pkp) + invreq_data['offer_absolute_expiry'] = {'seconds_from_epoch': int(time.time()) - 5} + with self.assertRaises(InvoiceRequestException): + verify_request_and_create_invoice(lnwallet, offer_data, invreq_data) + + offer_data, invreq_data = self.gen_base_offer_and_invreq(wkp, pkp, offer_extra={ + 'offer_absolute_expiry': {'seconds_from_epoch': int(time.time()) + 5} + }) + invoice_data = verify_request_and_create_invoice(lnwallet, offer_data, invreq_data) + + # offer/invreq amount matching + offer_data, invreq_data = self.gen_base_offer_and_invreq(wkp, pkp) + invoice_data = verify_request_and_create_invoice(lnwallet, offer_data, invreq_data) + self.assertEqual(invoice_data.get('invoice_amount').get('msat'), offer_data.get('offer_amount').get('amount')) + + # offer_data, invreq_data = self.gen_base_offer_and_invreq(wkp, pkp) + # del offer_data['offer_amount'] + # del invreq_data['offer_amount'] + # with self.assertRaises(InvoiceRequestException): + # verify_request_and_create_invoice(lnwallet, offer_data, invreq_data) + + # rcv capacity check + offer_data, invreq_data = self.gen_base_offer_and_invreq(wkp, pkp, + offer_extra={'offer_amount': {'amount': 2_000_000}}, + invreq_extra={'invreq_amount': {'msat': 2_000_000}}, + ) + with self.assertRaises(InvoiceRequestException): + verify_request_and_create_invoice(lnwallet, offer_data, invreq_data) + + # invoice_node_id == offer_issuer_id + offer_data, invreq_data = self.gen_base_offer_and_invreq(wkp, pkp) + invoice_data = verify_request_and_create_invoice(lnwallet, offer_data, invreq_data) + self.assertEqual(invoice_data.get('invoice_node_id').get('node_id'), wkp.pubkey) + + finally: + # end + await lnwallet.stop() diff --git a/tests/test_lnutil.py b/tests/test_lnutil.py index 944fcc8e7558..88e7aeb054f0 100644 --- a/tests/test_lnutil.py +++ b/tests/test_lnutil.py @@ -1066,6 +1066,16 @@ def test_channel_type(self): channel_type = ChannelType(0b10000000001000000000010).discard_unknown_and_check() self.assertEqual(ChannelType(0b10000000001000000000000), channel_type) + def test_to_tlv_bytes(self): + features = LnFeatures.OPTION_DATA_LOSS_PROTECT_REQ + self.assertEqual(features.to_tlv_bytes(), bfh('01')) + features = LnFeatures.OPTION_ROUTE_BLINDING_OPT + self.assertEqual(features.to_tlv_bytes(), bfh('02000000')) + features = LnFeatures.OPTION_DATA_LOSS_PROTECT_REQ |\ + LnFeatures.OPTION_ROUTE_BLINDING_OPT |\ + LnFeatures.BASIC_MPP_OPT + self.assertEqual(features.to_tlv_bytes(), bfh('02020001')) + @as_testnet async def test_decode_imported_channel_backup_v0(self): encrypted_cb = "channel_backup:Adn87xcGIs9H2kfp4VpsOaNKWCHX08wBoqq37l1cLYKGlJamTeoaLEwpJA81l1BXF3GP/mRxqkY+whZG9l51G8izIY/kmMSvnh0DOiZEdwaaT/1/MwEHfsEomruFqs+iW24SFJPHbMM7f80bDtIxcLfZkKmgcKBAOlcqtq+dL3U3yH74S8BDDe2L4snaxxpCjF0JjDMBx1UR/28D+QlIi+lbvv1JMaCGXf+AF1+3jLQf8+lVI+rvFdyArws6Ocsvjf+ANQeSGUwW6Nb2xICQcMRgr1DO7bO4pgGu408eYRr2v3ayJBVtnKwSwd49gF5SDSjTDAO4CCM0uj9H5RxyzH7fqotkd9J80MBr84RiBXAeXKz+Ap8608/FVqgQ9BOcn6LhuAQdE5zXpmbQyw5jUGkPvHuseR+rzthzncy01odUceqTNg==" diff --git a/tests/test_onion_message.py b/tests/test_onion_message.py index 4e381d63c8da..bbeca0114e75 100644 --- a/tests/test_onion_message.py +++ b/tests/test_onion_message.py @@ -6,6 +6,7 @@ import logging from functools import partial from types import MappingProxyType +from aiorpcx import NetAddress import electrum_ecc as ecc from electrum_ecc import ECPrivkey @@ -15,11 +16,12 @@ 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) + encrypt_hops_recipient_data, blinding_privkey) from electrum.crypto import get_ecdh, privkey_to_pubkey +from electrum.lntransport import LNPeerAddr, extract_nodeid from electrum.lnutil import LnFeatures, Keypair from electrum.onion_message import ( - blinding_privkey, create_blinded_path,OnionMessageManager, NoRouteFound, Timeout + create_blinded_path, OnionMessageManager, NoRouteFound, Timeout, NoOnionMessagePeers ) from electrum.util import bfh, read_json_file, OldTaskGroup, get_asyncio_loop from electrum.logging import console_stderr_handler @@ -161,15 +163,7 @@ def test_decrypt_onion_message(self): our_privkey = bfh(test_vectors['decrypt']['hops'][0]['privkey']) blinding = 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, is_onion_message=True, tlv_stream_name='onionmsg_tlv') + p = process_onion_packet(o, our_privkey, tlv_stream_name='onionmsg_tlv', blinding=blinding) self.assertEqual(p.hop_data.blind_fields, {}) self.assertEqual(p.hop_data.hmac, bfh('a5296325ba478ba1e1a9d1f30a2d5052b2e2889bbd64f72c72bc71d8817288a2')) @@ -281,19 +275,20 @@ def __init__(self): class MockLNWallet(test_lnpeer.MockLNWallet): async def add_peer(self, connect_str: str): - t1 = PutIntoOthersQueueTransport(self.node_keypair, 'test') - p1 = PeerInTests(self, keypair().pubkey, t1) + node_id, rest = extract_nodeid(connect_str) + p1 = MockPeer(node_id) self.peers[p1.pubkey] = p1 - p1.initialized.set_result(True) return p1 -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 @@ -322,11 +317,22 @@ def keypair(privkey: ECPrivkey): self.carol = keypair(ECPrivkey(privkey_bytes=b'\x43'*32)) 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( payload={'message': {'text': 'alice_timeout'.encode('utf-8')}}, - node_id_or_blinded_path=self.alice.pubkey) + node_id_or_blinded_paths=self.alice.pubkey) with self.assertRaises(Timeout): await t1 @@ -334,7 +340,7 @@ async def run_test1(self, t): async def run_test2(self, t): t2 = t.submit_send( payload={'message': {'text': 'bob_slow_timeout'.encode('utf-8')}}, - node_id_or_blinded_path=self.bob.pubkey) + node_id_or_blinded_paths=self.bob.pubkey) with self.assertRaises(Timeout): await t2 @@ -342,7 +348,7 @@ async def run_test2(self, t): async def run_test3(self, t, rkey): t3 = t.submit_send( payload={'message': {'text': 'carol_with_immediate_reply'.encode('utf-8')}}, - node_id_or_blinded_path=self.carol.pubkey, + node_id_or_blinded_paths=self.carol.pubkey, key=rkey) t3_result = await t3 @@ -351,19 +357,57 @@ async def run_test3(self, t, rkey): async def run_test4(self, t, rkey): t4 = t.submit_send( payload={'message': {'text': 'dave_with_slow_reply'.encode('utf-8')}}, - node_id_or_blinded_path=self.dave.pubkey, + node_id_or_blinded_paths=self.dave.pubkey, key=rkey) t4_result = await t4 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')}}, - node_id_or_blinded_path=self.eve.pubkey) + lnw = t.lnwallet + self.assertFalse(self.eve.pubkey in lnw.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.peers) + del lnw.peers[self.eve.pubkey] - with self.assertRaises(NoRouteFound): - await t5 + 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_3 + + self.assertEqual(c.exception.peer_address, LNPeerAddr('localhost', 1234, self.eve.pubkey)) + + async def run_test6(self, t, rkey): + # bob will not reply, fred will + t6 = t.submit_send( + payload={'message': {'text': 'send_dest_roundrobin'.encode('utf-8')}}, + node_id_or_blinded_paths=[self.bob.pubkey, self.fred.pubkey], + key=rkey + ) + + t6_result = await t6 + self.assertEqual(t6_result, ({'path_id': {'data': b'electrum' + rkey}}, {})) async def test_request_and_reply(self): n = MockNetwork() @@ -381,24 +425,34 @@ 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') - - lnw.peers[self.alice.pubkey] = MockPeer(self.alice.pubkey) - lnw.peers[self.bob.pubkey] = MockPeer(self.bob.pubkey, on_send_message=slow) - lnw.peers[self.carol.pubkey] = MockPeer(self.carol.pubkey, on_send_message=partial(withreply, rkey1)) - lnw.peers[self.dave.pubkey] = MockPeer(self.dave.pubkey, on_send_message=partial(slowwithreply, rkey2)) t = OnionMessageManager(lnw) t.start_network(network=n) try: await asyncio.sleep(TIME_STEP) + + await self.run_test_exception(t) + lnw.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.peers[self.alice.pubkey] = MockPeer(self.alice.pubkey) + lnw.peers[self.bob.pubkey] = MockPeer(self.bob.pubkey, on_send_message=slow) + lnw.peers[self.carol.pubkey] = MockPeer(self.carol.pubkey, on_send_message=partial(withreply, rkey1)) + lnw.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.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) await self.run_test3(t, rkey1) await self.run_test4(t, rkey2) await self.run_test5(t) + await self.run_test6(t, rkey3) self.logger.debug('tests in parallel') async with OldTaskGroup() as group: await group.spawn(self.run_test1(t)) @@ -406,6 +460,7 @@ def slowwithreply(key, *args, **kwargs): await group.spawn(self.run_test3(t, rkey1)) await group.spawn(self.run_test4(t, rkey2)) await group.spawn(self.run_test5(t)) + await group.spawn(self.run_test6(t, rkey3)) finally: await asyncio.sleep(TIME_STEP) diff --git a/tests/test_route_blinding.py b/tests/test_route_blinding.py new file mode 100644 index 000000000000..e41b965db015 --- /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('payload', hops_data, hop_shared_secrets) + + for i, hop in enumerate(hops_data): + self.assertEqual(hop.payload['encrypted_recipient_data']['encrypted_data'], + bfh(HOPS[i]['encrypted_data']), f'hop {i} not matching')