diff --git a/pycardano/metadata.py b/pycardano/metadata.py index 7a280017..6ddfa708 100644 --- a/pycardano/metadata.py +++ b/pycardano/metadata.py @@ -41,6 +41,7 @@ def _validate_type_and_size(data): if len(data) > self.MAX_ITEM_SIZE: raise InvalidArgumentException( f"The size of {data} exceeds {self.MAX_ITEM_SIZE} bytes." + "Use pycardano.serialization.ByteString for long bytes." ) elif isinstance(data, str): if len(data.encode("utf-8")) > self.MAX_ITEM_SIZE: diff --git a/pycardano/plutus.py b/pycardano/plutus.py index c8562874..3ee15800 100644 --- a/pycardano/plutus.py +++ b/pycardano/plutus.py @@ -14,11 +14,12 @@ from nacl.encoding import RawEncoder from nacl.hash import blake2b -from pycardano.exception import DeserializeException +from pycardano.exception import DeserializeException, InvalidArgumentException from pycardano.hash import DATUM_HASH_SIZE, SCRIPT_HASH_SIZE, DatumHash, ScriptHash from pycardano.nativescript import NativeScript from pycardano.serialization import ( ArrayCBORSerializable, + ByteString, CBORSerializable, DictCBORSerializable, IndefiniteList, @@ -468,6 +469,8 @@ class will reduce the complexity of serialization and deserialization tremendous >>> assert test == Test.from_cbor("d87a9f187b43333231ff") """ + MAX_BYTES_SIZE = 64 + @classproperty def CONSTR_ID(cls): """ @@ -489,13 +492,20 @@ def CONSTR_ID(cls): return getattr(cls, k) def __post_init__(self): - valid_types = (PlutusData, dict, IndefiniteList, int, bytes) + valid_types = (PlutusData, dict, IndefiniteList, int, ByteString, bytes) for f in fields(self): if inspect.isclass(f.type) and not issubclass(f.type, valid_types): raise TypeError( f"Invalid field type: {f.type}. A field in PlutusData should be one of {valid_types}" ) + data = getattr(self, f.name) + if isinstance(data, bytes) and len(data) > 64: + raise InvalidArgumentException( + f"The size of {data} exceeds {self.MAX_BYTES_SIZE} bytes. " + "Use pycardano.serialization.ByteString for long bytes." + ) + def to_shallow_primitive(self) -> CBORTag: primitives: Primitive = super().to_shallow_primitive() if primitives: @@ -553,6 +563,8 @@ def _dfs(obj): return {"int": obj} elif isinstance(obj, bytes): return {"bytes": obj.hex()} + elif isinstance(obj, ByteString): + return {"bytes": obj.value.hex()} elif isinstance(obj, IndefiniteList) or isinstance(obj, list): return {"list": [_dfs(item) for item in obj]} elif isinstance(obj, dict): @@ -667,7 +679,10 @@ def _dfs(obj): elif "int" in obj: return obj["int"] elif "bytes" in obj: - return bytes.fromhex(obj["bytes"]) + if len(obj["bytes"]) > 64: + return ByteString(bytes.fromhex(obj["bytes"])) + else: + return bytes.fromhex(obj["bytes"]) elif "list" in obj: return IndefiniteList([_dfs(item) for item in obj["list"]]) else: diff --git a/pycardano/serialization.py b/pycardano/serialization.py index 87aabd42..7b302e04 100644 --- a/pycardano/serialization.py +++ b/pycardano/serialization.py @@ -60,6 +60,22 @@ class IndefiniteFrozenList(FrozenList, IndefiniteList): # type: ignore pass +@dataclass +class ByteString: + value: bytes + + def __hash__(self): + return hash(self.value) + + def __eq__(self, other: object): + if isinstance(other, ByteString): + return self.value == other.value + elif isinstance(other, bytes): + return self.value == other + else: + return False + + @dataclass class RawCBOR: """A wrapper class for bytes that represents a CBOR value.""" @@ -160,6 +176,7 @@ def default_encoder( assert isinstance( value, ( + ByteString, CBORSerializable, IndefiniteList, RawCBOR, @@ -178,6 +195,15 @@ def default_encoder( for item in value: encoder.encode(item) encoder.write(b"\xff") + elif isinstance(value, ByteString): + if len(value.value) > 64: + encoder.write(b"\x5f") + for i in range(0, len(value.value), 64): + imax = min(i + 64, len(value.value)) + encoder.encode(value.value[i:imax]) + encoder.write(b"\xff") + else: + encoder.encode(value.value) elif isinstance(value, RawCBOR): encoder.write(value.cbor) elif isinstance(value, FrozenList): diff --git a/test/pycardano/test_plutus.py b/test/pycardano/test_plutus.py index f2aba0f9..ba3cd992 100644 --- a/test/pycardano/test_plutus.py +++ b/test/pycardano/test_plutus.py @@ -19,7 +19,7 @@ RedeemerTag, plutus_script_hash, ) -from pycardano.serialization import IndefiniteList +from pycardano.serialization import ByteString, IndefiniteList @dataclass @@ -396,3 +396,25 @@ class A(PlutusData): assert ( res == res2 ), "Same class has different default constructor id in two consecutive runs" + + +def test_plutus_data_long_bytes(): + @dataclass + class A(PlutusData): + a: ByteString + + quote = ( + "The line separating good and evil passes ... right through every human heart." + ) + + quote_hex = ( + "d866821a8e5890cf9f5f5840546865206c696e652073657061726174696e6720676f6f6420616" + "e64206576696c20706173736573202e2e2e207269676874207468726f7567682065766572794d" + "2068756d616e2068656172742effff" + ) + + A_tmp = A(ByteString(quote.encode())) + + assert ( + A_tmp.to_cbor_hex() == quote_hex + ), "Long metadata bytestring is encoded incorrectly."