From a805d82836b029cc656cc784beab5ea93ebfbbe7 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:21:15 +0200 Subject: [PATCH] chore(lightning): remove Blink and LNbits backends --- .env.example | 9 +- .github/workflows/ci.yml | 4 +- .github/workflows/regtest-mint.yml | 3 - .github/workflows/regtest-wallet.yml | 3 - CONTRIBUTING.md | 2 +- cashu/core/settings.py | 3 - cashu/lightning/__init__.py | 2 - cashu/lightning/blink.py | 633 ------------------ cashu/lightning/lnbits.py | 312 --------- cashu/mint/startup.py | 2 - .../test_lightning_backends_mocked.py | 154 ----- tests/mint/test_mint_lightning_blink.py | 448 ------------- tests/mint/test_mint_melt.py | 4 +- 13 files changed, 6 insertions(+), 1573 deletions(-) delete mode 100644 cashu/lightning/blink.py delete mode 100644 cashu/lightning/lnbits.py delete mode 100644 tests/mint/test_mint_lightning_blink.py diff --git a/.env.example b/.env.example index f9d4ff3e..9d5f6dee 100644 --- a/.env.example +++ b/.env.example @@ -63,7 +63,7 @@ MINT_DATABASE=data/mint # Funding source backends # Set one funding source backend for each unit -# Supported: FakeWallet, LndRestWallet, LndRPCWallet, CLNRestWallet, BlinkWallet, LNbitsWallet, StrikeWallet, SparkL2Wallet, CoreLightningRestWallet (deprecated) +# Supported: FakeWallet, LndRestWallet, LndRPCWallet, CLNRestWallet, StrikeWallet, SparkL2Wallet, CoreLightningRestWallet (deprecated) MINT_BACKEND_BOLT11_SAT=FakeWallet # Only works if a usd derivation path is set @@ -101,13 +101,6 @@ MINT_CORELIGHTNING_REST_URL=https://localhost:3001 MINT_CORELIGHTNING_REST_MACAROON="./clightning-2-rest/access.macaroon" MINT_CORELIGHTNING_REST_CERT="./clightning-2-rest/certificate.pem" -# Use with LNbitsWallet -MINT_LNBITS_ENDPOINT=https://legend.lnbits.com -MINT_LNBITS_KEY=yourkeyasdasdasd - -# Use with BlinkWallet -MINT_BLINK_KEY=blink_abcdefgh - # Use with StrikeWallet for BTC, USD, and EUR MINT_STRIKE_KEY=ABC123 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cedb438e..a8342073 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,7 +70,7 @@ jobs: python-version: ["3.12"] poetry-version: ["2.3.2"] backend-wallet-class: - ["LndRPCWallet", "LndRestWallet", "CLNRestWallet", "CoreLightningRestWallet", "LNbitsWallet"] + ["LndRPCWallet", "LndRestWallet", "CLNRestWallet", "CoreLightningRestWallet"] mint-database: ["./test_data/test_mint", "postgres://cashu:cashu@localhost:5432/cashu"] # mint-database: ["./test_data/test_mint"] with: @@ -86,7 +86,7 @@ jobs: python-version: ["3.12"] poetry-version: ["2.3.2"] backend-wallet-class: - ["LndRPCWallet", "LndRestWallet", "CLNRestWallet", "CoreLightningRestWallet", "LNbitsWallet"] + ["LndRPCWallet", "LndRestWallet", "CLNRestWallet", "CoreLightningRestWallet"] 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-mint.yml b/.github/workflows/regtest-mint.yml index 44114c98..e9f2eec3 100644 --- a/.github/workflows/regtest-mint.yml +++ b/.github/workflows/regtest-mint.yml @@ -55,9 +55,6 @@ jobs: MINT_TEST_DATABASE: ${{ inputs.mint-database }} TOR: false MINT_BACKEND_BOLT11_SAT: ${{ inputs.backend-wallet-class }} - # LNbits wallet - MINT_LNBITS_ENDPOINT: http://localhost:5001 - MINT_LNBITS_KEY: d08a3313322a4514af75d488bcc27eee # LndRestWallet MINT_LND_REST_ENDPOINT: https://localhost:8081/ MINT_LND_REST_CERT: ./regtest/data/lnd-3/tls.cert diff --git a/.github/workflows/regtest-wallet.yml b/.github/workflows/regtest-wallet.yml index 402e8b70..aef964b2 100644 --- a/.github/workflows/regtest-wallet.yml +++ b/.github/workflows/regtest-wallet.yml @@ -55,9 +55,6 @@ jobs: MINT_TEST_DATABASE: ${{ inputs.mint-database }} TOR: false MINT_BACKEND_BOLT11_SAT: ${{ inputs.backend-wallet-class }} - # LNbits wallet - MINT_LNBITS_ENDPOINT: http://localhost:5001 - MINT_LNBITS_KEY: d08a3313322a4514af75d488bcc27eee # LndRestWallet MINT_LND_REST_ENDPOINT: https://localhost:8081/ MINT_LND_REST_CERT: ./regtest/data/lnd-3/tls.cert diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1220c6dc..54aef522 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,7 +67,7 @@ You can choose one of the nodes as a backend for nutshell using the `.env` varia ``` # Choose one from: -# LndRPCWallet, LndRestWallet, CLNRestWallet, CoreLightningRestWallet, LNbitsWallet +# LndRPCWallet, LndRestWallet, CLNRestWallet, CoreLightningRestWallet MINT_BACKEND_BOLT11_SAT=LndRestWallet ``` diff --git a/cashu/core/settings.py b/cashu/core/settings.py index ef35321a..a8972257 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -114,10 +114,7 @@ class MintBackends(MintSettings): mint_backend_bolt11_usd: str = Field(default="") mint_backend_bolt11_eur: str = Field(default="") - mint_lnbits_endpoint: Optional[str] = Field(default=None) - mint_lnbits_key: Optional[str] = Field(default=None) mint_strike_key: Optional[str] = Field(default=None) - mint_blink_key: Optional[str] = Field(default=None) mint_spark_network: str = Field(default="TESTNET") mint_spark_api_key: Optional[str] = Field(default=None) diff --git a/cashu/lightning/__init__.py b/cashu/lightning/__init__.py index a1e4b35b..5cd9305f 100644 --- a/cashu/lightning/__init__.py +++ b/cashu/lightning/__init__.py @@ -1,10 +1,8 @@ # 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 from .lnd_grpc.lnd_grpc import LndRPCWallet # noqa: F401 from .lndrest import LndRestWallet # noqa: F401 from .sparkl2 import SparkL2Wallet # noqa: F401 diff --git a/cashu/lightning/blink.py b/cashu/lightning/blink.py deleted file mode 100644 index 80983779..00000000 --- a/cashu/lightning/blink.py +++ /dev/null @@ -1,633 +0,0 @@ -import json -import math -from typing import AsyncGenerator, Dict, Optional, Union - -import bolt11 -import httpx -from bolt11 import ( - decode, -) -from loguru import logger - -from ..core.base import Amount, MeltQuote, Unit -from ..core.models import PostMeltQuoteRequest -from ..core.settings import settings -from .base import ( - InvoiceResponse, - LightningBackend, - PaymentQuoteResponse, - PaymentResponse, - PaymentResult, - 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 -# according to https://github.com/GaloyMoney/blink/blob/7e79cc27304de9b9c2e7d7f4fdd3bac09df23aac/core/api/src/domain/bitcoin/index.ts#L60C1-L60C41 -MINIMUM_FEE_MSAT = 10_000 -# Margin for worst-case BTC/USD price swing between quote and payment. -# Blink has no rate-locking mechanism, so the fee reserve must cover this. -PRICE_SWING_MARGIN_PERCENT = 10 - -DIRECTION_SEND = "SEND" -DIRECTION_RECEIVE = "RECEIVE" -PROBE_FEE_TIMEOUT_SEC = 1 - - -INVOICE_RESULT_MAP = { - "PENDING": PaymentResult.PENDING, - "PAID": PaymentResult.SETTLED, - "EXPIRED": PaymentResult.FAILED, -} -PAYMENT_EXECUTION_RESULT_MAP = { - "SUCCESS": PaymentResult.SETTLED, - "PENDING": PaymentResult.PENDING, - "ALREADY_PAID": PaymentResult.FAILED, - "FAILURE": PaymentResult.FAILED, -} -PAYMENT_RESULT_MAP = { - "SUCCESS": PaymentResult.SETTLED, - "PENDING": PaymentResult.PENDING, - "FAILURE": PaymentResult.FAILED, -} - - -class BlinkWallet(LightningBackend): - """https://dev.blink.sv/ - Create API Key at: https://dashboard.blink.sv/ - """ - - wallet_ids: Dict[Unit, str] = {} - endpoint = "https://api.blink.sv/graphql" - - supported_units = {Unit.sat, Unit.msat, Unit.usd} - supports_description: bool = True - unit = Unit.sat - - def _fee_unit(self) -> Unit: - if self.unit == Unit.sat or self.unit == Unit.msat: - return Unit.sat - elif self.unit == Unit.usd: - return Unit.usd - raise Exception(f"Unsupported unit: {self.unit}") - - def __init__(self, unit: Unit = Unit.sat, **kwargs): - self.assert_unit_supported(unit) - self.unit = unit - 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=None, - ) - - async def status(self) -> StatusResponse: - try: - data = { - "query": "query me { me { defaultAccount { wallets { id walletCurrency balance }}}}", - "variables": {}, - } - r = await self.client.post( - url=self.endpoint, - data=json.dumps(data), # type: ignore - ) - r.raise_for_status() - except Exception as exc: - logger.error(f"Blink API error: {exc}") - return StatusResponse( - error_message=f"Failed to connect to {self.endpoint} due to: {exc}", - balance=Amount(self.unit, 0), - ) - - try: - resp: dict = r.json() - except Exception: - return StatusResponse( - error_message=( - f"Received invalid response from {self.endpoint}: {r.text}" - ), - balance=Amount(self.unit, 0), - ) - - balance = 0 - for wallet_dict in ( - resp.get("data", {}).get("me", {}).get("defaultAccount", {}).get("wallets") - ): - if wallet_dict.get("walletCurrency") == "USD": - self.wallet_ids[Unit.usd] = wallet_dict["id"] # type: ignore - if self.unit == Unit.usd: - balance = wallet_dict["balance"] # type: ignore - elif wallet_dict.get("walletCurrency") == "BTC": - self.wallet_ids[Unit.sat] = wallet_dict["id"] # type: ignore - if self.unit == Unit.sat or self.unit == Unit.msat: - balance = wallet_dict["balance"] # type: ignore - - return StatusResponse(error_message=None, balance=Amount(self.unit, 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) - - if self.unit == Unit.sat or self.unit == Unit.msat: - 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, - } - response_key = "lnInvoiceCreateOnBehalfOfRecipient" - elif self.unit == Unit.usd: - variables = { - "input": { - "amount": str(amount.to(Unit.usd).amount), - "recipientWalletId": self.wallet_ids[Unit.usd], - } - } - if description_hash: - variables["input"]["descriptionHash"] = description_hash.hex() - if memo: - variables["input"]["memo"] = memo - - data = { - "query": """ - mutation LnUsdInvoiceCreateOnBehalfOfRecipient($input: LnUsdInvoiceCreateOnBehalfOfRecipientInput!) { - lnUsdInvoiceCreateOnBehalfOfRecipient(input: $input) { - invoice { - paymentRequest - paymentHash - paymentSecret - satoshis - } - errors { - message path code - } - } - } - """, - "variables": variables, - } - response_key = "lnUsdInvoiceCreateOnBehalfOfRecipient" - else: - raise Exception(f"Unsupported unit: {self.unit}") - - try: - r = await self.client.post( - url=self.endpoint, - data=json.dumps(data), # type: ignore - ) - r.raise_for_status() - except Exception as e: - logger.error(f"Blink API error: {e}") - return InvoiceResponse(ok=False, error_message=str(e)) - - resp = r.json() - assert resp, "invalid response" - payment_request = ( - resp.get("data", {}) - .get(response_key, {}) - .get("invoice", {}) - .get("paymentRequest") - ) - assert payment_request, "payment request not found" - 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: - if self.unit == Unit.sat or self.unit == Unit.msat: - wallet_id = self.wallet_ids[Unit.sat] - elif self.unit == Unit.usd: - wallet_id = self.wallet_ids[Unit.usd] - else: - raise Exception(f"Unsupported unit: {self.unit}") - - variables = { - "input": { - "paymentRequest": quote.request, - "walletId": wallet_id, - } - } - data = { - "query": """ - mutation lnInvoicePaymentSend($input: LnInvoicePaymentInput!) { - lnInvoicePaymentSend(input: $input) { - errors { - message path code - } - status - transaction { - settlementAmount settlementFee settlementDisplayFee status - } - } - } - """, - "variables": variables, - } - - try: - r = await self.client.post( - url=self.endpoint, - data=json.dumps(data), # type: ignore - timeout=None, - ) - r.raise_for_status() - except Exception as e: - logger.error(f"Blink API error: {e}") - return PaymentResponse( - result=PaymentResult.UNKNOWN, - error_message=str(e), - ) - - resp: dict = r.json() - - error_message: Union[None, str] = None - fee: Union[None, int] = None - if resp.get("data", {}).get("lnInvoicePaymentSend", {}).get("errors"): - error_message = ( - resp["data"]["lnInvoicePaymentSend"]["errors"][0].get("message") # type: ignore - or "Unknown error" - ) - - status_str = resp.get("data", {}).get("lnInvoicePaymentSend", {}).get("status") - result = PAYMENT_EXECUTION_RESULT_MAP[status_str] - - if status_str == "ALREADY_PAID": - error_message = "Invoice already paid" - - if result == PaymentResult.FAILED: - return PaymentResponse( - result=result, - error_message=error_message, - checking_id=quote.request, - ) - - transaction = ( - resp.get("data", {}) - .get("lnInvoicePaymentSend", {}) - .get("transaction", {}) - ) - - checking_id = quote.request - preimage: Union[None, str] = None - payment_status = await self.get_payment_status(checking_id) - if payment_status.settled: - preimage = payment_status.preimage - - fee_amount: Union[None, Amount] = None - if transaction: - fee = transaction.get("settlementFee") - if fee is not None: - fee_amount = Amount(self._fee_unit(), fee) - - return PaymentResponse( - result=result, - checking_id=checking_id, - fee=fee_amount, - preimage=preimage, - error_message=error_message, - ) - - 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)) # type: ignore - r.raise_for_status() - except Exception as e: - logger.error(f"Blink API error: {e}") - return PaymentStatus(result=PaymentResult.UNKNOWN, error_message=str(e)) - resp: dict = r.json() - error_message = ( - resp.get("data", {}).get("lnInvoicePaymentStatus", {}).get("errors") - ) - if error_message: - logger.error( - "Blink Error", - error_message, - ) - return PaymentStatus( - result=PaymentResult.UNKNOWN, error_message=error_message - ) - result = INVOICE_RESULT_MAP[ - resp.get("data", {}).get("lnInvoicePaymentStatus", {}).get("status") - ] - return PaymentStatus(result=result) - - async def get_payment_status(self, checking_id: str) -> PaymentStatus: - payment_hash = bolt11.decode(checking_id).payment_hash - if self.unit == Unit.sat or self.unit == Unit.msat: - wallet_id = self.wallet_ids[Unit.sat] - elif self.unit == Unit.usd: - wallet_id = self.wallet_ids[Unit.usd] - else: - raise Exception(f"Unsupported unit: {self.unit}") - - variables = { - "paymentHash": payment_hash, - "walletId": wallet_id, - } - data = { - "query": """ - query TransactionsByPaymentHash($paymentHash: PaymentHash!, $walletId: WalletId!) { - me { - defaultAccount { - walletById(walletId: $walletId) { - transactionsByPaymentHash(paymentHash: $paymentHash) { - status - direction - settlementAmount - settlementFee - settlementVia { - ... on SettlementViaIntraLedger { - preImage - } - ... on SettlementViaLn { - preImage - } - } - } - } - } - } - } - """, - "variables": variables, - } - r = await self.client.post( - url=self.endpoint, - data=json.dumps(data), # type: ignore - ) - r.raise_for_status() - - resp: dict = r.json() - - # no result found, this payment has not been attempted before - if ( - not resp.get("data", {}) - .get("me", {}) - .get("defaultAccount", {}) - .get("walletById", {}) - .get("transactionsByPaymentHash") - ): - return PaymentStatus( - result=PaymentResult.UNKNOWN, error_message="No payment found" - ) - - all_payments_with_this_hash = ( - resp.get("data", {}) - .get("me", {}) - .get("defaultAccount", {}) - .get("walletById", {}) - .get("transactionsByPaymentHash") - ) - - # Blink API edge case: for a previously failed payment attempt, it returns the two payments with the same hash - # if there are two payments with the same hash with "direction" == "SEND" and "RECEIVE" - # it means that the payment previously failed and we can ignore the attempt and return - # PaymentStatus(status=FAILED) - if len(all_payments_with_this_hash) == 2: - directions = [p.get("direction") for p in all_payments_with_this_hash] - if DIRECTION_SEND in directions and DIRECTION_RECEIVE in directions: - return PaymentStatus( - result=PaymentResult.FAILED, error_message="Payment failed" - ) - - # if there is only one payment with the same hash, it means that the payment might have succeeded - # we only care about the payment with "direction" == "SEND" - payment = next( - ( - p - for p in all_payments_with_this_hash - if p.get("direction") == DIRECTION_SEND - ), - None, - ) - if not payment: - return PaymentStatus( - result=PaymentResult.UNKNOWN, error_message="No payment found" - ) - - result = PAYMENT_RESULT_MAP[payment["status"]] # type: ignore - preimage = payment["settlementVia"].get("preImage") # type: ignore - - fee = payment["settlementFee"] # type: ignore - fee_amount = Amount(self._fee_unit(), fee) - - return PaymentStatus( - result=result, - fee=fee_amount, - preimage=preimage, - ) - - async def _get_sats_per_usd(self) -> int: - data = { - "query": """ - query currencyConversionEstimation($amount: Float!, $currency: DisplayCurrency!) { - currencyConversionEstimation(amount: $amount, currency: $currency) { - btcSatAmount - usdCentAmount - } - } - """, - "variables": { - "amount": 1, - "currency": "USD" - }, - } - try: - r = await self.client.post( - url=self.endpoint, - data=json.dumps(data), # type: ignore - ) - r.raise_for_status() - except Exception as e: - logger.error(f"Blink currency conversion API error: {e}") - raise Exception(f"Failed to fetch conversion rate from Blink: {e}") - - resp: dict = r.json() - conversion = resp.get("data", {}).get("currencyConversionEstimation") - - if not conversion: - logger.error("Blink currencyConversionEstimation returned null") - raise Exception("Currency conversion service unavailable") - - sats_per_usd = conversion.get("btcSatAmount") - - if not sats_per_usd or sats_per_usd == 0: - logger.error(f"Invalid conversion data from Blink: btcSatAmount={sats_per_usd}") - raise Exception("Invalid conversion data: btcSatAmount is missing or zero") - - return int(sats_per_usd) - - def _sats_to_cents_with_rate(self, sats: int, sats_per_usd: int) -> int: - return math.ceil(sats * 100 / sats_per_usd) - - async def get_payment_quote( - self, melt_quote: PostMeltQuoteRequest - ) -> PaymentQuoteResponse: - bolt11 = melt_quote.request - invoice_obj = decode(bolt11) - assert invoice_obj.amount_msat, "invoice has no amount." - amount_msat = int(invoice_obj.amount_msat) - - if self.unit == Unit.sat or self.unit == Unit.msat: - 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, - } - response_key = "lnInvoiceFeeProbe" - elif self.unit == Unit.usd: - variables = { - "input": { - "paymentRequest": bolt11, - "walletId": self.wallet_ids[Unit.usd], - } - } - data = { - "query": """ - mutation lnUsdInvoiceFeeProbe($input: LnUsdInvoiceFeeProbeInput!) { - lnUsdInvoiceFeeProbe(input: $input) { - amount - errors { - message path code - } - } - } - """, - "variables": variables, - } - response_key = "lnUsdInvoiceFeeProbe" - else: - raise Exception(f"Unsupported unit: {self.unit}") - - fees_response_msat = 0 - try: - r = await self.client.post( - url=self.endpoint, - data=json.dumps(data), # type: ignore - timeout=PROBE_FEE_TIMEOUT_SEC, - ) - r.raise_for_status() - resp: dict = r.json() - if resp.get("data", {}).get(response_key, {}).get("errors"): - fees_response_msat = 0 - logger.debug( - f"Blink probe error: {resp['data'][response_key]['errors'][0].get('message')}" - ) - else: - fees_response_msat = ( - int(resp.get("data", {}).get(response_key, {}).get("amount")) - * 1000 - ) - except httpx.ReadTimeout: - pass - except Exception as e: - logger.error(f"Blink API error: {e}") - raise e - - fees_amount_msat: int = ( - math.ceil(amount_msat / 100 * BLINK_MAX_FEE_PERCENT / 1000) * 1000 - ) - - fees_msat: int = max( - fees_response_msat, - max( - fees_amount_msat, - MINIMUM_FEE_MSAT, - ), - ) - - if self.unit == Unit.sat or self.unit == Unit.msat: - fees = Amount(unit=Unit.msat, amount=fees_msat) - amount = Amount(unit=Unit.msat, amount=amount_msat) - return PaymentQuoteResponse( - checking_id=bolt11, - fee=fees.to(self.unit, round="up"), - amount=amount.to(self.unit, round="up"), - ) - elif self.unit == Unit.usd: - sats_per_usd = await self._get_sats_per_usd() - amount_sats = amount_msat // 1000 - fees_sats = fees_msat // 1000 - amount_cents = self._sats_to_cents_with_rate(amount_sats, sats_per_usd) - fees_cents = self._sats_to_cents_with_rate(fees_sats, sats_per_usd) - price_swing_cents = math.ceil( - amount_cents * PRICE_SWING_MARGIN_PERCENT / 100 - ) - fee_reserve_cents = fees_cents + price_swing_cents - return PaymentQuoteResponse( - checking_id=bolt11, - fee=Amount(Unit.usd, fee_reserve_cents), - amount=Amount(Unit.usd, amount_cents), - ) - else: - raise Exception(f"Unsupported unit: {self.unit}") - - async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: # type: ignore - raise NotImplementedError("paid_invoices_stream not implemented") diff --git a/cashu/lightning/lnbits.py b/cashu/lightning/lnbits.py deleted file mode 100644 index 79e58141..00000000 --- a/cashu/lightning/lnbits.py +++ /dev/null @@ -1,312 +0,0 @@ -# type: ignore -import asyncio -import json -from typing import AsyncGenerator, Optional - -import httpx -from bolt11 import ( - decode, -) -from loguru import logger -from websockets.client import connect - -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, - PaymentResult, - PaymentStatus, - StatusResponse, -) - - -class LNbitsWallet(LightningBackend): - """https://github.com/lnbits/lnbits""" - - supported_units = {Unit.sat} - unit = Unit.sat - supports_incoming_payment_stream: bool = True - supports_description: bool = True - - def __init__(self, unit: Unit = Unit.sat, **kwargs): - self.assert_unit_supported(unit) - self.unit = unit - self.endpoint = settings.mint_lnbits_endpoint - self.client = httpx.AsyncClient( - verify=not settings.debug, - headers={"X-Api-Key": settings.mint_lnbits_key}, - timeout=None, - ) - self.ws_url = f"{self.endpoint.replace('http', 'ws', 1)}/api/v1/ws/{settings.mint_lnbits_key}" - self.old_api = True - - async def status(self) -> StatusResponse: - try: - r = await self.client.get(url=f"{self.endpoint}/api/v1/wallet", timeout=15) - r.raise_for_status() - data: dict = r.json() - except Exception as exc: - return StatusResponse( - error_message=f"Failed to connect to {self.endpoint} due to: {exc}", - balance=Amount(self.unit, 0), - ) - if data.get("detail"): - return StatusResponse( - error_message=f"LNbits error: {data['detail']}", - balance=Amount(self.unit, 0), - ) - - return StatusResponse( - error_message=None, balance=Amount(Unit.sat, data["balance"] // 1000) - ) - - 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) - - data = {"out": False, "amount": amount.to(Unit.sat).amount} - if description_hash: - data["description_hash"] = description_hash.hex() - if unhashed_description: - data["unhashed_description"] = unhashed_description.hex() - - data["memo"] = memo or "" - try: - r = await self.client.post( - url=f"{self.endpoint}/api/v1/payments", json=data - ) - r.raise_for_status() - data = r.json() - except httpx.HTTPStatusError: - return InvoiceResponse( - ok=False, error_message=f"HTTP status: {r.reason_phrase}" - ) - except Exception as exc: - return InvoiceResponse(ok=False, error_message=str(exc)) - if data.get("detail"): - return InvoiceResponse(ok=False, error_message=data["detail"]) - - checking_id = data["checking_id"] - payment_request = data.get("bolt11") or data.get("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: - try: - r = await self.client.post( - url=f"{self.endpoint}/api/v1/payments", - json={"out": True, "bolt11": quote.request}, - timeout=None, - ) - r.raise_for_status() - data: dict = r.json() - except httpx.HTTPStatusError: - return PaymentResponse( - result=PaymentResult.FAILED, - error_message=f"HTTP status: {r.reason_phrase}", - ) - except Exception as exc: - return PaymentResponse(result=PaymentResult.FAILED, error_message=str(exc)) - if data.get("detail"): - return PaymentResponse( - result=PaymentResult.FAILED, error_message=data["detail"] - ) - - checking_id = data.get("payment_hash") - if not checking_id: - return PaymentResponse( - result=PaymentResult.UNKNOWN, error_message="No payment_hash received" - ) - - # we do this to get the fee and preimage - payment: PaymentStatus = await self.get_payment_status(checking_id) - - return PaymentResponse( - result=payment.result, - checking_id=checking_id, - fee=payment.fee, - preimage=payment.preimage, - ) - - async def get_invoice_status(self, checking_id: str) -> PaymentStatus: - try: - r = await self.client.get( - url=f"{self.endpoint}/api/v1/payments/{checking_id}" - ) - r.raise_for_status() - data: dict = r.json() - except Exception as e: - return PaymentStatus(result=PaymentResult.UNKNOWN, error_message=str(e)) - data: dict = r.json() - if data.get("detail"): - return PaymentStatus( - result=PaymentResult.UNKNOWN, error_message=data["detail"] - ) - - status = data.get("details", {}).get("status", None) - - if data.get("paid", False): - result = PaymentResult.SETTLED - elif status == "pending" or data.get("details", {}).get("pending", False): - result = PaymentResult.PENDING - elif status == "failed": - result = PaymentResult.FAILED - else: - result = PaymentResult.UNKNOWN - - return PaymentStatus( - result=result, - fee=Amount(unit=Unit.msat, amount=abs(data["details"]["fee"])), - preimage=data.get("preimage"), - ) - - async def get_payment_status(self, checking_id: str) -> PaymentStatus: - try: - r = await self.client.get( - url=f"{self.endpoint}/api/v1/payments/{checking_id}" - ) - r.raise_for_status() - data = r.json() - except httpx.HTTPStatusError as e: - if e.response.status_code != 404: - raise e - return PaymentStatus( - result=PaymentResult.UNKNOWN, error_message=e.response.text - ) - except Exception as e: - return PaymentStatus(result=PaymentResult.UNKNOWN, error_message=str(e)) - - if "paid" not in data and "details" not in data: - return PaymentStatus( - result=PaymentResult.UNKNOWN, error_message="invalid response" - ) - - status = data.get("details", {}).get("status", None) - - if data.get("paid", False): - result = PaymentResult.SETTLED - elif status == "pending" or data.get("details", {}).get("pending", False): - result = PaymentResult.PENDING - elif status == "failed": - result = PaymentResult.FAILED - else: - result = PaymentResult.UNKNOWN - - return PaymentStatus( - result=result, - fee=Amount(unit=Unit.msat, amount=abs(data["details"]["fee"])), - preimage=data.get("preimage"), - ) - - 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." - amount_msat = int(invoice_obj.amount_msat) - 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"), - ) - - async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: - retry_delay = 0 - max_retry_delay = settings.mint_retry_exponential_backoff_max_delay - - while True: - try: - # --- LNBITS RETRO-COMPATIBILITY --- - if self.old_api: - url = f"{self.endpoint}/api/v1/payments/sse" - - try: - sse_headers = self.client.headers.copy() - sse_headers.update( - { - "accept": "text/event-stream", - "cache-control": "no-cache", - "connection": "keep-alive", - } - ) - async with self.client.stream( - "GET", - url, - content="text/event-stream", - timeout=None, - headers=sse_headers, - ) as r: - # Reset retry delay on successful connection - retry_delay = 0 - sse_trigger = False - async for line in r.aiter_lines(): - if "Payment does not exist." in line: - logger.debug("New API detected. Setting old_api = False") - self.old_api = False - # The data we want to listen to is of this shape: - # event: payment-received - # data: {.., "payment_hash" : "asd"} - if line.startswith("event: payment-received"): - sse_trigger = True - continue - elif sse_trigger and line.startswith("data:"): - data = json.loads(line[len("data:") :]) - sse_trigger = False - yield data["payment_hash"] - else: - sse_trigger = False - - except (OSError, httpx.ReadError, httpx.ConnectError, httpx.ReadTimeout): - pass - - if self.old_api: - await asyncio.sleep(retry_delay) - # Exponential backoff - retry_delay = max(settings.mint_retry_exponential_backoff_base_delay, min(retry_delay * 2, max_retry_delay)) - continue - # --- END LNBITS RETRO-COMPATIBILITY --- - - async with connect(self.ws_url) as ws: - logger.info("connected to LNbits fundingsource websocket.") - # Reset retry delay on successful connection - retry_delay = 0 - while True: - message = await ws.recv() - message_dict = json.loads(message) - if ( - message_dict - and message_dict.get("payment") - and message_dict["payment"].get("payment_hash") - and message_dict["payment"].get("amount") > 0 - ): - payment_hash = message_dict["payment"]["payment_hash"] - logger.info(f"payment-received: {payment_hash}") - yield payment_hash - except Exception as exc: - logger.error( - f"lost connection to LNbits fundingsource websocket: '{exc}', retrying in {retry_delay}" - " seconds" - ) - await asyncio.sleep(retry_delay) - - # Exponential backoff - retry_delay = max(settings.mint_retry_exponential_backoff_base_delay, min(retry_delay * 2, max_retry_delay)) diff --git a/cashu/mint/startup.py b/cashu/mint/startup.py index 7974263a..827582bd 100644 --- a/cashu/mint/startup.py +++ b/cashu/mint/startup.py @@ -31,8 +31,6 @@ if key in [ "mint_private_key", "mint_seed_decryption_key", - "mint_lnbits_key", - "mint_blink_key", "mint_strike_key", "mint_spark_api_key", "mint_spark_mnemonic", diff --git a/tests/lightning/test_lightning_backends_mocked.py b/tests/lightning/test_lightning_backends_mocked.py index 29c51443..5c59252f 100644 --- a/tests/lightning/test_lightning_backends_mocked.py +++ b/tests/lightning/test_lightning_backends_mocked.py @@ -14,10 +14,8 @@ PostMeltRequestOptions, ) from cashu.lightning.base import PaymentResult, Unsupported -from cashu.lightning.blink import BlinkWallet from cashu.lightning.clnrest import CLNRestWallet from cashu.lightning.corelightningrest import CoreLightningRestWallet -from cashu.lightning.lnbits import LNbitsWallet # type: ignore[attr-defined] from cashu.lightning.lndrest import LndRestWallet from cashu.lightning.strike import StrikeWallet @@ -57,109 +55,6 @@ async def aiter_lines(self): yield line -@pytest.mark.asyncio -async def test_lnbits_status_returns_error_on_detail(): - wallet = object.__new__(LNbitsWallet) - wallet.unit = Unit.sat - wallet.endpoint = "https://lnbits.test" - - class Client: - async def get(self, url, timeout=None): - return _response(200, {"detail": "bad key"}) - - cast(Any, wallet).client = Client() - - status = await wallet.status() - assert status.error_message == "LNbits error: bad key" - assert status.balance.amount == 0 - - -@pytest.mark.asyncio -async def test_lnbits_create_invoice_http_error_returns_failure(): - wallet = object.__new__(LNbitsWallet) - wallet.unit = Unit.sat - wallet.endpoint = "https://lnbits.test" - - class Client: - async def post(self, url, json=None): - return _response(500, {"detail": "bad"}) - - cast(Any, wallet).client = Client() - - invoice = await wallet.create_invoice(Amount(Unit.sat, 2)) - assert not invoice.ok - assert "HTTP status" in str(invoice.error_message) - - -@pytest.mark.asyncio -async def test_lnbits_pay_invoice_without_hash_is_unknown(): - wallet = object.__new__(LNbitsWallet) - wallet.unit = Unit.sat - wallet.endpoint = "https://lnbits.test" - - class Client: - async def post(self, url, json=None, timeout=None): - return _response(200, {"paid": True}) - - cast(Any, wallet).client = Client() - result = await wallet.pay_invoice(_quote("lnbc1fake"), 1000) - assert result.result == PaymentResult.UNKNOWN - assert result.error_message == "No payment_hash received" - - -@pytest.mark.asyncio -async def test_lnbits_get_payment_status_rejects_invalid_response(): - wallet = object.__new__(LNbitsWallet) - wallet.unit = Unit.sat - wallet.endpoint = "https://lnbits.test" - - class Client: - async def get(self, url): - return _response(200, {"foo": "bar"}) - - cast(Any, wallet).client = Client() - status = await wallet.get_payment_status("hash") - assert status.result == PaymentResult.UNKNOWN - assert status.error_message == "invalid response" - - -@pytest.mark.asyncio -async def test_lnbits_get_invoice_status_maps_pending_and_failed(): - wallet = object.__new__(LNbitsWallet) - wallet.unit = Unit.sat - wallet.endpoint = "https://lnbits.test" - - class Client: - calls = 0 - - async def get(self, url): - Client.calls += 1 - if Client.calls == 1: - return _response( - 200, - { - "paid": False, - "details": {"status": "pending", "fee": -2}, - "preimage": None, - }, - ) - return _response( - 200, - { - "paid": False, - "details": {"status": "failed", "fee": -3}, - "preimage": None, - }, - ) - - cast(Any, wallet).client = Client() - pending = await wallet.get_invoice_status("hash") - failed = await wallet.get_invoice_status("hash") - - assert pending.result == PaymentResult.PENDING - assert failed.result == PaymentResult.FAILED - - @pytest.mark.asyncio async def test_strike_status_falls_back_to_usdt_for_usd_unit(): wallet = object.__new__(StrikeWallet) @@ -607,55 +502,6 @@ async def test_lndrest_get_payment_quote_uses_mpp_amount(monkeypatch): assert quote.fee == Amount(Unit.sat, fee_reserve(1500) // 1000) -@pytest.mark.asyncio -async def test_blink_get_payment_status_send_receive_pair_is_failed(monkeypatch): - wallet = object.__new__(BlinkWallet) - wallet.unit = Unit.sat - wallet.wallet_ids = {Unit.sat: "wbtc"} - wallet.endpoint = "https://blink.test" - - class Client: - async def post(self, *args, **kwargs): - return _response( - 200, - { - "data": { - "me": { - "defaultAccount": { - "walletById": { - "transactionsByPaymentHash": [ - {"direction": "SEND", "status": "FAILURE"}, - {"direction": "RECEIVE", "status": "FAILURE"}, - ] - } - } - } - } - }, - ) - - cast(Any, wallet).client = Client() - invoice = "lnbc10u1pjap7phpp50s9lzr3477j0tvacpfy2ucrs4q0q6cvn232ex7nt2zqxxxj8gxrsdpv2phhwetjv4jzqcneypqyc6t8dp6xu6twva2xjuzzda6qcqzzsxqrrsssp575z0n39w2j7zgnpqtdlrgz9rycner4eptjm3lz363dzylnrm3h4s9qyyssqfz8jglcshnlcf0zkw4qu8fyr564lg59x5al724kms3h6gpuhx9xrfv27tgx3l3u3cyf63r52u0xmac6max8mdupghfzh84t4hfsvrfsqwnuszf" - status = await wallet.get_payment_status(invoice) - assert status.result == PaymentResult.FAILED - assert status.error_message == "Payment failed" - - -@pytest.mark.asyncio -async def test_blink_get_sats_per_usd_raises_on_missing_conversion(): - wallet = object.__new__(BlinkWallet) - wallet.unit = Unit.usd - wallet.endpoint = "https://blink.test" - - class Client: - async def post(self, *args, **kwargs): - return _response(200, {"data": {"currencyConversionEstimation": None}}) - - cast(Any, wallet).client = Client() - with pytest.raises(Exception, match="Currency conversion service unavailable"): - await wallet._get_sats_per_usd() - - @pytest.mark.asyncio async def test_spark_pay_invoice_rejects_non_bolt11(): from cashu.lightning.sparkl2 import SparkL2Wallet diff --git a/tests/mint/test_mint_lightning_blink.py b/tests/mint/test_mint_lightning_blink.py deleted file mode 100644 index c18a7095..00000000 --- a/tests/mint/test_mint_lightning_blink.py +++ /dev/null @@ -1,448 +0,0 @@ -import pytest -import respx -from httpx import Response - -from cashu.core.base import Amount, MeltQuote, MeltQuoteState, Unit -from cashu.core.models import PostMeltQuoteRequest -from cashu.core.settings import settings -from cashu.lightning.blink import MINIMUM_FEE_MSAT, BlinkWallet # type: ignore - -settings.mint_blink_key = "123" -blink = BlinkWallet(unit=Unit.sat) -blink_usd = BlinkWallet(unit=Unit.usd) -payment_request = ( - "lnbc10u1pjap7phpp50s9lzr3477j0tvacpfy2ucrs4q0q6cvn232ex7nt2zqxxxj8gxrsdpv2phhwetjv4jzqcneypqyc6t8dp6xu6twva2xjuzzda6qcqzzsxqrrsss" - "p575z0n39w2j7zgnpqtdlrgz9rycner4eptjm3lz363dzylnrm3h4s9qyyssqfz8jglcshnlcf0zkw4qu8fyr564lg59x5al724kms3h6gpuhx9xrfv27tgx3l3u3cyf6" - "3r52u0xmac6max8mdupghfzh84t4hfsvrfsqwnuszf" -) # 1000 sat - -payment_request_10k = ( - "lnbc100u1pjaxuyzpp5wn37d3mx38haqs7nd5he4j7pq4r806e6s83jdksxrd77pnanm3zqdpv2phhwetjv4jzqcneypqyc6t8dp6xu6twva2xjuzzda6qcqzzsxqrrss" - "sp5ayy0uuhwgy8hwphvy7ptzpg2dfn8vt3vlgsk53rsvj76jvafhujs9qyyssqc8aj03s5au3tgu6pj0rm0ws4a838s8ffe3y3qkj77esh7qmgsz7qlvdlzgj6dvx7tx7" - "zn6k352z85rvdqvlszrevvzakp96a4pvyn2cpgaaks6" -) - -payment_request_4973 = ( - "lnbc49730n1pjaxuxnpp5zw0ry2w2heyuv7wk4r6z38vvgnaudfst0hl2p5xnv0mjkxtavg2qdpv2phhwetjv4jzqcneypqyc6t8dp6xu6twva2xjuzzda6qcqzzsxqrr" - "sssp5x8tv2ka0m95hgek25kauw540m0dx727stqqr07l8h37v5283sn5q9qyyssqeevcs6vxcdnerk5w5mwfmntsf8nze7nxrf97dywmga7v0742vhmxtjrulgu3kah4f" - "2r6025j974jpjg4mkqhv2gdls5k7e5cvwdf4wcp3ytsvx" -) -payment_request_1 = ( - "lnbc10n1pjaxujrpp5sqehn6h5p8xpa0c0lvj5vy3a537gxfk5e7h2ate2alfw3y5cm6xqdpv2phhwetjv4jzqcneypqyc6t8dp6xu6twva2xjuzzda6qcqzzsxqrrsss" - "p5fkxsvyl0r32mvnhv9cws4rp986v0wjl2lp93zzl8jejnuwzvpynq9qyyssqqmsnatsz87qrgls98c97dfa6l2z3rzg2x6kxmrvpz886rwjylmd56y3qxzfulrq03kkh" - "hwk6r32wes6pjt2zykhnsjn30c6uhuk0wugp3x74al" -) - - -@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, - "settlementVia": { - "preImage": "123", - }, - }, - } - } - } - 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, - state=MeltQuoteState.unpaid, - ) - payment = await blink.pay_invoice(quote, 1000) - assert payment.settled - 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_pay_invoice_failure(): - mock_response = { - "data": { - "lnInvoicePaymentSend": { - "status": "FAILURE", - "errors": [ - {"message": "This is the error", "codee": "ROUTE_FINDING_ERROR"}, - ], - } - } - } - 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, - state=MeltQuoteState.unpaid, - ) - payment = await blink.pay_invoice(quote, 1000) - assert not payment.settled - assert payment.fee is None - assert payment.error_message - assert "This is the error" in payment.error_message - assert payment.checking_id == payment_request - - -@respx.mock -@pytest.mark.asyncio -async def test_blink_pay_invoice_pending(): - mock_response = { - "data": { - "lnInvoicePaymentSend": { - "status": "PENDING", - } - } - } - 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, - state=MeltQuoteState.unpaid, - ) - payment = await blink.pay_invoice(quote, 1000) - assert not payment.settled - assert payment.result.name == "PENDING" - assert payment.fee is None - 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.settled - - -@respx.mock -@pytest.mark.asyncio -async def test_blink_get_payment_status(): - mock_response = { - "data": { - "me": { - "defaultAccount": { - "walletById": { - "transactionsByPaymentHash": [ - { - "status": "SUCCESS", - "settlementFee": 10, - "direction": "SEND", - "settlementVia": { - "preImage": "123", - }, - } - ] - } - } - } - } - } - respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response)) - status = await blink.get_payment_status(payment_request) - assert status.settled - assert status.fee - assert status.fee.amount == 10 - assert status.preimage == "123" - - -@respx.mock -@pytest.mark.asyncio -async def test_blink_get_payment_quote(): - # response says 1 sat fees but invoice (1000 sat) * 0.5% is 5 sat so we expect MINIMUM_FEE_MSAT/1000 sat - mock_response = {"data": {"lnInvoiceFeeProbe": {"amount": 1}}} - respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response)) - melt_quote_request = PostMeltQuoteRequest( - unit=Unit.sat.name, request=payment_request - ) - quote = await blink.get_payment_quote(melt_quote_request) - assert quote.checking_id == payment_request - assert quote.amount == Amount(Unit.sat, 1000) # sat - assert quote.fee == Amount(Unit.sat, MINIMUM_FEE_MSAT // 1000) # msat - - # response says 10 sat fees but invoice (1000 sat) * 0.5% is 5 sat so we expect 10 sat - mock_response = {"data": {"lnInvoiceFeeProbe": {"amount": 10}}} - respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response)) - melt_quote_request = PostMeltQuoteRequest( - unit=Unit.sat.name, request=payment_request - ) - quote = await blink.get_payment_quote(melt_quote_request) - assert quote.checking_id == payment_request - assert quote.amount == Amount(Unit.sat, 1000) # sat - assert quote.fee == Amount(Unit.sat, 10) # sat - - # response says 10 sat fees but invoice (4973 sat) * 0.5% is 24.865 sat so we expect 25 sat - mock_response = {"data": {"lnInvoiceFeeProbe": {"amount": 10}}} - respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response)) - melt_quote_request_4973 = PostMeltQuoteRequest( - unit=Unit.sat.name, request=payment_request_4973 - ) - quote = await blink.get_payment_quote(melt_quote_request_4973) - assert quote.checking_id == payment_request_4973 - assert quote.amount == Amount(Unit.sat, 4973) # sat - assert quote.fee == Amount(Unit.sat, 25) # sat - - # response says 0 sat fees but invoice (1 sat) * 0.5% is 0.005 sat so we expect MINIMUM_FEE_MSAT/1000 sat - mock_response = {"data": {"lnInvoiceFeeProbe": {"amount": 0}}} - respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response)) - melt_quote_request_1 = PostMeltQuoteRequest( - unit=Unit.sat.name, request=payment_request_1 - ) - quote = await blink.get_payment_quote(melt_quote_request_1) - assert quote.checking_id == payment_request_1 - assert quote.amount == Amount(Unit.sat, 1) # sat - assert quote.fee == Amount(Unit.sat, MINIMUM_FEE_MSAT // 1000) # msat - - -@respx.mock -@pytest.mark.asyncio -async def test_blink_get_payment_quote_backend_error(): - # response says error but invoice (1000 sat) * 0.5% is 5 sat so we expect 10 sat (MINIMUM_FEE_MSAT) - mock_response = {"data": {"lnInvoiceFeeProbe": {"errors": [{"message": "error"}]}}} - respx.post(blink.endpoint).mock(return_value=Response(200, json=mock_response)) - melt_quote_request = PostMeltQuoteRequest( - unit=Unit.sat.name, request=payment_request - ) - quote = await blink.get_payment_quote(melt_quote_request) - assert quote.checking_id == payment_request - assert quote.amount == Amount(Unit.sat, 1000) # sat - assert quote.fee == Amount(Unit.sat, MINIMUM_FEE_MSAT // 1000) # msat - - -PRICE_RESPONSE = { - "data": { - "currencyConversionEstimation": { - "btcSatAmount": 100, - "usdCentAmount": 100, - } - } -} - - -@respx.mock -@pytest.mark.asyncio -async def test_blink_status_usd(): - mock_response = { - "data": { - "me": { - "defaultAccount": { - "wallets": [ - {"walletCurrency": "USD", "id": "usd-wallet-123", "balance": 10000}, - {"walletCurrency": "BTC", "id": "btc-wallet-456", "balance": 100000}, - ] - } - } - } - } - respx.post(blink_usd.endpoint).mock(return_value=Response(200, json=mock_response)) - status = await blink_usd.status() - assert status.balance.amount == 10000 - assert status.balance.unit == Unit.usd - assert blink_usd.wallet_ids[Unit.usd] == "usd-wallet-123" - - -@respx.mock -@pytest.mark.asyncio -async def test_blink_create_invoice_usd(): - blink_usd.wallet_ids[Unit.usd] = "usd-wallet-123" - mock_response = { - "data": { - "lnUsdInvoiceCreateOnBehalfOfRecipient": { - "invoice": {"paymentRequest": payment_request} - } - } - } - respx.post(blink_usd.endpoint).mock(return_value=Response(200, json=mock_response)) - invoice = await blink_usd.create_invoice(Amount(Unit.usd, 1000)) - assert invoice.checking_id == invoice.payment_request - assert invoice.ok - - -@respx.mock -@pytest.mark.asyncio -async def test_blink_pay_invoice_usd(): - blink_usd.wallet_ids[Unit.usd] = "usd-wallet-123" - payment_response = { - "data": { - "lnInvoicePaymentSend": { - "status": "SUCCESS", - "transaction": { - "settlementAmount": -1010, - "settlementFee": 10, - "settlementVia": {"preImage": "abc123"}, - }, - } - } - } - status_response = { - "data": { - "me": { - "defaultAccount": { - "walletById": { - "transactionsByPaymentHash": [ - { - "status": "SUCCESS", - "settlementAmount": -1010, - "settlementFee": 10, - "direction": "SEND", - "settlementVia": {"preImage": "abc123"}, - } - ] - } - } - } - } - } - respx.post(blink_usd.endpoint).mock( - side_effect=[ - Response(200, json=payment_response), - Response(200, json=status_response), - Response(200, json=PRICE_RESPONSE), - ] - ) - quote = MeltQuote( - request=payment_request, - quote="asd", - method="bolt11", - checking_id=payment_request, - unit="usd", - amount=1000, - fee_reserve=100, - state=MeltQuoteState.unpaid, - ) - payment = await blink_usd.pay_invoice(quote, 1000) - assert payment.settled - assert payment.fee - assert payment.fee.unit == Unit.usd - assert payment.fee.amount == 10 - assert payment.error_message is None - - -@respx.mock -@pytest.mark.asyncio -async def test_blink_get_payment_status_usd(): - blink_usd.wallet_ids[Unit.usd] = "usd-wallet-123" - status_response = { - "data": { - "me": { - "defaultAccount": { - "walletById": { - "transactionsByPaymentHash": [ - { - "status": "SUCCESS", - "settlementAmount": -1010, - "settlementFee": 10, - "direction": "SEND", - "settlementVia": {"preImage": "abc123"}, - } - ] - } - } - } - } - } - respx.post(blink_usd.endpoint).mock( - side_effect=[ - Response(200, json=status_response), - Response(200, json=PRICE_RESPONSE), - ] - ) - status = await blink_usd.get_payment_status(payment_request) - assert status.settled - assert status.fee - assert status.fee.unit == Unit.usd - assert status.fee.amount == 10 - assert status.preimage == "abc123" - - -@respx.mock -@pytest.mark.asyncio -async def test_blink_get_payment_quote_usd(): - blink_usd.wallet_ids[Unit.usd] = "usd-wallet-123" - fee_probe_response = {"data": {"lnUsdInvoiceFeeProbe": {"amount": 10}}} - respx.post(blink_usd.endpoint).mock( - side_effect=[ - Response(200, json=fee_probe_response), - Response(200, json=PRICE_RESPONSE), - ] - ) - melt_quote_request = PostMeltQuoteRequest( - unit=Unit.usd.name, request=payment_request - ) - quote = await blink_usd.get_payment_quote(melt_quote_request) - assert quote.checking_id == payment_request - assert quote.amount.unit == Unit.usd - assert quote.fee.unit == Unit.usd - assert quote.amount.amount == 1000 - assert quote.fee.amount == 110 diff --git a/tests/mint/test_mint_melt.py b/tests/mint/test_mint_melt.py index 97418bc4..c706960f 100644 --- a/tests/mint/test_mint_melt.py +++ b/tests/mint/test_mint_melt.py @@ -407,6 +407,7 @@ async def test_melt_lightning_pay_invoice_failed_failed(ledger: Ledger, wallet: except LightningPaymentFailedError: pass + @pytest.mark.asyncio @pytest.mark.skipif(is_regtest, reason="only fake wallet") async def test_melt_lightning_unknown_status_keeps_proofs_pending( @@ -936,8 +937,7 @@ async def test_melt_early_return_leaves_no_orphan_blank_outputs( Both parametrize cases hit the same early-return branch: - offset == 0 → overpaid_fee == 0 (fee exactly matched reserve) - offset > 0 → overpaid_fee < 0 (backend took more than the - reserve, e.g. an LNbits backend skimming a service fee on top - of the routing fee) + reserve due to a service fee on top of the routing fee) """ settings.fakewallet_payment_state = PaymentResult.SETTLED.name settings.fakewallet_pay_invoice_state = ""