Skip to content

Commit 0fdbf86

Browse files
author
Karen Giannetto
committed
fix(models): enforce strict u64 range validation and normalize from_dict errors
1 parent b9e2cb9 commit 0fdbf86

2 files changed

Lines changed: 89 additions & 8 deletions

File tree

src/shade/models/invoice.py

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,35 @@
2424
_U64_FIELDS = ("id", "merchant_id")
2525
_I128_FIELDS = ("amount", "amount_paid", "amount_refunded")
2626
_TIMESTAMP_FIELDS = ("date_created", "date_paid", "expires_at")
27+
_U64_MAX = 2**64 - 1
28+
29+
30+
def _validate_u64(field: str, value: object) -> int:
31+
"""Coerce ``value`` to a strictly valid on-chain ``u64``.
32+
33+
Booleans and floats are rejected outright rather than coerced, since a
34+
silent bool->int or float->int cast could hide a malformed contract
35+
payload. Everything else is coerced via ``int()`` and range-checked
36+
against ``[0, 2**64 - 1]``.
37+
"""
38+
if isinstance(value, bool):
39+
raise ValueError(f"{field} must be an integer, not a boolean")
40+
if isinstance(value, float):
41+
raise ValueError(f"{field} must be an integer, not a float")
42+
if isinstance(value, int):
43+
result = value
44+
else:
45+
try:
46+
result = int(value)
47+
except (TypeError, ValueError) as exc:
48+
raise ValueError(
49+
f"{field} must be an integer, got {type(value).__name__}"
50+
) from exc
51+
if not 0 <= result <= _U64_MAX:
52+
raise ValueError(
53+
f"{field} must be between 0 and {_U64_MAX} (u64 range), got {result}"
54+
)
55+
return result
2756

2857

2958
class InvoiceStatus(str, Enum):
@@ -110,14 +139,20 @@ def _from_contract_dict(cls, data: dict) -> dict:
110139
(or ``None``) pass through untouched, so re-running a previously
111140
converted dict — as happens on a ``to_dict()`` -> ``from_dict()``
112141
round trip — is a no-op rather than a double conversion.
142+
143+
Raises:
144+
ValueError: If a ``u64`` or timestamp field is a boolean, a
145+
float, negative, or exceeds ``2**64 - 1``.
146+
OverflowError: If a timestamp is within the valid ``u64`` range
147+
but too large for :class:`~datetime.datetime` to represent.
113148
"""
114149
converted = dict(data)
115150

116151
for field in _U64_FIELDS:
117152
value = converted.get(field)
118-
if value is None or isinstance(value, bool) or isinstance(value, int):
153+
if value is None:
119154
continue
120-
converted[field] = int(value)
155+
converted[field] = _validate_u64(field, value)
121156

122157
for field in _I128_FIELDS:
123158
value = converted.get(field)
@@ -129,9 +164,8 @@ def _from_contract_dict(cls, data: dict) -> dict:
129164
value = converted.get(field)
130165
if value is None or isinstance(value, datetime):
131166
continue
132-
if isinstance(value, bool):
133-
raise ValueError(f"{field} must be a unix timestamp, not a boolean")
134-
converted[field] = datetime.fromtimestamp(int(value), tz=timezone.utc)
167+
timestamp = _validate_u64(field, value)
168+
converted[field] = datetime.fromtimestamp(timestamp, tz=timezone.utc)
135169

136170
return converted
137171

@@ -147,4 +181,10 @@ def from_dict(cls, data: dict) -> "Invoice":
147181
f"{cls.__name__}.from_dict() expects a dict, got "
148182
f"{type(data).__name__}"
149183
)
150-
return cls(**cls._from_contract_dict(data))
184+
try:
185+
converted = cls._from_contract_dict(data)
186+
except (ValueError, OverflowError) as err:
187+
field = str(err).split(" ", 1)[0]
188+
param = field if field in (*_U64_FIELDS, *_TIMESTAMP_FIELDS) else None
189+
raise InvalidRequestError(str(err), param=param) from err
190+
return cls(**converted)

tests/test_invoice.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from datetime import datetime, timezone
1+
from datetime import datetime, timedelta, timezone
22
from decimal import Decimal
33

44
import pytest
@@ -11,8 +11,9 @@
1111
PAYER = Keypair.random().public_key
1212

1313
NOW = int(datetime(2026, 8, 1, tzinfo=timezone.utc).timestamp())
14-
FUTURE = int(datetime(2026, 12, 1, tzinfo=timezone.utc).timestamp())
14+
FUTURE = int((datetime.now(timezone.utc) + timedelta(days=30)).timestamp())
1515
PAST = int(datetime(2020, 1, 1, tzinfo=timezone.utc).timestamp())
16+
U64_MAX = 2**64 - 1
1617

1718

1819
def _contract_response(**overrides):
@@ -161,6 +162,46 @@ def test_boolean_merchant_id_is_rejected():
161162
assert exc_info.value.param == "merchant_id"
162163

163164

165+
@pytest.mark.parametrize("field", ["id", "merchant_id"])
166+
def test_float_id_fields_are_rejected(field):
167+
with pytest.raises(InvalidRequestError) as exc_info:
168+
Invoice.from_dict(_contract_response(**{field: 1.9}))
169+
assert exc_info.value.param == field
170+
171+
172+
@pytest.mark.parametrize("field", ["date_created", "date_paid", "expires_at"])
173+
def test_float_timestamp_fields_are_rejected(field):
174+
with pytest.raises(InvalidRequestError) as exc_info:
175+
Invoice.from_dict(_contract_response(**{field: 1.9}))
176+
assert exc_info.value.param == field
177+
178+
179+
@pytest.mark.parametrize("field", ["id", "merchant_id"])
180+
def test_negative_id_fields_are_rejected(field):
181+
with pytest.raises(InvalidRequestError) as exc_info:
182+
Invoice.from_dict(_contract_response(**{field: -1}))
183+
assert exc_info.value.param == field
184+
185+
186+
def test_negative_expires_at_is_rejected():
187+
with pytest.raises(InvalidRequestError) as exc_info:
188+
Invoice.from_dict(_contract_response(expires_at=-100))
189+
assert exc_info.value.param == "expires_at"
190+
191+
192+
@pytest.mark.parametrize("field", ["id", "merchant_id"])
193+
def test_id_fields_exceeding_u64_max_are_rejected(field):
194+
with pytest.raises(InvalidRequestError) as exc_info:
195+
Invoice.from_dict(_contract_response(**{field: U64_MAX + 1}))
196+
assert exc_info.value.param == field
197+
198+
199+
def test_expires_at_exceeding_u64_max_is_rejected():
200+
with pytest.raises(InvalidRequestError) as exc_info:
201+
Invoice.from_dict(_contract_response(expires_at=U64_MAX + 1))
202+
assert exc_info.value.param == "expires_at"
203+
204+
164205
def test_from_dict_rejects_non_dict_input():
165206
with pytest.raises(InvalidRequestError) as excinfo:
166207
Invoice.from_dict(["id", 42])

0 commit comments

Comments
 (0)