Mint: Add clnrest.py Lightning backend (#551)

* log cln error

* return a string

* update corelightningrest to work with latest ver using rune

* fix mpp spec and backend support check

* refactor validation in ledger

* remove weird error

* fix mpp melt model

* corelightningrest.py: Added Multi-Mint payout support
lndrest.py: fix `quote.amount` is not always in sats + better checks

* small fix

* Fix quote.unit str2unit conversion + add missing imports

* settings enable mpp corelightning (default false)

* small fix

* fix `paid_invoice_stream`

* make format

* handle runes

* load rune

* rename to MINT_CORELIGHTNING_REST_RUNE

* try without cert

* port

* try except callback dispatcher

* clean up cln-rest streaming parser

* conftest: mint_corelightning_enable_mpp

* enable mpp in regtest.yaml

* fix error handling clnrest, remove lndrest changes

* CLNRest + CoreLightningRest

* clean up corelightningrest and get last index before starting the stream

* clean up

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
Co-authored-by: Richard Bensberg <r@coinbatsu.com>
This commit is contained in:
lollerfirst
2024-06-30 20:36:19 +02:00
committed by GitHub
parent 2739c3127a
commit ca272bc20f
11 changed files with 409 additions and 14 deletions

View File

@@ -57,7 +57,7 @@ MINT_DERIVATION_PATH="m/0'/0'/0'"
MINT_DATABASE=data/mint MINT_DATABASE=data/mint
# Funding source backends # Funding source backends
# Supported: FakeWallet, LndRestWallet, CoreLightningRestWallet, BlinkWallet, LNbitsWallet, StrikeWallet # Supported: FakeWallet, LndRestWallet, CLNRestWallet, BlinkWallet, LNbitsWallet, StrikeWallet, CoreLightningRestWallet (deprecated)
MINT_BACKEND_BOLT11_SAT=FakeWallet MINT_BACKEND_BOLT11_SAT=FakeWallet
# Only works if a usd derivation path is set # Only works if a usd derivation path is set
# MINT_BACKEND_BOLT11_SAT=FakeWallet # MINT_BACKEND_BOLT11_SAT=FakeWallet
@@ -72,7 +72,13 @@ MINT_LND_REST_CERT="/home/lnd/.lnd/tls.cert"
MINT_LND_REST_MACAROON="/home/lnd/.lnd/data/chain/bitcoin/regtest/admin.macaroon" MINT_LND_REST_MACAROON="/home/lnd/.lnd/data/chain/bitcoin/regtest/admin.macaroon"
MINT_LND_REST_CERT_VERIFY=True MINT_LND_REST_CERT_VERIFY=True
# Use with CLNRestWallet
MINT_CLNREST_URL=https://localhost:3010
MINT_CLNREST_CERT="./clightning-2/regtest/ca.pem"
MINT_CLNREST_RUNE="Base64string== or path to file containing the rune"
# Use with CoreLightningRestWallet # Use with CoreLightningRestWallet
# Note: CoreLightningRestWallet is deprecated, use CLNRestWallet instead
MINT_CORELIGHTNING_REST_URL=https://localhost:3001 MINT_CORELIGHTNING_REST_URL=https://localhost:3001
MINT_CORELIGHTNING_REST_MACAROON="./clightning-rest/access.macaroon" MINT_CORELIGHTNING_REST_MACAROON="./clightning-rest/access.macaroon"
MINT_CORELIGHTNING_REST_CERT="./clightning-2-rest/certificate.pem" MINT_CORELIGHTNING_REST_CERT="./clightning-2-rest/certificate.pem"

View File

@@ -32,7 +32,7 @@ jobs:
python-version: ["3.10"] python-version: ["3.10"]
poetry-version: ["1.7.1"] poetry-version: ["1.7.1"]
backend-wallet-class: backend-wallet-class:
["LndRestWallet", "CoreLightningRestWallet", "LNbitsWallet"] ["LndRestWallet", "CLNRestWallet", "CoreLightningRestWallet", "LNbitsWallet"]
# mint-database: ["./test_data/test_mint", "postgres://cashu:cashu@localhost:5432/cashu"] # mint-database: ["./test_data/test_mint", "postgres://cashu:cashu@localhost:5432/cashu"]
mint-database: ["./test_data/test_mint"] mint-database: ["./test_data/test_mint"]
with: with:

View File

@@ -70,10 +70,15 @@ jobs:
# LND_GRPC_PORT: 10009 # LND_GRPC_PORT: 10009
# LND_GRPC_CERT: ./regtest/data/lnd-3/tls.cert # LND_GRPC_CERT: ./regtest/data/lnd-3/tls.cert
# LND_GRPC_MACAROON: ./regtest/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon # LND_GRPC_MACAROON: ./regtest/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon
# CORELIGHTNING_RPC: ./regtest/data/clightning-1/regtest/lightning-rpc # CoreLightningRestWallet
MINT_CORELIGHTNING_REST_URL: https://localhost:3001 MINT_CORELIGHTNING_REST_URL: https://localhost:3001
MINT_CORELIGHTNING_REST_MACAROON: ./regtest/data/clightning-2-rest/access.macaroon MINT_CORELIGHTNING_REST_MACAROON: ./regtest/data/clightning-2-rest/access.macaroon
MINT_CORELIGHTNING_REST_CERT: ./regtest/data/clightning-2-rest/certificate.pem MINT_CORELIGHTNING_REST_CERT: ./regtest/data/clightning-2-rest/certificate.pem
# CLNRestWallet
MINT_CLNREST_URL: https://localhost:3010
MINT_CLNREST_RUNE: ./regtest/data/clightning-2/rune
MINT_CLNREST_CERT: ./regtest/data/clightning-2/regtest/ca.pem
MINT_CLNENABLE_MPP: false
run: | run: |
sudo chmod -R 777 . sudo chmod -R 777 .
make test make test

View File

@@ -193,6 +193,13 @@ class LndRestFundingSource(MintSettings):
mint_lnd_enable_mpp: bool = Field(default=False) mint_lnd_enable_mpp: bool = Field(default=False)
class CLNRestFundingSource(MintSettings):
mint_clnrest_url: Optional[str] = Field(default=None)
mint_clnrest_cert: Optional[str] = Field(default=None)
mint_clnrest_rune: Optional[str] = Field(default=None)
mint_clnrest_enable_mpp: bool = Field(default=False)
class CoreLightningRestFundingSource(MintSettings): class CoreLightningRestFundingSource(MintSettings):
mint_corelightning_rest_url: Optional[str] = Field(default=None) mint_corelightning_rest_url: Optional[str] = Field(default=None)
mint_corelightning_rest_macaroon: Optional[str] = Field(default=None) mint_corelightning_rest_macaroon: Optional[str] = Field(default=None)
@@ -203,6 +210,7 @@ class Settings(
EnvSettings, EnvSettings,
LndRestFundingSource, LndRestFundingSource,
CoreLightningRestFundingSource, CoreLightningRestFundingSource,
CLNRestFundingSource,
FakeWalletSettings, FakeWalletSettings,
MintLimits, MintLimits,
MintBackends, MintBackends,

View File

@@ -1,6 +1,7 @@
# type: ignore # type: ignore
from ..core.settings import settings from ..core.settings import settings
from .blink import BlinkWallet # noqa: F401 from .blink import BlinkWallet # noqa: F401
from .clnrest import CLNRestWallet # noqa: F401
from .corelightningrest import CoreLightningRestWallet # noqa: F401 from .corelightningrest import CoreLightningRestWallet # noqa: F401
from .fake import FakeWallet # noqa: F401 from .fake import FakeWallet # noqa: F401
from .lnbits import LNbitsWallet # noqa: F401 from .lnbits import LNbitsWallet # noqa: F401

364
cashu/lightning/clnrest.py Normal file
View File

@@ -0,0 +1,364 @@
import asyncio
import json
import os
import random
from typing import AsyncGenerator, Dict, Optional
import httpx
from bolt11 import (
Bolt11Exception,
decode,
)
from loguru import logger
from ..core.base import Amount, MeltQuote, Unit
from ..core.helpers import fee_reserve
from ..core.models import PostMeltQuoteRequest
from ..core.settings import settings
from .base import (
InvoiceResponse,
LightningBackend,
PaymentQuoteResponse,
PaymentResponse,
PaymentStatus,
StatusResponse,
Unsupported,
)
class CLNRestWallet(LightningBackend):
supported_units = set([Unit.sat, Unit.msat])
unit = Unit.sat
supports_mpp = False # settings.mint_clnrest_enable_mpp
supports_incoming_payment_stream: bool = True
def __init__(self, unit: Unit = Unit.sat, **kwargs):
self.assert_unit_supported(unit)
self.unit = unit
rune_settings = settings.mint_clnrest_rune
if not rune_settings:
raise Exception("missing rune for clnrest")
# load from file or use as is
if os.path.exists(rune_settings):
with open(rune_settings, "r") as f:
rune = f.read()
rune = rune.strip()
else:
rune = rune_settings
self.rune = rune
url = settings.mint_clnrest_url
if not url:
raise Exception("missing url for clnrest")
if not rune:
raise Exception("missing rune for clnrest")
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 = {
"rune": self.rune,
"accept": "application/json",
}
self.cert = settings.mint_clnrest_cert or False
self.client = httpx.AsyncClient(
base_url=self.url, 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.post("/v1/listfunds", timeout=5)
r.raise_for_status()
if r.is_error or "message" in r.json():
try:
data = r.json()
error_message = data["message"]
except Exception:
error_message = r.text
return StatusResponse(
error_message=(
f"Failed to connect to {self.url}, got: '{error_message}...'"
),
balance=0,
)
data = r.json()
if len(data) == 0:
return StatusResponse(error_message="no data", balance=0)
balance_msat = int(sum([c["our_amount_msat"] for c in data["channels"]]))
return StatusResponse(error_message=None, balance=balance_msat)
async def create_invoice(
self,
amount: Amount,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse:
self.assert_unit_supported(amount.unit)
label = f"lbl{random.random()}"
data: Dict = {
"amount_msat": amount.to(Unit.msat, round="up").amount,
"description": memo,
"label": label,
}
if description_hash and not unhashed_description:
raise Unsupported(
"'description_hash' unsupported by CLNRestWallet, "
"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(
"/v1/invoice",
data=data,
)
if r.is_error or "message" in r.json():
try:
data = r.json()
error_message = data["message"]
except Exception:
error_message = r.text
return InvoiceResponse(
ok=False,
checking_id=None,
payment_request=None,
error_message=error_message,
)
data = r.json()
assert "payment_hash" in data
assert "bolt11" in data
return InvoiceResponse(
ok=True,
checking_id=data["payment_hash"],
payment_request=data["bolt11"],
error_message=None,
)
async def pay_invoice(
self, quote: MeltQuote, fee_limit_msat: int
) -> PaymentResponse:
try:
invoice = decode(quote.request)
except Bolt11Exception as exc:
return PaymentResponse(
ok=False,
checking_id=None,
fee=None,
preimage=None,
error_message=str(exc),
)
if not invoice.amount_msat or invoice.amount_msat <= 0:
error_message = "0 amount invoices are not allowed"
return PaymentResponse(
ok=False,
checking_id=None,
fee=None,
preimage=None,
error_message=error_message,
)
quote_amount_msat = Amount(Unit[quote.unit], quote.amount).to(Unit.msat).amount
fee_limit_percent = fee_limit_msat / quote_amount_msat * 100
post_data = {
"bolt11": quote.request,
"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)
}
# Handle Multi-Mint payout where we must only pay part of the invoice amount
if quote_amount_msat != invoice.amount_msat:
if self.supports_mpp:
post_data["partial_msat"] = quote_amount_msat
else:
error_message = "mint does not support MPP"
return PaymentResponse(
ok=False,
checking_id=None,
fee=None,
preimage=None,
error_message=error_message,
)
r = await self.client.post("/v1/pay", data=post_data, timeout=None)
if r.is_error or "message" in r.json():
try:
data = r.json()
error_message = str(data["message"])
except Exception:
error_message = r.text
return PaymentResponse(
ok=False,
checking_id=None,
fee=None,
preimage=None,
error_message=error_message,
)
data = r.json()
if data["status"] != "complete":
return PaymentResponse(
ok=False,
checking_id=None,
fee=None,
preimage=None,
error_message="payment failed",
)
checking_id = data["payment_hash"]
preimage = data["payment_preimage"]
fee_msat = data["amount_sent_msat"] - data["amount_msat"]
return PaymentResponse(
ok=self.statuses.get(data["status"]),
checking_id=checking_id,
fee=Amount(unit=Unit.msat, amount=fee_msat) if fee_msat else None,
preimage=preimage,
error_message=None,
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
r = await self.client.post(
"/v1/listinvoices",
data={"payment_hash": checking_id},
)
try:
r.raise_for_status()
data = r.json()
if r.is_error or "message" in data or data.get("invoices") is None:
raise Exception("error in cln response")
return PaymentStatus(paid=self.statuses.get(data["invoices"][0]["status"]))
except Exception as e:
logger.error(f"Error getting invoice status: {e}")
return PaymentStatus(paid=None)
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
r = await self.client.post(
"/v1/listpays",
data={"payment_hash": checking_id},
)
try:
r.raise_for_status()
data = r.json()
if not data.get("pays"):
# payment not found
logger.error(f"payment not found: {data.get('pays')}")
raise Exception("payment not found")
if r.is_error or "message" in data:
message = data.get("message") or data
raise Exception(f"error in clnrest response: {message}")
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"]) - int(pay["amount_msat"])
preimage = pay["preimage"]
return PaymentStatus(
paid=self.statuses.get(pay["status"]),
fee=Amount(unit=Unit.msat, amount=fee_msat) if fee_msat else None,
preimage=preimage,
)
except Exception as e:
logger.error(f"Error getting payment status: {e}")
return PaymentStatus(paid=None)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
# call listinvoices to determine the last pay_index
r = await self.client.post("/v1/listinvoices")
r.raise_for_status()
data = r.json()
if r.is_error or "message" in data:
raise Exception("error in cln response")
self.last_pay_index = data["invoices"][-1]["pay_index"]
while True:
try:
url = "/v1/waitanyinvoice"
async with self.client.stream(
"POST",
url,
data={
"lastpay_index": self.last_pay_index,
},
timeout=None,
) as r:
async for line in r.aiter_lines():
inv = json.loads(line)
if "code" in inv and "message" in inv:
logger.error("Error in paid_invoices_stream:", inv)
raise Exception(inv["message"])
try:
paid = inv["status"] == "paid"
self.last_pay_index = inv["pay_index"]
if not paid:
continue
except Exception as e:
logger.error(f"Error in paid_invoices_stream: {e}")
continue
logger.trace(f"paid invoice: {inv}")
payment_hash = inv.get("payment_hash")
if payment_hash:
yield payment_hash
except Exception as exc:
logger.debug(
f"lost connection to clnrest invoices stream: '{exc}', "
"reconnecting..."
)
await asyncio.sleep(0.02)
async def get_payment_quote(
self, melt_quote: PostMeltQuoteRequest
) -> PaymentQuoteResponse:
invoice_obj = decode(melt_quote.request)
assert invoice_obj.amount_msat, "invoice has no amount."
assert invoice_obj.amount_msat > 0, "invoice has 0 amount."
amount_msat = invoice_obj.amount_msat
if melt_quote.is_mpp:
amount_msat = (
Amount(Unit[melt_quote.unit], melt_quote.mpp_amount)
.to(Unit.msat)
.amount
)
fees_msat = fee_reserve(amount_msat)
fees = Amount(unit=Unit.msat, amount=fees_msat)
amount = Amount(unit=Unit.msat, amount=amount_msat)
return PaymentQuoteResponse(
checking_id=invoice_obj.payment_hash,
fee=fees.to(self.unit, round="up"),
amount=amount.to(self.unit, round="up"),
)

View File

@@ -56,7 +56,9 @@ class CoreLightningRestWallet(LightningBackend):
} }
self.cert = settings.mint_corelightning_rest_cert or False self.cert = settings.mint_corelightning_rest_cert or False
self.client = httpx.AsyncClient(verify=self.cert, headers=self.auth) self.client = httpx.AsyncClient(
base_url=self.url, verify=self.cert, headers=self.auth
)
self.last_pay_index = 0 self.last_pay_index = 0
self.statuses = { self.statuses = {
"paid": True, "paid": True,
@@ -72,7 +74,7 @@ class CoreLightningRestWallet(LightningBackend):
logger.warning(f"Error closing wallet connection: {e}") logger.warning(f"Error closing wallet connection: {e}")
async def status(self) -> StatusResponse: async def status(self) -> StatusResponse:
r = await self.client.get(f"{self.url}/v1/listFunds", timeout=5) r = await self.client.get("/v1/listFunds", timeout=5)
r.raise_for_status() r.raise_for_status()
if r.is_error or "error" in r.json(): if r.is_error or "error" in r.json():
try: try:
@@ -124,7 +126,7 @@ class CoreLightningRestWallet(LightningBackend):
data["preimage"] = kwargs["preimage"] data["preimage"] = kwargs["preimage"]
r = await self.client.post( r = await self.client.post(
f"{self.url}/v1/invoice/genInvoice", "/v1/invoice/genInvoice",
data=data, data=data,
) )
@@ -177,7 +179,7 @@ class CoreLightningRestWallet(LightningBackend):
) )
fee_limit_percent = fee_limit_msat / invoice.amount_msat * 100 fee_limit_percent = fee_limit_msat / invoice.amount_msat * 100
r = await self.client.post( r = await self.client.post(
f"{self.url}/v1/pay", "/v1/pay",
data={ data={
"invoice": quote.request, "invoice": quote.request,
"maxfeepercent": f"{fee_limit_percent:.11}", "maxfeepercent": f"{fee_limit_percent:.11}",
@@ -226,7 +228,7 @@ class CoreLightningRestWallet(LightningBackend):
async def get_invoice_status(self, checking_id: str) -> PaymentStatus: async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
r = await self.client.get( r = await self.client.get(
f"{self.url}/v1/invoice/listInvoices", "/v1/invoice/listInvoices",
params={"payment_hash": checking_id}, params={"payment_hash": checking_id},
) )
try: try:
@@ -242,7 +244,7 @@ class CoreLightningRestWallet(LightningBackend):
async def get_payment_status(self, checking_id: str) -> PaymentStatus: async def get_payment_status(self, checking_id: str) -> PaymentStatus:
r = await self.client.get( r = await self.client.get(
f"{self.url}/v1/pay/listPays", "/v1/pay/listPays",
params={"payment_hash": checking_id}, params={"payment_hash": checking_id},
) )
try: try:
@@ -276,9 +278,17 @@ class CoreLightningRestWallet(LightningBackend):
return PaymentStatus(paid=None) return PaymentStatus(paid=None)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
# call listinvoices to determine the last pay_index
r = await self.client.get("/v1/invoice/listInvoices")
r.raise_for_status()
data = r.json()
if r.is_error or "error" in data:
raise Exception("error in cln response")
self.last_pay_index = data["invoices"][-1]["pay_index"]
while True: while True:
try: try:
url = f"{self.url}/v1/invoice/waitAnyInvoice/{self.last_pay_index}" url = f"/v1/invoice/waitAnyInvoice/{self.last_pay_index}"
async with self.client.stream("GET", url, timeout=None) as r: async with self.client.stream("GET", url, timeout=None) as r:
async for line in r.aiter_lines(): async for line in r.aiter_lines():
inv = json.loads(line) inv = json.loads(line)
@@ -299,7 +309,7 @@ class CoreLightningRestWallet(LightningBackend):
# yield payment_hash # yield payment_hash
# hack to return payment_hash if the above shouldn't work # hack to return payment_hash if the above shouldn't work
r = await self.client.get( r = await self.client.get(
f"{self.url}/v1/invoice/listInvoices", "/v1/invoice/listInvoices",
params={"label": inv["label"]}, params={"label": inv["label"]},
) )
paid_invoce = r.json() paid_invoce = r.json()

View File

@@ -34,6 +34,7 @@ for key, value in settings.dict().items():
"mint_lnd_rest_admin_macaroon", "mint_lnd_rest_admin_macaroon",
"mint_lnd_rest_invoice_macaroon", "mint_lnd_rest_invoice_macaroon",
"mint_corelightning_rest_macaroon", "mint_corelightning_rest_macaroon",
"mint_clnrest_rune",
]: ]:
value = "********" if value is not None else None value = "********" if value is not None else None

View File

@@ -38,8 +38,7 @@ class LedgerTasks(SupportsDb, SupportsBackends, SupportsEvents):
async def invoice_callback_dispatcher(self, checking_id: str) -> None: async def invoice_callback_dispatcher(self, checking_id: str) -> None:
logger.debug(f"Invoice callback dispatcher: {checking_id}") logger.debug(f"Invoice callback dispatcher: {checking_id}")
# TODO: Explicitly check for the quote payment state before setting it as paid # TODO: db read, quote.paid = True, db write should be refactored and moved to ledger.py
# db read, quote.paid = True, db write should be refactored and moved to ledger.py
quote = await self.crud.get_mint_quote(checking_id=checking_id, db=self.db) quote = await self.crud.get_mint_quote(checking_id=checking_id, db=self.db)
if not quote: if not quote:
logger.error(f"Quote not found for {checking_id}") logger.error(f"Quote not found for {checking_id}")

View File

@@ -47,6 +47,7 @@ settings.mint_seed_decryption_key = ""
settings.mint_max_balance = 0 settings.mint_max_balance = 0
settings.mint_transaction_rate_limit_per_minute = 60 settings.mint_transaction_rate_limit_per_minute = 60
settings.mint_lnd_enable_mpp = True settings.mint_lnd_enable_mpp = True
settings.mint_clnrest_enable_mpp = False
settings.mint_input_fee_ppk = 0 settings.mint_input_fee_ppk = 0
assert "test" in settings.cashu_dir assert "test" in settings.cashu_dir

View File

@@ -54,7 +54,7 @@ async def test_regtest_pay_mpp(wallet: Wallet, ledger: Ledger):
async def pay_mpp(amount: int, proofs: List[Proof], delay: float = 0.0): async def pay_mpp(amount: int, proofs: List[Proof], delay: float = 0.0):
await asyncio.sleep(delay) await asyncio.sleep(delay)
# wallet pays 32 sat of the invoice # wallet pays 32 sat of the invoice
quote = await wallet.melt_quote(invoice_payment_request, amount=32) quote = await wallet.melt_quote(invoice_payment_request, amount=amount)
assert quote.amount == amount assert quote.amount == amount
await wallet.melt( await wallet.melt(
proofs, proofs,