From 36767576b1a61ffaeed06c1b54b5f3de9b738216 Mon Sep 17 00:00:00 2001 From: Dr Alex Mitre <30060514+mitre88@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:01:30 -0600 Subject: [PATCH] fix: allow ARC4 struct from_bytes with undersized backing bytes Closes algorandfoundation/algorand-python-testing#45 --- src/_algopy_testing/arc4.py | 30 ++++++++++++++++++++++-------- tests/arc4/test_struct.py | 13 +++++++++++++ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/_algopy_testing/arc4.py b/src/_algopy_testing/arc4.py index 2fefea6..cd59e6d 100644 --- a/src/_algopy_testing/arc4.py +++ b/src/_algopy_testing/arc4.py @@ -1227,15 +1227,28 @@ def _update_backing_value(self) -> None: @classmethod def from_bytes(cls, value: algopy.Bytes | bytes, /) -> typing.Self: + # AVM does not validate total encoded length on construction; it errors later + # when an operation reads past the backing bytes (see algorand-python-testing#45). + raw = value.value if isinstance(value, Bytes) else bytes(value) tuple_type = _tuple_type_from_struct(cls) - tuple_value = tuple_type.from_bytes(value) - if not tuple_value: - return typing.cast("typing.Self", tuple_value) - # convert each decoded ARC-4 item to the declared field type (ARC-4 - # annotations pass through, native annotations go through arc4_to_native) + child_types = tuple_type._type_info.child_types + try: + decoded = _decode_tuple_items(raw, child_types, strict=True) + except ValueError: + decoded = _decode_tuple_items(raw, child_types, strict=False) + items = [ + _to_native_type(field.type, arc4_item) + for arc4_item, field in zip(decoded, cls._type_info.fields, strict=True) + ] + instance = object.__new__(cls) + MutableBytes.__init__(instance) + instance._value = raw + for field, item in zip(cls._type_info.fields, items, strict=True): + object.__setattr__(instance, field.name, item) + return instance items = [ _to_native_type(field.type, arc4_item) - for arc4_item, field in zip(tuple_value.native, cls._type_info.fields, strict=True) + for arc4_item, field in zip(decoded, cls._type_info.fields, strict=True) ] return cls(*items) @@ -1548,7 +1561,7 @@ def _encode( # noqa: PLR0912 def _decode_tuple_items( # noqa: PLR0912, PLR0915 - value: bytes, child_types: list[_TypeInfo] + value: bytes, child_types: list[_TypeInfo], *, strict: bool = True ) -> list[typing.Any]: dynamic_segments: list[list[int]] = [] # Store the start and end of a dynamic element value_partitions: list[bytes] = [] @@ -1597,7 +1610,8 @@ def _decode_tuple_items( # noqa: PLR0912, PLR0915 array_index += curr_len if array_index >= len(value) and i != len(child_types) - 1: - raise ValueError(f"input string is not long enough to be decoded: {value!r}") + if strict: + raise ValueError(f"input string is not long enough to be decoded: {value!r}") i += 1 diff --git a/tests/arc4/test_struct.py b/tests/arc4/test_struct.py index 3353a34..2341d59 100644 --- a/tests/arc4/test_struct.py +++ b/tests/arc4/test_struct.py @@ -359,6 +359,19 @@ def test_replace() -> None: assert x != y +class FixedArrayUInt64(arc4.Struct): + length: arc4.UInt16 + arr: arc4.StaticArray[arc4.UInt64, typing.Literal[4095]] + + +def test_struct_from_bytes_allows_short_input() -> None: + """AVM construction from undersized bytes succeeds; OOB field access errors later.""" + foo = FixedArrayUInt64.from_bytes(b"\x00\x00") + assert foo.length == 0 + with pytest.raises(ValueError, match="not long enough"): + _ = foo.arr[0] + + def _compare_abi_and_arc4_values( arc4_value: typing.Any, abi_value: typing.Any,