From 3472653c8362f43fc2ac93b0a3da21f62acd9d32 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 15 Oct 2023 17:09:03 +0200 Subject: [PATCH] use bolt11 lib --- cashu/core/bolt11.py | 369 --------------------------------- cashu/lightning/fake.py | 75 ++++--- cashu/mint/ledger.py | 5 +- cashu/wallet/api/router.py | 8 +- cashu/wallet/crud.py | 1 + cashu/wallet/htlc.py | 1 - cashu/wallet/lightning/main.py | 3 +- cashu/wallet/p2pk.py | 1 - cashu/wallet/secrets.py | 1 - cashu/wallet/wallet.py | 8 +- poetry.lock | 22 +- pyproject.toml | 1 + tests/test_wallet_api.py | 12 +- 13 files changed, 89 insertions(+), 418 deletions(-) delete mode 100644 cashu/core/bolt11.py diff --git a/cashu/core/bolt11.py b/cashu/core/bolt11.py deleted file mode 100644 index 3c59fbb5..00000000 --- a/cashu/core/bolt11.py +++ /dev/null @@ -1,369 +0,0 @@ -import hashlib -import re -import time -from binascii import unhexlify -from decimal import Decimal -from typing import List, NamedTuple, Optional - -import bitstring # type: ignore -import secp256k1 -from bech32 import CHARSET, bech32_decode, bech32_encode -from ecdsa import SECP256k1, VerifyingKey # type: ignore -from ecdsa.util import sigdecode_string # type: ignore - - -class Route(NamedTuple): - pubkey: str - short_channel_id: str - base_fee_msat: int - ppm_fee: int - cltv: int - - -class Invoice(object): - payment_hash: str - amount_msat: int = 0 - description: Optional[str] = None - description_hash: Optional[str] = None - payee: Optional[str] = None - date: int - expiry: int = 3600 - secret: Optional[str] = None - route_hints: List[Route] = [] - min_final_cltv_expiry: int = 18 - - -def decode(pr: str) -> Invoice: - """bolt11 decoder, - based on https://github.com/rustyrussell/lightning-payencode/blob/master/lnaddr.py - """ - - hrp, decoded_data = bech32_decode(pr) - if hrp is None or decoded_data is None: - raise ValueError("Bad bech32 checksum") - if not hrp.startswith("ln"): - raise ValueError("Does not start with ln") - - bitarray = _u5_to_bitarray(decoded_data) - - # final signature 65 bytes, split it off. - if len(bitarray) < 65 * 8: - raise ValueError("Too short to contain signature") - - # extract the signature - signature = bitarray[-65 * 8 :].tobytes() - - # the tagged fields as a bitstream - data = bitstring.ConstBitStream(bitarray[: -65 * 8]) - - # build the invoice object - invoice = Invoice() - - # decode the amount from the hrp - m = re.search(r"[^\d]+", hrp[2:]) - if m: - amountstr = hrp[2 + m.end() :] - if amountstr != "": - invoice.amount_msat = _unshorten_amount(amountstr) - - # pull out date - 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": - invoice.description = _trim_to_bytes(tagdata).decode("utf-8") - elif tag == "h" and data_length == 52: - invoice.description_hash = _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 - - -def encode(options): - """Convert options into LnAddr and pass it to the encoder""" - addr = LnAddr() - addr.currency = options["currency"] - addr.fallback = options["fallback"] if options["fallback"] else None - if options["amount"]: - addr.amount = options["amount"] - if options["timestamp"]: - addr.date = int(options["timestamp"]) - - addr.paymenthash = unhexlify(options["paymenthash"]) - - if options["description"]: - addr.tags.append(("d", options["description"])) - if options["description_hash"]: - addr.tags.append(("h", options["description_hash"])) - if options["expires"]: - addr.tags.append(("x", options["expires"])) - - if options["fallback"]: - addr.tags.append(("f", options["fallback"])) - if options["route"]: - for r in options["route"]: - splits = r.split("/") - route = [] - while len(splits) >= 5: - route.append( - ( - unhexlify(splits[0]), - unhexlify(splits[1]), - int(splits[2]), - int(splits[3]), - int(splits[4]), - ) - ) - splits = splits[5:] - assert len(splits) == 0 - addr.tags.append(("r", route)) - return lnencode(addr, options["privkey"]) - - -def lnencode(addr, privkey): - if addr.amount: - amount = Decimal(str(addr.amount)) - # We can only send down to millisatoshi. - if amount * 10**12 % 10: - raise ValueError( - "Cannot encode {}: too many decimal places".format(addr.amount) - ) - - amount = addr.currency + shorten_amount(amount) - else: - amount = addr.currency if addr.currency else "" - - hrp = "ln" + amount + "0n" - - # Start with the timestamp - data = bitstring.pack("uint:35", addr.date) - - # Payment hash - data += tagged_bytes("p", addr.paymenthash) - tags_set = set() - - for k, v in addr.tags: - # BOLT #11: - # - # A writer MUST NOT include more than one `d`, `h`, `n` or `x` fields, - if k in ("d", "h", "n", "x"): - if k in tags_set: - raise ValueError("Duplicate '{}' tag".format(k)) - - if k == "r": - route = bitstring.BitArray() - for step in v: - pubkey, channel, feebase, feerate, cltv = step - route.append( - bitstring.BitArray(pubkey) - + bitstring.BitArray(channel) - + bitstring.pack("intbe:32", feebase) - + bitstring.pack("intbe:32", feerate) - + bitstring.pack("intbe:16", cltv) - ) - data += tagged("r", route) - elif k == "f": - data += encode_fallback(v, addr.currency) - elif k == "d": - data += tagged_bytes("d", v.encode()) - elif k == "x": - # Get minimal length by trimming leading 5 bits at a time. - expirybits = bitstring.pack("intbe:64", v)[4:64] - while expirybits.startswith("0b00000"): - expirybits = expirybits[5:] - data += tagged("x", expirybits) - elif k == "h": - data += tagged_bytes("h", v) - elif k == "n": - data += tagged_bytes("n", v) - else: - # FIXME: Support unknown tags? - raise ValueError("Unknown tag {}".format(k)) - - tags_set.add(k) - - # BOLT #11: - # - # A writer MUST include either a `d` or `h` field, and MUST NOT include - # both. - if "d" in tags_set and "h" in tags_set: - raise ValueError("Cannot include both 'd' and 'h'") - if not "d" in tags_set and not "h" in tags_set: - raise ValueError("Must include either 'd' or 'h'") - - # We actually sign the hrp, then data (padded to 8 bits with zeroes). - privkey = secp256k1.PrivateKey(bytes(unhexlify(privkey))) - sig = privkey.ecdsa_sign_recoverable( - bytearray([ord(c) for c in hrp]) + data.tobytes() - ) - # This doesn't actually serialize, but returns a pair of values :( - sig, recid = privkey.ecdsa_recoverable_serialize(sig) - data += bytes(sig) + bytes([recid]) - - return bech32_encode(hrp, bitarray_to_u5(data)) - - -class LnAddr(object): - def __init__( - self, paymenthash=None, amount=None, currency="bc", tags=None, date=None - ): - self.date = int(time.time()) if not date else int(date) - self.tags = [] if not tags else tags - self.unknown_tags = [] - self.paymenthash = paymenthash - self.signature = None - self.pubkey = None - self.currency = currency - self.amount = amount - - def __str__(self): - return "LnAddr[{}, amount={}{} tags=[{}]]".format( - hexlify(self.pubkey.serialize()).decode("utf-8"), - self.amount, - self.currency, - ", ".join([k + "=" + str(v) for k, v in self.tags]), - ) - - -def shorten_amount(amount): - """Given an amount in bitcoin, shorten it""" - # Convert to pico initially - amount = int(amount * 10**12) - units = ["p", "n", "u", "m", ""] - for unit in units: - if amount % 1000 == 0: - amount //= 1000 - else: - break - return str(amount) + unit - - -def _unshorten_amount(amount: str) -> int: - """Given a shortened amount, return millisatoshis""" - # BOLT #11: - # The following `multiplier` letters are defined: - # - # * `m` (milli): multiply by 0.001 - # * `u` (micro): multiply by 0.000001 - # * `n` (nano): multiply by 0.000000001 - # * `p` (pico): multiply by 0.000000000001 - units = {"p": 10**12, "n": 10**9, "u": 10**6, "m": 10**3} - unit = str(amount)[-1] - - # BOLT #11: - # A reader SHOULD fail if `amount` contains a non-digit, or is followed by - # anything except a `multiplier` in the table above. - if not re.fullmatch(r"\d+[pnum]?", str(amount)): - raise ValueError("Invalid amount '{}'".format(amount)) - - if unit in units: - return int(int(amount[:-1]) * 100_000_000_000 / units[unit]) - else: - return int(amount) * 100_000_000_000 - - -def _pull_tagged(stream): - tag = stream.read(5).uint - length = stream.read(5).uint * 32 + stream.read(5).uint - return (CHARSET[tag], stream.read(length * 5), stream) - - -def is_p2pkh(currency, prefix): - return prefix == base58_prefix_map[currency][0] - - -def is_p2sh(currency, prefix): - return prefix == base58_prefix_map[currency][1] - - -# Tagged field containing BitArray -def tagged(char, l): - # Tagged fields need to be zero-padded to 5 bits. - while l.len % 5 != 0: - l.append("0b0") - return ( - bitstring.pack( - "uint:5, uint:5, uint:5", - CHARSET.find(char), - (l.len / 5) / 32, - (l.len / 5) % 32, - ) - + l - ) - - -def tagged_bytes(char, l): - return tagged(char, bitstring.BitArray(l)) - - -def _trim_to_bytes(barr): - # Adds a byte if necessary. - b = barr.tobytes() - if barr.len % 8 != 0: - return b[:-1] - 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: List[int]) -> bitstring.BitArray: - ret = bitstring.BitArray() - for a in arr: - ret += bitstring.pack("uint:5", a) - return ret - - -def bitarray_to_u5(barr): - assert barr.len % 5 == 0 - ret = [] - s = bitstring.ConstBitStream(barr) - while s.pos != s.len: - ret.append(s.read(5).uint) - return ret diff --git a/cashu/lightning/fake.py b/cashu/lightning/fake.py index 23dab7d0..044bd80d 100644 --- a/cashu/lightning/fake.py +++ b/cashu/lightning/fake.py @@ -2,9 +2,18 @@ import hashlib import random from datetime import datetime -from typing import AsyncGenerator, Dict, Optional, Set +from os import urandom +from typing import AsyncGenerator, Optional, Set + +from bolt11 import ( + Bolt11, + MilliSatoshi, + TagChar, + Tags, + decode, + encode, +) -from ..core.bolt11 import Invoice, decode, encode from .base import ( InvoiceResponse, PaymentResponse, @@ -41,39 +50,47 @@ async def create_invoice( memo: Optional[str] = None, description_hash: Optional[bytes] = None, unhashed_description: Optional[bytes] = None, - **kwargs, + expiry: Optional[int] = None, + payment_secret: Optional[bytes] = None, + **_, ) -> InvoiceResponse: - data: Dict = { - "out": False, - "amount": amount * 1000, - "currency": "bc", - "privkey": self.privkey, - "memo": memo, - "description_hash": b"", - "description": "", - "fallback": None, - "expires": kwargs.get("expiry"), - "timestamp": datetime.now().timestamp(), - "route": None, - "tags_set": [], - } + tags = Tags() + if description_hash: - data["tags_set"] = ["h"] - data["description_hash"] = description_hash + tags.add(TagChar.description_hash, description_hash.hex()) elif unhashed_description: - data["tags_set"] = ["d"] - data["description_hash"] = hashlib.sha256(unhashed_description).digest() + tags.add( + TagChar.description_hash, + hashlib.sha256(unhashed_description).hexdigest(), + ) else: - data["tags_set"] = ["d"] - data["memo"] = memo - data["description"] = memo - randomHash = ( + tags.add(TagChar.description, memo or "") + + if expiry: + tags.add(TagChar.expire_time, expiry) + + # random hash + checking_id = ( self.privkey[:6] + hashlib.sha256(str(random.getrandbits(256)).encode()).hexdigest()[6:] ) - data["paymenthash"] = randomHash - payment_request = encode(data) - checking_id = randomHash + + tags.add(TagChar.payment_hash, checking_id) + + if payment_secret: + secret = payment_secret.hex() + else: + secret = urandom(32).hex() + tags.add(TagChar.payment_secret, secret) + + bolt11 = Bolt11( + currency="bc", + amount_msat=MilliSatoshi(amount * 1000), + date=int(datetime.now().timestamp()), + tags=tags, + ) + + payment_request = encode(bolt11, self.privkey) return InvoiceResponse(True, checking_id, payment_request) @@ -106,5 +123,5 @@ async def get_payment_status(self, _: str) -> PaymentStatus: async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: while True: - value: Invoice = await self.queue.get() + value: Bolt11 = await self.queue.get() yield value.payment_hash diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 723b4895..90946986 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -2,9 +2,9 @@ import math from typing import Dict, List, Literal, Optional, Set, Tuple, Union +import bolt11 from loguru import logger -from ..core import bolt11 from ..core.base import ( DLEQ, BlindedMessage, @@ -470,6 +470,7 @@ async def melt( # verify amounts total_provided = sum_proofs(proofs) invoice_obj = bolt11.decode(invoice) + assert invoice_obj.amount_msat, "Amountless invoices not supported." invoice_amount = math.ceil(invoice_obj.amount_msat / 1000) if settings.mint_max_peg_out and invoice_amount > settings.mint_max_peg_out: raise NotAllowedError( @@ -538,6 +539,7 @@ async def get_melt_fees(self, pr: str) -> int: # if id does not exist (not internal), it returns paid = None if settings.lightning: decoded_invoice = bolt11.decode(pr) + assert decoded_invoice.amount_msat, "Amountless invoices not supported." amount_msat = decoded_invoice.amount_msat logger.trace( "get_melt_fees: checking lightning invoice:" @@ -549,6 +551,7 @@ async def get_melt_fees(self, pr: str) -> int: else: amount_msat = 0 internal = True + fees_msat = fee_reserve(amount_msat, internal) fee_sat = math.ceil(fees_msat / 1000) return fee_sat diff --git a/cashu/wallet/api/router.py b/cashu/wallet/api/router.py index 37ab482c..108fba89 100644 --- a/cashu/wallet/api/router.py +++ b/cashu/wallet/api/router.py @@ -86,7 +86,7 @@ async def start_wallet(): response_model=PaymentResponse, ) async def pay( - invoice: str = Query(default=..., description="Lightning invoice to pay"), + bolt11: str = Query(default=..., description="Lightning invoice to pay"), mint: str = Query( default=None, description="Mint URL to pay from (None for default mint)", @@ -95,7 +95,7 @@ async def pay( global wallet if mint: wallet = await mint_wallet(mint) - payment_response = await wallet.pay_invoice(invoice) + payment_response = await wallet.pay_invoice(bolt11) return payment_response @@ -105,7 +105,7 @@ async def pay( response_model=PaymentStatus, ) async def payment_state( - id: str = Query(default=None, description="Id of paid invoice"), + payment_hash: str = Query(default=None, description="Id of paid invoice"), mint: str = Query( default=None, description="Mint URL to create an invoice at (None for default mint)", @@ -114,7 +114,7 @@ async def payment_state( global wallet if mint: wallet = await mint_wallet(mint) - state = await wallet.get_payment_status(id) + state = await wallet.get_payment_status(payment_hash) return state diff --git a/cashu/wallet/crud.py b/cashu/wallet/crud.py index a5858537..2a835149 100644 --- a/cashu/wallet/crud.py +++ b/cashu/wallet/crud.py @@ -237,6 +237,7 @@ async def store_lightning_invoice( async def get_lightning_invoice( + *, db: Database, id: str = "", payment_hash: str = "", diff --git a/cashu/wallet/htlc.py b/cashu/wallet/htlc.py index a9e6e87c..8f25fc55 100644 --- a/cashu/wallet/htlc.py +++ b/cashu/wallet/htlc.py @@ -2,7 +2,6 @@ from datetime import datetime, timedelta from typing import List, Optional -from ..core import bolt11 as bolt11 from ..core.base import HTLCWitness, Proof from ..core.db import Database from ..core.htlc import ( diff --git a/cashu/wallet/lightning/main.py b/cashu/wallet/lightning/main.py index b4f47867..0b9ca859 100644 --- a/cashu/wallet/lightning/main.py +++ b/cashu/wallet/lightning/main.py @@ -1,6 +1,7 @@ import asyncio -import cashu.core.bolt11 as bolt11 +import bolt11 + from cashu.wallet.lightning import LightningWallet diff --git a/cashu/wallet/p2pk.py b/cashu/wallet/p2pk.py index 2844bc89..ec4121c3 100644 --- a/cashu/wallet/p2pk.py +++ b/cashu/wallet/p2pk.py @@ -3,7 +3,6 @@ from loguru import logger -from ..core import bolt11 as bolt11 from ..core.base import ( BlindedMessage, P2PKWitness, diff --git a/cashu/wallet/secrets.py b/cashu/wallet/secrets.py index 45601758..f22d3ea5 100644 --- a/cashu/wallet/secrets.py +++ b/cashu/wallet/secrets.py @@ -6,7 +6,6 @@ from loguru import logger from mnemonic import Mnemonic -from ..core import bolt11 as bolt11 from ..core.crypto.secp import PrivateKey from ..core.db import Database from ..core.settings import settings diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index e45e37fc..a2c21dc4 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -7,12 +7,12 @@ from posixpath import join from typing import Dict, List, Optional, Tuple, Union +import bolt11 import httpx from bip32 import BIP32 from httpx import Response from loguru import logger -from ..core import bolt11 as bolt11 from ..core.base import ( BlindedMessage, BlindedSignature, @@ -37,7 +37,6 @@ TokenV3Token, WalletKeyset, ) -from ..core.bolt11 import Invoice as InvoiceBolt11 from ..core.crypto import b_dhke from ..core.crypto.secp import PrivateKey, PublicKey from ..core.db import Database @@ -823,7 +822,7 @@ async def pay_lightning( p.melt_id = melt_id await update_proof(p, melt_id=melt_id, db=self.db) - decoded_invoice: InvoiceBolt11 = bolt11.decode(invoice) + decoded_invoice = bolt11.decode(invoice) invoice_obj = Invoice( amount=-sum_proofs(proofs), bolt11=invoice, @@ -1271,7 +1270,8 @@ async def get_pay_amount_with_fees(self, invoice: str): Decodes the amount from a Lightning invoice and returns the total amount (amount+fees) to be paid. """ - decoded_invoice: InvoiceBolt11 = bolt11.decode(invoice) + decoded_invoice = bolt11.decode(invoice) + assert decoded_invoice.amount_msat, "Amountless invoices not supported." # check if it's an internal payment fees = int((await self.check_fees(invoice))["fee"]) logger.debug(f"Mint wants {fees} sat as fee reserve.") diff --git a/poetry.lock b/poetry.lock index 029bc54e..6b230981 100644 --- a/poetry.lock +++ b/poetry.lock @@ -156,6 +156,26 @@ d = ["aiohttp (>=3.7.4)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] +[[package]] +name = "bolt11" +version = "2.0.5" +description = "A library for encoding and decoding BOLT11 payment requests." +category = "main" +optional = false +python-versions = ">=3.8.1" +files = [ + {file = "bolt11-2.0.5-py3-none-any.whl", hash = "sha256:6791c2edee804a4a8a7d092c689f8d2c01212271a33963ede4a988b7a6ce1b81"}, + {file = "bolt11-2.0.5.tar.gz", hash = "sha256:e6be2748b0c4a017900761f63d9944c1dde8f22fd2829006679a0e2346eaa47b"}, +] + +[package.dependencies] +base58 = "*" +bech32 = "*" +bitstring = "*" +click = "*" +ecdsa = "*" +secp256k1 = "*" + [[package]] name = "certifi" version = "2023.7.22" @@ -1713,4 +1733,4 @@ pgsql = ["psycopg2-binary"] [metadata] lock-version = "2.0" python-versions = "^3.8.1" -content-hash = "da6ee277a6bcfcf463868f01584b55b1670dbe6160afd3b114bce7fadffad0f5" +content-hash = "f6d0c2b084dff91f046d7d6b35fe3dde778b2c94b24de4d5a0e9eec206610154" diff --git a/pyproject.toml b/pyproject.toml index f5897c06..cec119b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ psycopg2-binary = { version = "^2.9.7", optional = true } httpx = "0.25.0" bip32 = "^3.4" mnemonic = "^0.20" +bolt11 = "^2.0.5" [tool.poetry.extras] pgsql = ["psycopg2-binary"] diff --git a/tests/test_wallet_api.py b/tests/test_wallet_api.py index 869ee789..3d46cbf8 100644 --- a/tests/test_wallet_api.py +++ b/tests/test_wallet_api.py @@ -103,13 +103,13 @@ async def test_burn_all(wallet: Wallet): async def test_pay(): with TestClient(app) as client: invoice = ( - "lnbc100n1pjzp22cpp58xvjxvagzywky9xz3vurue822aaax" - "735hzc5pj5fg307y58v5znqdq4vdshx6r4ypjx2ur0wd5hgl" - "h6ahauv24wdmac4zk478pmwfzd7sdvm8tje3dmfue3lc2g4l" - "9g40a073h39748uez9p8mxws5vqwjmkqr4wl5l7n4dlhj6z6" - "va963cqvufrs4" + "lnbc100n1pjjcqzfdq4gdshx6r4ypjx2ur0wd5hgpp58xvj8yn00d5" + "7uhshwzcwgy9uj3vwf5y2lr5fjf78s4w9l4vhr6xssp5stezsyty9r" + "hv3lat69g4mhqxqun56jyehhkq3y8zufh83xyfkmmq4usaqwrt5q4f" + "adm44g6crckp0hzvuyv9sja7t65hxj0ucf9y46qstkay7gfnwhuxgr" + "krf7djs38rml39l8wpn5ug9shp3n55quxhdecqfwxg23" ) - response = client.post(f"/lightning/pay_invoice?invoice={invoice}") + response = client.post(f"/lightning/pay_invoice?bolt11={invoice}") assert response.status_code == 200