diff --git a/.env.example b/.env.example index 7cf46c0..2a02071 100644 --- a/.env.example +++ b/.env.example @@ -56,8 +56,8 @@ MINT_DERIVATION_PATH="m/0'/0'/0'" MINT_DATABASE=data/mint # Lightning -# Supported: LndRestWallet, LNbitsWallet, FakeWallet -MINT_LIGHTNING_BACKEND=LNbitsWallet +# Supported: FakeWallet, LndRestWallet, CoreLightningRestWallet, LNbitsWallet, BlinkWallet, StrikeWallet +MINT_LIGHTNING_BACKEND=FakeWallet # for use with LNbitsWallet MINT_LNBITS_ENDPOINT=https://legend.lnbits.com @@ -73,6 +73,10 @@ MINT_CORELIGHTNING_REST_URL=https://localhost:3001 MINT_CORELIGHTNING_REST_MACAROON="./clightning-rest/access.macaroon" MINT_CORELIGHTNING_REST_CERT="./clightning-2-rest/certificate.pem" +MINT_BLINK_KEY=blink_abcdefgh + +MINT_STRIKE_KEY=ABC123 + # fee to reserve in percent of the amount LIGHTNING_FEE_PERCENT=1.0 # minimum fee to reserve diff --git a/cashu/core/settings.py b/cashu/core/settings.py index a7d126e..77bf6cb 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -92,8 +92,8 @@ class MintSettings(CashuSettings): mint_lnbits_endpoint: str = Field(default=None) mint_lnbits_key: str = Field(default=None) - mint_strike_key: str = Field(default=None) + mint_blink_key: str = Field(default=None) class FakeWalletSettings(MintSettings): diff --git a/cashu/lightning/__init__.py b/cashu/lightning/__init__.py index 6d6cc3e..89e4618 100644 --- a/cashu/lightning/__init__.py +++ b/cashu/lightning/__init__.py @@ -1,5 +1,6 @@ # type: ignore from ..core.settings import settings +from .blink import BlinkWallet # 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/blink.py b/cashu/lightning/blink.py new file mode 100644 index 0000000..d4f11e8 --- /dev/null +++ b/cashu/lightning/blink.py @@ -0,0 +1,343 @@ +# type: ignore +import asyncio +import json +import math +from typing import Dict, Optional + +import bolt11 +import httpx +from bolt11 import ( + decode, +) +from loguru import logger + +from ..core.base import Amount, MeltQuote, Unit +from ..core.settings import settings +from .base import ( + InvoiceResponse, + LightningBackend, + PaymentQuoteResponse, + PaymentResponse, + PaymentStatus, + StatusResponse, +) + +# according to https://github.com/GaloyMoney/galoy/blob/7e79cc27304de9b9c2e7d7f4fdd3bac09df23aac/core/api/src/domain/bitcoin/index.ts#L59 +BLINK_MAX_FEE_PERCENT = 0.5 + + +class BlinkWallet(LightningBackend): + """https://dev.blink.sv/ + Create API Key at: https://dashboard.blink.sv/ + """ + + units = set([Unit.sat, Unit.usd]) + wallet_ids: Dict[Unit, str] = {} + endpoint = "https://api.blink.sv/graphql" + invoice_statuses = {"PENDING": None, "PAID": True, "EXPIRED": False} + payment_execution_statuses = {"SUCCESS": True, "ALREADY_PAID": None} + payment_statuses = {"SUCCESS": True, "PENDING": None, "FAILURE": False} + + def __init__(self): + assert settings.mint_blink_key, "MINT_BLINK_KEY not set" + self.client = httpx.AsyncClient( + verify=not settings.debug, + headers={ + "X-Api-Key": settings.mint_blink_key, + "Content-Type": "application/json", + }, + base_url=self.endpoint, + timeout=15, + ) + + async def status(self) -> StatusResponse: + try: + r = await self.client.post( + url=self.endpoint, + data=( + '{"query":"query me { me { defaultAccount { wallets { id' + ' walletCurrency balance }}}}", "variables":{}}' + ), + ) + r.raise_for_status() + except Exception as exc: + logger.error(f"Blink API error: {str(exc)}") + return StatusResponse( + error_message=f"Failed to connect to {self.endpoint} due to: {exc}", + balance=0, + ) + + try: + resp: dict = r.json() + except Exception: + return StatusResponse( + error_message=( + f"Received invalid response from {self.endpoint}: {r.text}" + ), + balance=0, + ) + + balance = 0 + for wallet_dict in resp["data"]["me"]["defaultAccount"]["wallets"]: + if wallet_dict["walletCurrency"] == "USD": + self.wallet_ids[Unit.usd] = wallet_dict["id"] + elif wallet_dict["walletCurrency"] == "BTC": + self.wallet_ids[Unit.sat] = wallet_dict["id"] + balance = wallet_dict["balance"] + + return StatusResponse(error_message=None, balance=balance) + + async def create_invoice( + self, + amount: Amount, + memo: Optional[str] = None, + description_hash: Optional[bytes] = None, + unhashed_description: Optional[bytes] = None, + ) -> InvoiceResponse: + self.assert_unit_supported(amount.unit) + + variables = { + "input": { + "amount": str(amount.to(Unit.sat).amount), + "recipientWalletId": self.wallet_ids[Unit.sat], + } + } + if description_hash: + variables["input"]["descriptionHash"] = description_hash.hex() + if memo: + variables["input"]["memo"] = memo + + data = { + "query": """ + mutation LnInvoiceCreateOnBehalfOfRecipient($input: LnInvoiceCreateOnBehalfOfRecipientInput!) { + lnInvoiceCreateOnBehalfOfRecipient(input: $input) { + invoice { + paymentRequest + paymentHash + paymentSecret + satoshis + } + errors { + message path code + } + } + } + """, + "variables": variables, + } + try: + r = await self.client.post( + url=self.endpoint, + data=json.dumps(data), + ) + r.raise_for_status() + except Exception as e: + logger.error(f"Blink API error: {str(e)}") + return InvoiceResponse(ok=False, error_message=str(e)) + + resp = r.json() + assert resp, "invalid response" + payment_request = resp["data"]["lnInvoiceCreateOnBehalfOfRecipient"]["invoice"][ + "paymentRequest" + ] + checking_id = payment_request + + return InvoiceResponse( + ok=True, + checking_id=checking_id, + payment_request=payment_request, + ) + + async def pay_invoice( + self, quote: MeltQuote, fee_limit_msat: int + ) -> PaymentResponse: + variables = { + "input": { + "paymentRequest": quote.request, + "walletId": self.wallet_ids[Unit.sat], + } + } + data = { + "query": """ + mutation lnInvoicePaymentSend($input: LnInvoicePaymentInput!) { + lnInvoicePaymentSend(input: $input) { + errors { + message path code + } + status + transaction { + settlementAmount settlementFee status + } + } + } + """, + "variables": variables, + } + + try: + r = await self.client.post( + url=self.endpoint, + data=json.dumps(data), + ) + r.raise_for_status() + except Exception as e: + logger.error(f"Blink API error: {str(e)}") + return PaymentResponse(ok=False, error_message=str(e)) + + resp: dict = r.json() + paid = self.payment_execution_statuses[ + resp["data"]["lnInvoicePaymentSend"]["status"] + ] + fee = resp["data"]["lnInvoicePaymentSend"]["transaction"]["settlementFee"] + checking_id = quote.request + + return PaymentResponse( + ok=paid, + checking_id=checking_id, + fee=Amount(Unit.sat, fee), + preimage=None, + error_message="Invoice already paid." if paid is None else None, + ) + + async def get_invoice_status(self, checking_id: str) -> PaymentStatus: + variables = {"input": {"paymentRequest": checking_id}} + data = { + "query": """ + query lnInvoicePaymentStatus($input: LnInvoicePaymentStatusInput!) { + lnInvoicePaymentStatus(input: $input) { + errors { + message path code + } + status + } + } + """, + "variables": variables, + } + try: + r = await self.client.post(url=self.endpoint, data=json.dumps(data)) + r.raise_for_status() + except Exception as e: + logger.error(f"Blink API error: {str(e)}") + return PaymentStatus(paid=None) + resp: dict = r.json() + if resp["data"]["lnInvoicePaymentStatus"]["errors"]: + logger.error( + "Blink Error", resp["data"]["lnInvoicePaymentStatus"]["errors"] + ) + return PaymentStatus(paid=None) + paid = self.invoice_statuses[resp["data"]["lnInvoicePaymentStatus"]["status"]] + return PaymentStatus(paid=paid) + + async def get_payment_status(self, checking_id: str) -> PaymentStatus: + # Checking ID is the payment request and blink wants the payment hash + payment_hash = bolt11.decode(checking_id).payment_hash + variables = { + "paymentHash": payment_hash, + "walletId": self.wallet_ids[Unit.sat], + } + data = { + "query": """ + query TransactionsByPaymentHash($paymentHash: PaymentHash!, $walletId: WalletId!) { + me { + defaultAccount { + walletById(walletId: $walletId) { + transactionsByPaymentHash(paymentHash: $paymentHash) { + status + direction + settlementFee + } + } + } + } + } + """, + "variables": variables, + } + + try: + r = await self.client.post( + url=self.endpoint, + data=json.dumps(data), + ) + r.raise_for_status() + except Exception as e: + logger.error(f"Blink API error: {str(e)}") + return PaymentResponse(ok=False, error_message=str(e)) + + resp: dict = r.json() + # no result found + if not resp["data"]["me"]["defaultAccount"]["walletById"][ + "transactionsByPaymentHash" + ]: + return PaymentStatus(paid=None) + + paid = self.payment_statuses[ + resp["data"]["me"]["defaultAccount"]["walletById"][ + "transactionsByPaymentHash" + ][0]["status"] + ] + fee = resp["data"]["me"]["defaultAccount"]["walletById"][ + "transactionsByPaymentHash" + ][0]["settlementFee"] + + return PaymentStatus( + paid=paid, + fee=Amount(Unit.sat, fee), + preimage=None, + ) + + async def get_payment_quote(self, bolt11: str) -> PaymentQuoteResponse: + variables = { + "input": { + "paymentRequest": bolt11, + "walletId": self.wallet_ids[Unit.sat], + } + } + data = { + "query": """ + mutation lnInvoiceFeeProbe($input: LnInvoiceFeeProbeInput!) { + lnInvoiceFeeProbe(input: $input) { + amount + errors { + message path code + } + } + } + """, + "variables": variables, + } + + try: + r = await self.client.post( + url=self.endpoint, + data=json.dumps(data), + ) + r.raise_for_status() + except Exception as e: + logger.error(f"Blink API error: {str(e)}") + return PaymentResponse(ok=False, error_message=str(e)) + resp: dict = r.json() + + invoice_obj = decode(bolt11) + assert invoice_obj.amount_msat, "invoice has no amount." + + amount_msat = int(invoice_obj.amount_msat) + + fees_response_msat = int(resp["data"]["lnInvoiceFeeProbe"]["amount"]) * 1000 + # we either take fee_msat_response or the BLINK_MAX_FEE_PERCENT, whichever is higher + fees_msat = max( + fees_response_msat, math.ceil(amount_msat * BLINK_MAX_FEE_PERCENT) + ) + + fees = Amount(unit=Unit.msat, amount=fees_msat) + amount = Amount(unit=Unit.msat, amount=amount_msat) + return PaymentQuoteResponse(checking_id=bolt11, fee=fees, amount=amount) + + +async def main(): + pass + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/cashu/lightning/lnbits.py b/cashu/lightning/lnbits.py index f47558b..3896a4e 100644 --- a/cashu/lightning/lnbits.py +++ b/cashu/lightning/lnbits.py @@ -46,7 +46,7 @@ class LNbitsWallet(LightningBackend): except Exception: return StatusResponse( error_message=( - f"Failed to connect to {self.endpoint}, got: '{r.text[:200]}...'" + f"Received invalid response from {self.endpoint}: {r.text}" ), balance=0, ) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 2350283..1a97364 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -640,7 +640,7 @@ class Ledger(LedgerVerification, LedgerSpendingConditions): Returns: Tuple[str, List[BlindedMessage]]: Proof of payment and signed outputs for returning overpaid fees to wallet. """ - # get melt quote and settle transaction internally if possible + # get melt quote and check if it is paid melt_quote = await self.get_melt_quote(quote_id=quote) method = Method[melt_quote.method] unit = Unit[melt_quote.unit] @@ -676,6 +676,7 @@ class Ledger(LedgerVerification, LedgerSpendingConditions): # set proofs to pending to avoid race conditions await self._set_proofs_pending(proofs) try: + # settle the transaction internally if there is a mint quote with the same payment request melt_quote = await self.melt_mint_settle_internally(melt_quote) # quote not paid yet (not internal), pay it with the backend @@ -689,7 +690,9 @@ class Ledger(LedgerVerification, LedgerSpendingConditions): f" fee: {payment.fee.str() if payment.fee else 0}" ) if not payment.ok: - raise LightningError("Lightning payment unsuccessful.") + raise LightningError( + f"Lightning payment unsuccessful. {payment.error_message}" + ) if payment.fee: melt_quote.fee_paid = payment.fee.to( to_unit=unit, round="up" diff --git a/poetry.lock b/poetry.lock index ace9c37..a901cc7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1268,6 +1268,20 @@ six = ">=1.8.0" [package.extras] test = ["ipython", "mock", "pytest (>=3.0.5)"] +[[package]] +name = "respx" +version = "0.20.2" +description = "A utility for mocking out the Python HTTPX and HTTP Core libraries." +optional = false +python-versions = ">=3.7" +files = [ + {file = "respx-0.20.2-py2.py3-none-any.whl", hash = "sha256:ab8e1cf6da28a5b2dd883ea617f8130f77f676736e6e9e4a25817ad116a172c9"}, + {file = "respx-0.20.2.tar.gz", hash = "sha256:07cf4108b1c88b82010f67d3c831dae33a375c7b436e54d87737c7f9f99be643"}, +] + +[package.dependencies] +httpx = ">=0.21.0" + [[package]] name = "ruff" version = "0.0.284" @@ -1598,4 +1612,4 @@ pgsql = ["psycopg2-binary"] [metadata] lock-version = "2.0" python-versions = "^3.8.1" -content-hash = "f7aa2919aca77aa4d1dfcba18c6fc9694a2cc1d5cfd60e7ec991a615251fa86e" +content-hash = "7ad5150f23bb8ba229e43b3d329b4ec747791622f5e83357a5fae8aee9315fdc" diff --git a/pyproject.toml b/pyproject.toml index b501ab7..4f68921 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ pytest = "^7.4.0" ruff = "^0.0.284" pre-commit = "^3.3.3" fastapi-profiler = "^1.2.0" +respx = "^0.20.2" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/tests/test_mint_lightning_blink.py b/tests/test_mint_lightning_blink.py new file mode 100644 index 0000000..7339fc2 --- /dev/null +++ b/tests/test_mint_lightning_blink.py @@ -0,0 +1,137 @@ +import pytest +import respx +from httpx import Response + +from cashu.core.base import Amount, MeltQuote, Unit +from cashu.core.settings import settings +from cashu.lightning.blink import BlinkWallet + +settings.mint_blink_key = "123" +blink = BlinkWallet() +payment_request = ( + "lnbc10u1pjap7phpp50s9lzr3477j0tvacpfy2ucrs4q0q6cvn232ex7nt2zqxxxj8gxrsdpv2phhwetjv4jzqcneypqyc6t8dp6xu6twva2xjuzzda6qcqzzsxqrrsss" + "p575z0n39w2j7zgnpqtdlrgz9rycner4eptjm3lz363dzylnrm3h4s9qyyssqfz8jglcshnlcf0zkw4qu8fyr564lg59x5al724kms3h6gpuhx9xrfv27tgx3l3u3cyf6" + "3r52u0xmac6max8mdupghfzh84t4hfsvrfsqwnuszf" +) + + +@respx.mock +@pytest.mark.asyncio +async def test_blink_status(): + mock_response = { + "data": { + "me": { + "defaultAccount": { + "wallets": [ + {"walletCurrency": "USD", "id": "123", "balance": 32142}, + { + "walletCurrency": "BTC", + "id": "456", + "balance": 100000, + }, + ] + } + } + } + } + respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response)) + status = await blink.status() + assert status.balance == 100000 + + +@respx.mock +@pytest.mark.asyncio +async def test_blink_create_invoice(): + mock_response = { + "data": { + "lnInvoiceCreateOnBehalfOfRecipient": { + "invoice": {"paymentRequest": payment_request} + } + } + } + + respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response)) + invoice = await blink.create_invoice(Amount(Unit.sat, 1000)) + assert invoice.checking_id == invoice.payment_request + assert invoice.ok + + +@respx.mock +@pytest.mark.asyncio +async def test_blink_pay_invoice(): + mock_response = { + "data": { + "lnInvoicePaymentSend": { + "status": "SUCCESS", + "transaction": {"settlementFee": 10}, + } + } + } + respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response)) + quote = MeltQuote( + request=payment_request, + quote="asd", + method="bolt11", + checking_id=payment_request, + unit="sat", + amount=100, + fee_reserve=12, + paid=False, + ) + payment = await blink.pay_invoice(quote, 1000) + assert payment.ok + assert payment.fee + assert payment.fee.amount == 10 + assert payment.error_message is None + assert payment.checking_id == payment_request + + +@respx.mock +@pytest.mark.asyncio +async def test_blink_get_invoice_status(): + mock_response = { + "data": { + "lnInvoicePaymentStatus": { + "status": "PAID", + "errors": [], + } + } + } + respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response)) + status = await blink.get_invoice_status("123") + assert status.paid + + +@respx.mock +@pytest.mark.asyncio +async def test_blink_get_payment_status(): + mock_response = { + "data": { + "me": { + "defaultAccount": { + "walletById": { + "transactionsByPaymentHash": [ + {"status": "SUCCESS", "settlementFee": 10} + ] + } + } + } + } + } + respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response)) + status = await blink.get_payment_status(payment_request) + assert status.paid + assert status.fee + assert status.fee.amount == 10 + assert status.preimage is None + + +@respx.mock +@pytest.mark.asyncio +async def test_blink_get_payment_quote(): + mock_response = {"data": {"lnInvoiceFeeProbe": {"amount": 10}}} + respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response)) + quote = await blink.get_payment_quote(payment_request) + assert quote.checking_id == payment_request + assert quote.amount == Amount(Unit.msat, 1000000) # msat + assert quote.fee == Amount(Unit.msat, 500000) # msat