diff --git a/.env.example b/.env.example index d54b97629..a03fbde3b 100644 --- a/.env.example +++ b/.env.example @@ -63,10 +63,8 @@ MINT_DATABASE=data/mint # Funding source backends # Set one funding source backend for each unit -# Supported: FakeWallet, LndRestWallet, LndRPCWallet, CLNRestWallet, BlinkWallet, LNbitsWallet, StrikeWallet, CoreLightningRestWallet (deprecated) - +# Available backends: FakeWallet, LNbitsWallet, StrikeWallet, BlinkWallet, CoreLightningRestWallet, LndRestWallet, LndRPCWallet, SparkL2Wallet MINT_BACKEND_BOLT11_SAT=FakeWallet -# Only works if a usd derivation path is set # MINT_BACKEND_BOLT11_USD=FakeWallet # MINT_BACKEND_BOLT11_EUR=FakeWallet @@ -111,6 +109,11 @@ MINT_BLINK_KEY=blink_abcdefgh # Use with StrikeWallet for BTC, USD, and EUR MINT_STRIKE_KEY=ABC123 +# Use with SparkL2Wallet +# MINT_SPARK_NETWORK="TESTNET" # or "MAINNET", "REGTEST" +# MINT_SPARK_API_KEY="your_api_key" +# MINT_SPARK_MNEMONIC="abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + # 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 5723fe519..ef448a772 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -116,6 +116,10 @@ class MintBackends(MintSettings): 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) + mint_spark_mnemonic: Optional[str] = Field(default=None) + class MintLimits(MintSettings): mint_rate_limit: bool = Field( diff --git a/cashu/lightning/__init__.py b/cashu/lightning/__init__.py index dfa66b941..a1e4b35b4 100644 --- a/cashu/lightning/__init__.py +++ b/cashu/lightning/__init__.py @@ -7,6 +7,7 @@ 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 from .strike import StrikeWallet # noqa: F401 backend_settings = [ diff --git a/cashu/lightning/sparkl2.py b/cashu/lightning/sparkl2.py new file mode 100644 index 000000000..2b03893ad --- /dev/null +++ b/cashu/lightning/sparkl2.py @@ -0,0 +1,380 @@ +import asyncio +import os +from typing import AsyncGenerator, Optional + +import bolt11 +import breez_sdk_spark +from loguru import logger + +from cashu.core.base import Amount, MeltQuote, Unit +from cashu.core.models import PostMeltQuoteRequest +from cashu.core.settings import settings +from cashu.lightning.base import ( + InvoiceResponse, + LightningBackend, + PaymentQuoteResponse, + PaymentResponse, + PaymentResult, + PaymentStatus, + StatusResponse, +) + + +class _SdkEventListener(breez_sdk_spark.EventListener): + def __init__(self, wallet: "SparkL2Wallet"): + self.wallet = wallet + + async def on_event(self, event: breez_sdk_spark.SdkEvent): + # We only care about incoming payments + if isinstance(event, breez_sdk_spark.SdkEvent.PAYMENT_SUCCEEDED): + payment = event.payment + if not payment: + return + + # Only track incoming payments + if payment.payment_type == breez_sdk_spark.PaymentType.RECEIVE: + # The payment.details will contain the invoice if it's a lightning payment + # We extract the checking_id (payment hash) + details = payment.details + if details and details.is_lightning(): + htlc = details.htlc_details + if htlc and htlc.payment_hash: + await self.wallet.payment_queue.put(htlc.payment_hash) + # If it's a spark payment (on-chain/deposit/etc), we might not have a simple payment hash + # but for bolt11 invoices created via this backend, it will be lightning. + + +class SparkL2Wallet(LightningBackend): + """ + Spark L2 Wallet backend. + Uses the official Breez Spark SDK for Python (`breez-sdk-spark`). + """ + + supported_units = {Unit.sat, Unit.msat} + supports_mpp = False + supports_incoming_payment_stream = True + supports_description = True + + def __init__(self, unit: Unit, **kwargs): + self.assert_unit_supported(unit) + self.unit = unit + self.sdk: Optional[breez_sdk_spark.BreezSdk] = None + self.payment_queue: asyncio.Queue[str] = asyncio.Queue() + self.listener: Optional[_SdkEventListener] = None + + async def _ensure_sdk(self): + if self.sdk is not None: + return + + if not settings.mint_spark_mnemonic: + raise Exception("MINT_SPARK_MNEMONIC is required to initialize SparkL2Wallet. Please set a 12, 15, 18, 21, or 24-word seed phrase in your environment.") + + # Initialize the Breez SDK + seed = breez_sdk_spark.Seed.MNEMONIC( + mnemonic=settings.mint_spark_mnemonic, + passphrase=None + ) + + network_str = getattr(settings, "mint_spark_network", "TESTNET").upper() + if network_str == "MAINNET": + network = breez_sdk_spark.Network.MAINNET + elif network_str == "REGTEST": + network = breez_sdk_spark.Network.REGTEST + elif network_str == "SIGNET": + network = breez_sdk_spark.Network.SIGNET + else: + network = breez_sdk_spark.Network.TESTNET + + config = breez_sdk_spark.default_config(network) + if settings.mint_spark_api_key: + config.api_key = settings.mint_spark_api_key + + # Use a safe storage directory specific to this mint + storage_dir = os.path.join(settings.cashu_dir, "sparkl2_data") + os.makedirs(storage_dir, exist_ok=True) + + try: + self.sdk = await breez_sdk_spark.connect( + request=breez_sdk_spark.ConnectRequest( + config=config, + seed=seed, + storage_dir=storage_dir + ) + ) + + self.listener = _SdkEventListener(self) + await self.sdk.add_event_listener(self.listener) + + logger.info("Breez Spark SDK initialized successfully.") + + except Exception as e: + logger.error(f"Failed to initialize Breez Spark SDK: {e}") + raise Exception(f"Failed to initialize Breez Spark SDK: {e}") + + async def status(self) -> StatusResponse: + try: + await self._ensure_sdk() + + if not self.sdk: + return StatusResponse( + error_message="Spark SDK not initialized", + balance=Amount(self.unit, 0), + ) + + req = breez_sdk_spark.GetInfoRequest(ensure_synced=False) + info = await self.sdk.get_info(req) + + balance_sats = info.balance_sats + balance = Amount(Unit.sat, balance_sats) + if self.unit == Unit.msat: + balance = Amount(Unit.msat, balance_sats * 1000) + return StatusResponse(balance=balance) + except Exception as e: + return StatusResponse( + error_message=f"Failed to get status from Spark SDK: {str(e)}", + balance=Amount(self.unit, 0), + ) + + async def create_invoice( + self, + amount: Amount, + memo: Optional[str] = None, + description_hash: Optional[bytes] = None, + **kwargs, + ) -> InvoiceResponse: + self.assert_unit_supported(amount.unit) + await self._ensure_sdk() + + amount_sats = amount.to(Unit.sat, round="up").amount + + if not self.sdk: + raise Exception("SDK not initialized") + + try: + # We want to create a bolt11 invoice + req = breez_sdk_spark.ReceivePaymentRequest( + payment_method=breez_sdk_spark.ReceivePaymentMethod.BOLT11_INVOICE( + description=(memo or "") if not description_hash else "", + amount_sats=amount_sats, + expiry_secs=None, + payment_hash=None + ) + ) + + res = await self.sdk.receive_payment(req) + + # The response contains the invoice string + invoice = res.payment_request + + # The payment hash is our checking ID + invoice_obj = bolt11.decode(invoice) + + return InvoiceResponse( + ok=True, + checking_id=invoice_obj.payment_hash, + payment_request=invoice, + ) + except Exception as e: + logger.error(f"Failed to create invoice: {str(e)}") + return InvoiceResponse( + ok=False, + error_message=f"Failed to create invoice: {str(e)}" + ) + + async def pay_invoice( + self, quote: MeltQuote, fee_limit_msat: int + ) -> PaymentResponse: + await self._ensure_sdk() + if not self.sdk: + raise Exception("SDK not initialized") + + try: + # The Spark SDK has prepare_send_payment -> send_payment flow + prepare_req = breez_sdk_spark.PrepareSendPaymentRequest( + payment_request=quote.request, + amount=None, # Already in invoice + fee_policy=None # Can pass fee limits here if supported + ) + + prepare_res = await self.sdk.prepare_send_payment(prepare_req) + + if not prepare_res.payment_method.is_bolt11_invoice(): + return PaymentResponse( + result=PaymentResult.FAILED, + error_message="Only BOLT11 payments are supported" + ) + + # Ensure fee is within limits + pm = prepare_res.payment_method + fee_sats = pm.lightning_fee_sats or 0 + spark_fee = pm.spark_transfer_fee_sats or 0 + total_fee_sats = fee_sats + spark_fee + + if total_fee_sats * 1000 > fee_limit_msat: + return PaymentResponse( + result=PaymentResult.FAILED, + error_message=f"Fee estimate ({total_fee_sats} sats) exceeds limit ({fee_limit_msat // 1000} sats)" + ) + + send_req = breez_sdk_spark.SendPaymentRequest( + prepare_response=prepare_res + ) + + send_res = await self.sdk.send_payment(send_req) + + payment = send_res.payment + fee_amount = None + if payment.fees is not None: + if self.unit == Unit.msat: + fee_amount = Amount(Unit.msat, payment.fees * 1000) + else: + fee_amount = Amount(Unit.sat, payment.fees) + + preimage = None + if payment.details and payment.details.is_lightning(): + htlc = payment.details.htlc_details + if htlc: + preimage = htlc.preimage + + if payment.status == breez_sdk_spark.PaymentStatus.COMPLETED: + result = PaymentResult.SETTLED + elif payment.status == breez_sdk_spark.PaymentStatus.FAILED: + result = PaymentResult.FAILED + else: + result = PaymentResult.PENDING + + return PaymentResponse( + result=result, + checking_id=payment.id, + fee=fee_amount, + preimage=preimage, + ) + except Exception as e: + return PaymentResponse( + result=PaymentResult.PENDING, + error_message=f"Payment failed or unknown: {str(e)}" + ) + + async def get_invoice_status(self, checking_id: str) -> PaymentStatus: + # checking_id is the payment hash + await self._ensure_sdk() + if not self.sdk: + raise Exception("SDK not initialized") + + try: + # We must list payments and find the receive payment by hash + # Breez SDK provides list_payments but it might be inefficient. + # However, for Spark L2 it's local. + req = breez_sdk_spark.ListPaymentsRequest( + type_filter=[breez_sdk_spark.PaymentType.RECEIVE], + asset_filter=breez_sdk_spark.AssetFilter.BITCOIN(), + payment_details_filter=[breez_sdk_spark.PaymentDetailsFilter.LIGHTNING(None)], + ) + + res = await self.sdk.list_payments(req) + + for p in res.payments: + if p.payment_type == breez_sdk_spark.PaymentType.RECEIVE: + if p.details and p.details.is_lightning(): + htlc = p.details.htlc_details + if htlc and htlc.payment_hash == checking_id: + if p.status == breez_sdk_spark.PaymentStatus.COMPLETED: + return PaymentStatus(result=PaymentResult.SETTLED) + elif p.status == breez_sdk_spark.PaymentStatus.FAILED: + return PaymentStatus(result=PaymentResult.FAILED) + + # If not found in recent, assume pending + return PaymentStatus(result=PaymentResult.PENDING) + + except Exception as e: + return PaymentStatus(result=PaymentResult.UNKNOWN, error_message=str(e)) + + async def get_payment_status(self, checking_id: str) -> PaymentStatus: + await self._ensure_sdk() + if not self.sdk: + raise Exception("SDK not initialized") + + try: + req = breez_sdk_spark.GetPaymentRequest(payment_id=checking_id) + res = await self.sdk.get_payment(req) + + if not res or not res.payment: + return PaymentStatus(result=PaymentResult.UNKNOWN, error_message="Payment not found") + + payment = res.payment + fee_sats = payment.fees + fee_amount = None + if fee_sats is not None: + fee_amount = Amount(Unit.sat, fee_sats) + if self.unit == Unit.msat: + fee_amount = Amount(Unit.msat, fee_sats * 1000) + + preimage = None + if payment.details and payment.details.is_lightning(): + htlc = payment.details.htlc_details + if htlc: + preimage = htlc.preimage + + if payment.status == breez_sdk_spark.PaymentStatus.COMPLETED: + return PaymentStatus( + result=PaymentResult.SETTLED, + preimage=preimage, + fee=fee_amount + ) + elif payment.status == breez_sdk_spark.PaymentStatus.FAILED: + return PaymentStatus(result=PaymentResult.FAILED) + else: + return PaymentStatus(result=PaymentResult.PENDING) + + except Exception as e: + return PaymentStatus(result=PaymentResult.UNKNOWN, error_message=str(e)) + + async def get_payment_quote( + self, melt_quote: PostMeltQuoteRequest + ) -> PaymentQuoteResponse: + await self._ensure_sdk() + if not self.sdk: + raise Exception("SDK not initialized") + + try: + prepare_req = breez_sdk_spark.PrepareSendPaymentRequest( + payment_request=melt_quote.request, + amount=None, + fee_policy=None + ) + + prepare_res = await self.sdk.prepare_send_payment(prepare_req) + + if not prepare_res.payment_method.is_bolt11_invoice(): + raise Exception("Only BOLT11 payments are supported") + + pm = prepare_res.payment_method + fee_sats = pm.lightning_fee_sats or 0 + spark_fee = pm.spark_transfer_fee_sats or 0 + total_fee_sats = fee_sats + spark_fee + + fee_amount = Amount(Unit.sat, total_fee_sats) + if self.unit == Unit.msat: + fee_amount = Amount(Unit.msat, total_fee_sats * 1000) + + invoice_obj = bolt11.decode(melt_quote.request) + amount_msat = int(invoice_obj.amount_msat) if invoice_obj.amount_msat else 0 + amount_unit = Amount(Unit.msat, amount_msat) + + if self.unit == Unit.sat: + fee_amount = Amount(Unit.sat, fee_amount.to(Unit.sat, round="up").amount) + amount_unit = Amount(Unit.sat, amount_unit.to(Unit.sat, round="up").amount) + + return PaymentQuoteResponse( + checking_id=invoice_obj.payment_hash, + fee=fee_amount, + amount=amount_unit, + ) + except Exception as e: + raise Exception(f"Failed to get payment quote: {str(e)}") + + async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: + await self._ensure_sdk() + while True: + checking_id = await self.payment_queue.get() + yield checking_id diff --git a/poetry.lock b/poetry.lock index 16fcfaaf7..8367d0a9b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -220,6 +220,51 @@ bitstring = "*" click = "*" coincurve = "*" +[[package]] +name = "breez-sdk-spark" +version = "0.15.0" +description = "Python language bindings for the Breez Spark SDK" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "breez_sdk_spark-0.15.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bdc307a5ad08d08a964da394205b7656245d8500f68a705ca49f9a38f664587"}, + {file = "breez_sdk_spark-0.15.0-cp310-cp310-manylinux_2_31_aarch64.whl", hash = "sha256:fc9dd493a87132b9830613473b73b04e0183f9dd5d9b483d4b557ad07cec4604"}, + {file = "breez_sdk_spark-0.15.0-cp310-cp310-manylinux_2_31_x86_64.whl", hash = "sha256:a1f515c9a8dd68b6b7e596609abadf3508fcbb07206d974d4b0261c3e1b393a7"}, + {file = "breez_sdk_spark-0.15.0-cp310-cp310-win32.whl", hash = "sha256:6ace7c7dd9e0372d0ce8a24f377a85de12f7ee1356d39a1b4ee3528c8ca5a99b"}, + {file = "breez_sdk_spark-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1c1876f6d2d79d51f8b5d4b4ed6c84243286491974d716c4f62e94be53b679"}, + {file = "breez_sdk_spark-0.15.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8d9e0fe33faf6cce7a450bc5e6a13fbf88c68d13e49d295c7802464d5679dabe"}, + {file = "breez_sdk_spark-0.15.0-cp311-cp311-manylinux_2_31_aarch64.whl", hash = "sha256:4a78add31b108a48f9f0825229abfa4e85665f335a822504f43b4320a4f59333"}, + {file = "breez_sdk_spark-0.15.0-cp311-cp311-manylinux_2_31_x86_64.whl", hash = "sha256:0a0d3d155832b96224ef1ca0ed22fde9b3b2a84a4ea30361df556aea0ceafa53"}, + {file = "breez_sdk_spark-0.15.0-cp311-cp311-win32.whl", hash = "sha256:d725e06548edeba4a65c5fa9466672aeb9b741e1c4bb890cffa7b144023912b9"}, + {file = "breez_sdk_spark-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:ffd0251cba5c68aa023e9529a8ac97b04907564844c27a0e61d638d56ef3936d"}, + {file = "breez_sdk_spark-0.15.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:40e2b289009dfb18edf38c990299c37feff6c71f5a5beee57bde9e18a9d4fcd1"}, + {file = "breez_sdk_spark-0.15.0-cp312-cp312-manylinux_2_31_aarch64.whl", hash = "sha256:5eb6f39c9b673a0cf2b3edb35d648cf3e33758eca44e3d26c84a51c26e3890f9"}, + {file = "breez_sdk_spark-0.15.0-cp312-cp312-manylinux_2_31_x86_64.whl", hash = "sha256:1e3aa503ac85d4463e6fccd077492373662497e40035444498514cb60b18a0e0"}, + {file = "breez_sdk_spark-0.15.0-cp312-cp312-win32.whl", hash = "sha256:933e45597e889aa546508e823914050784fcdae05fde5c2185d5754163281611"}, + {file = "breez_sdk_spark-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:3967a729410bde2ca7f5b2a395bc1eb4dd63f1bb4ec86f6358373747532d58d0"}, + {file = "breez_sdk_spark-0.15.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c4a8fa8f52167a0aa1b2977844567421c9f4c87d957591187fce83dad054e296"}, + {file = "breez_sdk_spark-0.15.0-cp313-cp313-manylinux_2_31_aarch64.whl", hash = "sha256:c74c6005d226c129c7f1f2726c20eccd4755b85c7c776bc583b66a696c59dd57"}, + {file = "breez_sdk_spark-0.15.0-cp313-cp313-manylinux_2_31_x86_64.whl", hash = "sha256:50a05396da436c0881373ac918eac187d1ba13afebcf66d685bc56e7492c301a"}, + {file = "breez_sdk_spark-0.15.0-cp313-cp313-win32.whl", hash = "sha256:efb2a820c002bb7130061eade8bf3818fa79514b69af05cd21009deb20ee033c"}, + {file = "breez_sdk_spark-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:371b992f33d14d1147241b042d0614731634dceb5e688dafe0fb4cfa31d821c3"}, + {file = "breez_sdk_spark-0.15.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:dea96e42631b261aeccf7f61baed760f7c366968d33187c2adcd79e6e79531ac"}, + {file = "breez_sdk_spark-0.15.0-cp314-cp314-manylinux_2_31_aarch64.whl", hash = "sha256:3e008ff640967aef1891ba7a43828c3ab295297f1825080f8e087851d3bbdcc4"}, + {file = "breez_sdk_spark-0.15.0-cp314-cp314-manylinux_2_31_x86_64.whl", hash = "sha256:3f341ebe4de2e025fbca25b22183fa90a8af19029f7ddb84b53c4122fc51d2ac"}, + {file = "breez_sdk_spark-0.15.0-cp314-cp314-win32.whl", hash = "sha256:db410e8651fa5666c22d0c6039b4f872f5dec32cc66c7140d95af870c3533302"}, + {file = "breez_sdk_spark-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:5211a7e38df5d3bb327cb74b8b79bd9d735268ae8549489df09311b64d863b2c"}, + {file = "breez_sdk_spark-0.15.0-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:70a9b33c74856d9785ce2e63927b41f36980ff7b43321be3bd4db0ddeed05921"}, + {file = "breez_sdk_spark-0.15.0-cp38-cp38-manylinux_2_31_aarch64.whl", hash = "sha256:870a3dcde8549d34cf405ae85c11543165eedc9066a0ab3b5892fb80682d5dad"}, + {file = "breez_sdk_spark-0.15.0-cp38-cp38-manylinux_2_31_x86_64.whl", hash = "sha256:9e96f066e989a953d780ed041c4030fd53a9542bf703b49021f1aa205999544b"}, + {file = "breez_sdk_spark-0.15.0-cp38-cp38-win32.whl", hash = "sha256:076fac96444b2bd753a33d8e2c5e31f9525090bbc92f3e8a88f7ad1804bacc31"}, + {file = "breez_sdk_spark-0.15.0-cp38-cp38-win_amd64.whl", hash = "sha256:125c2ac701cc3843fbe287799bc287bcbd9f935dc8776eca24b1036bd365c0e4"}, + {file = "breez_sdk_spark-0.15.0-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:584bb29dd2ac9b353277eed852d161e2695a5024299eeaa40a16c47cf5e29d14"}, + {file = "breez_sdk_spark-0.15.0-cp39-cp39-manylinux_2_31_aarch64.whl", hash = "sha256:b1ada181964be0e63c2c9723161317de50d49c580b6e54b3725f852266f99e84"}, + {file = "breez_sdk_spark-0.15.0-cp39-cp39-manylinux_2_31_x86_64.whl", hash = "sha256:e16e401b6426b6cf7372cf1fc47afbb7788d050ac046841198b06c1f900c014f"}, + {file = "breez_sdk_spark-0.15.0-cp39-cp39-win32.whl", hash = "sha256:adec0ddc19d1861725055279fd5bb78fd90a4fbfb582ead2e56d723a8840fc44"}, + {file = "breez_sdk_spark-0.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:85ae9bc6f523ac6e132563225e408788c1b3c576828eec2834a88b24804bcbd9"}, +] + [[package]] name = "brotli" version = "1.1.0" @@ -2883,4 +2928,4 @@ cffi = ["cffi (>=1.11)"] [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "a1fda6a32a65cf0cb29a81558cc3b573f538a24cfb5a17d1a6f02f1f1f1c2110" +content-hash = "f2e8415f53275aef0423d15c6474037100af3cf216fc46979b3af4b8ab7f1889" diff --git a/pyproject.toml b/pyproject.toml index 615abade9..bd5d3c28a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ redis = "^5.1.1" brotli = "^1.1.0" zstandard = "^0.23.0" jinja2 = "^3.1.5" +breez-sdk-spark = "^0.15.0" [tool.poetry.group.dev.dependencies] pytest-asyncio = "^0.24.0" diff --git a/tests/lightning/test_lightning_backends_mocked.py b/tests/lightning/test_lightning_backends_mocked.py index 69e0685e2..3b53dca72 100644 --- a/tests/lightning/test_lightning_backends_mocked.py +++ b/tests/lightning/test_lightning_backends_mocked.py @@ -529,3 +529,59 @@ async def post(self, *args, **kwargs): 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 + wallet = object.__new__(SparkL2Wallet) + wallet.unit = Unit.sat + + async def mock_ensure_sdk(): + pass + wallet._ensure_sdk = mock_ensure_sdk + + class MockMethod: + def is_bolt11_invoice(self): + return False + + class MockPrepareResponse: + payment_method = MockMethod() + + class MockSDK: + async def prepare_send_payment(self, req): + return MockPrepareResponse() + + wallet.sdk = MockSDK() + + res = await wallet.pay_invoice(_quote("non-bolt11"), 1000) + assert res.result == PaymentResult.FAILED + assert "Only BOLT11 payments are supported" in res.error_message + + +@pytest.mark.asyncio +async def test_spark_get_payment_quote_rejects_non_bolt11(): + from cashu.lightning.sparkl2 import SparkL2Wallet + wallet = object.__new__(SparkL2Wallet) + wallet.unit = Unit.sat + + async def mock_ensure_sdk(): + pass + wallet._ensure_sdk = mock_ensure_sdk + + class MockMethod: + def is_bolt11_invoice(self): + return False + + class MockPrepareResponse: + payment_method = MockMethod() + + class MockSDK: + async def prepare_send_payment(self, req): + return MockPrepareResponse() + + wallet.sdk = MockSDK() + + melt_quote = PostMeltQuoteRequest(unit="sat", request="non-bolt11") + with pytest.raises(Exception, match="Only BOLT11 payments are supported"): + await wallet.get_payment_quote(melt_quote)