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/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/features.py b/cashu/mint/features.py index 3234d4a46..e9e48b7cb 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, @@ -75,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 @@ -125,6 +127,16 @@ def add_supported_features( 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()])), + } + return mint_features + def add_mpp_features( self, mint_features: Dict[int, Union[List[Any], Dict[str, Any]]] ): diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index fcf756a94..b914eecac 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,121 @@ 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 + 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 "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): + 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 + quotes = await self.db_write._set_mint_quotes_pending(quote_ids=payload.quotes) + + 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 + await self.db_write._unset_mint_quotes_pending( + quote_ids=payload.quotes, state=MintQuoteState.issued + ) + + except Exception as e: + # Revert pending status + await self.db_write._unset_mint_quotes_pending( + quote_ids=payload.quotes, state=MintQuoteState.paid + ) + 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) 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_batch.py b/tests/mint/test_mint_batch.py new file mode 100644 index 000000000..14730351d --- /dev/null +++ b/tests/mint/test_mint_batch.py @@ -0,0 +1,205 @@ +import asyncio + +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 +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) + + quotes = await ledger.mint_quote_check( + PostMintQuoteCheckRequest(quotes=[mint_quote1.quote, mint_quote2.quote]) + ) + assert len(quotes) == 2 + assert quotes[0].quote == mint_quote1.quote + assert quotes[0].amount == 64 + 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 in ["UNPAID", "PAID"] + + +@pytest.mark.asyncio +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) + + 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) + + promises = await ledger.mint_batch( + PostMintBatchRequest( + quotes=[mint_quote1.quote, mint_quote2.quote], + quote_amounts=[64, 32], + outputs=outputs, + signatures=[sig1, sig2], + ) + ) + + assert len(promises) == 2 + assert promises[0].amount == 64 + assert promises[1].amount == 32 + + +@pytest.mark.asyncio +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) + + assert mint_quote1.privkey + + sig1 = nut20.sign_mint_quote(mint_quote1.quote, outputs, mint_quote1.privkey) + + 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) + + +@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)}"