Skip to content

Commit 0cf1cd7

Browse files
committed
feat: add TextEnvelope class for JSON serialization and deserialization
1 parent bc363a2 commit 0cf1cd7

File tree

3 files changed

+178
-3
lines changed

3 files changed

+178
-3
lines changed

pycardano/serialization.py

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from __future__ import annotations
44

5+
import json
6+
import os
57
import re
68
import typing
79
from collections import OrderedDict, UserList, defaultdict
@@ -45,7 +47,11 @@
4547
from frozenlist import FrozenList
4648
from pprintpp import pformat
4749

48-
from pycardano.exception import DeserializeException, SerializeException
50+
from pycardano.exception import (
51+
DeserializeException,
52+
InvalidKeyTypeException,
53+
SerializeException,
54+
)
4955
from pycardano.types import check_type, typechecked
5056

5157
__all__ = [
@@ -63,6 +69,7 @@
6369
"OrderedSet",
6470
"NonEmptyOrderedSet",
6571
"CodedSerializable",
72+
"TextEnvelope",
6673
]
6774

6875
T = TypeVar("T")
@@ -1142,3 +1149,98 @@ def from_primitive(
11421149
raise DeserializeException(f"Invalid {cls.__name__} type {values[0]}")
11431150
# Cast using Type[CodedSerializable] instead of cls directly
11441151
return cast(Type[CodedSerializable], super()).from_primitive(values[1:])
1152+
1153+
1154+
@dataclass(repr=False)
1155+
class TextEnvelope(CBORSerializable):
1156+
"""A base class for TextEnvelope types that can be saved and loaded as JSON."""
1157+
1158+
KEY_TYPE = ""
1159+
DESCRIPTION = ""
1160+
1161+
def __init__(
1162+
self,
1163+
payload: Optional[bytes] = None,
1164+
key_type: Optional[str] = None,
1165+
description: Optional[str] = None,
1166+
):
1167+
self._payload = payload
1168+
self._key_type = key_type or self.KEY_TYPE
1169+
self._description = description or self.DESCRIPTION
1170+
1171+
@property
1172+
def payload(self) -> bytes:
1173+
if self._payload is None:
1174+
self._payload = self.to_cbor()
1175+
return self._payload
1176+
1177+
@property
1178+
def key_type(self) -> str:
1179+
return self._key_type
1180+
1181+
@property
1182+
def description(self) -> str:
1183+
return self._description
1184+
1185+
def to_json(self) -> str:
1186+
"""Serialize the key to JSON.
1187+
1188+
The json output has three fields: "type", "description", and "cborHex".
1189+
1190+
Returns:
1191+
str: JSON representation of the key.
1192+
"""
1193+
return json.dumps(
1194+
{
1195+
"type": self.key_type,
1196+
"description": self.description,
1197+
"cborHex": self.to_cbor_hex(),
1198+
}
1199+
)
1200+
1201+
@classmethod
1202+
def from_json(
1203+
cls: Type[TextEnvelope], data: str, validate_type=False
1204+
) -> TextEnvelope:
1205+
"""Restore a TextEnvelope from a JSON string.
1206+
1207+
Args:
1208+
data (str): JSON string.
1209+
validate_type (bool): Checks whether the type specified in json object is the same
1210+
as the class's default type.
1211+
1212+
Returns:
1213+
Key: The key restored from JSON.
1214+
1215+
Raises:
1216+
InvalidKeyTypeException: When `validate_type=True` and the type in json is not equal to the default type
1217+
of the Key class used.
1218+
"""
1219+
obj = json.loads(data)
1220+
1221+
if validate_type and obj["type"] != cls.KEY_TYPE:
1222+
raise InvalidKeyTypeException(
1223+
f"Expect key type: {cls.KEY_TYPE}, got {obj['type']} instead."
1224+
)
1225+
1226+
k = cls.from_cbor(obj["cborHex"])
1227+
1228+
assert isinstance(k, cls)
1229+
1230+
k._key_type = obj["type"]
1231+
k._description = obj["description"]
1232+
k._payload = k.to_cbor()
1233+
1234+
return k
1235+
1236+
def save(self, path: str):
1237+
if os.path.isfile(path):
1238+
if os.stat(path).st_size > 0:
1239+
raise IOError(f"File {path} already exists!")
1240+
with open(path, "w") as f:
1241+
f.write(self.to_json())
1242+
1243+
@classmethod
1244+
def load(cls, path: str):
1245+
with open(path) as f:
1246+
return cls.from_json(f.read())

pycardano/transaction.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
NonEmptyOrderedSet,
4040
OrderedSet,
4141
Primitive,
42+
TextEnvelope,
4243
default_encoder,
4344
limit_primitive_type,
4445
list_hook,
@@ -685,7 +686,7 @@ def id(self) -> TransactionId:
685686

686687

687688
@dataclass(repr=False)
688-
class Transaction(ArrayCBORSerializable):
689+
class Transaction(ArrayCBORSerializable, TextEnvelope):
689690
transaction_body: TransactionBody
690691

691692
transaction_witness_set: TransactionWitnessSet
@@ -694,6 +695,35 @@ class Transaction(ArrayCBORSerializable):
694695

695696
auxiliary_data: Optional[AuxiliaryData] = None
696697

698+
def __init__(
699+
self,
700+
transaction_body: TransactionBody,
701+
transaction_witness_set: TransactionWitnessSet,
702+
valid: bool = True,
703+
auxiliary_data: Optional[AuxiliaryData] = None,
704+
payload: Optional[bytes] = None,
705+
key_type: Optional[str] = None,
706+
description: Optional[str] = None,
707+
):
708+
self.transaction_body = transaction_body
709+
self.transaction_witness_set = transaction_witness_set
710+
self.valid = valid
711+
self.auxiliary_data = auxiliary_data
712+
ArrayCBORSerializable.__init__(self)
713+
TextEnvelope.__init__(self, payload, key_type, description)
714+
715+
@property
716+
def DESCRIPTION(self):
717+
return "Ledger Cddl Format"
718+
719+
@property
720+
def KEY_TYPE(self):
721+
return (
722+
"Unwitnessed Tx ConwayEra"
723+
if self.transaction_witness_set.is_empty()
724+
else "Signed Tx ConwayEra"
725+
)
726+
697727
@property
698728
def id(self) -> TransactionId:
699729
return self.transaction_body.id

pycardano/witness.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
ArrayCBORSerializable,
1919
MapCBORSerializable,
2020
NonEmptyOrderedSet,
21+
TextEnvelope,
2122
limit_primitive_type,
2223
list_hook,
2324
)
@@ -26,10 +27,26 @@
2627

2728

2829
@dataclass(repr=False)
29-
class VerificationKeyWitness(ArrayCBORSerializable):
30+
class VerificationKeyWitness(ArrayCBORSerializable, TextEnvelope):
3031
vkey: Union[VerificationKey, ExtendedVerificationKey]
3132
signature: bytes
3233

34+
KEY_TYPE = "TxWitness ConwayEra"
35+
DESCRIPTION = "Key Witness ShelleyEra"
36+
37+
def __init__(
38+
self,
39+
vkey: Union[VerificationKey, ExtendedVerificationKey],
40+
signature: bytes,
41+
payload: Optional[bytes] = None,
42+
key_type: Optional[str] = None,
43+
description: Optional[str] = None,
44+
):
45+
self.vkey = vkey
46+
self.signature = signature
47+
ArrayCBORSerializable.__init__(self)
48+
TextEnvelope.__init__(self, payload, key_type, description)
49+
3350
def __post_init__(self):
3451
# When vkey is in extended format, we need to convert it to non-extended, so it can match the
3552
# key hash of the input address we are trying to spend.
@@ -46,6 +63,19 @@ def from_primitive(
4663
signature=values[1],
4764
)
4865

66+
def to_shallow_primitive(self) -> Union[list, tuple]:
67+
"""Convert to a shallow primitive representation."""
68+
return [self.vkey.to_primitive(), self.signature]
69+
70+
def __eq__(self, other):
71+
if not isinstance(other, VerificationKeyWitness):
72+
return False
73+
else:
74+
return (
75+
self.vkey.payload == other.vkey.payload
76+
and self.signature == other.signature
77+
)
78+
4979

5080
@dataclass(repr=False)
5181
class TransactionWitnessSet(MapCBORSerializable):
@@ -126,3 +156,16 @@ def __post_init__(self):
126156
self.plutus_v2_script = NonEmptyOrderedSet(self.plutus_v2_script)
127157
if isinstance(self.plutus_v3_script, list):
128158
self.plutus_v3_script = NonEmptyOrderedSet(self.plutus_v3_script)
159+
160+
def is_empty(self) -> bool:
161+
"""Check if the witness set is empty."""
162+
return (
163+
not self.vkey_witnesses
164+
and not self.native_scripts
165+
and not self.bootstrap_witness
166+
and not self.plutus_v1_script
167+
and not self.plutus_data
168+
and not self.redeemer
169+
and not self.plutus_v2_script
170+
and not self.plutus_v3_script
171+
)

0 commit comments

Comments
 (0)