From 08acd6f52850b4e0f592a2ec4a1ed42383c05592 Mon Sep 17 00:00:00 2001 From: kvngmikey Date: Thu, 20 Aug 2026 11:10:13 +0100 Subject: [PATCH 1/6] fix(mint): align auth error codes with NUT-21/22 The clear and blind auth errors shipped codes in the 80000/81000 range, but NUT-21 and NUT-22 specify 30001/30002 and 31001-31004 respectively. Clients dispatching on the specified codes never matched. Remap all six classes to their specified codes. Error messages are unchanged, so callers matching on `detail` are unaffected. Add a parametrized test pinning each class to its specified code, and assert the code on the four raise paths that already had coverage. Closes #769 --- cashu/core/errors.py | 12 ++++----- tests/mint/test_mint_auth_server_unit.py | 33 +++++++++++++++++++++--- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/cashu/core/errors.py b/cashu/core/errors.py index e00453fc2..4fc93510f 100644 --- a/cashu/core/errors.py +++ b/cashu/core/errors.py @@ -234,7 +234,7 @@ def __init__(self): class ClearAuthRequiredError(CashuError): detail = "Endpoint requires clear auth" - code = 80001 + code = 30001 def __init__(self): super().__init__(self.detail, code=self.code) @@ -242,7 +242,7 @@ def __init__(self): class ClearAuthFailedError(CashuError): detail = "Clear authentication failed" - code = 80002 + code = 30002 def __init__(self): super().__init__(self.detail, code=self.code) @@ -250,7 +250,7 @@ def __init__(self): class BlindAuthRequiredError(CashuError): detail = "Endpoint requires blind auth" - code = 81001 + code = 31001 def __init__(self): super().__init__(self.detail, code=self.code) @@ -258,7 +258,7 @@ def __init__(self): class BlindAuthFailedError(CashuError): detail = "Blind authentication failed" - code = 81002 + code = 31002 def __init__(self): super().__init__(self.detail, code=self.code) @@ -266,7 +266,7 @@ def __init__(self): class BlindAuthAmountExceededError(CashuError): detail = "Maximum blind auth amount exceeded" - code = 81003 + code = 31003 def __init__(self, detail: Optional[str] = None): super().__init__(detail or self.detail, code=self.code) @@ -274,7 +274,7 @@ def __init__(self, detail: Optional[str] = None): class BlindAuthRateLimitExceededError(CashuError): detail = "Blind auth token mint rate limit exceeded" - code = 81004 + code = 31004 def __init__(self): super().__init__(self.detail, code=self.code) diff --git a/tests/mint/test_mint_auth_server_unit.py b/tests/mint/test_mint_auth_server_unit.py index 9644d3078..8e6be7b9d 100644 --- a/tests/mint/test_mint_auth_server_unit.py +++ b/tests/mint/test_mint_auth_server_unit.py @@ -9,7 +9,9 @@ BlindAuthAmountExceededError, BlindAuthFailedError, BlindAuthRateLimitExceededError, + BlindAuthRequiredError, ClearAuthFailedError, + ClearAuthRequiredError, ) from cashu.core.secret import Secret, Tags from cashu.core.settings import settings @@ -28,6 +30,25 @@ def _auth_token(secret: str = "secret", proof_id: str = "kid") -> str: return AuthProof.from_proof(proof).to_base64() +@pytest.mark.parametrize( + "error_class, code", + [ + (ClearAuthRequiredError, 30001), + (ClearAuthFailedError, 30002), + (BlindAuthRequiredError, 31001), + (BlindAuthFailedError, 31002), + (BlindAuthAmountExceededError, 31003), + (BlindAuthRateLimitExceededError, 31004), + ], +) +def test_auth_error_codes_match_nut21_and_nut22(error_class, code): + """Auth error codes are specified in NUT-21 and NUT-22, see + https://github.com/cashubtc/nuts/blob/main/error_codes.md + """ + assert error_class.code == code + assert error_class().code == code + + def test_verify_oicd_issuer_accepts_matching_issuer(): ledger = _ledger() ledger.issuer = "https://issuer.test" @@ -98,8 +119,9 @@ async def get_user(decoded): monkeypatch.setattr(ledger, "_get_user", get_user) - with pytest.raises(ClearAuthFailedError): + with pytest.raises(ClearAuthFailedError) as exc_info: await ledger.verify_clear_auth("token") + assert exc_info.value.code == 30002 @pytest.mark.asyncio @@ -118,8 +140,9 @@ def fail_limit(identifier, *args, **kwargs): monkeypatch.setattr("cashu.mint.auth.server.assert_limit", fail_limit) - with pytest.raises(BlindAuthRateLimitExceededError): + with pytest.raises(BlindAuthRateLimitExceededError) as exc_info: await ledger.verify_clear_auth("token") + assert exc_info.value.code == 31004 @pytest.mark.asyncio @@ -147,8 +170,9 @@ async def test_mint_blind_auth_enforces_maximum_outputs(monkeypatch): monkeypatch.setattr(settings, "mint_auth_max_blind_tokens", 2) outputs = [BlindedMessage(id="kid", amount=1, B_=f"b{i}") for i in range(3)] - with pytest.raises(BlindAuthAmountExceededError, match="Too many outputs"): + with pytest.raises(BlindAuthAmountExceededError, match="Too many outputs") as exc_info: await ledger.mint_blind_auth(outputs=outputs, user=User(id="alice")) + assert exc_info.value.code == 31003 @pytest.mark.asyncio @@ -264,9 +288,10 @@ async def test_verify_blind_auth_rejects_malformed_nut10_secret(): ).serialize() ) - with pytest.raises(BlindAuthFailedError): + with pytest.raises(BlindAuthFailedError) as exc_info: async with ledger.verify_blind_auth(token): pass + assert exc_info.value.code == 31002 @pytest.mark.asyncio From ec4cc68984da18f5a5dc9edb34db5157e2ff59e7 Mon Sep 17 00:00:00 2001 From: kvngmikey Date: Thu, 20 Aug 2026 11:33:07 +0100 Subject: [PATCH 2/6] fix(mint): resolve colliding NUT error codes TransactionUnitError duplicated TransactionMultipleUnitsError on 11009; remove it and raise the latter at its single call site. SecretTooLongError and WitnessTooLongError sat on 11003 and 11004, which the NUTs assign to "outputs already signed" and "outputs are pending". No NUT code covers input length limits, so both fall back to the generic 11000. Classes and messages are unchanged. --- cashu/core/errors.py | 11 ++--------- cashu/mint/verification.py | 3 +-- tests/mint/test_mint_verification.py | 12 ++++++++---- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/cashu/core/errors.py b/cashu/core/errors.py index 4fc93510f..e0b6480fd 100644 --- a/cashu/core/errors.py +++ b/cashu/core/errors.py @@ -75,14 +75,14 @@ def __init__(self, detail): class SecretTooLongError(TransactionError): - code = 11003 + code = 11000 def __init__(self, detail="secret too long"): super().__init__(detail, code=self.code) class WitnessTooLongError(TransactionError): - code = 11004 + code = 11000 def __init__(self, detail="witness too long"): super().__init__(detail, code=self.code) @@ -96,13 +96,6 @@ def __init__(self): super().__init__(self.detail, code=self.code) -class TransactionUnitError(TransactionError): - code = 11009 - - def __init__(self, detail): - super().__init__(detail, code=self.code) - - class TransactionAmountExceedsLimitError(TransactionError): code = 11006 diff --git a/cashu/mint/verification.py b/cashu/mint/verification.py index 7551178ad..f50e8cd7e 100644 --- a/cashu/mint/verification.py +++ b/cashu/mint/verification.py @@ -25,7 +25,6 @@ TransactionDuplicateOutputsError, TransactionError, TransactionMultipleUnitsError, - TransactionUnitError, TransactionUnitMismatchError, WitnessTooLongError, ) @@ -351,7 +350,7 @@ def _verify_units_match( def get_fees_for_proofs(self, proofs: List[Proof]) -> int: if not len({self.keysets[p.id].unit for p in proofs}) == 1: - raise TransactionUnitError("inputs have different units.") + raise TransactionMultipleUnitsError("inputs have different units.") fee = (sum([self.keysets[p.id].input_fee_ppk for p in proofs]) + 999) // 1000 return fee diff --git a/tests/mint/test_mint_verification.py b/tests/mint/test_mint_verification.py index 8e5a84822..050dd6e21 100644 --- a/tests/mint/test_mint_verification.py +++ b/tests/mint/test_mint_verification.py @@ -29,7 +29,6 @@ TransactionDuplicateOutputsError, TransactionError, TransactionMultipleUnitsError, - TransactionUnitError, TransactionUnitMismatchError, WitnessTooLongError, ) @@ -101,8 +100,9 @@ def test_verify_secret_criteria_rejects_too_long_secret(ledger: Ledger): secret="x" * (settings.mint_max_secret_length + 1), C="02" + "ab" * 32, ) - with pytest.raises(SecretTooLongError): + with pytest.raises(SecretTooLongError) as exc_info: ledger._verify_secret_criteria(p) + assert exc_info.value.code == 11000 # --------------------------------------------------------------------------- @@ -147,8 +147,9 @@ def test_verify_input_witness_criteria_rejects_long_witness(ledger: Ledger): C="02" + "ab" * 32, witness="w" * (settings.mint_max_witness_length + 1), ) - with pytest.raises(WitnessTooLongError): + with pytest.raises(WitnessTooLongError) as exc_info: ledger._verify_input_witness_criteria(p) + assert exc_info.value.code == 11000 # --------------------------------------------------------------------------- @@ -282,8 +283,11 @@ def test_get_fees_for_proofs_rejects_mixed_units(ledger: Ledger): p1.id = "k_sat" p2 = MagicMock() p2.id = "k_usd" - with pytest.raises(TransactionUnitError, match="inputs have different units"): + with pytest.raises( + TransactionMultipleUnitsError, match="inputs have different units" + ) as exc_info: ledger.get_fees_for_proofs([p1, p2]) + assert exc_info.value.code == 11009 finally: ledger.keysets = orig From 7f2a8abafcf08817d48978a53eb4a44fe89d8e43 Mon Sep 17 00:00:00 2001 From: kvngmikey Date: Thu, 20 Aug 2026 13:11:51 +0100 Subject: [PATCH 3/6] fix(mint): return NUT-04/05 codes for quote lifecycle errors Minting disabled, quote pending, invoice already paid and quote expired all surfaced as generic TransactionError (11000) or NotAllowedError (10000), neither of which appears in the NUT error code table. Add MintingDisabledError (20003), QuotePendingError (20005), InvoiceAlreadyPaidError (20006) and QuoteExpiredError (20007), and raise them at the eight sites that already detect these conditions. Each site keeps its existing message, so only the code changes. Melt-disabled keeps NotAllowedError: 20003 is mint-only per NUT-04. --- cashu/core/errors.py | 32 +++++++++++++++++ cashu/mint/ledger.py | 20 ++++++----- tests/mint/test_mint_operations.py | 57 +++++++++++++++++++++++++++++- 3 files changed, 100 insertions(+), 9 deletions(-) diff --git a/cashu/core/errors.py b/cashu/core/errors.py index e0b6480fd..9330b3623 100644 --- a/cashu/core/errors.py +++ b/cashu/core/errors.py @@ -201,6 +201,14 @@ def __init__(self): super().__init__(self.detail, code=self.code) +class MintingDisabledError(CashuError): + detail = "Minting is disabled" + code = 20003 + + def __init__(self, detail: Optional[str] = None): + super().__init__(detail or self.detail, code=self.code) + + class LightningPaymentFailedError(CashuError): detail = "Lightning payment failed" code = 20004 @@ -209,6 +217,30 @@ def __init__(self, detail: Optional[str] = None): super().__init__(detail or self.detail, code=self.code) +class QuotePendingError(CashuError): + detail = "quote is pending" + code = 20005 + + def __init__(self, detail: Optional[str] = None): + super().__init__(detail or self.detail, code=self.code) + + +class InvoiceAlreadyPaidError(CashuError): + detail = "invoice already paid" + code = 20006 + + def __init__(self, detail: Optional[str] = None): + super().__init__(detail or self.detail, code=self.code) + + +class QuoteExpiredError(CashuError): + detail = "quote is expired" + code = 20007 + + def __init__(self, detail: Optional[str] = None): + super().__init__(detail or self.detail, code=self.code) + + class QuoteSignatureInvalidError(CashuError): detail = "Signature for mint request invalid" code = 20008 diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 070ad8dee..60661468a 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -30,12 +30,16 @@ from ..core.errors import ( BatchDuplicateQuotesError, CashuError, + InvoiceAlreadyPaidError, KeysetInactiveError, LightningError, LightningPaymentFailedError, + MintingDisabledError, NotAllowedError, QuoteAlreadyIssuedError, + QuoteExpiredError, QuoteNotPaidError, + QuotePendingError, QuoteSignatureInvalidError, TransactionAmountExceedsLimitError, TransactionError, @@ -329,7 +333,7 @@ async def mint_quote(self, quote_request: PostMintQuoteRequest) -> MintQuote: f"Maximum mint amount is {settings.mint_max_mint_bolt11_sat} sat." ) if settings.mint_bolt11_disable_mint: - raise NotAllowedError("Minting with bolt11 is disabled.") + raise MintingDisabledError("Minting with bolt11 is disabled.") unit, method = self._verify_and_get_unit_method( quote_request.unit, Method.bolt11.name @@ -510,7 +514,7 @@ async def mint( quote = await self.get_mint_quote(quote_id) if quote.pending: - raise TransactionError("Mint quote already pending.") + raise QuotePendingError("Mint quote already pending.") if quote.issued: raise QuoteAlreadyIssuedError() if quote.state != MintQuoteState.paid: @@ -524,7 +528,7 @@ async def mint( if not quote.amount == sum_amount_outputs: raise TransactionError("amount to mint does not match quote amount") if quote.expiry and quote.expiry < int(time.time()): - raise TransactionError("quote expired") + raise QuoteExpiredError("quote expired") if not self._verify_mint_quote_witness(quote, outputs, signature): raise QuoteSignatureInvalidError() await self._store_blinded_messages(outputs, mint_id=quote_id) @@ -595,7 +599,7 @@ async def mint_batch( for quote in quotes: if quote.pending: - raise TransactionError("mint quote already pending") + raise QuotePendingError("mint quote already pending") if quote.issued: raise QuoteAlreadyIssuedError() if quote.state != MintQuoteState.paid: @@ -646,7 +650,7 @@ async def mint_batch( try: for quote in quotes: if quote.expiry and quote.expiry < int(time.time()): - raise TransactionError("quote expired") + raise QuoteExpiredError("quote expired") # Store all blinded messages await self._store_blinded_messages( @@ -685,7 +689,7 @@ def create_internal_melt_quote( if not mint_quote.method == method.name: raise TransactionError("methods do not match") if mint_quote.paid: - raise TransactionError("mint quote already paid") + raise InvoiceAlreadyPaidError("mint quote already paid") if mint_quote.issued: raise TransactionError("mint quote already issued") if not mint_quote.unpaid: @@ -979,7 +983,7 @@ async def melt_mint_settle_internally( # we settle the transaction internally if melt_quote.state == MeltQuoteState.paid: - raise TransactionError("melt quote already paid") + raise InvoiceAlreadyPaidError("melt quote already paid") # verify amounts from bolt11 invoice bolt11_request = melt_quote.request @@ -995,7 +999,7 @@ async def melt_mint_settle_internally( raise TransactionError("methods do not match") if mint_quote.paid: - raise TransactionError("mint quote already paid") + raise InvoiceAlreadyPaidError("mint quote already paid") if mint_quote.issued: raise TransactionError("mint quote already issued") diff --git a/tests/mint/test_mint_operations.py b/tests/mint/test_mint_operations.py index d13fb523a..f76aab5da 100644 --- a/tests/mint/test_mint_operations.py +++ b/tests/mint/test_mint_operations.py @@ -2,7 +2,14 @@ import pytest_asyncio from cashu.core.base import MeltQuoteState, MintQuoteState -from cashu.core.errors import OutputsAlreadySignedError, ProofsAlreadySpentError +from cashu.core.errors import ( + InvoiceAlreadyPaidError, + MintingDisabledError, + OutputsAlreadySignedError, + ProofsAlreadySpentError, + QuoteExpiredError, + QuotePendingError, +) from cashu.core.helpers import sum_proofs from cashu.core.models import PostMeltQuoteRequest, PostMintQuoteRequest from cashu.core.nuts import nut20 @@ -530,6 +537,54 @@ async def test_melt_preserves_change_signatures_order_integration(wallet1: Walle for i, proof in enumerate(change_proofs): assert proof.amount == expected_amounts[i] + +@pytest.mark.parametrize( + "error_class, code", + [ + (MintingDisabledError, 20003), + (QuotePendingError, 20005), + (InvoiceAlreadyPaidError, 20006), + (QuoteExpiredError, 20007), + ], +) +def test_quote_lifecycle_error_codes(error_class, code): + assert error_class.code == code + assert error_class().code == code + + +@pytest.mark.asyncio +async def test_mint_quote_disabled_raises_minting_disabled(ledger: Ledger, monkeypatch): + monkeypatch.setattr(settings, "mint_bolt11_disable_mint", True) + + with pytest.raises(MintingDisabledError) as exc_info: + await ledger.mint_quote(PostMintQuoteRequest(unit="sat", amount=128)) + assert exc_info.value.code == 20003 + + +@pytest.mark.asyncio +@pytest.mark.skipif(is_regtest, reason="only works with FakeWallet") +async def test_mint_pending_quote_raises_quote_pending( + wallet1: Wallet, ledger: Ledger +): + wallet_mint_quote = await wallet1.request_mint(128) + mint_quote = await ledger.get_mint_quote(wallet_mint_quote.quote) + assert mint_quote.state == MintQuoteState.paid + + secrets, rs, _ = await wallet1.generate_n_secrets(1) + outputs, rs = wallet1._construct_outputs([128], secrets, rs) + + await ledger.db_write._set_mint_quote_pending(mint_quote.quote) + try: + with pytest.raises(QuotePendingError) as exc_info: + await ledger.mint(outputs=outputs, quote_id=mint_quote.quote) + assert exc_info.value.code == 20005 + finally: + await ledger.db_write._unset_mint_quote_pending( + mint_quote.quote, MintQuoteState.paid + ) + + + # TODO: test keeps running forever, needs to be fixed # @pytest.mark.asyncio # async def test_websocket_quote_updates(wallet1: Wallet, ledger: Ledger): From 17d553d9428da79e56da0b951808419c1b268d8b Mon Sep 17 00:00:00 2001 From: kvngmikey Date: Thu, 20 Aug 2026 16:35:23 +0100 Subject: [PATCH 4/6] fix(mint): return NUT-03/04/05 codes for transaction errors Amountless invoices, amount mismatches and unsupported units surfaced as generic TransactionError (11000) or NotAllowedError (10000), neither of which appears in the NUT error code table. Add AmountlessInvoiceNotSupportedError (11011), AmountMismatchError (11012) and UnitNotSupportedError (11013), and raise them at the five sites that already detect these conditions. Each site keeps its existing message, so only the code changes. The method/unit backend check in _verify_and_get_unit_method keeps NotAllowedError: 11013 covers the unit, not the method. --- cashu/core/errors.py | 24 ++++++++++++++++++++++++ cashu/mint/ledger.py | 10 ++++++---- cashu/mint/verification.py | 5 ++++- tests/mint/test_mint_verification.py | 21 ++++++++++++++++++++- 4 files changed, 54 insertions(+), 6 deletions(-) diff --git a/cashu/core/errors.py b/cashu/core/errors.py index 9330b3623..c4f7cb58e 100644 --- a/cashu/core/errors.py +++ b/cashu/core/errors.py @@ -135,6 +135,30 @@ def __init__(self, detail: Optional[str] = None): super().__init__(detail, code=self.code) +class AmountlessInvoiceNotSupportedError(TransactionError): + detail = "Amountless invoice is not supported" + code = 11011 + + def __init__(self, detail: Optional[str] = None): + super().__init__(detail or self.detail, code=self.code) + + +class AmountMismatchError(TransactionError): + detail = "Amount in request does not equal invoice" + code = 11012 + + def __init__(self, detail: Optional[str] = None): + super().__init__(detail or self.detail, code=self.code) + + +class UnitNotSupportedError(TransactionError): + detail = "Unit in request is not supported" + code = 11013 + + def __init__(self, detail: Optional[str] = None): + super().__init__(detail or self.detail, code=self.code) + + class BatchDuplicateQuotesError(TransactionError): detail = "Duplicate quote IDs provided" code = 11016 diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 60661468a..535cdf93d 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -28,6 +28,8 @@ from ..core.crypto.secp import PrivateKey, PublicKey from ..core.db import Connection, Database from ..core.errors import ( + AmountlessInvoiceNotSupportedError, + AmountMismatchError, BatchDuplicateQuotesError, CashuError, InvoiceAlreadyPaidError, @@ -732,7 +734,7 @@ def validate_payment_quote( logger.error( f"expected {payment_quote.amount.to(Unit.msat).amount} msat but got {melt_quote.mpp_amount}" ) - raise TransactionError("quote amount not as requested") + raise AmountMismatchError("quote amount not as requested") # make sure the backend returned the amount with a correct unit if not payment_quote.amount.unit == unit: raise TransactionError("payment quote amount units do not match") @@ -802,7 +804,7 @@ async def melt_quote( # support only the bol11 method for now. invoice_obj = bolt11.decode(melt_quote.request) if not invoice_obj.amount_msat: - raise TransactionError("invoice has no amount.") + raise AmountlessInvoiceNotSupportedError("invoice has no amount.") # we set the expiry of this quote to the expiry of the bolt11 invoice now = int(time.time()) expiry = None @@ -990,9 +992,9 @@ async def melt_mint_settle_internally( invoice_obj = bolt11.decode(bolt11_request) if not invoice_obj.amount_msat: - raise TransactionError("invoice has no amount.") + raise AmountlessInvoiceNotSupportedError("invoice has no amount.") if not mint_quote.amount == melt_quote.amount: - raise TransactionError("amounts do not match") + raise AmountMismatchError("amounts do not match") if not bolt11_request == mint_quote.request: raise TransactionError("bolt11 requests do not match") if not mint_quote.method == melt_quote.method: diff --git a/cashu/mint/verification.py b/cashu/mint/verification.py index f50e8cd7e..594d4bcb5 100644 --- a/cashu/mint/verification.py +++ b/cashu/mint/verification.py @@ -26,6 +26,7 @@ TransactionError, TransactionMultipleUnitsError, TransactionUnitMismatchError, + UnitNotSupportedError, WitnessTooLongError, ) from ..core.nuts import nut20 @@ -384,7 +385,9 @@ def _verify_and_get_unit_method( unit = Unit[unit_str] if not any([unit == k.unit for k in self.keysets.values()]): - raise NotAllowedError(f"unit '{unit.name}' not supported in any keyset.") + raise UnitNotSupportedError( + f"unit '{unit.name}' not supported in any keyset." + ) if not self.backends.get(method) or unit not in self.backends[method]: raise NotAllowedError( diff --git a/tests/mint/test_mint_verification.py b/tests/mint/test_mint_verification.py index 050dd6e21..35f15ef2b 100644 --- a/tests/mint/test_mint_verification.py +++ b/tests/mint/test_mint_verification.py @@ -17,6 +17,8 @@ from cashu.core.crypto.b_dhke import hash_to_curve, step1_alice from cashu.core.crypto.secp import PrivateKey from cashu.core.errors import ( + AmountlessInvoiceNotSupportedError, + AmountMismatchError, InvalidProofsError, KeysetInactiveError, NoSecretInProofsError, @@ -30,6 +32,7 @@ TransactionError, TransactionMultipleUnitsError, TransactionUnitMismatchError, + UnitNotSupportedError, WitnessTooLongError, ) from cashu.core.nuts import nut11, nut20 @@ -335,6 +338,19 @@ def test_verify_equation_balanced_rejects_unbalanced(ledger: Ledger): # --------------------------------------------------------------------------- +@pytest.mark.parametrize( + "error_class, code", + [ + (AmountlessInvoiceNotSupportedError, 11011), + (AmountMismatchError, 11012), + (UnitNotSupportedError, 11013), + ], +) +def test_transaction_error_codes(error_class, code): + assert error_class.code == code + assert error_class().code == code + + def test_verify_and_get_unit_method_accepts_bolt11_sat(ledger: Ledger): u, m = ledger._verify_and_get_unit_method("sat", "bolt11") assert u == Unit.sat @@ -342,8 +358,11 @@ def test_verify_and_get_unit_method_accepts_bolt11_sat(ledger: Ledger): def test_verify_and_get_unit_method_rejects_unknown_unit(ledger: Ledger): - with pytest.raises(NotAllowedError, match="not supported in any keyset"): + with pytest.raises( + UnitNotSupportedError, match="not supported in any keyset" + ) as exc_info: ledger._verify_and_get_unit_method("auth", "bolt11") + assert exc_info.value.code == 11013 def test_verify_and_get_unit_method_rejects_unsupported_backend(ledger: Ledger): From dcefeb19aef4ca7beff493ba5c9683d424a1dd95 Mon Sep 17 00:00:00 2001 From: kvngmikey Date: Thu, 20 Aug 2026 19:38:30 +0100 Subject: [PATCH 5/6] fix(mint): return NUT codes from the locked quote guards DbWriteHelper re-checks quote state under the row lock, and that check is the authoritative one -- the matching check in ledger.py is an optimistic pre-check. Both raised the same conditions, but the locked path returned generic TransactionError (11000), so a client losing a race got 11000 where the winner got the spec code. Raise QuotePendingError (20005), QuoteNotPaidError (20001), QuoteAlreadyIssuedError (20002) and InvoiceAlreadyPaidError (20006) at the guards. The two melt guards tested for paid and pending together, so they are split to report the state the client actually hit; the same set of states raises, with messages unchanged. QuoteNotPaidError and QuoteAlreadyIssuedError take an optional detail so the guards keep their quote_id in the message. --- cashu/core/errors.py | 8 ++++---- cashu/mint/db/write.py | 38 ++++++++++++++++++------------------ tests/mint/test_mint_melt.py | 8 ++++---- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/cashu/core/errors.py b/cashu/core/errors.py index c4f7cb58e..eb776bbb2 100644 --- a/cashu/core/errors.py +++ b/cashu/core/errors.py @@ -213,16 +213,16 @@ class QuoteNotPaidError(CashuError): detail = "quote not paid" code = 20001 - def __init__(self): - super().__init__(self.detail, code=self.code) + def __init__(self, detail: Optional[str] = None): + super().__init__(detail or self.detail, code=self.code) class QuoteAlreadyIssuedError(CashuError): detail = "quote already issued" code = 20002 - def __init__(self): - super().__init__(self.detail, code=self.code) + def __init__(self, detail: Optional[str] = None): + super().__init__(detail or self.detail, code=self.code) class MintingDisabledError(CashuError): diff --git a/cashu/mint/db/write.py b/cashu/mint/db/write.py index e93d30a33..ff2598e03 100644 --- a/cashu/mint/db/write.py +++ b/cashu/mint/db/write.py @@ -15,7 +15,11 @@ ) from ...core.db import Connection, Database from ...core.errors import ( + InvoiceAlreadyPaidError, ProofsArePendingError, + QuoteAlreadyIssuedError, + QuoteNotPaidError, + QuotePendingError, TransactionError, ) from ..crud import LedgerCrud @@ -171,9 +175,9 @@ async def _set_mint_quote_pending(self, quote_id: str) -> MintQuote: if not quote: raise TransactionError("Mint quote not found.") if quote.pending: - raise TransactionError("Mint quote already pending.") + raise QuotePendingError("Mint quote already pending.") if not quote.paid: - raise TransactionError("Mint quote is not paid yet.") + raise QuoteNotPaidError("Mint quote is not paid yet.") # set the quote as pending self._set_mint_quote_state(quote, MintQuoteState.pending) logger.trace(f"crud: setting quote {quote_id} as PENDING") @@ -213,11 +217,13 @@ async def _set_mint_quotes_pending(self, quote_ids: List[str]) -> List[MintQuote if not quote: raise TransactionError(f"Mint quote {quote_id} not found.") if quote.pending: - raise TransactionError(f"Mint quote {quote_id} already pending.") + raise QuotePendingError(f"Mint quote {quote_id} already pending.") if quote.issued: - raise TransactionError(f"Mint quote {quote_id} is already issued.") + raise QuoteAlreadyIssuedError( + f"Mint quote {quote_id} is already issued." + ) if not quote.paid: - raise TransactionError(f"Mint quote {quote_id} is not paid yet.") + raise QuoteNotPaidError(f"Mint quote {quote_id} is not paid yet.") # set the quote as pending self._set_mint_quote_state(quote, MintQuoteState.pending) @@ -330,13 +336,10 @@ async def _set_melt_quote_pending( ) if len(quotes_db) == 0: raise TransactionError("Melt quote not found.") - if any( - [ - quote.state in [MeltQuoteState.pending, MeltQuoteState.paid] - for quote in quotes_db - ] - ): - raise TransactionError("Melt quote already paid or pending.") + if any([quote.state == MeltQuoteState.paid for quote in quotes_db]): + raise InvoiceAlreadyPaidError("Melt quote already paid or pending.") + if any([quote.state == MeltQuoteState.pending for quote in quotes_db]): + raise QuotePendingError("Melt quote already paid or pending.") # set the quote as pending quote_copy.state = MeltQuoteState.pending await self.crud.update_melt_quote(quote=quote_copy, db=self.db, conn=conn) @@ -441,13 +444,10 @@ async def _store_melt_quote(self, quote: MeltQuote): quotes_db = await self.crud.get_melt_quotes_by_checking_id( checking_id=quote.checking_id, db=self.db, conn=conn ) - if any( - [ - quote.state in [MeltQuoteState.pending, MeltQuoteState.paid] - for quote in quotes_db - ] - ): - raise TransactionError("Melt quote already paid or pending.") + if any([quote.state == MeltQuoteState.paid for quote in quotes_db]): + raise InvoiceAlreadyPaidError("Melt quote already paid or pending.") + if any([quote.state == MeltQuoteState.pending for quote in quotes_db]): + raise QuotePendingError("Melt quote already paid or pending.") # store the melt quote await self.crud.store_melt_quote(quote=quote, db=self.db, conn=conn) diff --git a/tests/mint/test_mint_melt.py b/tests/mint/test_mint_melt.py index 8d0e435df..05046e3f4 100644 --- a/tests/mint/test_mint_melt.py +++ b/tests/mint/test_mint_melt.py @@ -16,6 +16,7 @@ LightningPaymentFailedError, OutputsAlreadySignedError, OutputsArePendingError, + QuotePendingError, ) from cashu.core.models import PostMeltQuoteRequest, PostMintQuoteRequest from cashu.core.settings import settings @@ -593,8 +594,6 @@ async def test_set_melt_quote_pending_without_checking_id(ledger: Ledger): @pytest.mark.asyncio async def test_set_melt_quote_pending_prevents_duplicate_checking_id(ledger: Ledger): """Test that setting a melt quote as pending fails if another quote with same checking_id is already pending.""" - from cashu.core.errors import TransactionError - checking_id = "test_checking_id_duplicate" quote1 = MeltQuote( @@ -633,9 +632,10 @@ async def test_set_melt_quote_pending_prevents_duplicate_checking_id(ledger: Led # Attempt to set the second quote as pending should fail try: await ledger.db_write._set_melt_quote_pending(quote=quote2) - raise AssertionError("Expected TransactionError") - except TransactionError as e: + raise AssertionError("Expected QuotePendingError") + except QuotePendingError as e: assert "Melt quote already paid or pending." in str(e) + assert e.code == 20005 # Verify the second quote is still unpaid quote2_db = await ledger.crud.get_melt_quote( From 9583d96c6d6f4b9bc6e9d13f47a59a56810ef808 Mon Sep 17 00:00:00 2001 From: kvngmikey Date: Wed, 2 Sep 2026 06:11:45 +0100 Subject: [PATCH 6/6] fix(mint): carry NUT codes through the remaining quote state branches Split the melt pre-check in _prepare_melt so a repeat melt reports 20006 or 20005 instead of a generic 11000, add the missing issued branch to _set_mint_quote_pending, and convert the two internal-melt issued paths. --- cashu/mint/db/write.py | 2 ++ cashu/mint/ledger.py | 12 ++++++++---- tests/mint/test_mint_db.py | 22 +++++++++++++++++++++- tests/mint/test_mint_melt.py | 22 ++++++++++++++++++++++ 4 files changed, 53 insertions(+), 5 deletions(-) diff --git a/cashu/mint/db/write.py b/cashu/mint/db/write.py index ff2598e03..f9de8c6d0 100644 --- a/cashu/mint/db/write.py +++ b/cashu/mint/db/write.py @@ -176,6 +176,8 @@ async def _set_mint_quote_pending(self, quote_id: str) -> MintQuote: raise TransactionError("Mint quote not found.") if quote.pending: raise QuotePendingError("Mint quote already pending.") + if quote.issued: + raise QuoteAlreadyIssuedError(f"Mint quote {quote_id} is already issued.") if not quote.paid: raise QuoteNotPaidError("Mint quote is not paid yet.") # set the quote as pending diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 535cdf93d..e49267392 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -693,7 +693,7 @@ def create_internal_melt_quote( if mint_quote.paid: raise InvoiceAlreadyPaidError("mint quote already paid") if mint_quote.issued: - raise TransactionError("mint quote already issued") + raise QuoteAlreadyIssuedError("mint quote already issued") if not mint_quote.unpaid: raise TransactionError("mint quote is not unpaid") @@ -1003,7 +1003,7 @@ async def melt_mint_settle_internally( if mint_quote.paid: raise InvoiceAlreadyPaidError("mint quote already paid") if mint_quote.issued: - raise TransactionError("mint quote already issued") + raise QuoteAlreadyIssuedError("mint quote already issued") if mint_quote.state != MintQuoteState.unpaid: raise TransactionError("mint quote is not unpaid") @@ -1102,8 +1102,12 @@ async def _prepare_melt( # 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}") + if melt_quote.paid: + raise InvoiceAlreadyPaidError( + f"melt quote is not unpaid: {melt_quote.state}" + ) + if melt_quote.pending: + raise QuotePendingError(f"melt quote is not unpaid: {melt_quote.state}") unit, _ = self._verify_and_get_unit_method(melt_quote.unit, melt_quote.method) diff --git a/tests/mint/test_mint_db.py b/tests/mint/test_mint_db.py index cdfe617d8..cb4de1aa8 100644 --- a/tests/mint/test_mint_db.py +++ b/tests/mint/test_mint_db.py @@ -6,7 +6,7 @@ from fastapi import WebSocket from cashu.core.base import MeltQuoteState, MintQuoteState -from cashu.core.errors import ProofsArePendingError +from cashu.core.errors import ProofsArePendingError, QuoteAlreadyIssuedError from cashu.core.json_rpc.base import ( JSONRPCMethods, JSONRPCNotficationParams, @@ -208,6 +208,26 @@ async def set_state(quote, state): ) +@pytest.mark.asyncio +async def test_mint_quote_set_pending_rejects_issued_quote( + wallet: Wallet, ledger: Ledger +): + """An issued quote is neither pending nor paid, so the locked guard must not + fall through to QuoteNotPaidError.""" + mint_quote = await wallet.request_mint(128) + await pay_if_regtest(mint_quote.request) + _ = await ledger.get_mint_quote(mint_quote.quote) + + quote = await ledger.crud.get_mint_quote(quote_id=mint_quote.quote, db=ledger.db) + assert quote is not None + quote.state = MintQuoteState.issued + await ledger.crud.update_mint_quote(quote=quote, db=ledger.db) + + with pytest.raises(QuoteAlreadyIssuedError) as exc_info: + await ledger.db_write._set_mint_quote_pending(quote.quote) + assert exc_info.value.code == 20002 + + @pytest.mark.asyncio async def test_mint_quote_set_pending(wallet: Wallet, ledger: Ledger): mint_quote = await wallet.request_mint(128) diff --git a/tests/mint/test_mint_melt.py b/tests/mint/test_mint_melt.py index 05046e3f4..ca7964b70 100644 --- a/tests/mint/test_mint_melt.py +++ b/tests/mint/test_mint_melt.py @@ -13,6 +13,7 @@ Unit, ) from cashu.core.errors import ( + InvoiceAlreadyPaidError, LightningPaymentFailedError, OutputsAlreadySignedError, OutputsArePendingError, @@ -987,3 +988,24 @@ async def patched_pay_invoice(quote: MeltQuote, fee_limit_msat: int): f"Expected no orphan blank outputs for melt {melt_quote.quote}, " f"got {len(orphans)} with B_s {[o.B_ for o in orphans]}" ) + + +@pytest.mark.asyncio +async def test_prepare_melt_rejects_already_paid_quote(ledger: Ledger): + """_prepare_melt runs before the locked guard, so it must report the paid + state with its own code rather than a generic transaction error.""" + quote = MeltQuote( + quote="quote_id_already_paid", + method="bolt11", + request="lnbcfake", + checking_id="checking_id_already_paid", + unit="sat", + state=MeltQuoteState.paid, + amount=100, + fee_reserve=1, + ) + await ledger.crud.store_melt_quote(quote=quote, db=ledger.db) + + with pytest.raises(InvoiceAlreadyPaidError) as exc_info: + await ledger._prepare_melt(proofs=[], quote=quote.quote) + assert exc_info.value.code == 20006