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
16 changes: 16 additions & 0 deletions cashu/core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 0 additions & 2 deletions cashu/core/models/blind_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
)

Expand Down
2 changes: 1 addition & 1 deletion cashu/core/models/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
13 changes: 4 additions & 9 deletions cashu/core/models/melt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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
9 changes: 2 additions & 7 deletions cashu/core/models/mint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down
11 changes: 3 additions & 8 deletions cashu/core/models/restore.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down
15 changes: 5 additions & 10 deletions cashu/core/models/swap.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,18 @@
from typing import List, Optional

from pydantic import BaseModel, Field
from pydantic import BaseModel

from cashu.core.base import (
BlindedMessage,
BlindedMessage_Deprecated,
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):
Expand All @@ -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):
Expand Down
12 changes: 12 additions & 0 deletions cashu/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions cashu/mint/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
QuoteSignatureInvalidError,
TransactionAmountExceedsLimitError,
TransactionError,
TransactionMaxOutputsExceededError,
)
from ..core.helpers import sum_proofs
from ..core.models import (
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions cashu/mint/verification.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
TransactionDuplicateInputsError,
TransactionDuplicateOutputsError,
TransactionError,
TransactionMaxInputsExceededError,
TransactionMaxOutputsExceededError,
TransactionMultipleUnitsError,
TransactionUnitError,
TransactionUnitMismatchError,
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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.")
Expand Down
55 changes: 54 additions & 1 deletion tests/mint/test_mint_app_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
58 changes: 58 additions & 0 deletions tests/mint/test_mint_verification.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
TransactionDuplicateInputsError,
TransactionDuplicateOutputsError,
TransactionError,
TransactionMaxInputsExceededError,
TransactionMaxOutputsExceededError,
TransactionMultipleUnitsError,
TransactionUnitError,
TransactionUnitMismatchError,
Expand Down Expand Up @@ -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(
Expand Down
Loading