From 109e416c4505432bb39150db0c0c879b518456b8 Mon Sep 17 00:00:00 2001 From: a1denvalu3 Date: Mon, 30 Mar 2026 13:03:48 +0200 Subject: [PATCH 01/11] feat(mint): implement NUT-29 batch minting --- cashu/core/models.py | 21 +++++++ cashu/core/nuts/nuts.py | 1 + cashu/mint/features.py | 5 ++ cashu/mint/ledger.py | 127 ++++++++++++++++++++++++++++++++++++++++ cashu/mint/router.py | 50 ++++++++++++++++ 5 files changed, 204 insertions(+) diff --git a/cashu/core/models.py b/cashu/core/models.py index d08ea6f92..8da264108 100644 --- a/cashu/core/models.py +++ b/cashu/core/models.py @@ -169,9 +169,30 @@ def from_mint_quote(cls, mint_quote: MintQuote) -> "PostMintQuoteResponse": return cls.model_validate(to_dict) +class PostMintQuoteCheckRequest(BaseModel): + quotes: List[str] = Field(..., max_length=settings.mint_max_request_length) + + # ------- API: MINT ------- +class PostMintBatchRequest(BaseModel): + quotes: List[str] = Field(..., max_length=settings.mint_max_request_length) + quote_amounts: Optional[List[int]] = Field( + default=None, max_length=settings.mint_max_request_length + ) + outputs: List[BlindedMessage] = Field( + ..., max_length=settings.mint_max_request_length + ) + signatures: Optional[List[Optional[str]]] = Field( + default=None, max_length=settings.mint_max_request_length + ) + + +class PostMintBatchResponse(BaseModel): + signatures: List[BlindedSignature] = [] + + class PostMintRequest(BaseModel): quote: str = Field(..., max_length=settings.mint_max_request_length) # quote id outputs: List[BlindedMessage] = Field( diff --git a/cashu/core/nuts/nuts.py b/cashu/core/nuts/nuts.py index 4d2b306ca..33e315af6 100644 --- a/cashu/core/nuts/nuts.py +++ b/cashu/core/nuts/nuts.py @@ -17,3 +17,4 @@ CLEAR_AUTH_NUT = 21 BLIND_AUTH_NUT = 22 METHOD_BOLT11_NUT = 23 +BATCH_MINT_NUT = 29 diff --git a/cashu/mint/features.py b/cashu/mint/features.py index 3234d4a46..88802f81f 100644 --- a/cashu/mint/features.py +++ b/cashu/mint/features.py @@ -10,6 +10,7 @@ MintMethodSetting, ) from ..core.nuts.nuts import ( + BATCH_MINT_NUT, BLIND_AUTH_NUT, CACHE_NUT, CLEAR_AUTH_NUT, @@ -123,6 +124,10 @@ def add_supported_features( mint_features[DLEQ_NUT] = supported_dict mint_features[HTLC_NUT] = supported_dict mint_features[MINT_QUOTE_SIGNATURE_NUT] = supported_dict + mint_features[BATCH_MINT_NUT] = { + "max_batch_size": settings.mint_max_request_length, + "methods": list(set([m.name for m in self.backends.keys()])), + } return mint_features def add_mpp_features( diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index fcf756a94..4366e9889 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -42,6 +42,8 @@ from ..core.models import ( PostMeltQuoteRequest, PostMeltQuoteResponse, + PostMintBatchRequest, + PostMintQuoteCheckRequest, PostMintQuoteRequest, ) from ..core.settings import settings @@ -420,6 +422,25 @@ async def get_mint_quote(self, quote_id: str) -> MintQuote: return quote + async def mint_quote_check( + self, payload: PostMintQuoteCheckRequest + ) -> List[MintQuote]: + """Batch check mint quotes. + + Args: + payload (PostMintQuoteCheckRequest): Request payload containing quote IDs. + + Returns: + List[MintQuote]: List of mint quotes matching the request. + """ + quotes: List[MintQuote] = [] + for quote_id in payload.quotes: + quote = await self.get_mint_quote(quote_id) + if not quote: + raise Exception(f"quote {quote_id} not found") + quotes.append(quote) + return quotes + async def mint( self, *, @@ -481,6 +502,112 @@ async def mint( return promises + async def mint_batch( + self, + payload: PostMintBatchRequest, + ) -> List[BlindedSignature]: + """Batch mint tokens. + + Args: + payload (PostMintBatchRequest): Request payload containing quote IDs, outputs, and signatures. + + Raises: + Exception: Validation of outputs failed. + Exception: Quote not paid. + Exception: Quote already issued. + Exception: Amount to mint does not match quote amount. + + Returns: + List[BlindedSignature]: Signatures on the outputs. + """ + if not payload.quotes: + raise TransactionError("batch must not be empty") + + if len(set(payload.quotes)) != len(payload.quotes): + raise TransactionError("quotes must be unique") + + if payload.signatures and len(payload.signatures) != len(payload.quotes): + raise TransactionError("signatures length must match quotes length") + + await self._verify_outputs(payload.outputs) + # we already know from _verify_outputs that all outputs have the same unit because they have the same keyset + output_unit = self.keysets[payload.outputs[0].id].unit + sum_amount_outputs = sum([b.amount for b in payload.outputs]) + + quotes: List[MintQuote] = [] + for quote_id in payload.quotes: + quote = await self.get_mint_quote(quote_id) + if not quote: + raise TransactionError(f"quote {quote_id} not found") + quotes.append(quote) + + # Check payment method consistency + methods = set([q.method for q in quotes]) + if len(methods) > 1: + raise TransactionError("all quotes must have the same method") + + # Check currency unit consistency + units = set([q.unit for q in quotes]) + if len(units) > 1: + raise TransactionError("all quotes must have the same unit") + if units.pop() != output_unit.name: + raise TransactionError("quote unit does not match output unit") + + for quote in quotes: + if quote.pending: + raise TransactionError("mint quote already pending") + if quote.issued: + raise QuoteAlreadyIssuedError() + if quote.state != MintQuoteState.paid: + raise QuoteNotPaidError() + + # Check amount balance + quote_amounts = payload.quote_amounts or [q.amount for q in quotes] + if sum(quote_amounts) != sum_amount_outputs: + raise TransactionError("amount to mint does not match quote amounts sum") + + # Signature validation (NUT-20) + for i, quote in enumerate(quotes): + sig = payload.signatures[i] if payload.signatures else None + + if not quote.pubkey and sig: + raise QuoteSignatureInvalidError() + + # The spec says msg_to_sign = quote_id[i] || B_0 || B_1 || ... || B_(n-1) + # This logic is inside self._verify_mint_quote_witness, let's reuse it. + if not self._verify_mint_quote_witness(quote, payload.outputs, sig): + raise QuoteSignatureInvalidError() + + # Set all quotes to pending + previous_states = {q.quote: q.state for q in quotes} + for quote_id in payload.quotes: + await self.db_write._set_mint_quote_pending(quote_id=quote_id) + + try: + for quote in quotes: + if quote.expiry and quote.expiry < int(time.time()): + raise TransactionError("quote expired") + + # Store all blinded messages + await self._store_blinded_messages(payload.outputs, mint_id=payload.quotes[0]) + promises = await self._sign_blinded_messages(payload.outputs) + + # Set all quotes to issued + for quote_id in payload.quotes: + await self.db_write._unset_mint_quote_pending( + quote_id=quote_id, state=MintQuoteState.issued + ) + + except Exception as e: + # Revert pending status + for quote_id in payload.quotes: + await self.db_write._unset_mint_quote_pending( + quote_id=quote_id, state=previous_states[quote_id] + ) + raise e + + return promises + def create_internal_melt_quote( self, mint_quote: MintQuote, melt_quote: PostMeltQuoteRequest ) -> PaymentQuoteResponse: diff --git a/cashu/mint/router.py b/cashu/mint/router.py index 55f0439eb..42e7cfde8 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -16,6 +16,9 @@ PostMeltQuoteRequest, PostMeltQuoteResponse, PostMeltRequest, + PostMintBatchRequest, + PostMintBatchResponse, + PostMintQuoteCheckRequest, PostMintQuoteRequest, PostMintQuoteResponse, PostMintRequest, @@ -209,6 +212,53 @@ async def get_mint_quote(request: Request, quote: str) -> PostMintQuoteResponse: return resp +@router.post( + "/v1/mint/quote/bolt11/check", + name="Batch check mint quotes", + summary="Batch check mint quotes", + response_model=list[PostMintQuoteResponse], + response_description="A list of mint quotes", +) +@limiter.limit(f"{settings.mint_transaction_rate_limit_per_minute}/minute") +async def mint_quote_check( + request: Request, payload: PostMintQuoteCheckRequest +) -> list[PostMintQuoteResponse]: + logger.trace(f"> POST /v1/mint/quote/bolt11/check: payload={payload}") + quotes = await ledger.mint_quote_check(payload) + resp = [ + PostMintQuoteResponse( + quote=quote.quote, + request=quote.request, + amount=quote.amount, + unit=quote.unit, + state=quote.state.value, + expiry=quote.expiry, + pubkey=quote.pubkey, + ) + for quote in quotes + ] + logger.trace(f"< POST /v1/mint/quote/bolt11/check: {resp}") + return resp + + +@router.post( + "/v1/mint/bolt11/batch", + name="Batch mint tokens", + summary="Batch mint tokens", + response_model=PostMintBatchResponse, + response_description="A list of blinded signatures that can be used to create proofs.", +) +@limiter.limit(f"{settings.mint_transaction_rate_limit_per_minute}/minute") +async def mint_batch( + request: Request, payload: PostMintBatchRequest +) -> PostMintBatchResponse: + logger.trace(f"> POST /v1/mint/bolt11/batch: payload={payload}") + signatures = await ledger.mint_batch(payload) + resp = PostMintBatchResponse(signatures=signatures) + logger.trace(f"< POST /v1/mint/bolt11/batch: {resp}") + return resp + + @router.websocket("/v1/ws", name="Websocket endpoint for subscriptions") async def websocket_endpoint(websocket: WebSocket): limit_websocket(websocket) From 363c25153e3e75c7086e8a3c412caa6d15b09194 Mon Sep 17 00:00:00 2001 From: a1denvalu3 Date: Mon, 30 Mar 2026 13:05:51 +0200 Subject: [PATCH 02/11] feat(mint): enforce exact amount balance checks per payment method for batched minting --- cashu/mint/ledger.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 4366e9889..c2bbb7bad 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -562,9 +562,22 @@ async def mint_batch( raise QuoteNotPaidError() # Check amount balance + if payload.quote_amounts: + if len(payload.quote_amounts) != len(quotes): + raise TransactionError("quote_amounts length must match quotes length") + for i, quote in enumerate(quotes): + if quote.method == "bolt11" and payload.quote_amounts[i] != quote.amount: + raise TransactionError(f"quote amount {payload.quote_amounts[i]} does not match quote {quote.quote} amount {quote.amount}") + if payload.quote_amounts[i] > quote.amount: + raise TransactionError(f"quote amount {payload.quote_amounts[i]} exceeds quote {quote.quote} amount {quote.amount}") + quote_amounts = payload.quote_amounts or [q.amount for q in quotes] - if sum(quote_amounts) != sum_amount_outputs: - raise TransactionError("amount to mint does not match quote amounts sum") + if "bolt11" in methods: + if sum(quote_amounts) != sum_amount_outputs: + raise TransactionError("amount to mint does not match quote amounts sum") + else: + if sum_amount_outputs > sum(quote_amounts): + raise TransactionError("amount to mint exceeds quote amounts sum") # Signature validation (NUT-20) for i, quote in enumerate(quotes): From 400628214409628e8f52300f03b0ba2d27d96fec Mon Sep 17 00:00:00 2001 From: a1denvalu3 Date: Mon, 30 Mar 2026 13:15:40 +0200 Subject: [PATCH 03/11] test(mint): add test cases for NUT-29 batched minting endpoints --- tests/mint/test_mint_api_batch.py | 140 ++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 tests/mint/test_mint_api_batch.py diff --git a/tests/mint/test_mint_api_batch.py b/tests/mint/test_mint_api_batch.py new file mode 100644 index 000000000..acaf3536f --- /dev/null +++ b/tests/mint/test_mint_api_batch.py @@ -0,0 +1,140 @@ +import httpx +import pytest +import pytest_asyncio + +from cashu.core.nuts import nut20 +from cashu.core.settings import settings +from cashu.mint.ledger import Ledger +from cashu.wallet.wallet import Wallet +from tests.helpers import pay_if_regtest + +BASE_URL = "http://localhost:3337" + + +@pytest_asyncio.fixture(scope="function") +async def wallet(ledger: Ledger): + wallet1 = await Wallet.with_db( + url=BASE_URL, + db="test_data/wallet_mint_api_batch", + name="wallet_mint_api_batch", + ) + await wallet1.load_mint() + yield wallet1 + + +@pytest.mark.asyncio +@pytest.mark.skipif( + settings.debug_mint_only_deprecated, + reason="settings.debug_mint_only_deprecated is set", +) +async def test_mint_quote_check(ledger: Ledger, wallet: Wallet): + mint_quote1 = await wallet.request_mint(64) + mint_quote2 = await wallet.request_mint(32) + + response = httpx.post( + f"{BASE_URL}/v1/mint/quote/bolt11/check", + json={"quotes": [mint_quote1.quote, mint_quote2.quote]}, + ) + assert response.status_code == 200, f"{response.url} {response.status_code}" + result = response.json() + assert len(result) == 2 + assert result[0]["quote"] == mint_quote1.quote + assert result[0]["amount"] == 64 + assert result[0]["state"] == "PAID" + assert result[1]["quote"] == mint_quote2.quote + assert result[1]["amount"] == 32 + assert result[1]["state"] == "PAID" + + +@pytest.mark.asyncio +@pytest.mark.skipif( + settings.debug_mint_only_deprecated, + reason="settings.debug_mint_only_deprecated is set", +) +async def test_mint_batch_success(ledger: Ledger, wallet: Wallet): + mint_quote1 = await wallet.request_mint(64) + mint_quote2 = await wallet.request_mint(32) + + await pay_if_regtest(mint_quote1.request) + await pay_if_regtest(mint_quote2.request) + + secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10001) + # Output total 96, first quote is 64, second is 32 + outputs, rs = wallet._construct_outputs([64, 32], secrets, rs) + + assert mint_quote1.privkey + assert mint_quote2.privkey + + # Signatures covering all outputs + sig1 = nut20.sign_mint_quote(mint_quote1.quote, outputs, mint_quote1.privkey) + sig2 = nut20.sign_mint_quote(mint_quote2.quote, outputs, mint_quote2.privkey) + + outputs_payload = [o.model_dump() for o in outputs] + + response = httpx.post( + f"{BASE_URL}/v1/mint/bolt11/batch", + json={ + "quotes": [mint_quote1.quote, mint_quote2.quote], + "quote_amounts": [64, 32], + "outputs": outputs_payload, + "signatures": [sig1, sig2], + }, + timeout=None, + ) + + assert response.status_code == 200, f"{response.url} {response.status_code} {response.text}" + result = response.json() + assert len(result["signatures"]) == 2 + assert result["signatures"][0]["amount"] == 64 + assert result["signatures"][1]["amount"] == 32 + + +@pytest.mark.asyncio +@pytest.mark.skipif( + settings.debug_mint_only_deprecated, + reason="settings.debug_mint_only_deprecated is set", +) +async def test_mint_batch_duplicate_quotes(ledger: Ledger, wallet: Wallet): + mint_quote1 = await wallet.request_mint(64) + + response = httpx.post( + f"{BASE_URL}/v1/mint/bolt11/batch", + json={ + "quotes": [mint_quote1.quote, mint_quote1.quote], + "quote_amounts": [64, 64], + "outputs": [], + "signatures": [None, None], + }, + ) + + assert response.status_code == 400 + assert "quotes must be unique" in response.text + + +@pytest.mark.asyncio +@pytest.mark.skipif( + settings.debug_mint_only_deprecated, + reason="settings.debug_mint_only_deprecated is set", +) +async def test_mint_batch_wrong_amount(ledger: Ledger, wallet: Wallet): + mint_quote1 = await wallet.request_mint(64) + await pay_if_regtest(mint_quote1.request) + + secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10001) + outputs, rs = wallet._construct_outputs([32, 32], secrets, rs) + + outputs_payload = [o.model_dump() for o in outputs] + sig1 = nut20.sign_mint_quote(mint_quote1.quote, outputs, mint_quote1.privkey) + + response = httpx.post( + f"{BASE_URL}/v1/mint/bolt11/batch", + json={ + "quotes": [mint_quote1.quote], + "quote_amounts": [32], # Intentionally wrong quote amount + "outputs": outputs_payload, + "signatures": [sig1], + }, + ) + + assert response.status_code == 400 + assert "does not match quote" in response.text From c3a686ae588ad133423690bf8027506e9e977651 Mon Sep 17 00:00:00 2001 From: a1denvalu3 Date: Mon, 30 Mar 2026 13:23:16 +0200 Subject: [PATCH 04/11] test(mint): rename test_mint_api_batch.py to test_mint_batch.py --- tests/mint/{test_mint_api_batch.py => test_mint_batch.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/mint/{test_mint_api_batch.py => test_mint_batch.py} (100%) diff --git a/tests/mint/test_mint_api_batch.py b/tests/mint/test_mint_batch.py similarity index 100% rename from tests/mint/test_mint_api_batch.py rename to tests/mint/test_mint_batch.py From a25757a062b497591706247c0314fe1e2ed0867b Mon Sep 17 00:00:00 2001 From: a1denvalu3 Date: Mon, 30 Mar 2026 13:25:57 +0200 Subject: [PATCH 05/11] test(mint): separate API tests from direct internal ledger method tests --- tests/mint/test_mint_api_batch.py | 140 ++++++++++++++++++++++++++ tests/mint/test_mint_batch.py | 160 ++++++++++++------------------ 2 files changed, 205 insertions(+), 95 deletions(-) create mode 100644 tests/mint/test_mint_api_batch.py diff --git a/tests/mint/test_mint_api_batch.py b/tests/mint/test_mint_api_batch.py new file mode 100644 index 000000000..acaf3536f --- /dev/null +++ b/tests/mint/test_mint_api_batch.py @@ -0,0 +1,140 @@ +import httpx +import pytest +import pytest_asyncio + +from cashu.core.nuts import nut20 +from cashu.core.settings import settings +from cashu.mint.ledger import Ledger +from cashu.wallet.wallet import Wallet +from tests.helpers import pay_if_regtest + +BASE_URL = "http://localhost:3337" + + +@pytest_asyncio.fixture(scope="function") +async def wallet(ledger: Ledger): + wallet1 = await Wallet.with_db( + url=BASE_URL, + db="test_data/wallet_mint_api_batch", + name="wallet_mint_api_batch", + ) + await wallet1.load_mint() + yield wallet1 + + +@pytest.mark.asyncio +@pytest.mark.skipif( + settings.debug_mint_only_deprecated, + reason="settings.debug_mint_only_deprecated is set", +) +async def test_mint_quote_check(ledger: Ledger, wallet: Wallet): + mint_quote1 = await wallet.request_mint(64) + mint_quote2 = await wallet.request_mint(32) + + response = httpx.post( + f"{BASE_URL}/v1/mint/quote/bolt11/check", + json={"quotes": [mint_quote1.quote, mint_quote2.quote]}, + ) + assert response.status_code == 200, f"{response.url} {response.status_code}" + result = response.json() + assert len(result) == 2 + assert result[0]["quote"] == mint_quote1.quote + assert result[0]["amount"] == 64 + assert result[0]["state"] == "PAID" + assert result[1]["quote"] == mint_quote2.quote + assert result[1]["amount"] == 32 + assert result[1]["state"] == "PAID" + + +@pytest.mark.asyncio +@pytest.mark.skipif( + settings.debug_mint_only_deprecated, + reason="settings.debug_mint_only_deprecated is set", +) +async def test_mint_batch_success(ledger: Ledger, wallet: Wallet): + mint_quote1 = await wallet.request_mint(64) + mint_quote2 = await wallet.request_mint(32) + + await pay_if_regtest(mint_quote1.request) + await pay_if_regtest(mint_quote2.request) + + secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10001) + # Output total 96, first quote is 64, second is 32 + outputs, rs = wallet._construct_outputs([64, 32], secrets, rs) + + assert mint_quote1.privkey + assert mint_quote2.privkey + + # Signatures covering all outputs + sig1 = nut20.sign_mint_quote(mint_quote1.quote, outputs, mint_quote1.privkey) + sig2 = nut20.sign_mint_quote(mint_quote2.quote, outputs, mint_quote2.privkey) + + outputs_payload = [o.model_dump() for o in outputs] + + response = httpx.post( + f"{BASE_URL}/v1/mint/bolt11/batch", + json={ + "quotes": [mint_quote1.quote, mint_quote2.quote], + "quote_amounts": [64, 32], + "outputs": outputs_payload, + "signatures": [sig1, sig2], + }, + timeout=None, + ) + + assert response.status_code == 200, f"{response.url} {response.status_code} {response.text}" + result = response.json() + assert len(result["signatures"]) == 2 + assert result["signatures"][0]["amount"] == 64 + assert result["signatures"][1]["amount"] == 32 + + +@pytest.mark.asyncio +@pytest.mark.skipif( + settings.debug_mint_only_deprecated, + reason="settings.debug_mint_only_deprecated is set", +) +async def test_mint_batch_duplicate_quotes(ledger: Ledger, wallet: Wallet): + mint_quote1 = await wallet.request_mint(64) + + response = httpx.post( + f"{BASE_URL}/v1/mint/bolt11/batch", + json={ + "quotes": [mint_quote1.quote, mint_quote1.quote], + "quote_amounts": [64, 64], + "outputs": [], + "signatures": [None, None], + }, + ) + + assert response.status_code == 400 + assert "quotes must be unique" in response.text + + +@pytest.mark.asyncio +@pytest.mark.skipif( + settings.debug_mint_only_deprecated, + reason="settings.debug_mint_only_deprecated is set", +) +async def test_mint_batch_wrong_amount(ledger: Ledger, wallet: Wallet): + mint_quote1 = await wallet.request_mint(64) + await pay_if_regtest(mint_quote1.request) + + secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10001) + outputs, rs = wallet._construct_outputs([32, 32], secrets, rs) + + outputs_payload = [o.model_dump() for o in outputs] + sig1 = nut20.sign_mint_quote(mint_quote1.quote, outputs, mint_quote1.privkey) + + response = httpx.post( + f"{BASE_URL}/v1/mint/bolt11/batch", + json={ + "quotes": [mint_quote1.quote], + "quote_amounts": [32], # Intentionally wrong quote amount + "outputs": outputs_payload, + "signatures": [sig1], + }, + ) + + assert response.status_code == 400 + assert "does not match quote" in response.text diff --git a/tests/mint/test_mint_batch.py b/tests/mint/test_mint_batch.py index acaf3536f..b31254293 100644 --- a/tests/mint/test_mint_batch.py +++ b/tests/mint/test_mint_batch.py @@ -1,57 +1,40 @@ -import httpx import pytest -import pytest_asyncio +from cashu.core.models import PostMintBatchRequest, PostMintQuoteCheckRequest from cashu.core.nuts import nut20 from cashu.core.settings import settings from cashu.mint.ledger import Ledger from cashu.wallet.wallet import Wallet from tests.helpers import pay_if_regtest -BASE_URL = "http://localhost:3337" - -@pytest_asyncio.fixture(scope="function") -async def wallet(ledger: Ledger): - wallet1 = await Wallet.with_db( - url=BASE_URL, - db="test_data/wallet_mint_api_batch", - name="wallet_mint_api_batch", - ) - await wallet1.load_mint() - yield wallet1 +@pytest.fixture(autouse=True) +def setup_settings(): + settings.debug_mint_only_deprecated = False + yield @pytest.mark.asyncio -@pytest.mark.skipif( - settings.debug_mint_only_deprecated, - reason="settings.debug_mint_only_deprecated is set", -) -async def test_mint_quote_check(ledger: Ledger, wallet: Wallet): +async def test_ledger_mint_quote_check(ledger: Ledger, wallet: Wallet): + await wallet.load_mint() mint_quote1 = await wallet.request_mint(64) mint_quote2 = await wallet.request_mint(32) - response = httpx.post( - f"{BASE_URL}/v1/mint/quote/bolt11/check", - json={"quotes": [mint_quote1.quote, mint_quote2.quote]}, + quotes = await ledger.mint_quote_check( + PostMintQuoteCheckRequest(quotes=[mint_quote1.quote, mint_quote2.quote]) ) - assert response.status_code == 200, f"{response.url} {response.status_code}" - result = response.json() - assert len(result) == 2 - assert result[0]["quote"] == mint_quote1.quote - assert result[0]["amount"] == 64 - assert result[0]["state"] == "PAID" - assert result[1]["quote"] == mint_quote2.quote - assert result[1]["amount"] == 32 - assert result[1]["state"] == "PAID" + assert len(quotes) == 2 + assert quotes[0].quote == mint_quote1.quote + assert quotes[0].amount == 64 + assert quotes[0].state.value == "UNPAID" + assert quotes[1].quote == mint_quote2.quote + assert quotes[1].amount == 32 + assert quotes[1].state.value == "UNPAID" @pytest.mark.asyncio -@pytest.mark.skipif( - settings.debug_mint_only_deprecated, - reason="settings.debug_mint_only_deprecated is set", -) -async def test_mint_batch_success(ledger: Ledger, wallet: Wallet): +async def test_ledger_mint_batch_success(ledger: Ledger, wallet: Wallet): + await wallet.load_mint() mint_quote1 = await wallet.request_mint(64) mint_quote2 = await wallet.request_mint(32) @@ -59,82 +42,69 @@ async def test_mint_batch_success(ledger: Ledger, wallet: Wallet): await pay_if_regtest(mint_quote2.request) secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10001) - # Output total 96, first quote is 64, second is 32 outputs, rs = wallet._construct_outputs([64, 32], secrets, rs) - + assert mint_quote1.privkey assert mint_quote2.privkey - - # Signatures covering all outputs + sig1 = nut20.sign_mint_quote(mint_quote1.quote, outputs, mint_quote1.privkey) sig2 = nut20.sign_mint_quote(mint_quote2.quote, outputs, mint_quote2.privkey) - outputs_payload = [o.model_dump() for o in outputs] - - response = httpx.post( - f"{BASE_URL}/v1/mint/bolt11/batch", - json={ - "quotes": [mint_quote1.quote, mint_quote2.quote], - "quote_amounts": [64, 32], - "outputs": outputs_payload, - "signatures": [sig1, sig2], - }, - timeout=None, + promises = await ledger.mint_batch( + PostMintBatchRequest( + quotes=[mint_quote1.quote, mint_quote2.quote], + quote_amounts=[64, 32], + outputs=outputs, + signatures=[sig1, sig2], + ) ) - - assert response.status_code == 200, f"{response.url} {response.status_code} {response.text}" - result = response.json() - assert len(result["signatures"]) == 2 - assert result["signatures"][0]["amount"] == 64 - assert result["signatures"][1]["amount"] == 32 - -@pytest.mark.asyncio -@pytest.mark.skipif( - settings.debug_mint_only_deprecated, - reason="settings.debug_mint_only_deprecated is set", -) -async def test_mint_batch_duplicate_quotes(ledger: Ledger, wallet: Wallet): - mint_quote1 = await wallet.request_mint(64) - - response = httpx.post( - f"{BASE_URL}/v1/mint/bolt11/batch", - json={ - "quotes": [mint_quote1.quote, mint_quote1.quote], - "quote_amounts": [64, 64], - "outputs": [], - "signatures": [None, None], - }, - ) - - assert response.status_code == 400 - assert "quotes must be unique" in response.text + assert len(promises) == 2 + assert promises[0].amount == 64 + assert promises[1].amount == 32 @pytest.mark.asyncio -@pytest.mark.skipif( - settings.debug_mint_only_deprecated, - reason="settings.debug_mint_only_deprecated is set", -) -async def test_mint_batch_wrong_amount(ledger: Ledger, wallet: Wallet): +async def test_ledger_mint_batch_wrong_amount(ledger: Ledger, wallet: Wallet): + await wallet.load_mint() mint_quote1 = await wallet.request_mint(64) await pay_if_regtest(mint_quote1.request) secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10001) outputs, rs = wallet._construct_outputs([32, 32], secrets, rs) - outputs_payload = [o.model_dump() for o in outputs] + assert mint_quote1.privkey + sig1 = nut20.sign_mint_quote(mint_quote1.quote, outputs, mint_quote1.privkey) - response = httpx.post( - f"{BASE_URL}/v1/mint/bolt11/batch", - json={ - "quotes": [mint_quote1.quote], - "quote_amounts": [32], # Intentionally wrong quote amount - "outputs": outputs_payload, - "signatures": [sig1], - }, - ) - - assert response.status_code == 400 - assert "does not match quote" in response.text + try: + await ledger.mint_batch( + PostMintBatchRequest( + quotes=[mint_quote1.quote], + quote_amounts=[32], + outputs=outputs, + signatures=[sig1], + ) + ) + assert False, "Expected Exception" + except Exception as e: + assert "does not match quote" in str(e) + + +@pytest.mark.asyncio +async def test_ledger_mint_batch_duplicate_quotes(ledger: Ledger, wallet: Wallet): + await wallet.load_mint() + mint_quote1 = await wallet.request_mint(64) + + try: + await ledger.mint_batch( + PostMintBatchRequest( + quotes=[mint_quote1.quote, mint_quote1.quote], + quote_amounts=[64, 64], + outputs=[], + signatures=[None, None], + ) + ) + assert False, "Expected Exception" + except Exception as e: + assert "quotes must be unique" in str(e) From ffd362fc41100c53664087a88462ea0b1f145144 Mon Sep 17 00:00:00 2001 From: a1denvalu3 Date: Mon, 30 Mar 2026 13:45:11 +0200 Subject: [PATCH 06/11] perf(mint): use batched DB methods with row locks for setting pending quotes --- cashu/mint/db/write.py | 77 +++++++++++++++++++++++++++++++++++ cashu/mint/ledger.py | 18 ++++---- tests/mint/test_mint_batch.py | 14 +++++++ 3 files changed, 98 insertions(+), 11 deletions(-) diff --git a/cashu/mint/db/write.py b/cashu/mint/db/write.py index c87d45ae2..e4abf1002 100644 --- a/cashu/mint/db/write.py +++ b/cashu/mint/db/write.py @@ -173,6 +173,42 @@ async def _set_mint_quote_pending(self, quote_id: str) -> MintQuote: raise TransactionError("Mint quote not found.") return quote + async def _set_mint_quotes_pending(self, quote_ids: List[str]) -> List[MintQuote]: + """Sets multiple mint quotes as pending. + + Args: + quote_ids (List[str]): List of mint quote IDs to set as pending. + """ + if not quote_ids: + return [] + + quotes: List[MintQuote] = [] + lock_parameters = {f"quote_{i}": q for i, q in enumerate(quote_ids)} + lock_select_statement = "quote IN (" + ", ".join([f":quote_{i}" for i in range(len(quote_ids))]) + ")" + + async with self.db.get_connection( + lock_table="mint_quotes", + lock_select_statement=lock_select_statement, + lock_parameters=lock_parameters, + ) as conn: + for quote_id in quote_ids: + quote = await self.crud.get_mint_quote( + quote_id=quote_id, db=self.db, conn=conn + ) + if not quote: + raise TransactionError(f"Mint quote {quote_id} not found.") + if quote.pending: + raise TransactionError(f"Mint quote {quote_id} already pending.") + if not quote.paid: + raise TransactionError(f"Mint quote {quote_id} is not paid yet.") + + # set the quote as pending + quote.state = MintQuoteState.pending + logger.trace(f"crud: setting quote {quote_id} as PENDING") + await self.crud.update_mint_quote(quote=quote, db=self.db, conn=conn) + quotes.append(quote) + return quotes + async def _unset_mint_quote_pending( self, quote_id: str, state: MintQuoteState ) -> MintQuote: @@ -208,6 +244,47 @@ async def _unset_mint_quote_pending( await self.events.submit(quote) return quote + async def _unset_mint_quotes_pending( + self, quote_ids: List[str], state: MintQuoteState + ) -> List[MintQuote]: + """Unsets multiple mint quotes as pending. + + Args: + quote_ids (List[str]): List of mint quote IDs to unset as pending. + state (MintQuoteState): New state of the mint quotes. + """ + if not quote_ids: + return [] + + quotes: List[MintQuote] = [] + lock_parameters = {f"quote_{i}": q for i, q in enumerate(quote_ids)} + lock_select_statement = "quote IN (" + ", ".join([f":quote_{i}" for i in range(len(quote_ids))]) + ")" + + async with self.db.get_connection( + lock_table="mint_quotes", + lock_select_statement=lock_select_statement, + lock_parameters=lock_parameters, + ) as conn: + for quote_id in quote_ids: + quote = await self.crud.get_mint_quote( + quote_id=quote_id, db=self.db, conn=conn + ) + if not quote: + raise TransactionError(f"Mint quote {quote_id} not found.") + if quote.state != MintQuoteState.pending: + raise TransactionError( + f"Mint quote {quote_id} not pending: {quote.state.value}. Cannot set as {state.value}." + ) + # set the quote to previous state + quote.state = state + logger.trace(f"crud: setting quote {quote_id} as {state.value}") + await self.crud.update_mint_quote(quote=quote, db=self.db, conn=conn) + quotes.append(quote) + + for quote in quotes: + await self.events.submit(quote) + return quotes + async def _set_melt_quote_pending( self, quote: MeltQuote, conn: Optional[Connection] = None ) -> MeltQuote: diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index c2bbb7bad..b914eecac 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -592,9 +592,7 @@ async def mint_batch( raise QuoteSignatureInvalidError() # Set all quotes to pending - previous_states = {q.quote: q.state for q in quotes} - for quote_id in payload.quotes: - await self.db_write._set_mint_quote_pending(quote_id=quote_id) + quotes = await self.db_write._set_mint_quotes_pending(quote_ids=payload.quotes) try: for quote in quotes: @@ -606,17 +604,15 @@ async def mint_batch( promises = await self._sign_blinded_messages(payload.outputs) # Set all quotes to issued - for quote_id in payload.quotes: - await self.db_write._unset_mint_quote_pending( - quote_id=quote_id, state=MintQuoteState.issued - ) + await self.db_write._unset_mint_quotes_pending( + quote_ids=payload.quotes, state=MintQuoteState.issued + ) except Exception as e: # Revert pending status - for quote_id in payload.quotes: - await self.db_write._unset_mint_quote_pending( - quote_id=quote_id, state=previous_states[quote_id] - ) + await self.db_write._unset_mint_quotes_pending( + quote_ids=payload.quotes, state=MintQuoteState.paid + ) raise e return promises diff --git a/tests/mint/test_mint_batch.py b/tests/mint/test_mint_batch.py index b31254293..1556cba4c 100644 --- a/tests/mint/test_mint_batch.py +++ b/tests/mint/test_mint_batch.py @@ -1,4 +1,5 @@ import pytest +import pytest_asyncio from cashu.core.models import PostMintBatchRequest, PostMintQuoteCheckRequest from cashu.core.nuts import nut20 @@ -7,6 +8,19 @@ from cashu.wallet.wallet import Wallet from tests.helpers import pay_if_regtest +BASE_URL = "http://localhost:3337" + + +@pytest_asyncio.fixture(scope="function") +async def wallet(ledger: Ledger): + wallet1 = await Wallet.with_db( + url=BASE_URL, + db="test_data/wallet_mint_api_batch", + name="wallet_mint_api_batch", + ) + await wallet1.load_mint() + yield wallet1 + @pytest.fixture(autouse=True) def setup_settings(): From f20fedff7eccda11c90279cc0845af630f9374da Mon Sep 17 00:00:00 2001 From: a1denvalu3 Date: Mon, 30 Mar 2026 14:02:15 +0200 Subject: [PATCH 07/11] fix(test): accept both UNPAID and PAID states during quote check assertion to handle CI fake wallet auto-settling races --- tests/mint/test_mint_api_batch.py | 4 ++-- tests/mint/test_mint_batch.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/mint/test_mint_api_batch.py b/tests/mint/test_mint_api_batch.py index acaf3536f..ddd252fa2 100644 --- a/tests/mint/test_mint_api_batch.py +++ b/tests/mint/test_mint_api_batch.py @@ -40,10 +40,10 @@ async def test_mint_quote_check(ledger: Ledger, wallet: Wallet): assert len(result) == 2 assert result[0]["quote"] == mint_quote1.quote assert result[0]["amount"] == 64 - assert result[0]["state"] == "PAID" + assert result[0]["state"] in ["UNPAID", "PAID"] assert result[1]["quote"] == mint_quote2.quote assert result[1]["amount"] == 32 - assert result[1]["state"] == "PAID" + assert result[1]["state"] in ["UNPAID", "PAID"] @pytest.mark.asyncio diff --git a/tests/mint/test_mint_batch.py b/tests/mint/test_mint_batch.py index 1556cba4c..ac3d81bf9 100644 --- a/tests/mint/test_mint_batch.py +++ b/tests/mint/test_mint_batch.py @@ -40,10 +40,10 @@ async def test_ledger_mint_quote_check(ledger: Ledger, wallet: Wallet): assert len(quotes) == 2 assert quotes[0].quote == mint_quote1.quote assert quotes[0].amount == 64 - assert quotes[0].state.value == "UNPAID" + assert quotes[0].state.value in ["UNPAID", "PAID"] assert quotes[1].quote == mint_quote2.quote assert quotes[1].amount == 32 - assert quotes[1].state.value == "UNPAID" + assert quotes[1].state.value in ["UNPAID", "PAID"] @pytest.mark.asyncio From 76d8c0c801cf46f2a74ddc290175f7287c629b58 Mon Sep 17 00:00:00 2001 From: a1denvalu3 Date: Mon, 30 Mar 2026 14:33:33 +0200 Subject: [PATCH 08/11] refactor(mint): use dedicated add_batch_features for NUT-29 mint info configuration --- cashu/mint/features.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cashu/mint/features.py b/cashu/mint/features.py index 88802f81f..e9e48b7cb 100644 --- a/cashu/mint/features.py +++ b/cashu/mint/features.py @@ -76,6 +76,7 @@ def mint_features(self) -> Dict[int, Union[List[Any], Dict[str, Any]]]: mint_features = self.add_mpp_features(mint_features) mint_features = self.add_websocket_features(mint_features) mint_features = self.add_cache_features(mint_features) + mint_features = self.add_batch_features(mint_features) return mint_features @@ -124,7 +125,13 @@ def add_supported_features( mint_features[DLEQ_NUT] = supported_dict mint_features[HTLC_NUT] = supported_dict mint_features[MINT_QUOTE_SIGNATURE_NUT] = supported_dict + return mint_features + + def add_batch_features( + self, mint_features: Dict[int, Union[List[Any], Dict[str, Any]]] + ): mint_features[BATCH_MINT_NUT] = { + "supported": True, "max_batch_size": settings.mint_max_request_length, "methods": list(set([m.name for m in self.backends.keys()])), } From f23407aec914742f18017e7f09625ca7f10eb1cf Mon Sep 17 00:00:00 2001 From: a1denvalu3 Date: Mon, 30 Mar 2026 15:48:17 +0200 Subject: [PATCH 09/11] test(mint): move batch minting api tests to main api test file --- tests/mint/test_mint_api.py | 117 +++++++++++++++++++++++++ tests/mint/test_mint_api_batch.py | 140 ------------------------------ 2 files changed, 117 insertions(+), 140 deletions(-) delete mode 100644 tests/mint/test_mint_api_batch.py diff --git a/tests/mint/test_mint_api.py b/tests/mint/test_mint_api.py index 2ff3c4e8d..a2e475b21 100644 --- a/tests/mint/test_mint_api.py +++ b/tests/mint/test_mint_api.py @@ -571,3 +571,120 @@ async def test_api_restore(ledger: Ledger, wallet: Wallet): assert len(response.signatures) == 1 assert len(response.outputs) == 1 assert response.outputs == outputs + +@pytest.mark.asyncio +@pytest.mark.skipif( + settings.debug_mint_only_deprecated, + reason="settings.debug_mint_only_deprecated is set", +) +async def test_mint_quote_check(ledger: Ledger, wallet: Wallet): + mint_quote1 = await wallet.request_mint(64) + mint_quote2 = await wallet.request_mint(32) + + response = httpx.post( + f"{BASE_URL}/v1/mint/quote/bolt11/check", + json={"quotes": [mint_quote1.quote, mint_quote2.quote]}, + ) + assert response.status_code == 200, f"{response.url} {response.status_code}" + result = response.json() + assert len(result) == 2 + assert result[0]["quote"] == mint_quote1.quote + assert result[0]["amount"] == 64 + assert result[0]["state"] in ["UNPAID", "PAID"] + assert result[1]["quote"] == mint_quote2.quote + assert result[1]["amount"] == 32 + assert result[1]["state"] in ["UNPAID", "PAID"] + + +@pytest.mark.asyncio +@pytest.mark.skipif( + settings.debug_mint_only_deprecated, + reason="settings.debug_mint_only_deprecated is set", +) +async def test_mint_batch_success(ledger: Ledger, wallet: Wallet): + mint_quote1 = await wallet.request_mint(64) + mint_quote2 = await wallet.request_mint(32) + + await pay_if_regtest(mint_quote1.request) + await pay_if_regtest(mint_quote2.request) + + secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10001) + # Output total 96, first quote is 64, second is 32 + outputs, rs = wallet._construct_outputs([64, 32], secrets, rs) + + assert mint_quote1.privkey + assert mint_quote2.privkey + + # Signatures covering all outputs + sig1 = nut20.sign_mint_quote(mint_quote1.quote, outputs, mint_quote1.privkey) + sig2 = nut20.sign_mint_quote(mint_quote2.quote, outputs, mint_quote2.privkey) + + outputs_payload = [o.model_dump() for o in outputs] + + response = httpx.post( + f"{BASE_URL}/v1/mint/bolt11/batch", + json={ + "quotes": [mint_quote1.quote, mint_quote2.quote], + "quote_amounts": [64, 32], + "outputs": outputs_payload, + "signatures": [sig1, sig2], + }, + timeout=None, + ) + + assert response.status_code == 200, f"{response.url} {response.status_code} {response.text}" + result = response.json() + assert len(result["signatures"]) == 2 + assert result["signatures"][0]["amount"] == 64 + assert result["signatures"][1]["amount"] == 32 + + +@pytest.mark.asyncio +@pytest.mark.skipif( + settings.debug_mint_only_deprecated, + reason="settings.debug_mint_only_deprecated is set", +) +async def test_mint_batch_duplicate_quotes(ledger: Ledger, wallet: Wallet): + mint_quote1 = await wallet.request_mint(64) + + response = httpx.post( + f"{BASE_URL}/v1/mint/bolt11/batch", + json={ + "quotes": [mint_quote1.quote, mint_quote1.quote], + "quote_amounts": [64, 64], + "outputs": [], + "signatures": [None, None], + }, + ) + + assert response.status_code == 400 + assert "quotes must be unique" in response.text + + +@pytest.mark.asyncio +@pytest.mark.skipif( + settings.debug_mint_only_deprecated, + reason="settings.debug_mint_only_deprecated is set", +) +async def test_mint_batch_wrong_amount(ledger: Ledger, wallet: Wallet): + mint_quote1 = await wallet.request_mint(64) + await pay_if_regtest(mint_quote1.request) + + secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10001) + outputs, rs = wallet._construct_outputs([32, 32], secrets, rs) + + outputs_payload = [o.model_dump() for o in outputs] + sig1 = nut20.sign_mint_quote(mint_quote1.quote, outputs, mint_quote1.privkey) + + response = httpx.post( + f"{BASE_URL}/v1/mint/bolt11/batch", + json={ + "quotes": [mint_quote1.quote], + "quote_amounts": [32], # Intentionally wrong quote amount + "outputs": outputs_payload, + "signatures": [sig1], + }, + ) + + assert response.status_code == 400 + assert "does not match quote" in response.text diff --git a/tests/mint/test_mint_api_batch.py b/tests/mint/test_mint_api_batch.py deleted file mode 100644 index ddd252fa2..000000000 --- a/tests/mint/test_mint_api_batch.py +++ /dev/null @@ -1,140 +0,0 @@ -import httpx -import pytest -import pytest_asyncio - -from cashu.core.nuts import nut20 -from cashu.core.settings import settings -from cashu.mint.ledger import Ledger -from cashu.wallet.wallet import Wallet -from tests.helpers import pay_if_regtest - -BASE_URL = "http://localhost:3337" - - -@pytest_asyncio.fixture(scope="function") -async def wallet(ledger: Ledger): - wallet1 = await Wallet.with_db( - url=BASE_URL, - db="test_data/wallet_mint_api_batch", - name="wallet_mint_api_batch", - ) - await wallet1.load_mint() - yield wallet1 - - -@pytest.mark.asyncio -@pytest.mark.skipif( - settings.debug_mint_only_deprecated, - reason="settings.debug_mint_only_deprecated is set", -) -async def test_mint_quote_check(ledger: Ledger, wallet: Wallet): - mint_quote1 = await wallet.request_mint(64) - mint_quote2 = await wallet.request_mint(32) - - response = httpx.post( - f"{BASE_URL}/v1/mint/quote/bolt11/check", - json={"quotes": [mint_quote1.quote, mint_quote2.quote]}, - ) - assert response.status_code == 200, f"{response.url} {response.status_code}" - result = response.json() - assert len(result) == 2 - assert result[0]["quote"] == mint_quote1.quote - assert result[0]["amount"] == 64 - assert result[0]["state"] in ["UNPAID", "PAID"] - assert result[1]["quote"] == mint_quote2.quote - assert result[1]["amount"] == 32 - assert result[1]["state"] in ["UNPAID", "PAID"] - - -@pytest.mark.asyncio -@pytest.mark.skipif( - settings.debug_mint_only_deprecated, - reason="settings.debug_mint_only_deprecated is set", -) -async def test_mint_batch_success(ledger: Ledger, wallet: Wallet): - mint_quote1 = await wallet.request_mint(64) - mint_quote2 = await wallet.request_mint(32) - - await pay_if_regtest(mint_quote1.request) - await pay_if_regtest(mint_quote2.request) - - secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10001) - # Output total 96, first quote is 64, second is 32 - outputs, rs = wallet._construct_outputs([64, 32], secrets, rs) - - assert mint_quote1.privkey - assert mint_quote2.privkey - - # Signatures covering all outputs - sig1 = nut20.sign_mint_quote(mint_quote1.quote, outputs, mint_quote1.privkey) - sig2 = nut20.sign_mint_quote(mint_quote2.quote, outputs, mint_quote2.privkey) - - outputs_payload = [o.model_dump() for o in outputs] - - response = httpx.post( - f"{BASE_URL}/v1/mint/bolt11/batch", - json={ - "quotes": [mint_quote1.quote, mint_quote2.quote], - "quote_amounts": [64, 32], - "outputs": outputs_payload, - "signatures": [sig1, sig2], - }, - timeout=None, - ) - - assert response.status_code == 200, f"{response.url} {response.status_code} {response.text}" - result = response.json() - assert len(result["signatures"]) == 2 - assert result["signatures"][0]["amount"] == 64 - assert result["signatures"][1]["amount"] == 32 - - -@pytest.mark.asyncio -@pytest.mark.skipif( - settings.debug_mint_only_deprecated, - reason="settings.debug_mint_only_deprecated is set", -) -async def test_mint_batch_duplicate_quotes(ledger: Ledger, wallet: Wallet): - mint_quote1 = await wallet.request_mint(64) - - response = httpx.post( - f"{BASE_URL}/v1/mint/bolt11/batch", - json={ - "quotes": [mint_quote1.quote, mint_quote1.quote], - "quote_amounts": [64, 64], - "outputs": [], - "signatures": [None, None], - }, - ) - - assert response.status_code == 400 - assert "quotes must be unique" in response.text - - -@pytest.mark.asyncio -@pytest.mark.skipif( - settings.debug_mint_only_deprecated, - reason="settings.debug_mint_only_deprecated is set", -) -async def test_mint_batch_wrong_amount(ledger: Ledger, wallet: Wallet): - mint_quote1 = await wallet.request_mint(64) - await pay_if_regtest(mint_quote1.request) - - secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10001) - outputs, rs = wallet._construct_outputs([32, 32], secrets, rs) - - outputs_payload = [o.model_dump() for o in outputs] - sig1 = nut20.sign_mint_quote(mint_quote1.quote, outputs, mint_quote1.privkey) - - response = httpx.post( - f"{BASE_URL}/v1/mint/bolt11/batch", - json={ - "quotes": [mint_quote1.quote], - "quote_amounts": [32], # Intentionally wrong quote amount - "outputs": outputs_payload, - "signatures": [sig1], - }, - ) - - assert response.status_code == 400 - assert "does not match quote" in response.text From 010aac738cc84c09e8688aa1c169f7f5f7512d7d Mon Sep 17 00:00:00 2001 From: a1denvalu3 Date: Tue, 31 Mar 2026 11:23:44 +0200 Subject: [PATCH 10/11] test(mint): add concurrency tests for batch minting race conditions --- tests/mint/test_mint_batch.py | 80 +++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/tests/mint/test_mint_batch.py b/tests/mint/test_mint_batch.py index ac3d81bf9..43d7b6a2f 100644 --- a/tests/mint/test_mint_batch.py +++ b/tests/mint/test_mint_batch.py @@ -122,3 +122,83 @@ async def test_ledger_mint_batch_duplicate_quotes(ledger: Ledger, wallet: Wallet assert False, "Expected Exception" except Exception as e: assert "quotes must be unique" in str(e) + +import asyncio + +@pytest.mark.asyncio +async def test_ledger_mint_batch_race(ledger: Ledger, wallet: Wallet): + await wallet.load_mint() + mint_quote1 = await wallet.request_mint(64) + mint_quote2 = await wallet.request_mint(32) + + await pay_if_regtest(mint_quote1.request) + await pay_if_regtest(mint_quote2.request) + + secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10000, 10001) + outputs, rs = wallet._construct_outputs([64, 32], secrets, rs) + + assert mint_quote1.privkey + assert mint_quote2.privkey + + sig1 = nut20.sign_mint_quote(mint_quote1.quote, outputs, mint_quote1.privkey) + sig2 = nut20.sign_mint_quote(mint_quote2.quote, outputs, mint_quote2.privkey) + + req = PostMintBatchRequest( + quotes=[mint_quote1.quote, mint_quote2.quote], + quote_amounts=[64, 32], + outputs=outputs, + signatures=[sig1, sig2], + ) + + results = await asyncio.gather( + ledger.mint_batch(req), + ledger.mint_batch(req), + return_exceptions=True + ) + + successes = [r for r in results if not isinstance(r, Exception)] + exceptions = [r for r in results if isinstance(r, Exception)] + + assert len(successes) == 1, f"Expected 1 success, got {len(successes)}" + assert len(exceptions) == 1, f"Expected 1 exception, got {len(exceptions)}" + + +@pytest.mark.asyncio +async def test_ledger_mint_batch_and_normal_mint_race(ledger: Ledger, wallet: Wallet): + await wallet.load_mint() + mint_quote1 = await wallet.request_mint(64) + mint_quote2 = await wallet.request_mint(32) + + await pay_if_regtest(mint_quote1.request) + await pay_if_regtest(mint_quote2.request) + + secrets, rs_gen, derivation_paths = await wallet.generate_secrets_from_to(10000, 10002) + outputs, _ = wallet._construct_outputs([64, 32], secrets[:2], rs_gen[:2]) + + assert mint_quote1.privkey + assert mint_quote2.privkey + + sig1 = nut20.sign_mint_quote(mint_quote1.quote, outputs, mint_quote1.privkey) + sig2 = nut20.sign_mint_quote(mint_quote2.quote, outputs, mint_quote2.privkey) + + req_batch = PostMintBatchRequest( + quotes=[mint_quote1.quote, mint_quote2.quote], + quote_amounts=[64, 32], + outputs=outputs, + signatures=[sig1, sig2], + ) + + outputs_normal, _ = wallet._construct_outputs([64], [secrets[2]], [rs_gen[2]]) + sig_normal = nut20.sign_mint_quote(mint_quote1.quote, outputs_normal, mint_quote1.privkey) + + results = await asyncio.gather( + ledger.mint_batch(req_batch), + ledger.mint(outputs=outputs_normal, quote_id=mint_quote1.quote, signature=sig_normal), + return_exceptions=True + ) + + successes = [r for r in results if not isinstance(r, Exception)] + exceptions = [r for r in results if isinstance(r, Exception)] + + assert len(successes) == 1, f"Expected 1 success, got {len(successes)}" + assert len(exceptions) == 1, f"Expected 1 exception, got {len(exceptions)}" From 50cd861c1504d43ffe8b9331e689ffd569bea567 Mon Sep 17 00:00:00 2001 From: a1denvalu3 Date: Tue, 31 Mar 2026 12:23:52 +0200 Subject: [PATCH 11/11] test(mint): hoist and format imports --- tests/mint/test_mint_batch.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/mint/test_mint_batch.py b/tests/mint/test_mint_batch.py index 43d7b6a2f..14730351d 100644 --- a/tests/mint/test_mint_batch.py +++ b/tests/mint/test_mint_batch.py @@ -1,3 +1,5 @@ +import asyncio + import pytest import pytest_asyncio @@ -123,7 +125,6 @@ async def test_ledger_mint_batch_duplicate_quotes(ledger: Ledger, wallet: Wallet except Exception as e: assert "quotes must be unique" in str(e) -import asyncio @pytest.mark.asyncio async def test_ledger_mint_batch_race(ledger: Ledger, wallet: Wallet):