diff --git a/pycardano/plutus.py b/pycardano/plutus.py index b6b2ac3a..5e76658e 100644 --- a/pycardano/plutus.py +++ b/pycardano/plutus.py @@ -804,17 +804,43 @@ class RawPlutusData(CBORSerializable): data: RawDatum def to_primitive(self) -> Primitive: - def _dfs(obj): - if isinstance(obj, list) and obj: + def _dfs(obj, preserve=False): + # `preserve` tracks decoded structures: a CBOR round trip marks + # indefinite lists as IndefiniteList while definite lists stay + # plain lists, and re-encoding must keep that distinction or a + # restored datum no longer matches its original bytes. Inside an + # IndefiniteList, children are therefore visited in preserve + # mode; structures built by from_dict or by hand keep the + # historical behaviour of wrapping plain lists as indefinite. + if isinstance(obj, IndefiniteList) and obj: + return IndefiniteList([_dfs(item, True) for item in obj]) + elif isinstance(obj, list) and obj: + if preserve: + return [_dfs(item, True) for item in obj] return IndefiniteList([_dfs(item) for item in obj]) elif isinstance(obj, dict): - return {_dfs(k): _dfs(v) for k, v in obj.items()} - elif isinstance(obj, CBORTag) and isinstance(obj.value, list) and obj.value: + return {_dfs(k, preserve): _dfs(v, preserve) for k, v in obj.items()} + elif ( + isinstance(obj, CBORTag) + and isinstance(obj.value, (list, IndefiniteList)) + and obj.value + ): + child_preserve = preserve or isinstance(obj.value, IndefiniteList) if obj.tag != 102: - value = IndefiniteList([_dfs(item) for item in obj.value]) + value = IndefiniteList( + [_dfs(item, child_preserve) for item in obj.value] + ) else: - value = [_dfs(item) for item in obj.value] + value = [_dfs(item, child_preserve) for item in obj.value] return CBORTag(tag=obj.tag, value=value) + elif isinstance(obj, bytes) and len(obj) > 64: + # The ledger requires byte strings longer than 64 bytes in + # Plutus data to be chunked (indefinite-length encoding). + # from_dict wraps such bytes in ByteString, but a round trip + # through CBOR (e.g. deepcopy or from_cbor) decodes them back + # to plain bytes, so re-wrap them here to keep the chunked + # encoding stable across copies. + return ByteString(obj) return obj return _dfs(self.data) diff --git a/test/pycardano/test_plutus.py b/test/pycardano/test_plutus.py index 49eb1038..4a99902c 100644 --- a/test/pycardano/test_plutus.py +++ b/test/pycardano/test_plutus.py @@ -353,6 +353,37 @@ def test_clone_raw_plutus_data(): assert cloned_tag != tag +def test_raw_plutus_data_long_bytes_stay_chunked(): + # Byte strings longer than 64 bytes must keep their chunked + # (indefinite-length) encoding through deepcopy and CBOR round trips, + # otherwise the ledger rejects the datum. + data = RawPlutusData.from_dict( + {"constructor": 0, "fields": [{"bytes": "ab" * 182}]} + ) + assert bytes.fromhex("5f5840") in data.to_cbor() + assert copy.deepcopy(data).to_cbor() == data.to_cbor() + assert RawPlutusData.from_cbor(data.to_cbor()).to_cbor() == data.to_cbor() + + +def test_raw_plutus_data_long_bytes_nested_stay_chunked(): + map_data = RawPlutusData.from_dict( + {"map": [{"k": {"bytes": "cd" * 100}, "v": {"bytes": "ef" * 100}}]} + ) + list_data = RawPlutusData.from_dict({"list": [{"bytes": "ab" * 65}]}) + for datum in (map_data, list_data): + assert RawPlutusData.from_cbor(datum.to_cbor()).to_cbor() == datum.to_cbor() + assert copy.deepcopy(datum).to_cbor() == datum.to_cbor() + + +def test_raw_plutus_data_short_bytes_stay_definite(): + data = RawPlutusData.from_dict( + {"constructor": 1, "fields": [{"bytes": "ab" * 32}, {"int": 42}]} + ) + assert b"\x5f" not in data.to_cbor() + assert RawPlutusData.from_cbor(data.to_cbor()).to_cbor() == data.to_cbor() + assert copy.deepcopy(data).to_cbor() == data.to_cbor() + + def test_clone_plutus_data(): key_hash = bytes.fromhex("c2ff616e11299d9094ce0a7eb5b7284b705147a822f4ffbd471f971a") deadline = 1643235300000