Skip to content

Commit 59a86cd

Browse files
committed
wip
1 parent f13c512 commit 59a86cd

File tree

3 files changed

+165
-9
lines changed

3 files changed

+165
-9
lines changed

electrum/bolt12.py

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,19 +26,24 @@
2626
import copy
2727
import io
2828
import os
29+
import time
2930
from decimal import Decimal
3031

31-
from typing import Union, Optional, List, Tuple
32+
from typing import TYPE_CHECKING, Union, Optional, List, Tuple, Sequence
3233

3334
import electrum_ecc as ecc
3435

3536
from . import constants
3637
from .bitcoin import COIN
38+
from .crypto import sha256
3739
from .lnaddr import LnAddr
3840
from .lnmsg import OnionWireSerializer, batched
39-
from .onion_message import Timeout
41+
from .onion_message import Timeout, get_blinded_reply_paths, create_blinded_path, get_blinded_paths_to_me
4042
from .segwit_addr import bech32_decode, DecodedBech32, convertbits
4143

44+
if TYPE_CHECKING:
45+
from .lnworker import LNWallet
46+
4247

4348
def is_offer(data: str) -> bool:
4449
d = bech32_decode(data, ignore_long_length=True, with_checksum=False)
@@ -168,6 +173,8 @@ async def request_invoice(
168173
else:
169174
node_id = bolt12_offer['offer_issuer_id']['id']
170175

176+
# spec: MUST set invreq_payer_id to a transient public key.
177+
# spec: MUST remember the secret key corresponding to invreq_payer_id.
171178
session_key = os.urandom(32)
172179
blinding = ecc.ECPrivkey(session_key).get_public_key_bytes()
173180

@@ -241,6 +248,54 @@ async def request_invoice(
241248
return invoice_data, invoice_tlv
242249

243250

251+
def verify_request_and_create_invoice(
252+
lnwallet: 'LNWallet',
253+
bolt12_offer: dict,
254+
bolt12_invreq: dict,
255+
preimage,
256+
) -> dict:
257+
# TODO check offer fields in invreq are equal
258+
# TODO check constraints, like expiry, offer_amount etc
259+
# copy the invreq and offer fields
260+
invoice = copy.deepcopy(bolt12_invreq)
261+
del invoice['signature'] # remove the signature from the invreq
262+
now = int(time.time())
263+
invoice_payment_hash = sha256(preimage)
264+
invoice.update({
265+
'invoice_created_at': {'timestamp': now},
266+
'invoice_relative_expiry': {'seconds_from_creation': 3600}, # TODO hardcoded
267+
'invoice_payment_hash': {'payment_hash': invoice_payment_hash}
268+
})
269+
270+
# spec: if invreq_amount is present: MUST set invoice_amount to invreq_amount
271+
# otherwise: 'expected' amount (or amount == 0 invoice? or min_htlc_msat from channel set?)
272+
amount_msat = 0
273+
if bolt12_invreq.get('invreq_amount'):
274+
amount_msat = bolt12_invreq['invreq_amount']['msat']
275+
invoice.update({
276+
'invoice_amount': {'msat': amount_msat}
277+
})
278+
279+
# spec: if offer_issuer_id is present: MUST set invoice_node_id to the offer_issuer_id
280+
# spec: otherwise, if offer_paths is present: MUST set invoice_node_id to the final blinded_node_id
281+
# on the path it received the invoice request
282+
if bolt12_offer.get('offer_issuer_id'):
283+
invoice.update({
284+
'invoice_node_id': {'node_id': bolt12_offer['offer_issuer_id']['id']}
285+
})
286+
else:
287+
# we don't have invreq used path available here atm.
288+
raise Exception('branch not implemented, electrum should set offer_issuer_id')
289+
290+
invoice_paths, payinfo = get_blinded_paths_to_me(lnwallet)
291+
invoice.update({
292+
'invoice_paths': {'paths': invoice_paths},
293+
'invoice_blindedpay': {'payinfo': payinfo}
294+
})
295+
296+
return invoice
297+
298+
244299
# wraps remote invoice_error
245300
class Bolt12InvoiceError(Exception): pass
246301

electrum/lnworker.py

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import dns.exception
2828
from aiorpcx import run_in_thread, NetAddress, ignore_after
2929

30+
from .bolt12 import encode_invoice
3031
from .logging import Logger
3132
from .i18n import _
3233
from .json_db import stored_in
@@ -55,7 +56,7 @@
5556
sha256, chacha20_encrypt, chacha20_decrypt, pw_encode_with_version_and_mac, pw_decode_with_version_and_mac
5657
)
5758

58-
from .onion_message import OnionMessageManager
59+
from .onion_message import OnionMessageManager, send_onion_message_to, encode_blinded_path
5960
from .lntransport import (
6061
LNTransport, LNResponderTransport, LNTransportBase, LNPeerAddr, split_host_port, extract_nodeid,
6162
ConnStringFormatError
@@ -3782,3 +3783,77 @@ def get_forwarding_failure(self, payment_key: str) -> Tuple[Optional[bytes], Opt
37823783
error_bytes = bytes.fromhex(error_hex) if error_hex else None
37833784
failure_message = OnionRoutingFailure.from_bytes(bytes.fromhex(failure_hex)) if failure_hex else None
37843785
return error_bytes, failure_message
3786+
3787+
def on_bolt12_invoice_request(self, recipient_data: dict, payload: dict):
3788+
# match to offer
3789+
self.logger.debug(f'on_bolt12_invoice_request: {recipient_data=} {payload=}')
3790+
offer_id = recipient_data['path_id']['data']
3791+
offer = self.wallet.get_offer(offer_id)
3792+
if offer is None:
3793+
self.logger.warning('no matching offer for invoice_request')
3794+
return
3795+
3796+
invreq_tlv = payload['invoice_request']['invoice_request']
3797+
invreq = bolt12.decode_invoice_request(invreq_tlv)
3798+
self.logger.info(f'invoice_request: {invreq=}')
3799+
self.logger.debug(f'invoice_request for {offer=}')
3800+
3801+
# two scenarios:
3802+
# 1) not in response to offer (no offer_issuer_id or offer_paths)
3803+
# MUST reject the invoice request if any of the following are present:
3804+
# offer_chains, offer_features or offer_quantity_max.
3805+
# MUST reject the invoice request if invreq_amount is not present.
3806+
# MAY use offer_amount (or offer_currency) for informational display to user.
3807+
# if it sends an invoice in response:
3808+
# MUST use invreq_paths if present, otherwise MUST use invreq_payer_id as the node id to send to.
3809+
3810+
# 2) response to offer.
3811+
#
3812+
# MUST reject the invoice request if the offer fields do not exactly match a valid, unexpired offer.
3813+
# if offer_paths is present:
3814+
# MUST ignore the invoice_request if it did not arrive via one of those paths.
3815+
# otherwise:
3816+
# MUST ignore any invoice_request if it arrived via a blinded path.
3817+
# if offer_quantity_max is present:
3818+
# MUST reject the invoice request if there is no invreq_quantity field.
3819+
# if offer_quantity_max is non-zero:
3820+
# MUST reject the invoice request if invreq_quantity is zero, OR greater than offer_quantity_max.
3821+
# otherwise:
3822+
# MUST reject the invoice request if there is an invreq_quantity field.
3823+
# if offer_amount is present:
3824+
# MUST calculate the expected amount using the offer_amount:
3825+
# if offer_currency is not the invreq_chain currency, convert to the invreq_chain currency.
3826+
# if invreq_quantity is present, multiply by invreq_quantity.quantity.
3827+
# if invreq_amount is present:
3828+
# MUST reject the invoice request if invreq_amount.msat is less than the expected amount.
3829+
# MAY reject the invoice request if invreq_amount.msat greatly exceeds the expected amount.
3830+
# otherwise (no offer_amount):
3831+
# MUST reject the invoice request if it does not contain invreq_amount.
3832+
# SHOULD send an invoice in response using the onionmsg_tlv reply_path.
3833+
3834+
is_response_to_offer = True # always assume scenario 2 for now
3835+
3836+
if is_response_to_offer:
3837+
node_id_or_blinded_path = encode_blinded_path(payload['reply_path']['path'])
3838+
else:
3839+
# spec: MUST use invreq_paths if present, otherwise MUST use invreq_payer_id as the node id to send to.
3840+
if 'invreq_paths' in invreq:
3841+
node_id_or_blinded_path = invreq['invreq_paths']['paths'][0] # take first
3842+
else:
3843+
node_id_or_blinded_path = invreq['invreq_payer_id']
3844+
3845+
preimage = os.urandom(32)
3846+
invoice = bolt12.verify_request_and_create_invoice(self, offer, invreq, preimage)
3847+
if invoice is None: # TODO: use exception, not None
3848+
self.logger.error('could not generate invoice')
3849+
# TODO: send invoice_error if request not totally bogus
3850+
return
3851+
invoice_payment_hash = sha256(preimage)
3852+
self.save_preimage(invoice_payment_hash, preimage)
3853+
3854+
destination_payload = {
3855+
'invoice': {'invoice': encode_invoice(invoice, self.node_keypair.privkey)}
3856+
}
3857+
3858+
send_onion_message_to(self, node_id_or_blinded_path, destination_payload)
3859+

electrum/onion_message.py

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,19 @@
2929
import time
3030
from random import random
3131

32-
from typing import TYPE_CHECKING, Optional, Sequence, NamedTuple
32+
from typing import TYPE_CHECKING, Optional, Sequence, NamedTuple, Tuple
3333

3434
import electrum_ecc as ecc
3535

36+
from electrum.channel_db import get_mychannel_policy
3637
from electrum.lnrouter import PathEdge
3738
from electrum.logging import get_logger, Logger
3839
from electrum.crypto import sha256, get_ecdh
3940
from electrum.lnmsg import OnionWireSerializer
4041
from electrum.lnonion import (get_bolt04_onion_key, OnionPacket, process_onion_packet,
4142
OnionHopsDataSingle, decrypt_onionmsg_data_tlv, encrypt_onionmsg_data_tlv,
4243
get_shared_secrets_along_route, new_onion_packet, encrypt_hops_recipient_data)
43-
from electrum.lnutil import LnFeatures
44+
from electrum.lnutil import LnFeatures, REMOTE
4445
from electrum.util import OldTaskGroup, log_exceptions
4546

4647

@@ -368,8 +369,9 @@ def get_blinded_reply_paths(
368369
"""construct a list of blinded reply-paths for onion message.
369370
"""
370371
mydata = {'path_id': {'data': path_id}} # same path_id used in every reply path
371-
return get_blinded_paths_to_me(lnwallet, mydata, max_paths=max_paths,
372-
preferred_node_id=preferred_node_id, onion_message=True)
372+
paths, payinfo = get_blinded_paths_to_me(lnwallet, mydata, max_paths=max_paths,
373+
preferred_node_id=preferred_node_id, onion_message=True)
374+
return paths
373375

374376

375377
def get_blinded_paths_to_me(
@@ -379,7 +381,7 @@ def get_blinded_paths_to_me(
379381
max_paths: int = PAYMENT_PATHS_MAX,
380382
preferred_node_id: bytes = None,
381383
onion_message: bool = False
382-
) -> Sequence[dict]:
384+
) -> Tuple[Sequence[dict], Sequence[dict]]:
383385
"""construct a list of blinded paths.
384386
current logic:
385387
- uses channels peers if not onion_message
@@ -395,13 +397,27 @@ def get_blinded_paths_to_me(
395397
lnwallet.peers.get(chan.node_id).their_features.supports(LnFeatures.OPTION_ONION_MESSAGE_OPT)]
396398

397399
result = []
400+
payinfo = []
398401
mynodeid = lnwallet.node_keypair.pubkey
399402
if len(my_channels):
400403
# randomize list, but prefer preferred_node_id
401404
rchans = sorted(my_channels, key=lambda x: random() if x.node_id != preferred_node_id else 0)
402405
for chan in rchans[:max_paths]:
403406
blinded_path = create_blinded_path(os.urandom(32), [chan.node_id, mynodeid], final_recipient_data)
404407
result.append(blinded_path)
408+
if not onion_message: # add payinfo, assumption: len(blinded_path) == 2 (us and peer)
409+
# get policy
410+
cp = get_mychannel_policy(chan.short_channel_id, chan.node_id,
411+
{chan.short_channel_id: chan})
412+
payinfo.append({
413+
'fee_base_msat': cp.fee_base_msat,
414+
'fee_proportional_millionths': cp.fee_proportional_millionths,
415+
'cltv_expiry_delta': cp.cltv_delta,
416+
'htlc_minimum_msat': cp.htlc_minimum_msat,
417+
'htlc_maximum_msat': cp.htlc_maximum_msat,
418+
'flen': 0,
419+
'features': bytes(0)
420+
})
405421
elif onion_message:
406422
# we can use peers even without channels for onion messages
407423
my_onionmsg_peers = [peer for peer in lnwallet.peers.values() if
@@ -413,7 +429,17 @@ def get_blinded_paths_to_me(
413429
blinded_path = create_blinded_path(os.urandom(32), [peer.pubkey, mynodeid], final_recipient_data)
414430
result.append(blinded_path)
415431

416-
return result
432+
return result, payinfo
433+
434+
435+
def encode_blinded_path(blinded_path: dict):
436+
with io.BytesIO() as blinded_path_fd:
437+
OnionWireSerializer.write_field(
438+
fd=blinded_path_fd,
439+
field_type='blinded_path',
440+
count=1,
441+
value=blinded_path)
442+
return blinded_path_fd.getvalue()
417443

418444

419445
class Timeout(Exception): pass

0 commit comments

Comments
 (0)