Wallets: add cln-rest (#1775)

* receive and pay works

* fix linter issues

* import Paymentstatus from core.models

* fix test real payment

* fix get_payment_status check in lnbits

* fix tests?

* simplify

* refactor AsyncClient

* inline import of get_wallet_class

fixes the previous cyclic import

* invoice stream working

* add notes as a reminder to get rid of labels when cln-rest supports payment_hash

* create Payment dummy classmethod

* remove unnecessary fields from dummy

* fixes tests?

* fix model

* fix cln bug (#1814)

* auth header

* rename cln to corelightning

* add clnrest to admin_ui

* add to clnrest allowed sources

* add allowed sources to .env.example

* allow macaroon files

* add corelightning rest to workflow

* proper env names

* cleanup routine

* log wallet connection errors and fix macaroon clnrest

* print error on connection fails

* clnrest: handle disconnects faster

* fix test use of get_payment_status

* make format

* clnrest: add unhashed_description

* add unhashed_description to test

* description_hash test

* unhashed_description not supported by clnrest

* fix checking_id return in api_payments_create_invoice

* refactor test to use client instead of api_payments

* formatting, some errorlogging

* fix test 1

* fix other tests, paid statuses was missing

* error handling

* revert unnecessary changes (#1854)

* apply review of motorina0

---------

Co-authored-by: jackstar12 <jkranawetter05@gmail.com>
Co-authored-by: jackstar12 <62219658+jackstar12@users.noreply.github.com>
Co-authored-by: dni  <office@dnilabs.com>
This commit is contained in:
callebtc 2023-08-23 08:59:39 +02:00 committed by GitHub
parent bb5033d225
commit 3a653630f1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 396 additions and 42 deletions

View file

@ -69,9 +69,9 @@ LNBITS_SITE_DESCRIPTION="Some description about your service, will display if ti
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
# Choose from LNPayWallet, OpenNodeWallet, ClicheWallet,
# LndWallet, LndRestWallet, CoreLightningWallet, EclairWallet,
# LnTipsWallet, LNbitsWallet, SparkWallet, FakeWallet,
# which fundingsources are allowed in the admin ui
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, OpenNodeWallet"
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
# just so you can see the UI before dealing with this file.

View file

@ -134,6 +134,50 @@ jobs:
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
CoreLightningRestWallet:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9"]
poetry-version: ["1.3.1"]
steps:
- uses: actions/checkout@v3
- name: Set up Poetry ${{ matrix.poetry-version }}
uses: abatilo/actions-poetry@v2
with:
poetry-version: ${{ matrix.poetry-version }}
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
- name: Setup Regtest
run: |
docker build -t lnbitsdocker/lnbits-legend .
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
cd docker
chmod +x ./tests
./tests
sudo chmod -R a+rwx .
- name: Install dependencies
run: |
poetry install
- name: Run tests
env:
PYTHONUNBUFFERED: 1
PORT: 5123
LNBITS_DATA_FOLDER: ./data
LNBITS_BACKEND_WALLET_CLASS: CoreLightningRestWallet
CORELIGHTNING_REST_URL: https://localhost:3001
CORELIGHTNING_REST_MACAROON: ./docker/data/clightning-2-rest/access.macaroon
CORELIGHTNING_REST_CERT: ./docker/data/clightning-2-rest/certificate.pem
run: |
sudo chmod -R a+rwx . && rm -rf ./data && mkdir -p ./data
make test-real-wallet
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
LNbitsWallet:
runs-on: ubuntu-latest
strategy:

View file

@ -140,7 +140,8 @@ async def check_funding_source() -> None:
f"working properly: '{error_message}'",
RuntimeWarning,
)
except Exception:
except Exception as e:
logger.error(f"Error connecting to {WALLET.__class__.__name__}: {e}")
pass
if settings.lnbits_admin_ui and retry_counter == timeout:

View file

@ -236,6 +236,23 @@
}
}
],
[
'CoreLightningRestWallet',
{
corelightning_rest_url: {
value: null,
label: 'Endpoint'
},
corelightning_rest_cert: {
value: null,
label: 'Certificate'
},
corelightning_rest_macaroon: {
value: null,
label: 'Macaroon'
}
}
],
[
'LndRestWallet',
{

View file

@ -219,7 +219,7 @@ async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
async with db.connect() as conn:
try:
_, payment_request = await create_invoice(
payment_hash, payment_request = await create_invoice(
wallet_id=wallet.id,
amount=amount,
memo=memo,
@ -231,6 +231,11 @@ async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
internal=data.internal,
conn=conn,
)
# NOTE: we get the checking_id with a seperate query because create_invoice does not return it
# and it would be a big hustle to change its return type (used across extensions)
payment_db = await get_standalone_payment(payment_hash, conn=conn)
assert payment_db is not None, "payment not found"
checking_id = payment_db.checking_id
except InvoiceFailure as e:
raise HTTPException(status_code=520, detail=str(e))
except Exception as exc:
@ -273,7 +278,7 @@ async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
"payment_hash": invoice.payment_hash,
"payment_request": payment_request,
# maintain backwards compatibility with API clients:
"checking_id": invoice.payment_hash,
"checking_id": checking_id,
"lnurl_response": lnurl_response,
}

View file

@ -137,6 +137,12 @@ class CoreLightningFundingSource(LNbitsSettings):
clightning_rpc: Optional[str] = Field(default=None)
class CoreLightningRestFundingSource(LNbitsSettings):
corelightning_rest_url: Optional[str] = Field(default=None)
corelightning_rest_macaroon: Optional[str] = Field(default=None)
corelightning_rest_cert: Optional[str] = Field(default=None)
class EclairFundingSource(LNbitsSettings):
eclair_url: Optional[str] = Field(default=None)
eclair_pass: Optional[str] = Field(default=None)
@ -207,6 +213,7 @@ class FundingSourcesSettings(
LNbitsFundingSource,
ClicheFundingSource,
CoreLightningFundingSource,
CoreLightningRestFundingSource,
EclairFundingSource,
LndRestFundingSource,
LndGrpcFundingSource,
@ -282,6 +289,7 @@ class SuperUserSettings(LNbitsSettings):
"VoidWallet",
"FakeWallet",
"CoreLightningWallet",
"CoreLightningRestWallet",
"LndRestWallet",
"EclairWallet",
"LndWallet",

View file

@ -7,8 +7,9 @@ from lnbits.settings import settings
from lnbits.wallets.base import Wallet
from .cliche import ClicheWallet
from .cln import CoreLightningWallet
from .cln import CoreLightningWallet as CLightningWallet
from .corelightning import CoreLightningWallet
from .corelightning import CoreLightningWallet as CLightningWallet
from .corelightningrest import CoreLightningRestWallet
from .eclair import EclairWallet
from .fake import FakeWallet
from .lnbits import LNbitsWallet

View file

@ -43,14 +43,14 @@ class CoreLightningWallet(Wallet):
self.rpc = settings.corelightning_rpc or settings.clightning_rpc
self.ln = LightningRpc(self.rpc)
# check if description_hash is supported (from CLN>=v0.11.0)
# check if description_hash is supported (from corelightning>=v0.11.0)
self.supports_description_hash = (
"deschashonly" in self.ln.help("invoice")["help"][0]["command"]
"deschashonly" in self.ln.help("invoice")["help"][0]["command"] # type: ignore
)
# check last payindex so we can listen from that point on
self.last_pay_index = 0
invoices = self.ln.listinvoices()
invoices: dict = self.ln.listinvoices() # type: ignore
for inv in invoices["invoices"][::-1]:
if "pay_index" in inv:
self.last_pay_index = inv["pay_index"]
@ -58,7 +58,7 @@ class CoreLightningWallet(Wallet):
async def status(self) -> StatusResponse:
try:
funds = self.ln.listfunds()
funds: dict = self.ln.listfunds() # type: ignore
return StatusResponse(
None, sum([int(ch["our_amount_msat"]) for ch in funds["channels"]])
)
@ -79,11 +79,11 @@ class CoreLightningWallet(Wallet):
try:
if description_hash and not unhashed_description:
raise Unsupported(
"'description_hash' unsupported by CLN, provide 'unhashed_description'"
"'description_hash' unsupported by CoreLightning, provide 'unhashed_description'"
)
if unhashed_description and not self.supports_description_hash:
raise Unsupported("unhashed_description")
r = self.ln.invoice(
r: dict = self.ln.invoice( # type: ignore
msatoshi=msat,
label=label,
description=unhashed_description.decode()
@ -96,12 +96,12 @@ class CoreLightningWallet(Wallet):
expiry=kwargs.get("expiry"),
)
if r.get("code") and r.get("code") < 0:
if r.get("code") and r.get("code") < 0: # type: ignore
raise Exception(r.get("message"))
return InvoiceResponse(True, r["payment_hash"], r["bolt11"], "")
except RpcError as exc:
error_message = f"CLN method '{exc.method}' failed with '{exc.error.get('message') or exc.error}'."
error_message = f"CoreLightning method '{exc.method}' failed with '{exc.error.get('message') or exc.error}'." # type: ignore
return InvoiceResponse(False, None, None, error_message)
except Exception as e:
return InvoiceResponse(False, None, None, str(e))
@ -125,9 +125,9 @@ class CoreLightningWallet(Wallet):
r = await wrapped(self.ln, payload)
except RpcError as exc:
try:
error_message = exc.error["attempts"][-1]["fail_reason"]
error_message = exc.error["attempts"][-1]["fail_reason"] # type: ignore
except Exception:
error_message = f"CLN method '{exc.method}' failed with '{exc.error.get('message') or exc.error}'."
error_message = f"CoreLightning method '{exc.method}' failed with '{exc.error.get('message') or exc.error}'." # type: ignore
return PaymentResponse(False, None, None, None, error_message)
except Exception as exc:
return PaymentResponse(False, None, None, None, str(exc))
@ -139,8 +139,8 @@ class CoreLightningWallet(Wallet):
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try:
r = self.ln.listinvoices(payment_hash=checking_id)
except Exception:
r: dict = self.ln.listinvoices(payment_hash=checking_id) # type: ignore
except RpcError:
return PaymentStatus(None)
if not r["invoices"]:
return PaymentStatus(None)
@ -160,7 +160,7 @@ class CoreLightningWallet(Wallet):
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
try:
r = self.ln.listpays(payment_hash=checking_id)
r: dict = self.ln.listpays(payment_hash=checking_id) # type: ignore
except Exception:
return PaymentStatus(None)
if "pays" not in r or not r["pays"]:
@ -192,6 +192,6 @@ class CoreLightningWallet(Wallet):
yield paid["payment_hash"]
except Exception as exc:
logger.error(
f"lost connection to cln invoices stream: '{exc}', retrying in 5 seconds"
f"lost connection to corelightning invoices stream: '{exc}', retrying in 5 seconds"
)
await asyncio.sleep(5)

View file

@ -0,0 +1,255 @@
import asyncio
import json
import random
from typing import AsyncGenerator, Dict, Optional
import httpx
from loguru import logger
from lnbits import bolt11 as lnbits_bolt11
from lnbits.settings import settings
from .base import (
InvoiceResponse,
PaymentResponse,
PaymentStatus,
StatusResponse,
Unsupported,
Wallet,
)
from .macaroon import load_macaroon
class CoreLightningRestWallet(Wallet):
def __init__(self):
macaroon = settings.corelightning_rest_macaroon
assert macaroon, "missing cln-rest macaroon"
self.macaroon = load_macaroon(macaroon)
url = settings.corelightning_rest_url
if not url:
raise Exception("missing url for corelightning-rest")
if not macaroon:
raise Exception("missing macaroon for corelightning-rest")
self.url = url[:-1] if url.endswith("/") else url
self.url = (
f"https://{self.url}" if not self.url.startswith("http") else self.url
)
self.auth = {
"macaroon": self.macaroon,
"encodingtype": "hex",
"accept": "application/json",
}
self.cert = settings.corelightning_rest_cert or False
self.client = httpx.AsyncClient(verify=self.cert, headers=self.auth)
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:
r = await self.client.get(f"{self.url}/v1/channel/localremotebal", timeout=5)
r.raise_for_status()
if r.is_error or "error" in r.json():
try:
data = r.json()
error_message = data["error"]
except Exception:
error_message = r.text
return StatusResponse(
f"Failed to connect to {self.url}, got: '{error_message}...'", 0
)
data = r.json()
if len(data) == 0:
return StatusResponse("no data", 0)
return StatusResponse(None, int(data.get("localBalance") * 1000))
async def create_invoice(
self,
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse:
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"]
r = await self.client.post(
f"{self.url}/v1/invoice/genInvoice",
data=data,
)
if r.is_error or "error" in r.json():
try:
data = r.json()
error_message = data["error"]
except Exception:
error_message = r.text
return InvoiceResponse(False, None, None, error_message)
data = r.json()
assert "payment_hash" in data
assert "bolt11" in data
# NOTE: use payment_hash when corelightning-rest updates and supports it
# return InvoiceResponse(True, data["payment_hash"], data["bolt11"], None)
return InvoiceResponse(True, label, data["bolt11"], None)
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
invoice = lnbits_bolt11.decode(bolt11)
fee_limit_percent = fee_limit_msat / invoice.amount_msat * 100
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,
)
if r.is_error or "error" in r.json():
try:
data = r.json()
error_message = data["error"]
except Exception:
error_message = r.text
return PaymentResponse(False, None, None, None, error_message)
data = r.json()
if data["status"] != "complete":
return PaymentResponse(False, None, None, None, "payment failed")
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
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
# get invoice bolt11 from checking_id
# corelightning-rest wants the "label" here....
# NOTE: We can get rid of all labels and use payment_hash when
# corelightning-rest updates and supports it
r = await self.client.get(
f"{self.url}/v1/invoice/listInvoices",
params={"label": 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 PaymentStatus(None)
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
from lnbits.core import get_standalone_payment
payment = await get_standalone_payment(checking_id)
if not payment:
raise ValueError(f"Payment with checking_id {checking_id} not found")
r = await self.client.get(
f"{self.url}/v1/pay/listPays",
params={"invoice": payment.bolt11},
)
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 PaymentStatus(None)
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}")
yield inv["label"]
# NOTE: use payment_hash when corelightning-rest updates
# and supports it
# 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_invoce = r.json()
# logger.trace(f"paid invoice: {paid_invoce}")
# yield paid_invoce["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)

View file

@ -18,12 +18,19 @@ def load_macaroon(macaroon: str) -> str:
:rtype: str
"""
# if the macaroon is a file path, load it
# if the macaroon is a file path, load it and return hex version
if macaroon.split(".")[-1] == "macaroon":
with open(macaroon, "rb") as f:
macaroon_bytes = f.read()
return macaroon_bytes.hex()
else:
# if macaroon is a provided string
# check if it is hex, if so, return
try:
bytes.fromhex(macaroon)
return macaroon
except ValueError:
pass
# convert the bas64 macaroon to hex
try:
macaroon = base64.b64decode(macaroon).hex()

View file

@ -305,26 +305,30 @@ async def test_api_payment_with_key(invoice, inkey_headers_from):
# check POST /api/v1/payments: invoice creation with a description hash
@pytest.mark.skipif(
WALLET.__class__.__name__ in ["CoreLightningWallet"],
WALLET.__class__.__name__ in ["CoreLightningWallet", "CoreLightningRestWallet"],
reason="wallet does not support description_hash",
)
@pytest.mark.asyncio
async def test_create_invoice_with_description_hash(client, inkey_headers_to):
data = await get_random_invoice_data()
descr_hash = hashlib.sha256("asdasdasd".encode()).hexdigest()
description = "asdasdasd"
descr_hash = hashlib.sha256(description.encode()).hexdigest()
data["description_hash"] = descr_hash
data["unhashed_description"] = description.encode().hex()
response = await client.post(
"/api/v1/payments", json=data, headers=inkey_headers_to
)
invoice = response.json()
invoice_bolt11 = bolt11.decode(invoice["payment_request"])
assert invoice_bolt11.description_hash == descr_hash
assert invoice_bolt11.description is None
return invoice
@pytest.mark.skipif(
WALLET.__class__.__name__ in ["CoreLightningRestWallet"],
reason="wallet does not support unhashed_description",
)
@pytest.mark.asyncio
async def test_create_invoice_with_unhashed_description(client, inkey_headers_to):
data = await get_random_invoice_data()
@ -367,10 +371,12 @@ async def test_pay_real_invoice(
assert payment.payment_hash == invoice["payment_hash"]
# check the payment status
response = await api_payment(
invoice["payment_hash"], inkey_headers_from["X-Api-Key"]
response = await client.get(
f'/api/v1/payments/{invoice["payment_hash"]}', headers=inkey_headers_from
)
assert response["paid"]
assert response.status_code < 300
payment_status = response.json()
assert payment_status["paid"]
status = await WALLET.get_payment_status(invoice["payment_hash"])
assert status.paid
@ -392,23 +398,32 @@ async def test_create_real_invoice(client, adminkey_headers_from, inkey_headers_
)
assert response.status_code < 300
invoice = response.json()
response = await api_payment(
invoice["payment_hash"], inkey_headers_from["X-Api-Key"]
response = await client.get(
f'/api/v1/payments/{invoice["payment_hash"]}', headers=inkey_headers_from
)
assert not response["paid"]
assert response.status_code < 300
payment_status = response.json()
assert not payment_status["paid"]
async def listen():
async for payment_hash in get_wallet_class().paid_invoices_stream():
assert payment_hash == invoice["payment_hash"]
return
found_checking_id = False
async for checking_id in get_wallet_class().paid_invoices_stream():
if checking_id == invoice["checking_id"]:
found_checking_id = True
return
assert found_checking_id
task = asyncio.create_task(listen())
pay_real_invoice(invoice["payment_request"])
await asyncio.wait_for(task, timeout=3)
response = await api_payment(
invoice["payment_hash"], inkey_headers_from["X-Api-Key"]
response = await client.get(
f'/api/v1/payments/{invoice["payment_hash"]}', headers=inkey_headers_from
)
assert response["paid"]
assert response.status_code < 300
payment_status = response.json()
assert payment_status["paid"]
await asyncio.sleep(0.3)
balance = await get_node_balance_sats()
@ -442,6 +457,7 @@ async def test_pay_real_invoice_set_pending_and_check_state(
)
assert response["paid"]
# make sure that the backend also thinks it's paid
status = await WALLET.get_payment_status(invoice["payment_hash"])
assert status.paid
@ -618,8 +634,8 @@ async def test_receive_real_invoice_set_pending_and_check_state(
assert not response["paid"]
async def listen():
async for payment_hash in get_wallet_class().paid_invoices_stream():
assert payment_hash == invoice["payment_hash"]
async for checking_id in get_wallet_class().paid_invoices_stream():
assert checking_id == invoice["checking_id"]
return
task = asyncio.create_task(listen())