Skip to content

feat(mint): Add Spark L2 wallet backend#1019

Open
a1denvalu3 wants to merge 24 commits into
mainfrom
feat/spark-l2-backend
Open

feat(mint): Add Spark L2 wallet backend#1019
a1denvalu3 wants to merge 24 commits into
mainfrom
feat/spark-l2-backend

Conversation

@a1denvalu3

@a1denvalu3 a1denvalu3 commented May 26, 2026

Copy link
Copy Markdown
Collaborator

A

@codecov

codecov Bot commented May 26, 2026

Copy link
Copy Markdown

❌ 13 Tests Failed:

Tests completed Failed Passed Skipped
709 13 696 77
View the top 3 failed test(s) by shortest run time
tests.mint.test_mint_regtest::test_lightning_pay_invoice
Stack Traces | 0.613s run time
ledger = <cashu.mint.ledger.Ledger object at 0x7ff64275a1d0>

    @pytest.mark.asyncio
    @pytest.mark.skipif(is_fake, reason="only regtest")
    async def test_lightning_pay_invoice(ledger: Ledger):
        invoice_dict = get_real_invoice(64)
        request = invoice_dict["payment_request"]
        quote = MeltQuote(
            quote="test",
            method=Method.bolt11.name,
            unit=Unit.sat.name,
            state=MeltQuoteState.unpaid,
            request=request,
            checking_id="test",
            amount=64,
            fee_reserve=0,
        )
        payment = await ledger.backends[Method.bolt11][Unit.sat].pay_invoice(quote, 1000)
>       assert payment.settled
E       assert False
E        +  where False = PaymentResponse(result=<PaymentResult.FAILED: 2>, checking_id=None, fee=None, preimage=None, error_message='SendPaymen...2:43.075338203+00:00", grpc_status:12, grpc_message:"unknown method SendPaymentSync for service lnrpc.Lightning"}"\n>').settled

tests/mint/test_mint_regtest.py:128: AssertionError
tests.mint.test_mint_init::test_regtest_check_nonexisting_melt_quote
Stack Traces | 1.05s run time
self = <sqlalchemy.dialects.postgresql.asyncpg.AsyncAdapt_asyncpg_cursor object at 0x7fa371fdfa60>
operation = 'DROP SCHEMA public CASCADE;', parameters = ()

    async def _prepare_and_execute(self, operation, parameters):
        adapt_connection = self._adapt_connection
    
        async with adapt_connection._execute_mutex:
            if not adapt_connection._started:
                await adapt_connection._start_transaction()
    
            if parameters is None:
                parameters = ()
    
            try:
                prepared_stmt, attributes = await adapt_connection._prepare(
                    operation, self._invalidate_schema_cache_asof
                )
    
                if attributes:
                    self.description = [
                        (
                            attr.name,
                            attr.type.oid,
                            None,
                            None,
                            None,
                            None,
                            None,
                        )
                        for attr in attributes
                    ]
                else:
                    self.description = None
    
                if self.server_side:
                    self._cursor = await prepared_stmt.cursor(*parameters)
                    self.rowcount = -1
                else:
>                   self._rows = deque(await prepared_stmt.fetch(*parameters))

../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10.../dialects/postgresql/asyncpg.py:545: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10........./site-packages/asyncpg/prepared_stmt.py:176: in fetch
    data = await self.__bind_execute(args, 0, timeout)
../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10........./site-packages/asyncpg/prepared_stmt.py:267: in __bind_execute
    data, status, _ = await self.__do_execute(
../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10........./site-packages/asyncpg/prepared_stmt.py:256: in __do_execute
    return await executor(protocol)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

>   ???
E   asyncpg.exceptions.DeadlockDetectedError: deadlock detected
E   DETAIL:  Process 335 waits for AccessExclusiveLock on relation 45634 of database 16384; blocked by process 95.
E   Process 95 waits for AccessShareLock on relation 45601 of database 16384; blocked by process 335.
E   HINT:  See server log for query details.

asyncpg/protocol/protocol.pyx:206: DeadlockDetectedError

The above exception was the direct cause of the following exception:

self = <sqlalchemy.engine.base.Connection object at 0x7fa37299c610>
dialect = <sqlalchemy.dialects.postgresql.asyncpg.PGDialect_asyncpg object at 0x7fa358d201f0>
context = <sqlalchemy.dialects.postgresql.asyncpg.PGExecutionContext_asyncpg object at 0x7fa3728a7910>
statement = <sqlalchemy.dialects.postgresql.asyncpg.PGCompiler_asyncpg object at 0x7fa358d20af0>
parameters = [()]

    def _exec_single_context(
        self,
        dialect: Dialect,
        context: ExecutionContext,
        statement: Union[str, Compiled],
        parameters: Optional[_AnyMultiExecuteParams],
    ) -> CursorResult[Any]:
        """continue the _execute_context() method for a single DBAPI
        cursor.execute() or cursor.executemany() call.
    
        """
        if dialect.bind_typing is BindTyping.SETINPUTSIZES:
            generic_setinputsizes = context._prepare_set_input_sizes()
    
            if generic_setinputsizes:
                try:
                    dialect.do_set_input_sizes(
                        context.cursor, generic_setinputsizes, context
                    )
                except BaseException as e:
                    self._handle_dbapi_exception(
                        e, str(statement), parameters, None, context
                    )
    
        cursor, str_statement, parameters = (
            context.cursor,
            context.statement,
            context.parameters,
        )
    
        effective_parameters: Optional[_AnyExecuteParams]
    
        if not context.executemany:
            effective_parameters = parameters[0]
        else:
            effective_parameters = parameters
    
        if self._has_events or self.engine._has_events:
            for fn in self.dispatch.before_cursor_execute:
                str_statement, effective_parameters = fn(
                    self,
                    cursor,
                    str_statement,
                    effective_parameters,
                    context,
                    context.executemany,
                )
    
        if self._echo:
            self._log_info(str_statement)
    
            stats = context._get_cache_stats()
    
            if not self.engine.hide_parameters:
                self._log_info(
                    "[%s] %r",
                    stats,
                    sql_util._repr_params(
                        effective_parameters,
                        batches=10,
                        ismulti=context.executemany,
                    ),
                )
            else:
                self._log_info(
                    "[%s] [SQL parameters hidden due to hide_parameters=True]",
                    stats,
                )
    
        evt_handled: bool = False
        try:
            if context.execute_style is ExecuteStyle.EXECUTEMANY:
                effective_parameters = cast(
                    "_CoreMultiExecuteParams", effective_parameters
                )
                if self.dialect._has_events:
                    for fn in self.dialect.dispatch.do_executemany:
                        if fn(
                            cursor,
                            str_statement,
                            effective_parameters,
                            context,
                        ):
                            evt_handled = True
                            break
                if not evt_handled:
                    self.dialect.do_executemany(
                        cursor,
                        str_statement,
                        effective_parameters,
                        context,
                    )
            elif not effective_parameters and context.no_parameters:
                if self.dialect._has_events:
                    for fn in self.dialect.dispatch.do_execute_no_params:
                        if fn(cursor, str_statement, context):
                            evt_handled = True
                            break
                if not evt_handled:
                    self.dialect.do_execute_no_params(
                        cursor, str_statement, context
                    )
            else:
                effective_parameters = cast(
                    "_CoreSingleExecuteParams", effective_parameters
                )
                if self.dialect._has_events:
                    for fn in self.dialect.dispatch.do_execute:
                        if fn(
                            cursor,
                            str_statement,
                            effective_parameters,
                            context,
                        ):
                            evt_handled = True
                            break
                if not evt_handled:
>                   self.dialect.do_execute(
                        cursor, str_statement, effective_parameters, context
                    )

../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10.../sqlalchemy/engine/base.py:1964: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10.../sqlalchemy/engine/default.py:942: in do_execute
    cursor.execute(statement, parameters)
../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10.../dialects/postgresql/asyncpg.py:580: in execute
    self._adapt_connection.await_(
../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10.../sqlalchemy/util/_concurrency_py3k.py:132: in await_only
    return current.parent.switch(awaitable)  # type: ignore[no-any-return,attr-defined] # noqa: E501
../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10.../sqlalchemy/util/_concurrency_py3k.py:196: in greenlet_spawn
    value = await result
../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10.../dialects/postgresql/asyncpg.py:558: in _prepare_and_execute
    self._handle_exception(error)
../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10.../dialects/postgresql/asyncpg.py:508: in _handle_exception
    self._adapt_connection._handle_exception(error)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <AdaptedConnection <asyncpg.connection.Connection object at 0x7fa360407010>>
error = DeadlockDetectedError('deadlock detected')

    def _handle_exception(self, error):
        if self._connection.is_closed():
            self._transaction = None
            self._started = False
    
        if not isinstance(error, AsyncAdapt_asyncpg_dbapi.Error):
            exception_mapping = self.dbapi._asyncpg_error_translate
    
            for super_ in type(error).__mro__:
                if super_ in exception_mapping:
                    translated_error = exception_mapping[super_](
                        "%s: %s" % (type(error), error)
                    )
                    translated_error.pgcode = translated_error.sqlstate = (
                        getattr(error, "sqlstate", None)
                    )
>                   raise translated_error from error
E                   sqlalchemy.dialects.postgresql.asyncpg.AsyncAdapt_asyncpg_dbapi.Error: <class 'asyncpg.exceptions.DeadlockDetectedError'>: deadlock detected
E                   DETAIL:  Process 335 waits for AccessExclusiveLock on relation 45634 of database 16384; blocked by process 95.
E                   Process 95 waits for AccessShareLock on relation 45601 of database 16384; blocked by process 335.
E                   HINT:  See server log for query details.

../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10.../dialects/postgresql/asyncpg.py:792: Error

The above exception was the direct cause of the following exception:

request = <SubRequest 'ledger' for <Coroutine test_regtest_check_nonexisting_melt_quote>>
kwargs = {}, func = <function ledger at 0x7fa374bafac0>
event_loop_fixture_id = 'event_loop'
setup = <function _wrap_asyncgen_fixture.<locals>._asyncgen_fixture_wrapper.<locals>.setup at 0x7fa358d528c0>
finalizer = <function _wrap_asyncgen_fixture.<locals>._asyncgen_fixture_wrapper.<locals>.finalizer at 0x7fa358d523b0>

    @functools.wraps(fixture)
    def _asyncgen_fixture_wrapper(request: FixtureRequest, **kwargs: Any):
        func = _perhaps_rebind_fixture_func(fixture, request.instance)
        event_loop_fixture_id = _get_event_loop_fixture_id_for_async_fixture(
            request, func
        )
        event_loop = request.getfixturevalue(event_loop_fixture_id)
        kwargs.pop(event_loop_fixture_id, None)
        gen_obj = func(**_add_kwargs(func, kwargs, event_loop, request))
    
        async def setup():
            res = await gen_obj.__anext__()  # type: ignore[union-attr]
            return res
    
        def finalizer() -> None:
            """Yield again, to finalize."""
    
            async def async_finalizer() -> None:
                try:
                    await gen_obj.__anext__()  # type: ignore[union-attr]
                except StopAsyncIteration:
                    pass
                else:
                    msg = "Async generator fixture didn't stop."
                    msg += "Yield only once."
                    raise ValueError(msg)
    
            event_loop.run_until_complete(async_finalizer())
    
>       result = event_loop.run_until_complete(setup())

../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10....../site-packages/pytest_asyncio/plugin.py:343: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
.../hostedtoolcache/Python/3.10.20.../x64/lib/python3.10/asyncio/base_events.py:649: in run_until_complete
    return future.result()
../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10....../site-packages/pytest_asyncio/plugin.py:325: in setup
    res = await gen_obj.__anext__()  # type: ignore[union-attr]
tests/conftest.py:100: in ledger
    await conn.execute("DROP SCHEMA public CASCADE;")
cashu/core/db.py:95: in execute
    return await self.conn.execute(self.rewrite_query(query), values)
../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10.../ext/asyncio/session.py:463: in execute
    result = await greenlet_spawn(
../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10.../sqlalchemy/util/_concurrency_py3k.py:201: in greenlet_spawn
    result = context.throw(*sys.exc_info())
../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10.../sqlalchemy/orm/session.py:2365: in execute
    return self._execute_internal(
../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10.../sqlalchemy/orm/session.py:2260: in _execute_internal
    result = conn.execute(
../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10.../sqlalchemy/engine/base.py:1416: in execute
    return meth(
../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10.../sqlalchemy/sql/elements.py:515: in _execute_on_connection
    return connection._execute_clauseelement(
../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10.../sqlalchemy/engine/base.py:1638: in _execute_clauseelement
    ret = self._execute_context(
../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10.../sqlalchemy/engine/base.py:1843: in _execute_context
    return self._exec_single_context(
../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10.../sqlalchemy/engine/base.py:1983: in _exec_single_context
    self._handle_dbapi_exception(
../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10.../sqlalchemy/engine/base.py:2352: in _handle_dbapi_exception
    raise sqlalchemy_exception.with_traceback(exc_info[2]) from e
../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10.../sqlalchemy/engine/base.py:1964: in _exec_single_context
    self.dialect.do_execute(
../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10.../sqlalchemy/engine/default.py:942: in do_execute
    cursor.execute(statement, parameters)
../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10.../dialects/postgresql/asyncpg.py:580: in execute
    self._adapt_connection.await_(
../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10.../sqlalchemy/util/_concurrency_py3k.py:132: in await_only
    return current.parent.switch(awaitable)  # type: ignore[no-any-return,attr-defined] # noqa: E501
../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10.../sqlalchemy/util/_concurrency_py3k.py:196: in greenlet_spawn
    value = await result
../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10.../dialects/postgresql/asyncpg.py:558: in _prepare_and_execute
    self._handle_exception(error)
../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10.../dialects/postgresql/asyncpg.py:508: in _handle_exception
    self._adapt_connection._handle_exception(error)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <AdaptedConnection <asyncpg.connection.Connection object at 0x7fa360407010>>
error = DeadlockDetectedError('deadlock detected')

    def _handle_exception(self, error):
        if self._connection.is_closed():
            self._transaction = None
            self._started = False
    
        if not isinstance(error, AsyncAdapt_asyncpg_dbapi.Error):
            exception_mapping = self.dbapi._asyncpg_error_translate
    
            for super_ in type(error).__mro__:
                if super_ in exception_mapping:
                    translated_error = exception_mapping[super_](
                        "%s: %s" % (type(error), error)
                    )
                    translated_error.pgcode = translated_error.sqlstate = (
                        getattr(error, "sqlstate", None)
                    )
>                   raise translated_error from error
E                   sqlalchemy.exc.DBAPIError: (sqlalchemy.dialects.postgresql.asyncpg.Error) <class 'asyncpg.exceptions.DeadlockDetectedError'>: deadlock detected
E                   DETAIL:  Process 335 waits for AccessExclusiveLock on relation 45634 of database 16384; blocked by process 95.
E                   Process 95 waits for AccessShareLock on relation 45601 of database 16384; blocked by process 335.
E                   HINT:  See server log for query details.
E                   [SQL: DROP SCHEMA public CASCADE;]
E                   (Background on this error at: https://sqlalche..../e/20/dbapi)

../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10.../dialects/postgresql/asyncpg.py:792: DBAPIError
tests.mint.test_mint_fees::test_melt_external_with_fees
Stack Traces | 1.84s run time
wallet1 = <cashu.wallet.wallet.Wallet object at 0x7ff654edd2d0>
ledger = <cashu.mint.ledger.Ledger object at 0x7ff654edfe50>

    @pytest.mark.asyncio
    @pytest.mark.skipif(is_fake, reason="only works with Regtest")
    async def test_melt_external_with_fees(wallet1: Wallet, ledger: Ledger):
        # set fees to 100 ppk
        set_ledger_keyset_fees(100, ledger, wallet1)
    
        # mint twice so we have enough to pay the second invoice back
        mint_quote = await wallet1.request_mint(128)
        await pay_if_regtest(mint_quote.request)
        await wallet1.mint(128, quote_id=mint_quote.quote)
        assert wallet1.balance == 128
    
        invoice_dict = get_real_invoice(64)
        invoice_payment_request = invoice_dict["payment_request"]
    
        mint_quote = await wallet1.melt_quote(invoice_payment_request)
        total_amount = mint_quote.amount + mint_quote.fee_reserve
        send_proofs, fee = await wallet1.select_to_send(
            wallet1.proofs, total_amount, include_fees=True
        )
        melt_quote = await ledger.melt_quote(
            PostMeltQuoteRequest(request=invoice_payment_request, unit="sat")
        )
    
        melt_quote_pre_payment = await ledger.get_melt_quote(melt_quote.quote)
        assert melt_quote_pre_payment.state != MeltQuoteState.paid, "melt quote should not be paid"
    
        assert melt_quote.state != MeltQuoteState.paid, "melt quote should not be paid"
>       await ledger.melt(proofs=send_proofs, quote=melt_quote.quote)

tests/mint/test_mint_fees.py:274: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <cashu.mint.ledger.Ledger object at 0x7ff654edfe50>

    async def melt(
        self,
        *,
        proofs: List[Proof],
        quote: str,
        outputs: Optional[List[BlindedMessage]] = None,
    ) -> PostMeltQuoteResponse:
        """Invalidates proofs and pays a Lightning invoice.
    
        Args:
            proofs (List[Proof]): Proofs provided for paying the Lightning invoice
            quote (str): ID of the melt quote.
            outputs (Optional[List[BlindedMessage]]): Blank outputs for returning overpaid fees to the wallet.
    
        Raises:
            e: Lightning payment unsuccessful
    
        Returns:
            PostMeltQuoteResponse: Melt quote response.
        """
        # make sure we're allowed to melt
        if self.disable_melt and settings.mint_disable_melt_on_error:
            raise NotAllowedError("Melt is disabled. Please contact the operator.")
    
        # get melt quote and check if it was already paid
        melt_quote = await self.get_melt_quote(quote_id=quote)
        if not melt_quote.unpaid:
            raise TransactionError(f"melt quote is not unpaid: {melt_quote.state}")
    
        unit, method = self._verify_and_get_unit_method(
            melt_quote.unit, melt_quote.method
        )
    
        # make sure that the proofs are in the same unit as the quote
        self._verify_proofs_unit(proofs, expected_unit=unit)
    
        # make sure that the outputs (for fee return) are in the same unit as the quote
        if outputs:
            # _verify_outputs checks if all outputs have the same unit
            await self._verify_outputs(
                outputs, skip_amount_check=True, expected_unit=unit
            )
    
        # verify SIG_ALL signatures
        message_to_sign = (
            "".join([p.secret for p in proofs] + [o.B_ for o in outputs or []]) + quote
        )
        self._verify_sigall_spending_conditions(proofs, outputs or [], message_to_sign)
    
        # verify that the amount of the input proofs is equal to the amount of the quote
        total_provided = sum_proofs(proofs)
        input_fees = self.get_fees_for_proofs(proofs)
        total_needed = melt_quote.amount + melt_quote.fee_reserve + input_fees
        # we need the fees specifically for lightning to return the overpaid fees
        fee_reserve_provided = total_provided - melt_quote.amount - input_fees
        if total_provided < total_needed:
            raise TransactionError(
                f"not enough inputs provided for melt. Provided: {total_provided}, needed: {total_needed}"
            )
        if fee_reserve_provided < melt_quote.fee_reserve:
            raise TransactionError(
                f"not enough fee reserve provided for melt. Provided fee reserve: {fee_reserve_provided}, needed: {melt_quote.fee_reserve}"
            )
    
        # verify inputs and their spending conditions
        # note, we do not verify outputs here, as they are only used for returning overpaid fees
        # We must have called _verify_outputs here already! (see above)
        await self.verify_inputs_and_outputs(proofs=proofs)
    
        # set quote and proofs to pending to avoid race conditions
        melt_quote = await self.db_write.verify_and_set_melt_quote_pending(
            quote=melt_quote, proofs=proofs, keysets=self.keysets
        )
    
        try:
            # store the change outputs
            if outputs:
                await self._store_blinded_messages(outputs, melt_id=melt_quote.quote)
    
            # if the melt corresponds to an internal mint, mark both as paid
            melt_quote = await self.melt_mint_settle_internally(melt_quote, proofs)
        except Exception as e:
            logger.debug(f"Melt failed before backend payment: {e}")
            await self.db_write.unset_melt_quote_pending_and_proofs(
                quote=melt_quote,
                proofs=proofs,
                keysets=self.keysets,
                state=MeltQuoteState.unpaid,
            )
            raise e
    
        # quote not paid yet (not internal), pay it with the backend
        if not melt_quote.paid:
            logger.debug(f"Lightning: pay invoice {melt_quote.request}")
            try:
                fee_limit_msat = (
                    Amount(Unit[melt_quote.unit], melt_quote.fee_reserve)
                    .to(Unit.msat)
                    .amount
                )
                payment = await self.backends[method][unit].pay_invoice(
                    melt_quote, fee_limit_msat
                )
                logger.debug(
                    f"Melt – Result: {payment.result.name}: preimage: {payment.preimage},"
                    f" fee: {payment.fee.str() if payment.fee is not None else 'None'}"
                )
                if (
                    payment.checking_id
                    and payment.checking_id != melt_quote.checking_id
                ):
                    logger.warning(
                        f"pay_invoice returned different checking_id: {payment.checking_id} than melt quote: {melt_quote.checking_id}. Will use it for potentially checking payment status later."
                    )
                    melt_quote.checking_id = payment.checking_id
                    await self.crud.update_melt_quote(quote=melt_quote, db=self.db)
            except Exception as e:
                logger.error(f"Exception during pay_invoice: {e}")
                payment = PaymentResponse(
                    result=PaymentResult.UNKNOWN,
                    error_message=str(e),
                )
    
            match payment.result:
                case PaymentResult.FAILED | PaymentResult.UNKNOWN:
                    # explicitly check payment status for failed or unknown payment states
                    checking_id = payment.checking_id or melt_quote.checking_id
                    logger.debug(
                        f"Payment state is {payment.result.name}.{' Error: ' + payment.error_message + '.' if payment.error_message else ''} Checking status for {checking_id}."
                    )
                    try:
                        status = await self.backends[method][unit].get_payment_status(
                            checking_id
                        )
                    except Exception as e:
                        # Something went wrong. We might have lost connection to the backend. Keep transaction pending and return.
                        logger.error(
                            f"Lightning backend error: could not check payment status. Proofs for melt quote {melt_quote.quote} are stuck as PENDING.\nError: {e}"
                        )
                        self.disable_melt = True
                        return PostMeltQuoteResponse.from_melt_quote(melt_quote)
    
                    match status.result:
                        case PaymentResult.FAILED | PaymentResult.UNKNOWN:
                            # Everything as expected. Payment AND a status check both agree on a failure. We roll back the transaction.
                            await self.db_write.unset_melt_quote_pending_and_proofs(
                                quote=melt_quote,
                                proofs=proofs,
                                keysets=self.keysets,
                                state=MeltQuoteState.unpaid,
                            )
                            if status.error_message:
                                logger.error(
                                    f"Status check error: {status.error_message}"
                                )
>                           raise LightningPaymentFailedError(
                                f"Lightning payment failed{': ' + payment.error_message if payment.error_message else ''}."
                            )
E                           cashu.core.errors.LightningPaymentFailedError: Lightning payment failed: SendPaymentSync failed: <AioRpcError of RPC that terminated with:
E                           	status = StatusCode.UNIMPLEMENTED
E                           	details = "unknown method SendPaymentSync for service lnrpc.Lightning"
E                           	debug_error_string = "UNKNOWN:Error received from peer  {created_time:"2026-06-26T13:20:54.0728472+00:00", grpc_status:12, grpc_message:"unknown method SendPaymentSync for service lnrpc.Lightning"}"
E                           >.

cashu/mint/ledger.py:1181: LightningPaymentFailedError
tests.mint.test_mint_operations::test_melt_external
Stack Traces | 1.99s run time
wallet1 = <cashu.wallet.wallet.Wallet object at 0x7ff643d677c0>
ledger = <cashu.mint.ledger.Ledger object at 0x7ff6543c8040>

    @pytest.mark.asyncio
    @pytest.mark.skipif(is_fake, reason="only works with Regtest")
    async def test_melt_external(wallet1: Wallet, ledger: Ledger):
        # mint twice so we have enough to pay the second invoice back
        mint_quote = await wallet1.request_mint(128)
        await pay_if_regtest(mint_quote.request)
        await wallet1.mint(128, quote_id=mint_quote.quote)
        assert wallet1.balance == 128
    
        invoice_dict = get_real_invoice(64)
        invoice_payment_request = invoice_dict["payment_request"]
    
        melt_quote = await wallet1.melt_quote(invoice_payment_request)
        assert melt_quote.state != MeltQuoteState.paid, "mint quote should not be paid"
        assert melt_quote.state == MeltQuoteState.unpaid
    
        total_amount = melt_quote.amount + melt_quote.fee_reserve
        _, send_proofs = await wallet1.swap_to_send(wallet1.proofs, total_amount)
        melt_quote = await ledger.melt_quote(
            PostMeltQuoteRequest(request=invoice_payment_request, unit="sat")
        )
    
        if not settings.debug_mint_only_deprecated:
            melt_quote_response_pre_payment = await wallet1.get_melt_quote(melt_quote.quote)
            assert melt_quote_response_pre_payment
            assert melt_quote_response_pre_payment.state == MeltQuoteState.unpaid, (
                "melt quote should not be paid"
            )
            assert melt_quote_response_pre_payment.amount == melt_quote.amount
    
        melt_quote_pre_payment = await ledger.get_melt_quote(melt_quote.quote)
        assert melt_quote_pre_payment.state != MeltQuoteState.paid, (
            "melt quote should not be paid"
        )
        assert melt_quote_pre_payment.state == MeltQuoteState.unpaid
    
        assert melt_quote.state != MeltQuoteState.paid, "melt quote should not be paid"
>       await ledger.melt(proofs=send_proofs, quote=melt_quote.quote)

tests/mint/test_mint_operations.py:122: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <cashu.mint.ledger.Ledger object at 0x7ff6543c8040>

    async def melt(
        self,
        *,
        proofs: List[Proof],
        quote: str,
        outputs: Optional[List[BlindedMessage]] = None,
    ) -> PostMeltQuoteResponse:
        """Invalidates proofs and pays a Lightning invoice.
    
        Args:
            proofs (List[Proof]): Proofs provided for paying the Lightning invoice
            quote (str): ID of the melt quote.
            outputs (Optional[List[BlindedMessage]]): Blank outputs for returning overpaid fees to the wallet.
    
        Raises:
            e: Lightning payment unsuccessful
    
        Returns:
            PostMeltQuoteResponse: Melt quote response.
        """
        # make sure we're allowed to melt
        if self.disable_melt and settings.mint_disable_melt_on_error:
            raise NotAllowedError("Melt is disabled. Please contact the operator.")
    
        # get melt quote and check if it was already paid
        melt_quote = await self.get_melt_quote(quote_id=quote)
        if not melt_quote.unpaid:
            raise TransactionError(f"melt quote is not unpaid: {melt_quote.state}")
    
        unit, method = self._verify_and_get_unit_method(
            melt_quote.unit, melt_quote.method
        )
    
        # make sure that the proofs are in the same unit as the quote
        self._verify_proofs_unit(proofs, expected_unit=unit)
    
        # make sure that the outputs (for fee return) are in the same unit as the quote
        if outputs:
            # _verify_outputs checks if all outputs have the same unit
            await self._verify_outputs(
                outputs, skip_amount_check=True, expected_unit=unit
            )
    
        # verify SIG_ALL signatures
        message_to_sign = (
            "".join([p.secret for p in proofs] + [o.B_ for o in outputs or []]) + quote
        )
        self._verify_sigall_spending_conditions(proofs, outputs or [], message_to_sign)
    
        # verify that the amount of the input proofs is equal to the amount of the quote
        total_provided = sum_proofs(proofs)
        input_fees = self.get_fees_for_proofs(proofs)
        total_needed = melt_quote.amount + melt_quote.fee_reserve + input_fees
        # we need the fees specifically for lightning to return the overpaid fees
        fee_reserve_provided = total_provided - melt_quote.amount - input_fees
        if total_provided < total_needed:
            raise TransactionError(
                f"not enough inputs provided for melt. Provided: {total_provided}, needed: {total_needed}"
            )
        if fee_reserve_provided < melt_quote.fee_reserve:
            raise TransactionError(
                f"not enough fee reserve provided for melt. Provided fee reserve: {fee_reserve_provided}, needed: {melt_quote.fee_reserve}"
            )
    
        # verify inputs and their spending conditions
        # note, we do not verify outputs here, as they are only used for returning overpaid fees
        # We must have called _verify_outputs here already! (see above)
        await self.verify_inputs_and_outputs(proofs=proofs)
    
        # set quote and proofs to pending to avoid race conditions
        melt_quote = await self.db_write.verify_and_set_melt_quote_pending(
            quote=melt_quote, proofs=proofs, keysets=self.keysets
        )
    
        try:
            # store the change outputs
            if outputs:
                await self._store_blinded_messages(outputs, melt_id=melt_quote.quote)
    
            # if the melt corresponds to an internal mint, mark both as paid
            melt_quote = await self.melt_mint_settle_internally(melt_quote, proofs)
        except Exception as e:
            logger.debug(f"Melt failed before backend payment: {e}")
            await self.db_write.unset_melt_quote_pending_and_proofs(
                quote=melt_quote,
                proofs=proofs,
                keysets=self.keysets,
                state=MeltQuoteState.unpaid,
            )
            raise e
    
        # quote not paid yet (not internal), pay it with the backend
        if not melt_quote.paid:
            logger.debug(f"Lightning: pay invoice {melt_quote.request}")
            try:
                fee_limit_msat = (
                    Amount(Unit[melt_quote.unit], melt_quote.fee_reserve)
                    .to(Unit.msat)
                    .amount
                )
                payment = await self.backends[method][unit].pay_invoice(
                    melt_quote, fee_limit_msat
                )
                logger.debug(
                    f"Melt – Result: {payment.result.name}: preimage: {payment.preimage},"
                    f" fee: {payment.fee.str() if payment.fee is not None else 'None'}"
                )
                if (
                    payment.checking_id
                    and payment.checking_id != melt_quote.checking_id
                ):
                    logger.warning(
                        f"pay_invoice returned different checking_id: {payment.checking_id} than melt quote: {melt_quote.checking_id}. Will use it for potentially checking payment status later."
                    )
                    melt_quote.checking_id = payment.checking_id
                    await self.crud.update_melt_quote(quote=melt_quote, db=self.db)
            except Exception as e:
                logger.error(f"Exception during pay_invoice: {e}")
                payment = PaymentResponse(
                    result=PaymentResult.UNKNOWN,
                    error_message=str(e),
                )
    
            match payment.result:
                case PaymentResult.FAILED | PaymentResult.UNKNOWN:
                    # explicitly check payment status for failed or unknown payment states
                    checking_id = payment.checking_id or melt_quote.checking_id
                    logger.debug(
                        f"Payment state is {payment.result.name}.{' Error: ' + payment.error_message + '.' if payment.error_message else ''} Checking status for {checking_id}."
                    )
                    try:
                        status = await self.backends[method][unit].get_payment_status(
                            checking_id
                        )
                    except Exception as e:
                        # Something went wrong. We might have lost connection to the backend. Keep transaction pending and return.
                        logger.error(
                            f"Lightning backend error: could not check payment status. Proofs for melt quote {melt_quote.quote} are stuck as PENDING.\nError: {e}"
                        )
                        self.disable_melt = True
                        return PostMeltQuoteResponse.from_melt_quote(melt_quote)
    
                    match status.result:
                        case PaymentResult.FAILED | PaymentResult.UNKNOWN:
                            # Everything as expected. Payment AND a status check both agree on a failure. We roll back the transaction.
                            await self.db_write.unset_melt_quote_pending_and_proofs(
                                quote=melt_quote,
                                proofs=proofs,
                                keysets=self.keysets,
                                state=MeltQuoteState.unpaid,
                            )
                            if status.error_message:
                                logger.error(
                                    f"Status check error: {status.error_message}"
                                )
>                           raise LightningPaymentFailedError(
                                f"Lightning payment failed{': ' + payment.error_message if payment.error_message else ''}."
                            )
E                           cashu.core.errors.LightningPaymentFailedError: Lightning payment failed: SendPaymentSync failed: <AioRpcError of RPC that terminated with:
E                           	status = StatusCode.UNIMPLEMENTED
E                           	details = "unknown method SendPaymentSync for service lnrpc.Lightning"
E                           	debug_error_string = "UNKNOWN:Error received from peer  {created_time:"2026-06-26T13:21:35.151992677+00:00", grpc_status:12, grpc_message:"unknown method SendPaymentSync for service lnrpc.Lightning"}"
E                           >.

cashu/mint/ledger.py:1181: LightningPaymentFailedError
tests.mint.test_mint_api::test_melt_external
Stack Traces | 2.05s run time
ledger = <cashu.mint.ledger.Ledger object at 0x7ff66c2a5810>
wallet = <cashu.wallet.wallet.Wallet object at 0x7ff66c126230>

    @pytest.mark.asyncio
    @pytest.mark.skipif(
        settings.debug_mint_only_deprecated,
        reason="settings.debug_mint_only_deprecated is set",
    )
    @pytest.mark.skipif(
        is_fake,
        reason="only works on regtest",
    )
    async def test_melt_external(ledger: Ledger, wallet: Wallet):
        # internal invoice
        mint_quote = await wallet.request_mint(64)
        await pay_if_regtest(mint_quote.request)
        await wallet.mint(64, quote_id=mint_quote.quote)
        assert wallet.balance == 64
    
        invoice_dict = get_real_invoice(62)
        invoice_payment_request = invoice_dict["payment_request"]
    
        quote = await wallet.melt_quote(invoice_payment_request)
        assert quote.amount == 62
        assert quote.fee_reserve == 2
    
        keep, send = await wallet.swap_to_send(wallet.proofs, 64)
        inputs_payload = [p.to_dict() for p in send]
    
        # outputs for change
        secrets, rs, derivation_paths = await wallet.generate_n_secrets(1)
        outputs, rs = wallet._construct_outputs([2], secrets, rs)
        outputs_payload = [o.model_dump() for o in outputs]
    
        response = httpx.post(
            f"{BASE_URL}.../v1/melt/bolt11",
            json={
                "quote": quote.quote,
                "inputs": inputs_payload,
                "outputs": outputs_payload,
            },
            timeout=None,
        )
>       response.raise_for_status()

tests/mint/test_mint_api.py:503: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <Response [400 Bad Request]>

    def raise_for_status(self) -> "Response":
        """
        Raise the `HTTPStatusError` if one occurred.
        """
        request = self._request
        if request is None:
            raise RuntimeError(
                "Cannot call `raise_for_status` as the request "
                "instance has not been set on this response."
            )
    
        if self.is_success:
            return self
    
        if self.has_redirect_location:
            message = (
                "{error_type} '{0.status_code} {0.reason_phrase}' for url '{0.url}'\n"
                "Redirect location: '{0.headers[location]}'\n"
                "For more information check: https://developer.mozilla..../Web/HTTP/Status/{0.status_code}"
            )
        else:
            message = (
                "{error_type} '{0.status_code} {0.reason_phrase}' for url '{0.url}'\n"
                "For more information check: https://developer.mozilla..../Web/HTTP/Status/{0.status_code}"
            )
    
        status_class = self.status_code // 100
        error_types = {
            1: "Informational response",
            3: "Redirect response",
            4: "Client error",
            5: "Server error",
        }
        error_type = error_types.get(status_class, "Invalid status code")
        message = message.format(self, error_type=error_type)
>       raise HTTPStatusError(message, request=request, response=self)
E       httpx.HTTPStatusError: Client error '400 Bad Request' for url 'http://localhost:3337.../v1/melt/bolt11'
E       For more information check: https://developer.mozilla..../Web/HTTP/Status/400

../../../..../pypoetry/virtualenvs/cashu-7hbqvzQf-py3.10/lib/python3.10.../site-packages/httpx/_models.py:758: HTTPStatusError
tests.mint.test_mint_regtest::test_lightning_pay_invoice_pending_success
Stack Traces | 2.62s run time
ledger = <cashu.mint.ledger.Ledger object at 0x7ff64226d090>

    @pytest.mark.asyncio
    @pytest.mark.skipif(is_fake, reason="only regtest")
    async def test_lightning_pay_invoice_pending_success(ledger: Ledger):
        # create a hold invoice
        preimage, invoice_dict = get_hold_invoice(64)
        request = str(invoice_dict["payment_request"])
    
        # we call get_payment_quote to get a checking_id that we will use to check for the failed pending state later with get_payment_status
        payment_quote = await ledger.backends[Method.bolt11][Unit.sat].get_payment_quote(
            PostMeltQuoteRequest(request=request, unit=Unit.sat.name)
        )
        checking_id = payment_quote.checking_id
    
        # pay the invoice
        quote = MeltQuote(
            quote="test",
            method=Method.bolt11.name,
            unit=Unit.sat.name,
            state=MeltQuoteState.unpaid,
            request=request,
            checking_id=checking_id,
            amount=64,
            fee_reserve=0,
        )
    
        async def pay():
            payment = await ledger.backends[Method.bolt11][Unit.sat].pay_invoice(
                quote, 1000
            )
            return payment
    
        task = asyncio.create_task(pay())
        await asyncio.sleep(SLEEP_TIME)
    
        # check the payment status
        status = await ledger.backends[Method.bolt11][Unit.sat].get_payment_status(
            quote.checking_id
        )
>       assert status.pending
E       assert False
E        +  where False = PaymentStatus(result=<PaymentResult.UNKNOWN: 4>, fee=None, preimage=None, error_message=None).pending

tests/mint/test_mint_regtest.py:229: AssertionError
tests.mint.test_mint_regtest::test_lightning_pay_invoice_pending_failure
Stack Traces | 2.64s run time
ledger = <cashu.mint.ledger.Ledger object at 0x7ff64210ba60>

    @pytest.mark.asyncio
    @pytest.mark.skipif(is_fake, reason="only regtest")
    async def test_lightning_pay_invoice_pending_failure(ledger: Ledger):
        # create a hold invoice
        preimage, invoice_dict = get_hold_invoice(64)
        request = str(invoice_dict["payment_request"])
        payment_hash = bolt11.decode(request).payment_hash
    
        # we call get_payment_quote to get a checking_id that we will use to check for the failed pending state later with get_payment_status
        payment_quote = await ledger.backends[Method.bolt11][Unit.sat].get_payment_quote(
            PostMeltQuoteRequest(request=request, unit=Unit.sat.name)
        )
        checking_id = payment_quote.checking_id
    
        # pay the invoice
        quote = MeltQuote(
            quote="test",
            method=Method.bolt11.name,
            unit=Unit.sat.name,
            state=MeltQuoteState.unpaid,
            request=request,
            checking_id=checking_id,
            amount=64,
            fee_reserve=0,
        )
    
        async def pay():
            payment = await ledger.backends[Method.bolt11][Unit.sat].pay_invoice(
                quote, 1000
            )
            return payment
    
        task = asyncio.create_task(pay())
        await asyncio.sleep(SLEEP_TIME)
    
        # check the payment status
        status = await ledger.backends[Method.bolt11][Unit.sat].get_payment_status(
            quote.checking_id
        )
>       assert status.pending
E       assert False
E        +  where False = PaymentStatus(result=<PaymentResult.UNKNOWN: 4>, fee=None, preimage=None, error_message=None).pending

tests/mint/test_mint_regtest.py:292: AssertionError
tests.mint.test_mint_melt::test_mint_pay_with_duplicate_checking_id
Stack Traces | 2.91s run time
self = <cashu.wallet.wallet.Wallet object at 0x7ff643ba42b0>
proofs = [Proof(id='01d8a63077d0a51f9855f066409782ffcb322dc8a2265291865221ed06c039f6bc', amount=1, secret='5a5e70bbb18868e608c1...f066409782ffcb322dc8a2265291865221ed06c039f6bc:77', mint_id='019f0417-9a33-7ccd-b71a-3b04cfca90ca', melt_id=None), ...]
invoice = 'lnbcrt640n1p4rulx4pp592jxm78e0v2hkewv8d3tadjplzlf3yw4nf2z7hdeju5uux4wjkvqdqqcqzzsxqyz5vqsp5t7hf8j8y3299x0c58t3v5x929w...qxpqysgq3nyqvtxvnqpfhkph2jt2whyg4kfdasn4shv8yw53w2yz4h5duulrvnpq59j059t237x3nqdzlzcpef2vy9dk9933ycfvru6l3dkeu9gq4396en'
fee_reserve_sat = 2, quote_id = '019f0417-a1b9-70c3-a2b1-30f2c4a7c094'
prefer_async = None

    async def melt(
        self,
        proofs: List[Proof],
        invoice: str,
        fee_reserve_sat: int,
        quote_id: str,
        prefer_async: Optional[bool] = None,
    ) -> PostMeltQuoteResponse:
        """Pays a lightning invoice and returns the status of the payment.
    
        Args:
            proofs (List[Proof]): List of proofs to be spent.
            invoice (str): Lightning invoice to be paid.
            fee_reserve_sat (int): Amount of fees to be reserved for the payment.
            prefer_async (Optional[bool]): Whether to pay asynchronously.
        """
    
        # Make sure we're operating on an independent copy of proofs
        proofs = copy.copy(proofs)
    
        # Generate a number of blank outputs for any overpaid fees. As described in
        # NUT-08, the mint will imprint these outputs with a value depending on the
        # amount of fees we overpaid.
        n_change_outputs = calculate_number_of_blank_outputs(fee_reserve_sat)
        (
            change_secrets,
            change_rs,
            change_derivation_paths,
        ) = await self.generate_n_secrets(n_change_outputs)
        change_outputs, change_rs = self._construct_outputs(
            n_change_outputs * [1], change_secrets, change_rs
        )
    
        await self.set_reserved_for_melt(proofs, reserved=True, quote_id=quote_id)
        proofs = self.sign_proofs_inplace_melt(proofs, change_outputs, quote_id)
        try:
>           melt_quote_resp = await super().melt(
                quote_id, proofs, change_outputs, prefer_async=prefer_async
            )

cashu/wallet/wallet.py:897: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
cashu/wallet/v1_api.py:91: in wrapper
    return await func(self, *args, **kwargs)
cashu/wallet/v1_api.py:104: in wrapper
    return await func(self, *args, **kwargs)
cashu/wallet/v1_api.py:547: in melt
    raise e
cashu/wallet/v1_api.py:536: in melt
    self.raise_on_error_request(resp)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

resp = <Response [400 Bad Request]>

    @staticmethod
    def raise_on_error_request(
        resp: Response,
    ) -> None:
        """Raises an exception if the response from the mint contains an error.
    
        Args:
            resp_dict (Response): Response dict (previously JSON) from mint
    
        Raises:
            Exception: if the response contains an error
        """
        try:
            resp_dict = resp.json()
        except json.JSONDecodeError:
            resp.raise_for_status()
            return
        if "detail" in resp_dict:
            logger.trace(f"Error from mint: {resp_dict}")
            error_message = f"Mint Error: {resp_dict['detail']}"
            if "code" in resp_dict:
                error_message += f" (Code: {resp_dict['code']})"
>           raise Exception(error_message)
E           Exception: Mint Error: Lightning payment failed: SendPaymentSync failed: <AioRpcError of RPC that terminated with:
E           	status = StatusCode.UNIMPLEMENTED
E           	details = "unknown method SendPaymentSync for service lnrpc.Lightning"
E           	debug_error_string = "UNKNOWN:Error received from peer  {created_time:"2026-06-26T13:21:25.701542546+00:00", grpc_status:12, grpc_message:"unknown method SendPaymentSync for service lnrpc.Lightning"}"
E           >. (Code: 20004)

cashu/wallet/v1_api.py:145: Exception

During handling of the above exception, another exception occurred:

wallet = <cashu.wallet.wallet.Wallet object at 0x7ff643ba42b0>

    @pytest.mark.asyncio
    @pytest.mark.skipif(is_fake, reason="only regtest")
    async def test_mint_pay_with_duplicate_checking_id(wallet):
        mint_quote1 = await wallet.request_mint(1024)
        mint_quote2 = await wallet.request_mint(1024)
        await pay_if_regtest(mint_quote1.request)
        await pay_if_regtest(mint_quote2.request)
    
        proofs1 = await wallet.mint(amount=1024, quote_id=mint_quote1.quote)
        proofs2 = await wallet.mint(amount=1024, quote_id=mint_quote2.quote)
    
        invoice = get_real_invoice(64)["payment_request"]
    
        # Get two melt quotes for the same invoice
        melt_quote1 = await wallet.melt_quote(invoice)
        melt_quote2 = await wallet.melt_quote(invoice)
    
>       response1 = await wallet.melt(
            proofs=proofs1,
            invoice=invoice,
            fee_reserve_sat=melt_quote1.fee_reserve,
            quote_id=melt_quote1.quote,
        )

tests/mint/test_mint_melt.py:765: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <cashu.wallet.wallet.Wallet object at 0x7ff643ba42b0>
proofs = [Proof(id='01d8a63077d0a51f9855f066409782ffcb322dc8a2265291865221ed06c039f6bc', amount=1, secret='5a5e70bbb18868e608c1...f066409782ffcb322dc8a2265291865221ed06c039f6bc:77', mint_id='019f0417-9a33-7ccd-b71a-3b04cfca90ca', melt_id=None), ...]
invoice = 'lnbcrt640n1p4rulx4pp592jxm78e0v2hkewv8d3tadjplzlf3yw4nf2z7hdeju5uux4wjkvqdqqcqzzsxqyz5vqsp5t7hf8j8y3299x0c58t3v5x929w...qxpqysgq3nyqvtxvnqpfhkph2jt2whyg4kfdasn4shv8yw53w2yz4h5duulrvnpq59j059t237x3nqdzlzcpef2vy9dk9933ycfvru6l3dkeu9gq4396en'
fee_reserve_sat = 2, quote_id = '019f0417-a1b9-70c3-a2b1-30f2c4a7c094'
prefer_async = None

    async def melt(
        self,
        proofs: List[Proof],
        invoice: str,
        fee_reserve_sat: int,
        quote_id: str,
        prefer_async: Optional[bool] = None,
    ) -> PostMeltQuoteResponse:
        """Pays a lightning invoice and returns the status of the payment.
    
        Args:
            proofs (List[Proof]): List of proofs to be spent.
            invoice (str): Lightning invoice to be paid.
            fee_reserve_sat (int): Amount of fees to be reserved for the payment.
            prefer_async (Optional[bool]): Whether to pay asynchronously.
        """
    
        # Make sure we're operating on an independent copy of proofs
        proofs = copy.copy(proofs)
    
        # Generate a number of blank outputs for any overpaid fees. As described in
        # NUT-08, the mint will imprint these outputs with a value depending on the
        # amount of fees we overpaid.
        n_change_outputs = calculate_number_of_blank_outputs(fee_reserve_sat)
        (
            change_secrets,
            change_rs,
            change_derivation_paths,
        ) = await self.generate_n_secrets(n_change_outputs)
        change_outputs, change_rs = self._construct_outputs(
            n_change_outputs * [1], change_secrets, change_rs
        )
    
        await self.set_reserved_for_melt(proofs, reserved=True, quote_id=quote_id)
        proofs = self.sign_proofs_inplace_melt(proofs, change_outputs, quote_id)
        try:
            melt_quote_resp = await super().melt(
                quote_id, proofs, change_outputs, prefer_async=prefer_async
            )
        except Exception as e:
            logger.debug(f"Mint error: {e}")
            # remove the melt_id in proofs and set reserved to False
            await self.set_reserved_for_melt(proofs, reserved=False, quote_id=None)
>           raise Exception(f"could not pay invoice: {e}")
E           Exception: could not pay invoice: Mint Error: Lightning payment failed: SendPaymentSync failed: <AioRpcError of RPC that terminated with:
E           	status = StatusCode.UNIMPLEMENTED
E           	details = "unknown method SendPaymentSync for service lnrpc.Lightning"
E           	debug_error_string = "UNKNOWN:Error received from peer  {created_time:"2026-06-26T13:21:25.701542546+00:00", grpc_status:12, grpc_message:"unknown method SendPaymentSync for service lnrpc.Lightning"}"
E           >. (Code: 20004)

cashu/wallet/wallet.py:904: Exception
tests.mint.test_mint_melt::test_melt_race_condition_fixed
Stack Traces | 3.22s run time
wallet = <cashu.wallet.wallet.Wallet object at 0x7ff643a7e740>
ledger = <cashu.mint.ledger.Ledger object at 0x7ff643bcfd60>

    @pytest.mark.asyncio
    @pytest.mark.skipif(is_deprecated_api_only, reason="Can't run on the deprecated API")
    async def test_melt_race_condition_fixed(wallet: Wallet, ledger: Ledger):
        import asyncio
    
        # Setup: Get proofs and a melt quote
        # Mint set 1 (128 sat)
        mq1 = await wallet.request_mint(128)
        await pay_if_regtest(mq1.request)
        proofs1 = await wallet.mint(128, quote_id=mq1.quote)
    
        # Mint set 2 (128 sat)
        mq2 = await wallet.request_mint(128)
        await pay_if_regtest(mq2.request)
        proofs2 = await wallet.mint(128, quote_id=mq2.quote)
    
        # Invoice for 64 sats (+2 fee = 66 sats needed)
        invoice = (
            get_real_invoice(64)["payment_request"]
            if is_regtest
            else "lnbcrt640n1pn0r3tfpp5e30xac756gvd26cn3tgsh8ug6ct555zrvl7vsnma5cwp4g7auq5qdqqcqzzsxqyz5vqsp5xfhtzg0y3mekv6nsdnj43c346smh036t4f8gcfa2zwpxzwcryqvs9qxpqysgqw5juev8y3zxpdu0mvdrced5c6a852f9x7uh57g6fgjgcg5muqzd5474d7xgh770frazel67eejfwelnyr507q46hxqehala880rhlqspw07ta0"
        )
        melt_quote1 = await wallet.melt_quote(invoice)
        melt_quote2 = await wallet.melt_quote(invoice)
    
        assert melt_quote1.quote != melt_quote2.quote
    
        responses = await asyncio.gather(
            ledger.melt(proofs=proofs1, quote=melt_quote1.quote),
            ledger.melt(proofs=proofs2, quote=melt_quote2.quote),
            return_exceptions=True,
        )
    
        failures = [r for r in responses if isinstance(r, Exception)]
        successes = [r for r in responses if not isinstance(r, Exception)]
    
>       assert len(successes) == 1
E       assert 0 == 1
E        +  where 0 = len([])

tests/mint/test_mint_melt.py:820: AssertionError
tests.mint.test_mint_regtest::test_regtest_pending_quote
Stack Traces | 3.75s run time
wallet = <cashu.wallet.wallet.Wallet object at 0x7ff64210be80>
ledger = <cashu.mint.ledger.Ledger object at 0x7ff64218dbd0>

    @pytest.mark.asyncio
    @pytest.mark.skipif(is_fake, reason="only regtest")
    async def test_regtest_pending_quote(wallet: Wallet, ledger: Ledger):
        # fill wallet
        mint_quote = await wallet.request_mint(64)
        await pay_if_regtest(mint_quote.request)
        await wallet.mint(64, quote_id=mint_quote.quote)
        assert wallet.balance == 64
    
        # create hodl invoice
        preimage, invoice_dict = get_hold_invoice(16)
        invoice_payment_request = str(invoice_dict["payment_request"])
    
        # wallet pays the invoice
        quote = await wallet.melt_quote(invoice_payment_request)
        total_amount = quote.amount + quote.fee_reserve
        _, send_proofs = await wallet.swap_to_send(wallet.proofs, total_amount)
        asyncio.create_task(ledger.melt(proofs=send_proofs, quote=quote.quote))
        # asyncio.create_task(
        #     wallet.melt(
        #         proofs=send_proofs,
        #         invoice=invoice_payment_request,
        #         fee_reserve_sat=quote.fee_reserve,
        #         quote_id=quote.quote,
        #     )
        # )
        await asyncio.sleep(SLEEP_TIME)
    
        # expect that melt quote is still pending
        melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs(
            db=ledger.db
        )
>       assert melt_quotes
E       assert []

tests/mint/test_mint_regtest.py:347: AssertionError
tests.mint.test_mint_init::test_startup_regtest_pending_quote_unknown
Stack Traces | 3.78s run time
wallet = <cashu.wallet.wallet.Wallet object at 0x7ff6544e0100>
ledger = <cashu.mint.ledger.Ledger object at 0x7ff65442a6e0>

    @pytest.mark.asyncio
    @pytest.mark.skipif(is_fake, reason="only regtest")
    async def test_startup_regtest_pending_quote_unknown(wallet: Wallet, ledger: Ledger):
        """Simulate an unknown payment by executing a pending payment, then
        manipulating the melt_quote in the mint's db so that its checking_id
        points to an unknown payment."""
    
        # fill wallet
        mint_quote = await wallet.request_mint(64)
        await pay_if_regtest(mint_quote.request)
        await wallet.mint(64, quote_id=mint_quote.quote)
        assert wallet.balance == 64
    
        # create hodl invoice
        preimage, invoice_dict = get_hold_invoice(16)
        invoice_payment_request = str(invoice_dict["payment_request"])
        invoice_obj = bolt11.decode(invoice_payment_request)
        preimage_hash = invoice_obj.payment_hash
    
        # wallet pays the invoice
        quote = await wallet.melt_quote(invoice_payment_request)
        total_amount = quote.amount + quote.fee_reserve
        _, send_proofs = await wallet.swap_to_send(wallet.proofs, total_amount)
        asyncio.create_task(
            wallet.melt(
                proofs=send_proofs,
                invoice=invoice_payment_request,
                fee_reserve_sat=quote.fee_reserve,
                quote_id=quote.quote,
            )
        )
        await asyncio.sleep(SLEEP_TIME)
    
        # expect that proofs are pending
        states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs])
>       assert all([s.pending for s in states])
E       assert False
E        +  where False = all([False, False])

tests/mint/test_mint_init.py:440: AssertionError
tests.mint.test_mint_init::test_startup_regtest_pending_quote_success
Stack Traces | 3.79s run time
wallet = <cashu.wallet.wallet.Wallet object at 0x7ff654897190>
ledger = <cashu.mint.ledger.Ledger object at 0x7ff654c07b50>

    @pytest.mark.asyncio
    @pytest.mark.skipif(is_fake, reason="only regtest")
    async def test_startup_regtest_pending_quote_success(wallet: Wallet, ledger: Ledger):
        # fill wallet
        mint_quote = await wallet.request_mint(64)
        await pay_if_regtest(mint_quote.request)
        await wallet.mint(64, quote_id=mint_quote.quote)
        assert wallet.balance == 64
    
        # create hodl invoice
        preimage, invoice_dict = get_hold_invoice(16)
        invoice_payment_request = str(invoice_dict["payment_request"])
    
        # wallet pays the invoice
        quote = await wallet.melt_quote(invoice_payment_request)
        total_amount = quote.amount + quote.fee_reserve
        _, send_proofs = await wallet.swap_to_send(wallet.proofs, total_amount)
        asyncio.create_task(
            wallet.melt(
                proofs=send_proofs,
                invoice=invoice_payment_request,
                fee_reserve_sat=quote.fee_reserve,
                quote_id=quote.quote,
            )
        )
        await asyncio.sleep(SLEEP_TIME)
        # expect that proofs are pending
        states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs])
>       assert all([s.pending for s in states])
E       assert False
E        +  where False = all([False, False])

tests/mint/test_mint_init.py:335: AssertionError
tests.mint.test_mint_init::test_startup_regtest_pending_quote_failure
Stack Traces | 3.79s run time
wallet = <cashu.wallet.wallet.Wallet object at 0x7ff6545f97b0>
ledger = <cashu.mint.ledger.Ledger object at 0x7ff654b7d1b0>

    @pytest.mark.asyncio
    @pytest.mark.skipif(is_fake, reason="only regtest")
    async def test_startup_regtest_pending_quote_failure(wallet: Wallet, ledger: Ledger):
        """Simulate a failure to pay the hodl invoice by canceling it."""
        # fill wallet
        mint_quote = await wallet.request_mint(64)
        await pay_if_regtest(mint_quote.request)
        await wallet.mint(64, quote_id=mint_quote.quote)
        assert wallet.balance == 64
    
        # create hodl invoice
        preimage, invoice_dict = get_hold_invoice(16)
        invoice_payment_request = str(invoice_dict["payment_request"])
        invoice_obj = bolt11.decode(invoice_payment_request)
        preimage_hash = invoice_obj.payment_hash
    
        # wallet pays the invoice
        quote = await wallet.melt_quote(invoice_payment_request)
        total_amount = quote.amount + quote.fee_reserve
        _, send_proofs = await wallet.swap_to_send(wallet.proofs, total_amount)
        asyncio.create_task(
            wallet.melt(
                proofs=send_proofs,
                invoice=invoice_payment_request,
                fee_reserve_sat=quote.fee_reserve,
                quote_id=quote.quote,
            )
        )
        await asyncio.sleep(SLEEP_TIME)
    
        # expect that proofs are pending
        states = await ledger.db_read.get_proofs_states([p.Y for p in send_proofs])
>       assert all([s.pending for s in states])
E       assert False
E        +  where False = all([False, False])

tests/mint/test_mint_init.py:386: AssertionError
tests.mint.test_mint_init::test_startup_regtest_pending_quote_pending
Stack Traces | 4.01s run time
wallet = <cashu.wallet.wallet.Wallet object at 0x7ff6549d3790>
ledger = <cashu.mint.ledger.Ledger object at 0x7ff654807640>

    @pytest.mark.asyncio
    @pytest.mark.skipif(is_fake, reason="only regtest")
    async def test_startup_regtest_pending_quote_pending(wallet: Wallet, ledger: Ledger):
        # fill wallet
        mint_quote = await wallet.request_mint(64)
        await pay_if_regtest(mint_quote.request)
        await wallet.mint(64, quote_id=mint_quote.quote)
        assert wallet.balance == 64
    
        # create hodl invoice
        preimage, invoice_dict = get_hold_invoice(16)
        invoice_payment_request = str(invoice_dict["payment_request"])
    
        # wallet pays the invoice
        quote = await wallet.melt_quote(invoice_payment_request)
        total_amount = quote.amount + quote.fee_reserve
        _, send_proofs = await wallet.swap_to_send(wallet.proofs, total_amount)
        asyncio.create_task(
            wallet.melt(
                proofs=send_proofs,
                invoice=invoice_payment_request,
                fee_reserve_sat=quote.fee_reserve,
                quote_id=quote.quote,
            )
        )
        await asyncio.sleep(SLEEP_TIME)
    
        # run startup routine
        await ledger._check_pending_proofs_and_melt_quotes()
    
        # expect that melt quote is still pending
        melt_quotes = await ledger.crud.get_all_melt_quotes_from_pending_proofs(
            db=ledger.db
        )
>       assert melt_quotes
E       assert []

tests/mint/test_mint_init.py:297: AssertionError

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

Comment thread cashu/lightning/sparkl2.py
Comment thread cashu/lightning/sparkl2.py Outdated
Comment on lines +98 to +101
# We run this in a thread because breez SDK connect is blocking in python (currently)
# Actually, `connect` is async in Python. Let's await it.
# wait, breez_sdk_spark python binding functions are actually async? No they are sync.
if inspect.iscoroutinefunction(breez_sdk_spark.connect):

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is horrible and should be part of the codebase as well.

Comment thread cashu/lightning/sparkl2.py Outdated
)

req = breez_sdk_spark.GetInfoRequest(ensure_synced=False)
if inspect.iscoroutinefunction(self.sdk.get_info):

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here. figure this out this from the documentation.

@a1denvalu3

Copy link
Copy Markdown
Collaborator Author

Thanks for the feedback! I've removed all inspect.iscoroutinefunction blocks (since they are fully supported native coroutines in the Breez python bindings) and replaced all getattr property accesses with safe, native object property dereferences after checking the variant type. Let me know if anything else catches your eye.

@a1denvalu3

Copy link
Copy Markdown
Collaborator Author

@callebtc Should we derive a mnemonic from the mint private key, or should we let it as is where the operator specifies a mnemonic for the breeze sdk as well as the secret for the mint?

Comment thread cashu/lightning/sparkl2.py

@KvngMikey KvngMikey left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@a1denvalu3 a1denvalu3 force-pushed the feat/spark-l2-backend branch from 0219176 to 7115a65 Compare June 18, 2026 08:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

2 participants