Skip to content

Mark Keysets as Deleted.#797

Open
KvngMikey wants to merge 21 commits into
cashubtc:mainfrom
KvngMikey:wallet-deleted-keysets
Open

Mark Keysets as Deleted.#797
KvngMikey wants to merge 21 commits into
cashubtc:mainfrom
KvngMikey:wallet-deleted-keysets

Conversation

@KvngMikey

@KvngMikey KvngMikey commented Oct 5, 2025

Copy link
Copy Markdown
Contributor

Fixes #731

Summary

This PR fixes the wallet behavior when a keyset disappears from a mint's /keysets response.

Changes

  • Updated load_mint_keysets to mark missing keysets as deleted.
  • Added update_keyset_active in crud.py to use named SQL parameters for SQLAlchemy async compatibility.
  • Verified with pytest tests/wallet/test_wallet.py -k keyset (all keyset tests now pass).

Comment thread cashu/wallet/wallet.py Outdated
Comment thread cashu/wallet/wallet.py
Comment thread tests/wallet/test_wallet.py Outdated
Comment thread tests/wallet/test_wallet.py Outdated

@callebtc callebtc 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 but one outstanding question regarding the removed line await self.load_keysets_from_db()

what's the justification for that?

@KvngMikey

KvngMikey commented Oct 8, 2025

Copy link
Copy Markdown
Contributor Author

LGTM but one outstanding question regarding the removed line await self.load_keysets_from_db()

what's the justification for that?

@callebtc I replaced "await self.load_keysets_from_db()" because we now build self.keysets inline with the already-fetched list (keysets_in_db) filtered by k.unit == self.unit and k.active.
That list is fresh (we re-fetch after marking inactive), so the old helper’s extra query + loop is redundant.
The trace message is still printed two lines below, so no observability is lost.

@KvngMikey KvngMikey requested a review from callebtc October 8, 2025 14:21
@callebtc

Copy link
Copy Markdown
Collaborator

Hi @KvngMikey, sorry for getting back to you late. We have a crud called update_keyset that supports setting the active flag (latest main also can update the fee, worth a merge of main into this branch). Why not use that directly?

@KvngMikey

Copy link
Copy Markdown
Contributor Author

Hi @KvngMikey, sorry for getting back to you late. We have a crud called update_keyset that supports setting the active flag (latest main also can update the fee, worth a merge of main into this branch). Why not use that directly?

hi @callebtc , thanks for checking my work, I have addressed your request now.

@callebtc

Copy link
Copy Markdown
Collaborator

we talked about this off band: there should probably be a boolean deleted column in the keyset table of the wallet and if we flip it to True, the keyset shouldn't be loaded from the db, when we use load_keysets(). wanna try that?

@KvngMikey

Copy link
Copy Markdown
Contributor Author

we talked about this off band: there should probably be a boolean deleted column in the keyset table of the wallet and if we flip it to True, the keyset shouldn't be loaded from the db, when we use load_keysets(). wanna try that?

yes, i remember and i'm working on it.

@KvngMikey KvngMikey force-pushed the wallet-deleted-keysets branch from 9a9688c to dfd79ed Compare December 18, 2025 21:28
@KvngMikey

Copy link
Copy Markdown
Contributor Author

@callebtc PR updated, ready for re-review, thank you.

@KvngMikey

Copy link
Copy Markdown
Contributor Author

question:
when this PR goes in, would we need to update NUT01 to show that keysets can now be marked as deleted ?

cc @callebtc @a1denvalu3

@codecov

codecov Bot commented Jan 16, 2026

Copy link
Copy Markdown

❌ 1 Tests Failed:

Tests completed Failed Passed Skipped
656 1 655 70
View the top 2 failed test(s) by shortest run time
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 0x7f5e99384700>
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 301 waits for AccessExclusiveLock on relation 41208 of database 16384; blocked by process 95.
E   Process 95 waits for RowExclusiveLock on relation 41139 of database 16384; blocked by process 301.
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 0x7f5e932f9ae0>
dialect = <sqlalchemy.dialects.postgresql.asyncpg.PGDialect_asyncpg object at 0x7f5e932ef0d0>
context = <sqlalchemy.dialects.postgresql.asyncpg.PGExecutionContext_asyncpg object at 0x7f5e93205ab0>
statement = <sqlalchemy.dialects.postgresql.asyncpg.PGCompiler_asyncpg object at 0x7f5e93205c30>
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 0x7f5e995584f0>>
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 301 waits for AccessExclusiveLock on relation 41208 of database 16384; blocked by process 95.
E                   Process 95 waits for RowExclusiveLock on relation 41139 of database 16384; blocked by process 301.
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 0x7f5e99a285e0>
event_loop_fixture_id = 'event_loop'
setup = <function _wrap_asyncgen_fixture.<locals>._asyncgen_fixture_wrapper.<locals>.setup at 0x7f5e93267f40>
finalizer = <function _wrap_asyncgen_fixture.<locals>._asyncgen_fixture_wrapper.<locals>.finalizer at 0x7f5e93267eb0>

    @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 0x7f5e995584f0>>
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 301 waits for AccessExclusiveLock on relation 41208 of database 16384; blocked by process 95.
E                   Process 95 waits for RowExclusiveLock on relation 41139 of database 16384; blocked by process 301.
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_melt
Stack Traces | 3.94s run time
self = <cashu.wallet.wallet.Wallet object at 0x7f457f1799f0>
proofs = [Proof(id='01d8a63077d0a51f9855f066409782ffcb322dc8a2265291865221ed06c039f6bc', amount=64, secret='6a7e3204c4681498e38...path='HMAC-SHA256:01d8a63077d0a51f9855f066409782ffcb322dc8a2265291865221ed06c039f6bc:562', mint_id=None, melt_id=None)]
invoice = 'lnbc640n1p4qyvux9qypqqqdqqxqrrsssp5qpp35l93qef2dd6sdfuelw2kl2cs49r3wuyhw2mhupzw56advusqpp5ay49djutvcqvayvlvxskdhy3qfj...mag59mhq5yfv6c8j8pxvyalydddy7wecsxmavq8hluxrsrk4ta0nezupjj4sq3f5mn7y5lydxt07t9tq7jjua0kspx3cusqae7snxux249qnhtqpda2pug'
fee_reserve_sat = 0, quote_id = 'HbjQ4WMgCWKD_r6yAR7OBRYe6Xw5sty0INre_S7d'

    async def melt(
        self, proofs: List[Proof], invoice: str, fee_reserve_sat: int, quote_id: str
    ) -> 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.
    
        """
    
        # 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)

cashu/wallet/wallet.py:891: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
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:526: in melt
    raise e
cashu/wallet/v1_api.py:515: 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: mint quote already paid (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 0x7f457f1799f0>

    @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:335: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <cashu.wallet.wallet.Wallet object at 0x7f457f1799f0>
proofs = [Proof(id='01d8a63077d0a51f9855f066409782ffcb322dc8a2265291865221ed06c039f6bc', amount=64, secret='6a7e3204c4681498e38...path='HMAC-SHA256:01d8a63077d0a51f9855f066409782ffcb322dc8a2265291865221ed06c039f6bc:562', mint_id=None, melt_id=None)]
invoice = 'lnbc640n1p4qyvux9qypqqqdqqxqrrsssp5qpp35l93qef2dd6sdfuelw2kl2cs49r3wuyhw2mhupzw56advusqpp5ay49djutvcqvayvlvxskdhy3qfj...mag59mhq5yfv6c8j8pxvyalydddy7wecsxmavq8hluxrsrk4ta0nezupjj4sq3f5mn7y5lydxt07t9tq7jjua0kspx3cusqae7snxux249qnhtqpda2pug'
fee_reserve_sat = 0, quote_id = 'HbjQ4WMgCWKD_r6yAR7OBRYe6Xw5sty0INre_S7d'

    async def melt(
        self, proofs: List[Proof], invoice: str, fee_reserve_sat: int, quote_id: str
    ) -> 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.
    
        """
    
        # 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)
        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: mint quote already paid (Code: 11000)

cashu/wallet/wallet.py:896: Exception

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

@ye0man ye0man added this to nutshell Jan 21, 2026
@github-project-automation github-project-automation Bot moved this to In progress in nutshell Jan 21, 2026
@callebtc

Copy link
Copy Markdown
Collaborator

question: when this PR goes in, would we need to update NUT01 to show that keysets can now be marked as deleted ?

cc @callebtc @a1denvalu3

nope, this is just for the wallet and not related to the API. the mint just deletes the keyset

@KvngMikey

Copy link
Copy Markdown
Contributor Author

question: when this PR goes in, would we need to update NUT01 to show that keysets can now be marked as deleted ?
cc @callebtc @a1denvalu3

nope, this is just for the wallet and not related to the API. the mint just deletes the keyset

great, thank you !

@KvngMikey KvngMikey force-pushed the wallet-deleted-keysets branch from d1aa49d to 6624071 Compare February 9, 2026 10:05
@KvngMikey

Copy link
Copy Markdown
Contributor Author

@callebtc - this has also been rebased and is ready.

cc @a1denvalu3

Comment thread cashu/core/base.py Outdated
Comment thread cashu/core/base.py Outdated
Comment thread cashu/core/base.py Outdated
@ye0man ye0man added this to the 0.20.0 milestone Mar 10, 2026
Copilot AI review requested due to automatic review settings March 27, 2026 10:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review is ineligible. To be eligible to request a review, you need a paid Copilot license, or your organization must enable Copilot code review.

Comment thread cashu/wallet/wallet.py Outdated
Comment thread cashu/wallet/wallet.py Outdated
@KvngMikey KvngMikey force-pushed the wallet-deleted-keysets branch from 4b4ccac to 5364262 Compare March 27, 2026 18:45
Comment thread cashu/wallet/wallet.py Outdated
@a1denvalu3

Copy link
Copy Markdown
Collaborator

title: "Wallet permanently loses unspent funds when a mint stops reporting a keyset"
slug: wallet-loses-funds-on-deleted-keyset
date: 2026-04-14
status: confirmed
severity: high
target: nutshell
nuts: [NUT-02]

Summary

The changes introduced in PR #797 mark keysets as deleted (deleted_at = int(time.time())) if they disappear from the mint's /v1/keysets response. However, get_keysets defaults to excluding deleted keysets, which causes load_keysets_from_db() to remove these keysets from self.keysets. Consequently, load_proofs() completely ignores any unspent proofs belonging to deleted keysets. This causes valid, unspent user funds to silently vanish from the wallet's balance and memory, rendering them permanently unspendable.

Root Cause

In cashu/wallet/wallet.py (load_mint_keysets), keysets missing from the mint's response are marked as deleted:

        for key_id in (local_ids - active_ids):
            ks = keysets_in_db_dict[key_id]
            if ks.deleted_at is None:
                ks.deleted_at = int(time.time())
            await update_keyset(keyset=ks, db=self.db)

Subsequently, load_keysets_from_db() filters out these keysets. In cashu/wallet/wallet.py (load_proofs), the wallet only loads proofs if their keyset ID exists in self.keysets:

            else:
                for keyset_id in self.keysets:
                    proofs = await get_proofs(db=self.db, id=keyset_id, conn=conn)
                    self.proofs.extend(proofs)

Because the deleted keysets are no longer in self.keysets, their unspent proofs are skipped entirely.

Attack Steps

  1. A user holds unspent proofs associated with a specific keyset in their wallet.
  2. The mint rotates its keysets and decides to remove the old keyset from its /v1/keysets payload (e.g., to reduce payload size or deprecate the keyset).
  3. The user connects their wallet to the mint, triggering load_mint_keysets().
  4. The wallet marks the old keyset as deleted in its database since it is no longer reported by the mint.
  5. The wallet calls load_proofs(), which fails to load any unspent proofs belonging to the deleted keyset.
  6. The user's balance for those proofs drops to zero, and the funds are permanently lost from the wallet UI and internal memory.

Impact

Any user holding tokens from a keyset that a mint stops returning will permanently lose access to those funds in their wallet. The proofs are silently hidden and cannot be swapped, sent, or recovered without manually editing the SQLite database. This constitutes a high-severity loss of funds/denial of service.

Test Results

A test script confirming this behavior failed initially (showing the balance dropped from 100 to 0). Applying the proposed fix successfully resolved the test.

Balance before mint drop: 100
Balance after mint drop: 0
VULNERABILITY CONFIRMED: Proofs from deleted keyset are no longer loaded into the wallet, causing permanent loss of funds.

Proposed Fix

Before marking a keyset as deleted in load_mint_keysets, check if there are any unspent proofs associated with it. If there are, do not mark it as deleted.

        for key_id in (local_ids - active_ids):
            ks = keysets_in_db_dict[key_id]
            
            # Check if there are any unspent proofs for this keyset
            unspent = await get_proofs(db=self.db, id=key_id)
            if unspent:
                logger.warning(f"Keyset {key_id} disappeared from mint but has unspent proofs. Not deleting.")
                continue

            logger.debug(f"Keyset {key_id} no longer reported by mint, marking as deleted")
            if ks.deleted_at is None:
                ks.deleted_at = int(time.time())
            await update_keyset(keyset=ks, db=self.db)

@a1denvalu3

Copy link
Copy Markdown
Collaborator

title: "Reappearing keysets are never undeleted, causing permanent loss of funds"
slug: permanent-keyset-deletion
date: 2026-04-13
status: confirmed
severity: high
target: nutshell
nuts: [NUT-02]

Summary

The wallet tracks keysets that are reported by the mint and marks them as deleted (deleted_at = int(time.time())) if they disappear from the mint's active keysets list. However, if a keyset reappears (e.g., after a mint configuration fix, network error, or pagination bug), the wallet fails to clear the deleted_at flag. Consequently, the keyset remains permanently excluded from self.keysets, rendering any proofs associated with it permanently unspendable by active_proofs().

Root Cause

In cashu/wallet/wallet.py (inside load_mint_keysets), when updating existing keysets, the deleted_at column is never reset to None if the keyset is found in the current mint's response.

        for mint_keyset in mint_keysets_dict.values():
            if mint_keyset.id in keysets_in_db_dict:
                changed = False
                if (not mint_keyset.active ... ):
                    ...
                if (mint_keyset.input_fee_ppk ... ):
                    ...
                # MISSING: Check and reset deleted_at

Because get_keysets defaults to exclude_deleted=True, load_keysets_from_db() subsequently fails to load this keyset into self.keysets. Thus, active_proofs() will permanently filter out any proofs matching this keyset ID, causing a loss of funds for the user.

Attack Steps

  1. The user's wallet loads a valid keyset and mints proofs against it.
  2. The mint temporarily fails to include the keyset in its /v1/keys response (e.g., due to a temporary misconfiguration, outage, or reverse proxy bug).
  3. The user's wallet calls load_mint_keysets() and permanently marks the keyset with deleted_at = timestamp.
  4. The mint resolves the issue, and the keyset reappears in /v1/keys.
  5. The user's wallet calls load_mint_keysets() again. It detects the keyset in the DB and checks for active or fee changes but fails to reset deleted_at = None.
  6. The keyset remains deleted in the DB. The user cannot spend any proofs from this keyset.

Impact

Users can permanently lose access to their funds if the mint temporarily fails to report a keyset. The wallet will permanently drop the keyset, rendering valid proofs unspendable. This constitutes a high-severity denial of service and potential loss of funds.

Test Results

A test script confirming this behavior failed initially (showing the keyset was permanently excluded from wallet.keysets). Applying the proposed fix successfully resolved the test.

Proposed Fix

In cashu/wallet/wallet.py, load_mint_keysets, reset deleted_at to None when an existing keyset reappears in the mint's keyset list:

                if keysets_in_db_dict[mint_keyset.id].deleted_at is not None:
                    keysets_in_db_dict[mint_keyset.id].deleted_at = None
                    changed = True

@ye0man ye0man modified the milestones: 0.20.0, 0.21.0 May 5, 2026
@ye0man ye0man moved this from In progress to Needs Review in nutshell Jun 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Needs Review

Development

Successfully merging this pull request may close these issues.

Bug: Wallet should mark keysets as "deleted" if they disappear from the mint's keysets response

5 participants