From c4ccb4f7330ab41afd7e461572b32e703519b042 Mon Sep 17 00:00:00 2001 From: b-l-u-e <8102260+blue@users.noreply.github.com> Date: Thu, 21 May 2026 22:17:41 +0300 Subject: [PATCH] feat(mint): enforce NUT 11014/11015 max inputs and outputs --- cashu/core/errors.py | 16 ++++++++ cashu/core/models/blind_auth.py | 2 - cashu/core/models/check.py | 2 +- cashu/core/models/melt.py | 13 ++----- cashu/core/models/mint.py | 9 +---- cashu/core/models/restore.py | 11 ++---- cashu/core/models/swap.py | 15 +++---- cashu/core/settings.py | 12 ++++++ cashu/mint/ledger.py | 3 ++ cashu/mint/verification.py | 6 +++ tests/mint/test_mint_app_router.py | 55 +++++++++++++++++++++++++- tests/mint/test_mint_verification.py | 58 ++++++++++++++++++++++++++++ 12 files changed, 164 insertions(+), 38 deletions(-) diff --git a/cashu/core/errors.py b/cashu/core/errors.py index fedd11de6..58b04d6f1 100644 --- a/cashu/core/errors.py +++ b/cashu/core/errors.py @@ -142,6 +142,22 @@ def __init__(self, detail: Optional[str] = None): super().__init__(detail, code=self.code) +class TransactionMaxInputsExceededError(TransactionError): + detail = "Max inputs exceeded" + code = 11014 + + def __init__(self, detail: Optional[str] = None): + super().__init__(detail or self.detail, code=self.code) + + +class TransactionMaxOutputsExceededError(TransactionError): + detail = "Max outputs exceeded" + code = 11015 + + def __init__(self, detail: Optional[str] = None): + super().__init__(detail or self.detail, code=self.code) + + class KeysetError(CashuError): detail = "keyset error" code = 12000 diff --git a/cashu/core/models/blind_auth.py b/cashu/core/models/blind_auth.py index 74e5943e8..6f49b0e72 100644 --- a/cashu/core/models/blind_auth.py +++ b/cashu/core/models/blind_auth.py @@ -3,13 +3,11 @@ from pydantic import BaseModel, Field from cashu.core.base import BlindedMessage, BlindedSignature -from cashu.core.settings import settings class PostAuthBlindMintRequest(BaseModel): outputs: List[BlindedMessage] = Field( ..., - max_length=settings.mint_max_request_length, description="Blinded messages for creating blind auth tokens.", ) diff --git a/cashu/core/models/check.py b/cashu/core/models/check.py index 68d3fb7ac..4166853bb 100644 --- a/cashu/core/models/check.py +++ b/cashu/core/models/check.py @@ -18,7 +18,7 @@ class PostCheckStateResponse(BaseModel): class CheckSpendableRequest_deprecated(BaseModel): - proofs: List[Proof] = Field(..., max_length=settings.mint_max_request_length) + proofs: List[Proof] class CheckSpendableResponse_deprecated(BaseModel): diff --git a/cashu/core/models/melt.py b/cashu/core/models/melt.py index be5539692..e6fecf20d 100644 --- a/cashu/core/models/melt.py +++ b/cashu/core/models/melt.py @@ -9,15 +9,12 @@ Proof, ) from cashu.core.constants import MAX_PAYMENT_REQUEST_LEN, MAX_QUOTE_ID_LEN -from cashu.core.settings import settings class PostMeltRequest(BaseModel): quote: str = Field(..., max_length=MAX_QUOTE_ID_LEN) # quote id - inputs: List[Proof] = Field(..., max_length=settings.mint_max_request_length) - outputs: Union[List[BlindedMessage], None] = Field( - None, max_length=settings.mint_max_request_length - ) + inputs: List[Proof] + outputs: Union[List[BlindedMessage], None] = None prefer_async: Optional[bool] = None @@ -28,8 +25,6 @@ class PostMeltResponse_deprecated(BaseModel): class PostMeltRequest_deprecated(BaseModel): - proofs: List[Proof] = Field(..., max_length=settings.mint_max_request_length) + proofs: List[Proof] pr: str = Field(..., max_length=MAX_PAYMENT_REQUEST_LEN) - outputs: Union[List[BlindedMessage_Deprecated], None] = Field( - None, max_length=settings.mint_max_request_length - ) + outputs: Union[List[BlindedMessage_Deprecated], None] = None diff --git a/cashu/core/models/mint.py b/cashu/core/models/mint.py index ee7bd6e42..b5943bb5d 100644 --- a/cashu/core/models/mint.py +++ b/cashu/core/models/mint.py @@ -4,14 +4,11 @@ from cashu.core.base import BlindedMessage, BlindedMessage_Deprecated, BlindedSignature from cashu.core.constants import MAX_QUOTE_ID_LEN, MAX_SIG_LEN -from cashu.core.settings import settings class PostMintRequest(BaseModel): quote: str = Field(..., max_length=MAX_QUOTE_ID_LEN) # quote id - outputs: List[BlindedMessage] = Field( - ..., max_length=settings.mint_max_request_length - ) + outputs: List[BlindedMessage] signature: Optional[str] = Field( default=None, max_length=MAX_SIG_LEN ) # NUT-20 quote signature @@ -27,9 +24,7 @@ class GetMintResponse_deprecated(BaseModel): class PostMintRequest_deprecated(BaseModel): - outputs: List[BlindedMessage_Deprecated] = Field( - ..., max_length=settings.mint_max_request_length - ) + outputs: List[BlindedMessage_Deprecated] class PostMintResponse_deprecated(BaseModel): diff --git a/cashu/core/models/restore.py b/cashu/core/models/restore.py index 9b8ec5ca9..e1c29f1c0 100644 --- a/cashu/core/models/restore.py +++ b/cashu/core/models/restore.py @@ -1,21 +1,16 @@ from typing import List -from pydantic import BaseModel, Field +from pydantic import BaseModel from cashu.core.base import BlindedMessage, BlindedMessage_Deprecated, BlindedSignature -from cashu.core.settings import settings class PostRestoreRequest(BaseModel): - outputs: List[BlindedMessage] = Field( - ..., max_length=settings.mint_max_request_length - ) + outputs: List[BlindedMessage] class PostRestoreRequest_Deprecated(BaseModel): - outputs: List[BlindedMessage_Deprecated] = Field( - ..., max_length=settings.mint_max_request_length - ) + outputs: List[BlindedMessage_Deprecated] class PostRestoreResponse(BaseModel): diff --git a/cashu/core/models/swap.py b/cashu/core/models/swap.py index c6206a980..7b707c368 100644 --- a/cashu/core/models/swap.py +++ b/cashu/core/models/swap.py @@ -1,6 +1,6 @@ from typing import List, Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel from cashu.core.base import ( BlindedMessage, @@ -8,14 +8,11 @@ BlindedSignature, Proof, ) -from cashu.core.settings import settings class PostSwapRequest(BaseModel): - inputs: List[Proof] = Field(..., max_length=settings.mint_max_request_length) - outputs: List[BlindedMessage] = Field( - ..., max_length=settings.mint_max_request_length - ) + inputs: List[Proof] + outputs: List[BlindedMessage] class PostSwapResponse(BaseModel): @@ -24,11 +21,9 @@ class PostSwapResponse(BaseModel): # deprecated since 0.13.0 class PostSwapRequest_Deprecated(BaseModel): - proofs: List[Proof] = Field(..., max_length=settings.mint_max_request_length) + proofs: List[Proof] amount: Optional[int] = None - outputs: List[BlindedMessage_Deprecated] = Field( - ..., max_length=settings.mint_max_request_length - ) + outputs: List[BlindedMessage_Deprecated] class PostSwapResponse_Deprecated(BaseModel): diff --git a/cashu/core/settings.py b/cashu/core/settings.py index 07bf128fa..f0398fe57 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -161,6 +161,18 @@ class MintLimits(MintSettings): title="Maximum request length", description="Maximum length of REST API request arrays.", ) + mint_max_inputs: int = Field( + default=1000, + gt=0, + title="Maximum inputs per transaction", + description="Maximum number of proofs (inputs) per swap, melt, or check request.", + ) + mint_max_outputs: int = Field( + default=1000, + gt=0, + title="Maximum outputs per transaction", + description="Maximum number of blinded outputs per mint, swap, melt change, or restore.", + ) mint_peg_out_only: bool = Field( # deprecated for mint_bolt11_disable_mint default=False, diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 8e292e1b5..1e4a778b5 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -37,6 +37,7 @@ QuoteSignatureInvalidError, TransactionAmountExceedsLimitError, TransactionError, + TransactionMaxOutputsExceededError, ) from ..core.helpers import sum_proofs from ..core.models import ( @@ -1124,6 +1125,8 @@ async def swap( async def restore( self, outputs: List[BlindedMessage] ) -> Tuple[List[BlindedMessage], List[BlindedSignature]]: + if len(outputs) > settings.mint_max_outputs: + raise TransactionMaxOutputsExceededError() signatures: List[BlindedSignature] = [] return_outputs: List[BlindedMessage] = [] async with self.db.get_connection() as conn: diff --git a/cashu/mint/verification.py b/cashu/mint/verification.py index 4230a86f4..ddb5f7139 100644 --- a/cashu/mint/verification.py +++ b/cashu/mint/verification.py @@ -23,6 +23,8 @@ TransactionDuplicateInputsError, TransactionDuplicateOutputsError, TransactionError, + TransactionMaxInputsExceededError, + TransactionMaxOutputsExceededError, TransactionMultipleUnitsError, TransactionUnitError, TransactionUnitMismatchError, @@ -81,6 +83,8 @@ async def _verify_inputs( logger.trace(f"Verifying {len(proofs)} proofs.") if not proofs: raise TransactionError("no proofs provided.") + if len(proofs) > settings.mint_max_inputs: + raise TransactionMaxInputsExceededError() # Verify amounts of inputs if not all([self._verify_amount(p.amount) for p in proofs]): raise TransactionError("invalid amount.") @@ -128,6 +132,8 @@ async def _verify_outputs( logger.trace(f"Verifying {len(outputs)} outputs.") if not outputs: raise TransactionError("no outputs provided.") + if len(outputs) > settings.mint_max_outputs: + raise TransactionMaxOutputsExceededError() # Verify all outputs have the same keyset id if not all([o.id == outputs[0].id for o in outputs]): raise TransactionError("outputs have different keyset ids.") diff --git a/tests/mint/test_mint_app_router.py b/tests/mint/test_mint_app_router.py index 80c6074d0..7f5c18956 100644 --- a/tests/mint/test_mint_app_router.py +++ b/tests/mint/test_mint_app_router.py @@ -13,7 +13,11 @@ ProofState, Unit, ) -from cashu.core.errors import NotAllowedError +from cashu.core.errors import ( + NotAllowedError, + TransactionMaxInputsExceededError, + TransactionMaxOutputsExceededError, +) from cashu.core.settings import settings from cashu.mint import app as app_module from cashu.mint import middleware as middleware_module @@ -310,6 +314,55 @@ def test_router_restore_requires_outputs(monkeypatch): assert response.json() == {"detail": "no outputs provided.", "code": 0} +@pytest.mark.asyncio +async def test_router_swap_returns_11014_when_max_inputs_exceeded(ledger, monkeypatch): + monkeypatch.setattr(settings, "mint_max_inputs", 1) + monkeypatch.setattr(router_module, "ledger", ledger) + app = _build_router_app() + client = TestClient(app) + kid = next(iter(ledger.keysets.keys())) + response = client.post( + "/v1/swap", + json={ + "inputs": [ + {"id": kid, "amount": 1, "secret": "s1", "C": "00"}, + {"id": kid, "amount": 1, "secret": "s2", "C": "00"}, + ], + "outputs": [{"id": kid, "amount": 1, "B_": "ab"}], + }, + ) + assert response.status_code == 400 + assert response.json() == { + "detail": TransactionMaxInputsExceededError.detail, + "code": TransactionMaxInputsExceededError.code, + } + + +@pytest.mark.asyncio +async def test_router_restore_returns_11015_when_max_outputs_exceeded( + ledger, monkeypatch +): + monkeypatch.setattr(settings, "mint_max_outputs", 1) + monkeypatch.setattr(router_module, "ledger", ledger) + app = _build_router_app() + client = TestClient(app) + kid = next(iter(ledger.keysets.keys())) + response = client.post( + "/v1/restore", + json={ + "outputs": [ + {"id": kid, "amount": 1, "B_": "aa"}, + {"id": kid, "amount": 1, "B_": "bb"}, + ] + }, + ) + assert response.status_code == 400 + assert response.json() == { + "detail": TransactionMaxOutputsExceededError.detail, + "code": TransactionMaxOutputsExceededError.code, + } + + def test_router_quote_routes_and_swap(monkeypatch): monkeypatch.setattr(router_module, "ledger", _dummy_ledger()) app = _build_router_app() diff --git a/tests/mint/test_mint_verification.py b/tests/mint/test_mint_verification.py index 6557f89a1..273fa7c60 100644 --- a/tests/mint/test_mint_verification.py +++ b/tests/mint/test_mint_verification.py @@ -27,6 +27,8 @@ TransactionDuplicateInputsError, TransactionDuplicateOutputsError, TransactionError, + TransactionMaxInputsExceededError, + TransactionMaxOutputsExceededError, TransactionMultipleUnitsError, TransactionUnitError, TransactionUnitMismatchError, @@ -109,6 +111,62 @@ def test_verify_secret_criteria_rejects_too_long_secret(ledger: Ledger): # --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_max_inputs_exceeded(ledger: Ledger, monkeypatch): + monkeypatch.setattr(settings, "mint_max_inputs", 1) + p1 = Proof.from_dict( + { + "amount": 8, + "secret": "66687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f2925", + "C": "02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904", + "id": "009a1f293253e41e", + } + ) + p2 = Proof.from_dict( + { + "amount": 8, + "secret": "77798aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f2926", + "C": "02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904", + "id": "009a1f293253e41e", + } + ) + + await assert_err( + ledger.verify_inputs_and_outputs(proofs=[p1, p2], outputs=None), + TransactionMaxInputsExceededError(), + ) + + +@pytest.mark.asyncio +async def test_max_outputs_exceeded(ledger: Ledger, monkeypatch): + monkeypatch.setattr(settings, "mint_max_outputs", 1) + kid = next(iter(ledger.keysets.keys())) + outputs = [ + BlindedMessage(id=kid, amount=8, B_="02" + "00" * 32), + BlindedMessage(id=kid, amount=8, B_="02" + "11" * 32), + ] + + await assert_err( + ledger._verify_outputs(outputs), + TransactionMaxOutputsExceededError(), + ) + + +@pytest.mark.asyncio +async def test_restore_max_outputs_exceeded(ledger: Ledger, monkeypatch): + monkeypatch.setattr(settings, "mint_max_outputs", 1) + kid = next(iter(ledger.keysets.keys())) + outputs = [ + BlindedMessage(id=kid, amount=8, B_="02" + "00" * 32), + BlindedMessage(id=kid, amount=8, B_="02" + "11" * 32), + ] + + await assert_err( + ledger.restore(outputs), + TransactionMaxOutputsExceededError(), + ) + + @pytest.mark.asyncio async def test_witness_too_long(ledger: Ledger): proof = Proof.from_dict(