diff --git a/cashu/core/models/__init__.py b/cashu/core/models/__init__.py index fdcacc46..61904755 100644 --- a/cashu/core/models/__init__.py +++ b/cashu/core/models/__init__.py @@ -30,6 +30,8 @@ ) from .mint_quote import ( PostMintQuoteCheckRequest, + PostMintQuoteCheckResponse, + PostMintQuoteCheckUnknownResponse, PostMintQuoteRequest, PostMintQuoteResponse, ) @@ -62,6 +64,8 @@ "PostMintRequest", "PostMintResponse", "PostMintQuoteCheckRequest", + "PostMintQuoteCheckResponse", + "PostMintQuoteCheckUnknownResponse", "PostMintQuoteRequest", "PostMintQuoteResponse", "PostRestoreRequest", diff --git a/cashu/core/models/mint_quote.py b/cashu/core/models/mint_quote.py index b400596f..8c609bb4 100644 --- a/cashu/core/models/mint_quote.py +++ b/cashu/core/models/mint_quote.py @@ -1,4 +1,4 @@ -from typing import Annotated, List, Optional +from typing import Annotated, List, Literal, Optional from pydantic import BaseModel, Field @@ -29,6 +29,24 @@ class PostMintQuoteCheckRequest(BaseModel): ) +class PostMintQuoteCheckUnknownResponse(BaseModel): + quote: str + unknown: Literal[True] = True + + +class PostMintQuoteCheckResponse(BaseModel): + quote: str + request: str + amount: int + unit: str + method: str + amount_paid: int + amount_issued: int + updated_at: int + expiry: Optional[int] = None + pubkey: Optional[str] = None + + class PostMintQuoteResponse(BaseModel): quote: str # quote id request: str # input payment request diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index a04edf88..f50ad602 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -459,23 +459,28 @@ async def get_mint_quote(self, quote_id: str) -> MintQuote: return quote - async def mint_quote_check( - self, payload: PostMintQuoteCheckRequest - ) -> List[MintQuote]: + async def mint_quote_check( + self, payload: PostMintQuoteCheckRequest + ) -> List[Optional[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 TransactionError(f"quote {quote_id} not found") - quotes.append(quote) + List[Optional[MintQuote]]: Mint quotes or ``None`` for unknown IDs, + in request order. + """ + quotes: List[Optional[MintQuote]] = [] + for quote_id in payload.quotes: + stored_quote = await self.crud.get_mint_quote( + quote_id=quote_id, db=self.db + ) + if not stored_quote: + quotes.append(None) + continue + quote = await self.get_mint_quote(quote_id) + quotes.append(quote) return quotes async def mint( @@ -592,13 +597,17 @@ async def mint_batch( 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() + for quote in quotes: + if quote.pending: + raise TransactionError("mint quote already pending") + if ( + quote.amount_paid is None + or quote.amount_issued is None + or quote.amount_paid - quote.amount_issued <= 0 + ): + if quote.issued: + raise QuoteAlreadyIssuedError() + raise QuoteNotPaidError() # Check amount balance if payload.quote_amounts: diff --git a/cashu/mint/router.py b/cashu/mint/router.py index cddd5f61..fac56c2e 100644 --- a/cashu/mint/router.py +++ b/cashu/mint/router.py @@ -2,6 +2,7 @@ import html import os import time +from typing import Union from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect from fastapi.responses import HTMLResponse @@ -23,6 +24,8 @@ PostMintBatchRequest, PostMintBatchResponse, PostMintQuoteCheckRequest, + PostMintQuoteCheckResponse, + PostMintQuoteCheckUnknownResponse, PostMintQuoteRequest, PostMintQuoteResponse, PostMintRequest, @@ -419,30 +422,35 @@ async def get_mint_quote(request: Request, quote: str) -> PostMintQuoteResponse: "/v1/mint/quote/bolt11/check", name="Batch check mint quotes", summary="Batch check mint quotes", - response_model=list[PostMintQuoteResponse], + response_model=list[ + Union[PostMintQuoteCheckResponse, PostMintQuoteCheckUnknownResponse] + ], 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]: +) -> list[Union[PostMintQuoteCheckResponse, PostMintQuoteCheckUnknownResponse]]: 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, - method=quote.method, - state=str(quote.state.value), - expiry=quote.expiry, - pubkey=quote.pubkey, - amount_paid=quote.amount_paid, - amount_issued=quote.amount_issued, - updated_at=quote.updated_at, + ( + PostMintQuoteCheckResponse( + quote=quote.quote, + request=quote.request, + amount=quote.amount, + unit=quote.unit, + method=quote.method, + expiry=quote.expiry, + pubkey=quote.pubkey, + amount_paid=quote.amount_paid or 0, + amount_issued=quote.amount_issued or 0, + updated_at=quote.updated_at or quote.created_time or int(time.time()), + ) + if quote + else PostMintQuoteCheckUnknownResponse(quote=quote_id) ) - for quote in quotes + for quote_id, quote in zip(payload.quotes, quotes) ] logger.trace(f"< POST /v1/mint/quote/bolt11/check: {resp}") return resp diff --git a/tests/mint/test_mint_api.py b/tests/mint/test_mint_api.py index ee86b261..627bb09f 100644 --- a/tests/mint/test_mint_api.py +++ b/tests/mint/test_mint_api.py @@ -550,11 +550,55 @@ async def test_mint_quote_check(ledger: Ledger, wallet: Wallet): assert result[0]["quote"] == mint_quote1.quote assert result[0]["amount"] == 64 assert result[0]["method"] == "bolt11" - assert result[0]["state"] in ["UNPAID", "PAID"] + assert "state" not in result[0] assert result[1]["quote"] == mint_quote2.quote assert result[1]["amount"] == 32 assert result[1]["method"] == "bolt11" - assert result[1]["state"] in ["UNPAID", "PAID"] + assert "state" not in result[1] + + +@pytest.mark.asyncio +async def test_mint_quote_check_returns_positional_unknown_quotes( + 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, + "not-a-valid-quote-id", + "01989999-9999-7999-8999-999999999999", + mint_quote2.quote, + ] + }, + ) + + assert response.status_code == 200, f"{response.url} {response.status_code}" + result = response.json() + assert len(result) == 4 + assert result[0]["quote"] == mint_quote1.quote + assert result[1] == {"quote": "not-a-valid-quote-id", "unknown": True} + assert result[2] == { + "quote": "01989999-9999-7999-8999-999999999999", + "unknown": True, + } + assert result[3]["quote"] == mint_quote2.quote + + +@pytest.mark.asyncio +async def test_mint_quote_check_returns_unknown_for_unknown_quotes( + ledger: Ledger, +): + response = httpx.post( + f"{BASE_URL}/v1/mint/quote/bolt11/check", + json={"quotes": ["not-a-valid-quote-id"]}, + ) + + assert response.status_code == 200, f"{response.url} {response.status_code}" + assert response.json() == [{"quote": "not-a-valid-quote-id", "unknown": True}] @pytest.mark.asyncio diff --git a/tests/mint/test_mint_batch.py b/tests/mint/test_mint_batch.py index 475efa47..67c5bab2 100644 --- a/tests/mint/test_mint_batch.py +++ b/tests/mint/test_mint_batch.py @@ -44,6 +44,47 @@ async def test_ledger_mint_quote_check(ledger: Ledger, wallet: Wallet): assert quotes[1].state.value in ["UNPAID", "PAID"] +@pytest.mark.asyncio +async def test_ledger_mint_quote_check_returns_positional_unknown_quotes( + 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, + "not-a-valid-quote-id", + "01989999-9999-7999-8999-999999999999", + mint_quote2.quote, + ] + ) + ) + + assert quotes[0] and quotes[0].quote == mint_quote1.quote + assert quotes[1] is None + assert quotes[2] is None + assert quotes[3] and quotes[3].quote == mint_quote2.quote + + +@pytest.mark.asyncio +async def test_ledger_mint_quote_check_returns_none_for_unknown_quotes( + ledger: Ledger, +): + quotes = await ledger.mint_quote_check( + PostMintQuoteCheckRequest( + quotes=[ + "not-a-valid-quote-id", + "01989999-9999-7999-8999-999999999999", + ] + ) + ) + + assert quotes == [None, None] + + @pytest.mark.asyncio async def test_ledger_mint_batch_success(ledger: Ledger, wallet: Wallet): await wallet.load_mint() @@ -577,16 +618,14 @@ async def test_ledger_mint_batch_single_quote(ledger: Ledger, wallet: Wallet): async def test_ledger_mint_quote_check_nonexistent_quote( ledger: Ledger, wallet: Wallet ): - """Checking nonexistent quote should fail.""" + """Checking nonexistent quotes should return positional unknowns.""" await wallet.load_mint() - try: - await ledger.mint_quote_check( - PostMintQuoteCheckRequest(quotes=["nonexistent_quote_id"]) - ) - assert False, "Expected Exception" - except Exception as e: - assert "not found" in str(e) + quotes = await ledger.mint_quote_check( + PostMintQuoteCheckRequest(quotes=["nonexistent_quote_id"]) + ) + + assert quotes == [None] @pytest.mark.asyncio