Skip to content
Open
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
21 changes: 21 additions & 0 deletions cashu/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions cashu/core/nuts/nuts.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@
CLEAR_AUTH_NUT = 21
BLIND_AUTH_NUT = 22
METHOD_BOLT11_NUT = 23
BATCH_MINT_NUT = 29
77 changes: 77 additions & 0 deletions cashu/mint/db/write.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions cashu/mint/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
MintMethodSetting,
)
from ..core.nuts.nuts import (
BATCH_MINT_NUT,
BLIND_AUTH_NUT,
CACHE_NUT,
CLEAR_AUTH_NUT,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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]]]
):
Expand Down
136 changes: 136 additions & 0 deletions cashu/mint/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@
from ..core.models import (
PostMeltQuoteRequest,
PostMeltQuoteResponse,
PostMintBatchRequest,
PostMintQuoteCheckRequest,
PostMintQuoteRequest,
)
from ..core.settings import settings
Expand Down Expand Up @@ -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,
*,
Expand Down Expand Up @@ -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:
Expand Down
50 changes: 50 additions & 0 deletions cashu/mint/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
PostMeltQuoteRequest,
PostMeltQuoteResponse,
PostMeltRequest,
PostMintBatchRequest,
PostMintBatchResponse,
PostMintQuoteCheckRequest,
PostMintQuoteRequest,
PostMintQuoteResponse,
PostMintRequest,
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading