Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cashu/core/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
)
from .mint_quote import (
PostMintQuoteCheckRequest,
PostMintQuoteCheckResponse,
PostMintQuoteCheckUnknownResponse,
PostMintQuoteRequest,
PostMintQuoteResponse,
)
Expand Down Expand Up @@ -62,6 +64,8 @@
"PostMintRequest",
"PostMintResponse",
"PostMintQuoteCheckRequest",
"PostMintQuoteCheckResponse",
"PostMintQuoteCheckUnknownResponse",
"PostMintQuoteRequest",
"PostMintQuoteResponse",
"PostRestoreRequest",
Expand Down
20 changes: 19 additions & 1 deletion cashu/core/models/mint_quote.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Annotated, List, Optional
from typing import Annotated, List, Literal, Optional

from pydantic import BaseModel, Field

Expand Down Expand Up @@ -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
Expand Down
45 changes: 27 additions & 18 deletions cashu/mint/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
38 changes: 23 additions & 15 deletions cashu/mint/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,6 +24,8 @@
PostMintBatchRequest,
PostMintBatchResponse,
PostMintQuoteCheckRequest,
PostMintQuoteCheckResponse,
PostMintQuoteCheckUnknownResponse,
PostMintQuoteRequest,
PostMintQuoteResponse,
PostMintRequest,
Expand Down Expand Up @@ -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
Expand Down
48 changes: 46 additions & 2 deletions tests/mint/test_mint_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 47 additions & 8 deletions tests/mint/test_mint_batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
Loading