From 2cecaa229b2df764c466c8459b6834cf6f8b1047 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sun, 30 Aug 2020 17:40:28 -0300 Subject: [PATCH] bolt11.py now supports everything. --- lnbits/bolt11.py | 81 ++++++++++++++++++++++--- lnbits/extensions/withdraw/views_api.py | 26 ++++---- requirements.txt | 1 + 3 files changed, 87 insertions(+), 21 deletions(-) diff --git a/lnbits/bolt11.py b/lnbits/bolt11.py index 81154be4c..d93446171 100644 --- a/lnbits/bolt11.py +++ b/lnbits/bolt11.py @@ -2,14 +2,32 @@ import bitstring import re +import hashlib +from typing import List, NamedTuple from bech32 import bech32_decode, CHARSET +from ecdsa import SECP256k1, VerifyingKey +from ecdsa.util import sigdecode_string +from binascii import unhexlify + + +class Route(NamedTuple): + pubkey: str + short_channel_id: str + base_fee_msat: int + ppm_fee: int + cltv: int class Invoice(object): - def __init__(self): - self.payment_hash: str = None - self.amount_msat: int = 0 - self.description: str = None + payment_hash: str = None + amount_msat: int = 0 + description: str = None + payee: str = None + date: int = None + expiry: int = 3600 + secret: str = None + route_hints: List[Route] = [] + min_final_cltv_expiry: int = 18 def decode(pr: str) -> Invoice: @@ -26,13 +44,20 @@ def decode(pr: str) -> Invoice: data = u5_to_bitarray(data) - # Final signature 65 bytes, split it off. + # final signature 65 bytes, split it off. if len(data) < 65 * 8: raise ValueError("Too short to contain signature") + + # extract the signature + signature = data[-65 * 8 :].tobytes() + + # the tagged fields as a bitstream data = bitstring.ConstBitStream(data[: -65 * 8]) + # build the invoice object invoice = Invoice() + # decode the amount from the hrp m = re.search("[^\d]+", hrp[2:]) if m: amountstr = hrp[2 + m.end() :] @@ -40,11 +65,10 @@ def decode(pr: str) -> Invoice: invoice.amount_msat = unshorten_amount(amountstr) # pull out date - data.read(35).uint + invoice.date = data.read(35).uint while data.pos != data.len: tag, tagdata, data = pull_tagged(data) - data_length = len(tagdata) / 5 if tag == "d": @@ -53,6 +77,41 @@ def decode(pr: str) -> Invoice: invoice.description = trim_to_bytes(tagdata).hex() elif tag == "p" and data_length == 52: invoice.payment_hash = trim_to_bytes(tagdata).hex() + elif tag == "x": + invoice.expiry = tagdata.uint + elif tag == "n": + invoice.payee = trim_to_bytes(tagdata).hex() + # this won't work in most cases, we must extract the payee + # from the signature + elif tag == "s": + invoice.secret = trim_to_bytes(tagdata).hex() + elif tag == "r": + s = bitstring.ConstBitStream(tagdata) + while s.pos + 264 + 64 + 32 + 32 + 16 < s.len: + route = Route( + pubkey=s.read(264).tobytes().hex(), + short_channel_id=readable_scid(s.read(64).intbe), + base_fee_msat=s.read(32).intbe, + ppm_fee=s.read(32).intbe, + cltv=s.read(16).intbe, + ) + invoice.route_hints.append(route) + + # BOLT #11: + # A reader MUST check that the `signature` is valid (see the `n` tagged + # field specified below). + # A reader MUST use the `n` field to validate the signature instead of + # performing signature recovery if a valid `n` field is provided. + message = bytearray([ord(c) for c in hrp]) + data.tobytes() + sig = signature[0:64] + if invoice.payee: + key = VerifyingKey.from_string(unhexlify(invoice.payee), curve=SECP256k1) + key.verify(sig, message, hashlib.sha256, sigdecode=sigdecode_string) + else: + keys = VerifyingKey.from_public_key_recovery(sig, message, SECP256k1, hashlib.sha256) + signaling_byte = signature[64] + key = keys[int(signaling_byte)] + invoice.payee = key.to_string("compressed").hex() return invoice @@ -101,6 +160,14 @@ def trim_to_bytes(barr): return b +def readable_scid(short_channel_id: int) -> str: + return "{blockheight}x{transactionindex}x{outputindex}".format( + blockheight=((short_channel_id >> 40) & 0xFFFFFF), + transactionindex=((short_channel_id >> 16) & 0xFFFFFF), + outputindex=(short_channel_id & 0xFFFF), + ) + + def u5_to_bitarray(arr): ret = bitstring.BitArray() for a in arr: diff --git a/lnbits/extensions/withdraw/views_api.py b/lnbits/extensions/withdraw/views_api.py index f21da5301..c261d82ce 100644 --- a/lnbits/extensions/withdraw/views_api.py +++ b/lnbits/extensions/withdraw/views_api.py @@ -2,12 +2,11 @@ from datetime import datetime from flask import g, jsonify, request from http import HTTPStatus from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl -import shortuuid # type: ignore +import shortuuid # type: ignore from lnbits.core.crud import get_user from lnbits.core.services import pay_invoice from lnbits.decorators import api_check_wallet_key, api_validate_post_request -from lnbits.helpers import urlsafe_short_hash from lnbits.extensions.withdraw import withdraw_ext from .crud import ( @@ -49,7 +48,7 @@ def api_link_retrieve(link_id): if link.wallet != g.wallet.id: return jsonify({"message": "Not your withdraw link."}), HTTPStatus.FORBIDDEN - + return jsonify({**link._asdict(), **{"lnurl": link.lnurl}}), HTTPStatus.OK @@ -80,7 +79,7 @@ def api_link_create_or_update(link_id=None): usescsv += "," + str(i + 1) else: usescsv += "," + str(1) - usescsv = usescsv[1:] + usescsv = usescsv[1:] if link_id: link = get_withdraw_link(link_id, 0) @@ -109,7 +108,9 @@ def api_link_delete(link_id): return "", HTTPStatus.NO_CONTENT -#FOR LNURLs WHICH ARE NOT UNIQUE + +# FOR LNURLs WHICH ARE NOT UNIQUE + @withdraw_ext.route("/api/v1/lnurl/", methods=["GET"]) def api_lnurl_response(unique_hash): @@ -123,13 +124,14 @@ def api_lnurl_response(unique_hash): usescsv = "" for x in range(1, link.uses - link.used): usescsv += "," + str(1) - usescsv = usescsv[1:] + usescsv = usescsv[1:] link = update_withdraw_link(link.id, used=link.used + 1, usescsv=usescsv) - return jsonify(link.lnurl_response.dict()), HTTPStatus.OK -#FOR LNURLs WHICH ARE UNIQUE + +# FOR LNURLs WHICH ARE UNIQUE + @withdraw_ext.route("/api/v1/lnurl//", methods=["GET"]) def api_lnurl_multi_response(unique_hash, id_unique_hash): @@ -139,11 +141,7 @@ def api_lnurl_multi_response(unique_hash, id_unique_hash): return jsonify({"status": "ERROR", "reason": "LNURL-withdraw not found."}), HTTPStatus.OK useslist = link.usescsv.split(",") usescsv = "" - hashed = [] found = False - print(link.uses - link.used) - print("link.uses - link.used") - print("svfsfv") if link.is_unique == 0: for x in range(link.uses - link.used): usescsv += "," + str(1) @@ -159,10 +157,10 @@ def api_lnurl_multi_response(unique_hash, id_unique_hash): if not found: return jsonify({"status": "ERROR", "reason": "LNURL-withdraw not found."}), HTTPStatus.OK - usescsv = usescsv[1:] + usescsv = usescsv[1:] link = update_withdraw_link(link.id, used=link.used + 1, usescsv=usescsv) return jsonify(link.lnurl_response.dict()), HTTPStatus.OK - + @withdraw_ext.route("/api/v1/lnurl/cb/", methods=["GET"]) def api_lnurl_callback(unique_hash): diff --git a/requirements.txt b/requirements.txt index 3dbe51384..65bc4bb8e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ cerberus==1.3.2 certifi==2020.6.20 chardet==3.0.4 click==7.1.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' +ecdsa==0.16.0 flask-assets==2.0 flask-compress==1.5.0 flask-cors==3.0.8