lnbits-legend/lnbits/wallets/corelightningrest.py
Vlad Stan bfda0b62da
test: add unit tests for wallets (funding sources) (#2363)
* test: initial commit

* feat: allow external label for `create_invoice` (useful for testing)

* chore: code format

* fix: ignore temp coverage files

* feat: add properties to the Status classes for a better readability

* fix: add extra validation for data

* fix: comment out bad `status.pending` (to be fixed in core)

* fix: 404 tests

* test: first draft of generic rest wallet tests

* test: migrate two more tests

* feat: add response type

* feat: test exceptions

* test: extract first `create_invoice` test

* chore: reminder

* add: error test

* chore: code format

* chore: experiment

* feat: adapt parsing

* refactor: data structure

* fix: some tests

* refactor: extract methods

* fix: make response uniform

* fix: test data

* chore: clean-up

* fix: uniform responses

* fix: user agent

* fix: user agent

* fix: user-agent again

* test: add `with error` test

* feat: customize test name

* fix: better exception handling for `status`

* fix: add `try-catch` for `raise_for_status`

* test: with no mocks

* chore: clean-up generalized tests

* chore: code format

* chore: code format

* chore: remove extracted tests

* test: add `create_invoice`: error test

* add: test for `create_invoice` with http 404

* test: extract `test_pay_invoice_ok`

* test: extract `test_pay_invoice_error_response`

* test: extract `test_pay_invoice_http_404`

* test: add "missing data"

* test: add `bad-json`

* test: add `no mocks` for `create_invoice`

* test: add `no mocks` for `pay_invoice`

* test: add `bad json` tests

* chore: re-order tests

* fix: response type

* test: add `missing data` test for `pay_imvoice`

* chore: re-order tests

* test: add `success` test for `get_invoice_status `

* feat: update test structure

* test: new status

* test: add more test

* fix: error handling

* chore: code clean-up

* test: add success test for `get_payment_status `

* test: add `pending` tests for `check_payment_status`

* chore: remove extracted tests

* test: add more tests

* test: add `no mocks` test

* fix: funding source loading

* refactor: extract `rest_wallet_fixtures_from_json` function

* chore: update comment

* feat: cover `cleanup` call also

* chore: code format

* refactor: start to extract data model

* refactor: extract mock class

* fix: typings

* refactor: improve typings

* chore: add some documentation

* chore: final clean-up

* chore: rename file

* chore: `poetry add --dev pytest_httpserver` (after rebase)
2024-04-08 12:18:21 +03:00

300 lines
11 KiB
Python

import asyncio
import json
import random
from typing import AsyncGenerator, Dict, Optional
import httpx
from bolt11 import Bolt11Exception
from bolt11.decode import decode
from loguru import logger
from lnbits.settings import settings
from .base import (
InvoiceResponse,
PaymentPendingStatus,
PaymentResponse,
PaymentStatus,
StatusResponse,
Unsupported,
Wallet,
)
from .macaroon import load_macaroon
class CoreLightningRestWallet(Wallet):
def __init__(self):
if not settings.corelightning_rest_url:
raise ValueError(
"cannot initialize CoreLightningRestWallet: "
"missing corelightning_rest_url"
)
if not settings.corelightning_rest_macaroon:
raise ValueError(
"cannot initialize CoreLightningRestWallet: "
"missing corelightning_rest_macaroon"
)
macaroon = load_macaroon(settings.corelightning_rest_macaroon)
if not macaroon:
raise ValueError(
"cannot initialize CoreLightningRestWallet: "
"invalid corelightning_rest_macaroon provided"
)
self.url = self.normalize_endpoint(settings.corelightning_rest_url)
headers = {
"macaroon": macaroon,
"encodingtype": "hex",
"accept": "application/json",
"User-Agent": settings.user_agent,
}
self.cert = settings.corelightning_rest_cert or False
self.client = httpx.AsyncClient(verify=self.cert, headers=headers)
self.last_pay_index = 0
self.statuses = {
"paid": True,
"complete": True,
"failed": False,
"pending": None,
}
async def cleanup(self):
try:
await self.client.aclose()
except RuntimeError as e:
logger.warning(f"Error closing wallet connection: {e}")
async def status(self) -> StatusResponse:
try:
r = await self.client.get(
f"{self.url}/v1/channel/localremotebal", timeout=5
)
r.raise_for_status()
data = r.json()
if len(data) == 0:
return StatusResponse("no data", 0)
if "error" in data:
return StatusResponse(f"""Server error: '{data["error"]}'""", 0)
if r.is_error or "localBalance" not in data:
return StatusResponse(f"Server error: '{r.text}'", 0)
return StatusResponse(None, int(data.get("localBalance") * 1000))
except json.JSONDecodeError:
return StatusResponse("Server error: 'invalid json response'", 0)
except Exception as exc:
logger.warning(exc)
return StatusResponse(f"Unable to connect to {self.url}.", 0)
async def create_invoice(
self,
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse:
label = kwargs.get("label", f"lbl{random.random()}")
data: Dict = {
"amount": amount * 1000,
"description": memo,
"label": label,
}
if description_hash and not unhashed_description:
raise Unsupported(
"'description_hash' unsupported by CoreLightningRest, "
"provide 'unhashed_description'"
)
if unhashed_description:
data["description"] = unhashed_description.decode("utf-8")
if kwargs.get("expiry"):
data["expiry"] = kwargs["expiry"]
if kwargs.get("preimage"):
data["preimage"] = kwargs["preimage"]
try:
r = await self.client.post(
f"{self.url}/v1/invoice/genInvoice",
data=data,
)
r.raise_for_status()
data = r.json()
if len(data) == 0:
return InvoiceResponse(False, None, None, "no data")
if "error" in data:
return InvoiceResponse(
False, None, None, f"""Server error: '{data["error"]}'"""
)
if r.is_error:
return InvoiceResponse(False, None, None, f"Server error: '{r.text}'")
if "payment_hash" not in data or "bolt11" not in data:
return InvoiceResponse(
False, None, None, "Server error: 'missing required fields'"
)
return InvoiceResponse(True, data["payment_hash"], data["bolt11"], None)
except json.JSONDecodeError:
return InvoiceResponse(
False, None, None, "Server error: 'invalid json response'"
)
except Exception as exc:
logger.warning(exc)
return InvoiceResponse(
False, None, None, f"Unable to connect to {self.url}."
)
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
try:
invoice = decode(bolt11)
except Bolt11Exception as exc:
return PaymentResponse(False, None, None, None, str(exc))
if not invoice.amount_msat or invoice.amount_msat <= 0:
error_message = "0 amount invoices are not allowed"
return PaymentResponse(False, None, None, None, error_message)
fee_limit_percent = fee_limit_msat / invoice.amount_msat * 100
try:
r = await self.client.post(
f"{self.url}/v1/pay",
data={
"invoice": bolt11,
"maxfeepercent": f"{fee_limit_percent:.11}",
"exemptfee": 0, # so fee_limit_percent is applied even on payments
# with fee < 5000 millisatoshi (which is default value of exemptfee)
},
timeout=None,
)
r.raise_for_status()
data = r.json()
if "error" in data:
return PaymentResponse(False, None, None, None, data["error"])
if r.is_error:
return PaymentResponse(False, None, None, None, r.text)
if (
"payment_hash" not in data
or "payment_preimage" not in data
or "msatoshi_sent" not in data
or "msatoshi" not in data
or "status" not in data
):
return PaymentResponse(
False, None, None, None, "Server error: 'missing required fields'"
)
checking_id = data["payment_hash"]
preimage = data["payment_preimage"]
fee_msat = data["msatoshi_sent"] - data["msatoshi"]
return PaymentResponse(
self.statuses.get(data["status"]), checking_id, fee_msat, preimage, None
)
except json.JSONDecodeError:
return PaymentResponse(
False, None, None, None, "Server error: 'invalid json response'"
)
except Exception as exc:
logger.info(f"Failed to pay invoice {bolt11}")
logger.warning(exc)
return PaymentResponse(
False, None, None, None, f"Unable to connect to {self.url}."
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
r = await self.client.get(
f"{self.url}/v1/invoice/listInvoices",
params={"payment_hash": checking_id},
)
try:
r.raise_for_status()
data = r.json()
if r.is_error or "error" in data or data.get("invoices") is None:
raise Exception("error in cln response")
return PaymentStatus(self.statuses.get(data["invoices"][0]["status"]))
except Exception as e:
logger.error(f"Error getting invoice status: {e}")
return PaymentPendingStatus()
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
r = await self.client.get(
f"{self.url}/v1/pay/listPays",
params={"payment_hash": checking_id},
)
try:
r.raise_for_status()
data = r.json()
if r.is_error or "error" in data or not data.get("pays"):
raise Exception("error in corelightning-rest response")
pay = data["pays"][0]
fee_msat, preimage = None, None
if self.statuses[pay["status"]]:
# cut off "msat" and convert to int
fee_msat = -int(pay["amount_sent_msat"][:-4]) - int(
pay["amount_msat"][:-4]
)
preimage = pay["preimage"]
return PaymentStatus(self.statuses.get(pay["status"]), fee_msat, preimage)
except Exception as e:
logger.error(f"Error getting payment status: {e}")
return PaymentPendingStatus()
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while True:
try:
url = f"{self.url}/v1/invoice/waitAnyInvoice/{self.last_pay_index}"
async with self.client.stream("GET", url, timeout=None) as r:
async for line in r.aiter_lines():
inv = json.loads(line)
if "error" in inv and "message" in inv["error"]:
logger.error("Error in paid_invoices_stream:", inv)
raise Exception(inv["error"]["message"])
try:
paid = inv["status"] == "paid"
self.last_pay_index = inv["pay_index"]
if not paid:
continue
except Exception:
continue
logger.trace(f"paid invoice: {inv}")
# NOTE: use payment_hash when corelightning-rest returns it
# when using waitAnyInvoice
# payment_hash = inv["payment_hash"]
# yield payment_hash
# hack to return payment_hash if the above shouldn't work
r = await self.client.get(
f"{self.url}/v1/invoice/listInvoices",
params={"label": inv["label"]},
)
paid_invoice = r.json()
logger.trace(f"paid invoice: {paid_invoice}")
assert self.statuses[
paid_invoice["invoices"][0]["status"]
], "streamed invoice not paid"
assert "invoices" in paid_invoice, "no invoices in response"
assert len(paid_invoice["invoices"]), "no invoices in response"
yield paid_invoice["invoices"][0]["payment_hash"]
except Exception as exc:
logger.debug(
f"lost connection to corelightning-rest invoices stream: '{exc}', "
"reconnecting..."
)
await asyncio.sleep(0.02)