Skip to content

fix a variety of issues with SIG_ALL and its interaction with P2PK and HTLC#1008

Open
SatsAndSports wants to merge 5 commits into
cashubtc:mainfrom
SatsAndSports:fix.nut10.support
Open

fix a variety of issues with SIG_ALL and its interaction with P2PK and HTLC#1008
SatsAndSports wants to merge 5 commits into
cashubtc:mainfrom
SatsAndSports:fix.nut10.support

Conversation

@SatsAndSports

@SatsAndSports SatsAndSports commented May 18, 2026

Copy link
Copy Markdown

Fixes Issue: #1009 which reports a variety of issues related to P2PK and HTLC and SIG_ALL. Even if some of those features have been partially implemented and tested together, problems arise when they are combined; for example, a post-locktime HTLC with SIG_ALL

Starting with the mint:

The existing code had NUT-10 code (i.e. P2PK and HTLC) in multiple places. In this PR, following the CDK architecture, all the NUT-10 logic is moved into a single boolean function. This unifies checking for swap and melt and minimizes code duplication.

The main functions in cashu/mint/conditions.py now are:

  • _verify_transaction is a new entry point for all verification of any swap or melt.
  • _verify_input_output_spending_conditions is an existing function that has been rewritten to verify the inputs and outputs (and quote, for a melt), looking just at the NUT-10 spending conditions (i.e. P2PK and HTLC). It returns True or raises an error. It delegates to various new helpers to deal with all the various P2PK or HTLC or SIG_ALL.
  • _verify_p2pk_or_htlc_spending_requirements is the low level function to count valid signatures and, if necessary, the pre-image. This one function is reused across all the various contexts, for any NUT-10 secret that appears in the inputs of any swap or melt.

DataStructures: This PR introduces a similar SpendingRequirements class as used in the CDK, to unify the requirements encoded in a P2PK/HTLC Secret.

Wallet: The only wallet change here is to keep up with the new SIG_ALL message format.

@github-project-automation github-project-automation Bot moved this to Backlog in nutshell May 18, 2026
@SatsAndSports SatsAndSports marked this pull request as draft May 18, 2026 21:26
@SatsAndSports

SatsAndSports commented May 18, 2026

Copy link
Copy Markdown
Author

There's still a lot of AI slop here. My first priority is completing all the tests and ensuring they are good and correct; the CDK tests are very helpful here as the CDK has a broad range of tests for this, then I'll review and tidy up the implementation here

Update a few days later: ready for review now

Comment thread cashu/core/p2pk.py Outdated
from .errors import InvalidProofsError
from .secret import Secret, SecretKind

if TYPE_CHECKING:

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.

?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Found a fix, i.e. removing the TYPE_CHECKING import and if entirely. But I'm curious what you think before pushing the fix

TL:DR it's about an import that was only used by ruff and not actually used at runtime. Therefore, the idea is to avoid this unnecessary-at-runtime import, perhaps making runtime slightly more efficient and perhaps making the code more maintainable by signalling to maintainers which imports are really needed for which purpose

This PR introduces a new function def sig_all_swap_message(proofs: List["Proof"], outputs: List["BlindedMessage"]) -> str:. I'll double check if this new function is necessary; maybe it's redundant in this draft PR

(With the fix, we can remove the " from that signature and simply use List[Proof] and List[BlindedMessage]. But that's a slightly different issue to the issue described in this comment)

ruff needs an import of Proof and BlindedMessage, otherwise we get this error : cashu/core/p2pk.py:76:64: F821 Undefined name BlindedMessage. So that requires importing those two. However, as this import is needed only by ruff (and maybe also by mypy?) and is not actually used at runtime, we can use the if TYPE_CHECKING guard to skip the runtime import.

Question. TYPE_CHECKING allows us to skip imports that aren't needed at runtime, and which are needed only for linting and type-checking. It might make the code more maintainable, by helping readers understand what code is really needed. Fewer runtime deps means a smaller chance of things going run in complex ways at runtime. But it might make things less maintainable for maintainers who don't know TYPE_CHECKING (I discovered it only yesterday!). Should we leave this as-is, using TYPE_CHECKING like this, or just go with the import at runtime?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I've removed that TYPE_CHECKING stuff. I find it interesting, and tempted to keep it. But it's a broader issue, unrelated to the bug-fixing scope of this PR, and therefore I've removed it

@SatsAndSports SatsAndSports changed the title (draft PR) fix a variety of issues with SIG_ALL and its interaction with P2PK and HTLC fix a variety of issues with SIG_ALL and its interaction with P2PK and HTLC May 24, 2026
@SatsAndSports SatsAndSports marked this pull request as ready for review May 24, 2026 14:50
Comment thread cashu/mint/auth/server.py
"does not enforce spending conditions in this path. Treating it "
"as a plain secret."
)
except Exception:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Are auth tokens supposed to follow NUT-10?

If I remember correctly, the existing data structure for an auth proof doesn't have a witness field and therefore I guess it couldn't have been enforced. Therefore, I added this code to make it more explicit.

I couldn't see an easily way to integrate the NUT-10 logic into auth, in the same way that it's integrated into swap and melt.

Should we add something more explicit in the nuts, perhaps adjust NUT-10 to emphasize that it's for swap and melt and not for auth?

@codecov

codecov Bot commented Jun 8, 2026

Copy link
Copy Markdown

❌ 3 Tests Failed:

Tests completed Failed Passed Skipped
734 3 731 86
View the top 3 failed test(s) by shortest run time
tests.wallet.test_wallet_lightning::test_pay_invoice_internal
Stack Traces | 0.638s run time
wallet = <cashu.wallet.lightning.lightning.LightningWallet object at 0x7fbce3acf1f0>

    @pytest.mark.asyncio
    @pytest.mark.skipif(is_regtest, reason="only works with FakeWallet")
    async def test_pay_invoice_internal(wallet: LightningWallet):
        # fill wallet
        invoice = await wallet.create_invoice(64)
        assert invoice.payment_request
        await wallet.get_invoice_status(invoice.payment_request)
        assert wallet.available_balance >= 64
    
        # pay invoice
        invoice2 = await wallet.create_invoice(16)
        assert invoice2.payment_request
        status = await wallet.pay_invoice(invoice2.payment_request)
    
>       assert status.settled
E       AssertionError: assert False
E        +  where False = PaymentResponse(result=<PaymentResult.FAILED: 2>, checking_id=None, fee=None, preimage=None, error_message='could not pay invoice: Mint Error: no outputs provided. (Code: 11000)').settled

tests/wallet/test_wallet_lightning.py:115: AssertionError
tests.mint.test_mint_init::test_regtest_check_nonexisting_melt_quote
Stack Traces | 1.06s run time
self = <sqlalchemy.dialects.postgresql.asyncpg.AsyncAdapt_asyncpg_cursor object at 0x7f27e67792a0>
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 315 waits for AccessExclusiveLock on relation 45409 of database 16384; blocked by process 95.
E   Process 95 waits for RowExclusiveLock on relation 45340 of database 16384; blocked by process 315.
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 0x7f27e6ec94b0>
dialect = <sqlalchemy.dialects.postgresql.asyncpg.PGDialect_asyncpg object at 0x7f27e69cca60>
context = <sqlalchemy.dialects.postgresql.asyncpg.PGExecutionContext_asyncpg object at 0x7f27e7edcbb0>
statement = <sqlalchemy.dialects.postgresql.asyncpg.PGCompiler_asyncpg object at 0x7f27e7d46b00>
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 0x7f27e690bf10>>
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 315 waits for AccessExclusiveLock on relation 45409 of database 16384; blocked by process 95.
E                   Process 95 waits for RowExclusiveLock on relation 45340 of database 16384; blocked by process 315.
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 0x7f27ee06dea0>
event_loop_fixture_id = 'event_loop'
setup = <function _wrap_asyncgen_fixture.<locals>._asyncgen_fixture_wrapper.<locals>.setup at 0x7f27e687cee0>
finalizer = <function _wrap_asyncgen_fixture.<locals>._asyncgen_fixture_wrapper.<locals>.finalizer at 0x7f27e687c8b0>

    @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:101: 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 0x7f27e690bf10>>
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 315 waits for AccessExclusiveLock on relation 45409 of database 16384; blocked by process 95.
E                   Process 95 waits for RowExclusiveLock on relation 45340 of database 16384; blocked by process 315.
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.wallet.test_wallet::test_get_melt_quote_state
Stack Traces | 2.21s run time
self = <cashu.wallet.wallet.Wallet object at 0x7fbce39c3eb0>
proofs = [Proof(id='01d8a63077d0a51f9855f066409782ffcb322dc8a2265291865221ed06c039f6bc', amount=64, secret='6f20b736081d9be8bf7...path='HMAC-SHA256:01d8a63077d0a51f9855f066409782ffcb322dc8a2265291865221ed06c039f6bc:607', mint_id=None, melt_id=None)]
invoice = 'lnbc640n1p4zvu4h9qypqqqdqqxqrrsssp5g6re9carzk9ue39uyl7ck8934ftzj0p8c6xxq8cc60u9nm6u9ncspp5fkg5wqt0rrehs5tv6jwyxc6etwt...4mkj6gwql30reat9phn82az2q9fjx7d4m4um7a7edgfv36e05e4tf22p8ytr7ug2ms02vtz9f97c89qz80dn5jg0xt848kegprkhujzycn04wscpe56plk'
fee_reserve_sat = 0, quote_id = 'NBQfae-LkSw0g6-_fqjKSGQdwRiGbvG6-Pu_sruA'
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:866: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
cashu/wallet/v1_api.py:90: in wrapper
    return await func(self, *args, **kwargs)
cashu/wallet/v1_api.py:103: in wrapper
    return await func(self, *args, **kwargs)
cashu/wallet/v1_api.py:529: in melt
    raise e
cashu/wallet/v1_api.py:518: 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: no outputs provided. (Code: 11000)

cashu/wallet/v1_api.py:144: Exception

During handling of the above exception, another exception occurred:

wallet1 = <cashu.wallet.wallet.Wallet object at 0x7fbce39c3eb0>

    @pytest.mark.asyncio
    @pytest.mark.skipif(is_deprecated_api_only, reason="Deprecated API only")
    async def test_get_melt_quote_state(wallet1: Wallet):
        mint_quote = await wallet1.request_mint(128)
        await pay_if_regtest(mint_quote.request)
        await wallet1.mint(128, quote_id=mint_quote.quote)
        invoice_payment_request = ""
        if is_regtest:
            invoice_dict = get_real_invoice(64)
            invoice_payment_request = invoice_dict["payment_request"]
    
        if is_fake:
            mint_quote = await wallet1.request_mint(64)
            invoice_payment_request = mint_quote.request
        quote = await wallet1.melt_quote(invoice_payment_request)
        assert quote.state == MeltQuoteState.unpaid
        assert quote.request == invoice_payment_request
        total_amount = quote.amount + quote.fee_reserve
        _, send_proofs = await wallet1.swap_to_send(wallet1.proofs, total_amount)
>       melt_response = await wallet1.melt(
            proofs=send_proofs,
            invoice=invoice_payment_request,
            fee_reserve_sat=quote.fee_reserve,
            quote_id=quote.quote,
        )

tests/wallet/test_wallet.py:390: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <cashu.wallet.wallet.Wallet object at 0x7fbce39c3eb0>
proofs = [Proof(id='01d8a63077d0a51f9855f066409782ffcb322dc8a2265291865221ed06c039f6bc', amount=64, secret='6f20b736081d9be8bf7...path='HMAC-SHA256:01d8a63077d0a51f9855f066409782ffcb322dc8a2265291865221ed06c039f6bc:607', mint_id=None, melt_id=None)]
invoice = 'lnbc640n1p4zvu4h9qypqqqdqqxqrrsssp5g6re9carzk9ue39uyl7ck8934ftzj0p8c6xxq8cc60u9nm6u9ncspp5fkg5wqt0rrehs5tv6jwyxc6etwt...4mkj6gwql30reat9phn82az2q9fjx7d4m4um7a7edgfv36e05e4tf22p8ytr7ug2ms02vtz9f97c89qz80dn5jg0xt848kegprkhujzycn04wscpe56plk'
fee_reserve_sat = 0, quote_id = 'NBQfae-LkSw0g6-_fqjKSGQdwRiGbvG6-Pu_sruA'
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: no outputs provided. (Code: 11000)

cashu/wallet/wallet.py:873: Exception
tests.wallet.test_wallet::test_melt
Stack Traces | 2.26s run time
self = <cashu.wallet.wallet.Wallet object at 0x7fbce1aa70a0>
proofs = [Proof(id='01d8a63077d0a51f9855f066409782ffcb322dc8a2265291865221ed06c039f6bc', amount=64, secret='61002337725f69423fd...path='HMAC-SHA256:01d8a63077d0a51f9855f066409782ffcb322dc8a2265291865221ed06c039f6bc:588', mint_id=None, melt_id=None)]
invoice = 'lnbc640n1p4zvu449qypqqqdqqxqrrsssp5f3uw4d8xtacrz5msjzuw9qtjc0k6qshc3uvvmjvf966cdsskyhuspp5r3r27p4rsd6zg2yezljdwrfdke7...hfys87cq67p3w3urlypc0wjuuz9ahe97aaavmmydlm23fqx7w4sjetmhmlkh6e2tmx2ml2089r9ly45jj52f9yyfd8quwqxz5a3ag9zp8nx6qmqpc973w4'
fee_reserve_sat = 0, quote_id = '70eVgGEG-SAICpY8h96WT86sFzLTQNQ1BOt0T6fP'
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:866: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
cashu/wallet/v1_api.py:90: in wrapper
    return await func(self, *args, **kwargs)
cashu/wallet/v1_api.py:103: in wrapper
    return await func(self, *args, **kwargs)
cashu/wallet/v1_api.py:529: in melt
    raise e
cashu/wallet/v1_api.py:518: 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: no outputs provided. (Code: 11000)

cashu/wallet/v1_api.py:144: Exception

During handling of the above exception, another exception occurred:

wallet1 = <cashu.wallet.wallet.Wallet object at 0x7fbce1aa70a0>

    @pytest.mark.asyncio
    async def test_melt(wallet1: Wallet):
        # mint twice so we have enough to pay the second invoice back
        topup_mint_quote = await wallet1.request_mint(128)
        await pay_if_regtest(topup_mint_quote.request)
        await wallet1.mint(128, quote_id=topup_mint_quote.quote)
        assert wallet1.balance == 128
    
        invoice_payment_request = ""
        if is_regtest:
            invoice_dict = get_real_invoice(64)
            invoice_payment_request = invoice_dict["payment_request"]
    
        if is_fake:
            mint_quote = await wallet1.request_mint(64)
            invoice_payment_request = mint_quote.request
    
        quote = await wallet1.melt_quote(invoice_payment_request)
        total_amount = quote.amount + quote.fee_reserve
    
        if is_regtest:
            # we expect a fee reserve of 2 sat for regtest
            assert total_amount == 66
            assert quote.fee_reserve == 2
        if is_fake:
            # we expect a fee reserve of 0 sat for fake
            assert total_amount == 64
            assert quote.fee_reserve == 0
    
        if not settings.debug_mint_only_deprecated:
            quote_resp = await wallet1.get_melt_quote(quote.quote)
            assert quote_resp
            assert quote_resp.amount == quote.amount
    
        _, send_proofs = await wallet1.swap_to_send(wallet1.proofs, total_amount)
    
>       melt_response = await wallet1.melt(
            proofs=send_proofs,
            invoice=invoice_payment_request,
            fee_reserve_sat=quote.fee_reserve,
            quote_id=quote.quote,
        )

tests/wallet/test_wallet.py:327: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <cashu.wallet.wallet.Wallet object at 0x7fbce1aa70a0>
proofs = [Proof(id='01d8a63077d0a51f9855f066409782ffcb322dc8a2265291865221ed06c039f6bc', amount=64, secret='61002337725f69423fd...path='HMAC-SHA256:01d8a63077d0a51f9855f066409782ffcb322dc8a2265291865221ed06c039f6bc:588', mint_id=None, melt_id=None)]
invoice = 'lnbc640n1p4zvu449qypqqqdqqxqrrsssp5f3uw4d8xtacrz5msjzuw9qtjc0k6qshc3uvvmjvf966cdsskyhuspp5r3r27p4rsd6zg2yezljdwrfdke7...hfys87cq67p3w3urlypc0wjuuz9ahe97aaavmmydlm23fqx7w4sjetmhmlkh6e2tmx2ml2089r9ly45jj52f9yyfd8quwqxz5a3ag9zp8nx6qmqpc973w4'
fee_reserve_sat = 0, quote_id = '70eVgGEG-SAICpY8h96WT86sFzLTQNQ1BOt0T6fP'
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: no outputs provided. (Code: 11000)

cashu/wallet/wallet.py:873: Exception
tests.wallet.test_wallet_regtest_mpp::test_regtest_pay_mpp_cancel_payment_pay_partial_invoice
Stack Traces | 4.82s run time
wallet = <cashu.wallet.wallet.Wallet object at 0x7ffadfa45660>
ledger = <cashu.mint.ledger.Ledger object at 0x7ffae0182530>

    @pytest.mark.asyncio
    @pytest.mark.skipif(is_fake, reason="only regtest")
    async def test_regtest_pay_mpp_cancel_payment_pay_partial_invoice(
        wallet: Wallet, ledger: Ledger
    ):
        # make sure that mpp is supported by the bolt11-sat backend
        if not ledger.backends[Method["bolt11"]][wallet.unit].supports_mpp:
            pytest.skip("backend does not support mpp")
    
        # create a hold invoice that we can cancel
        preimage, invoice_dict = get_hold_invoice(64)
        invoice_payment_request = str(invoice_dict.get("payment_request", ""))
        invoice_obj = bolt11.decode(invoice_payment_request)
        payment_hash = invoice_obj.payment_hash
    
        # Use a shared container to store the result
        result_container = []
    
        async def _mint_pay_mpp(invoice: str, amount: int) -> PaymentResponse:
            ret = await ledger.backends[Method["bolt11"]][wallet.unit].pay_invoice(
                MeltQuote(
                    request=invoice,
                    amount=amount,
                    fee_reserve=0,
                    quote="",
                    method="bolt11",
                    checking_id="",
                    unit=wallet.unit.name,
                    state=MeltQuoteState.pending,
                ),
                0,
            )
            return ret
    
        # Create a wrapper function that will store the result
        def thread_func():
            result = asyncio.run(_mint_pay_mpp(invoice_payment_request, 32))
            result_container.append(result)
    
        t1 = threading.Thread(target=thread_func)
        t1.start()
        await asyncio.sleep(SLEEP_TIME)
    
        # cancel the invoice
        cancel_invoice(payment_hash)
        await asyncio.sleep(SLEEP_TIME)
    
        t1.join()
        # Get the result from the container
>       assert result_container[0].failed
E       IndexError: list index out of range

tests/wallet/test_wallet_regtest_mpp.py:277: IndexError

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

@a1denvalu3

Copy link
Copy Markdown
Collaborator

@SatsAndSports needs rebase on the latest main

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