diff --git a/.env.example b/.env.example index a23d7cf..5e4e5c0 100644 --- a/.env.example +++ b/.env.example @@ -57,7 +57,7 @@ MINT_DERIVATION_PATH="m/0'/0'/0'" MINT_DATABASE=data/mint # Funding source backends -# Supported: FakeWallet, LndRestWallet, CoreLightningRestWallet, BlinkWallet, LNbitsWallet, StrikeWallet +# Supported: FakeWallet, LndRestWallet, CLNRestWallet, BlinkWallet, LNbitsWallet, StrikeWallet, CoreLightningRestWallet (deprecated) MINT_BACKEND_BOLT11_SAT=FakeWallet # Only works if a usd derivation path is set # 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_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 +# Note: CoreLightningRestWallet is deprecated, use CLNRestWallet instead MINT_CORELIGHTNING_REST_URL=https://localhost:3001 MINT_CORELIGHTNING_REST_MACAROON="./clightning-rest/access.macaroon" MINT_CORELIGHTNING_REST_CERT="./clightning-2-rest/certificate.pem" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d035af3..2d4d6ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: python-version: ["3.10"] poetry-version: ["1.7.1"] 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"] with: diff --git a/.github/workflows/regtest.yml b/.github/workflows/regtest.yml index 247dd02..cc39634 100644 --- a/.github/workflows/regtest.yml +++ b/.github/workflows/regtest.yml @@ -70,10 +70,15 @@ jobs: # LND_GRPC_PORT: 10009 # LND_GRPC_CERT: ./regtest/data/lnd-3/tls.cert # 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_MACAROON: ./regtest/data/clightning-2-rest/access.macaroon 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: | sudo chmod -R 777 . make test diff --git a/cashu/core/settings.py b/cashu/core/settings.py index d4bca1f..c5208b2 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -193,6 +193,13 @@ class LndRestFundingSource(MintSettings): 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): mint_corelightning_rest_url: Optional[str] = Field(default=None) mint_corelightning_rest_macaroon: Optional[str] = Field(default=None) @@ -203,6 +210,7 @@ class Settings( EnvSettings, LndRestFundingSource, CoreLightningRestFundingSource, + CLNRestFundingSource, FakeWalletSettings, MintLimits, MintBackends, diff --git a/cashu/lightning/__init__.py b/cashu/lightning/__init__.py index 6bbf1dd..a2bf566 100644 --- a/cashu/lightning/__init__.py +++ b/cashu/lightning/__init__.py @@ -1,6 +1,7 @@ # type: ignore from ..core.settings import settings from .blink import BlinkWallet # noqa: F401 +from .clnrest import CLNRestWallet # noqa: F401 from .corelightningrest import CoreLightningRestWallet # noqa: F401 from .fake import FakeWallet # noqa: F401 from .lnbits import LNbitsWallet # noqa: F401 diff --git a/cashu/lightning/clnrest.py b/cashu/lightning/clnrest.py new file mode 100644 index 0000000..8d4a4c1 --- /dev/null +++ b/cashu/lightning/clnrest.py @@ -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"), + ) diff --git a/cashu/lightning/corelightningrest.py b/cashu/lightning/corelightningrest.py index ce62a51..94dcf6f 100644 --- a/cashu/lightning/corelightningrest.py +++ b/cashu/lightning/corelightningrest.py @@ -56,7 +56,9 @@ class CoreLightningRestWallet(LightningBackend): } 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.statuses = { "paid": True, @@ -72,7 +74,7 @@ class CoreLightningRestWallet(LightningBackend): logger.warning(f"Error closing wallet connection: {e}") 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() if r.is_error or "error" in r.json(): try: @@ -124,7 +126,7 @@ class CoreLightningRestWallet(LightningBackend): data["preimage"] = kwargs["preimage"] r = await self.client.post( - f"{self.url}/v1/invoice/genInvoice", + "/v1/invoice/genInvoice", data=data, ) @@ -177,7 +179,7 @@ class CoreLightningRestWallet(LightningBackend): ) fee_limit_percent = fee_limit_msat / invoice.amount_msat * 100 r = await self.client.post( - f"{self.url}/v1/pay", + "/v1/pay", data={ "invoice": quote.request, "maxfeepercent": f"{fee_limit_percent:.11}", @@ -226,7 +228,7 @@ class CoreLightningRestWallet(LightningBackend): async def get_invoice_status(self, checking_id: str) -> PaymentStatus: r = await self.client.get( - f"{self.url}/v1/invoice/listInvoices", + "/v1/invoice/listInvoices", params={"payment_hash": checking_id}, ) try: @@ -242,7 +244,7 @@ class CoreLightningRestWallet(LightningBackend): async def get_payment_status(self, checking_id: str) -> PaymentStatus: r = await self.client.get( - f"{self.url}/v1/pay/listPays", + "/v1/pay/listPays", params={"payment_hash": checking_id}, ) try: @@ -276,9 +278,17 @@ class CoreLightningRestWallet(LightningBackend): 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.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: 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 for line in r.aiter_lines(): inv = json.loads(line) @@ -299,7 +309,7 @@ class CoreLightningRestWallet(LightningBackend): # 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", + "/v1/invoice/listInvoices", params={"label": inv["label"]}, ) paid_invoce = r.json() diff --git a/cashu/mint/startup.py b/cashu/mint/startup.py index 9dcf0df..942f71b 100644 --- a/cashu/mint/startup.py +++ b/cashu/mint/startup.py @@ -34,6 +34,7 @@ for key, value in settings.dict().items(): "mint_lnd_rest_admin_macaroon", "mint_lnd_rest_invoice_macaroon", "mint_corelightning_rest_macaroon", + "mint_clnrest_rune", ]: value = "********" if value is not None else None diff --git a/cashu/mint/tasks.py b/cashu/mint/tasks.py index edd61a0..4149ce6 100644 --- a/cashu/mint/tasks.py +++ b/cashu/mint/tasks.py @@ -38,8 +38,7 @@ class LedgerTasks(SupportsDb, SupportsBackends, SupportsEvents): async def invoice_callback_dispatcher(self, checking_id: str) -> None: logger.debug(f"Invoice callback dispatcher: {checking_id}") - # TODO: Explicitly check for the quote payment state before setting it as paid - # db read, quote.paid = True, db write should be refactored and moved to ledger.py + # TODO: 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) if not quote: logger.error(f"Quote not found for {checking_id}") diff --git a/tests/conftest.py b/tests/conftest.py index a76d3ce..260bf76 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -47,6 +47,7 @@ settings.mint_seed_decryption_key = "" settings.mint_max_balance = 0 settings.mint_transaction_rate_limit_per_minute = 60 settings.mint_lnd_enable_mpp = True +settings.mint_clnrest_enable_mpp = False settings.mint_input_fee_ppk = 0 assert "test" in settings.cashu_dir diff --git a/tests/test_wallet_regtest_mpp.py b/tests/test_wallet_regtest_mpp.py index 7628013..0059c9d 100644 --- a/tests/test_wallet_regtest_mpp.py +++ b/tests/test_wallet_regtest_mpp.py @@ -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): await asyncio.sleep(delay) # 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 await wallet.melt( proofs,