Skip to content

Add TextEnvelope class for saving and loading Witness and Transaction files #448

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 103 additions & 1 deletion pycardano/serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

import json
import os
import re
import typing
from collections import OrderedDict, UserList, defaultdict
Expand Down Expand Up @@ -45,7 +47,11 @@
from frozenlist import FrozenList
from pprintpp import pformat

from pycardano.exception import DeserializeException, SerializeException
from pycardano.exception import (
DeserializeException,
InvalidKeyTypeException,
SerializeException,
)
from pycardano.types import check_type, typechecked

__all__ = [
Expand All @@ -63,6 +69,7 @@
"OrderedSet",
"NonEmptyOrderedSet",
"CodedSerializable",
"TextEnvelope",
]

T = TypeVar("T")
Expand Down Expand Up @@ -1142,3 +1149,98 @@ def from_primitive(
raise DeserializeException(f"Invalid {cls.__name__} type {values[0]}")
# Cast using Type[CodedSerializable] instead of cls directly
return cast(Type[CodedSerializable], super()).from_primitive(values[1:])


@dataclass(repr=False)
class TextEnvelope(CBORSerializable):
"""A base class for TextEnvelope types that can be saved and loaded as JSON."""
Comment on lines +1154 to +1156
Copy link
Collaborator

@cffls cffls Jun 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be a good idea to combine this directly with CBORSerializable, which would instantly enable all pycardano classes to save to and load from json. I don't think private fields, key_type and description, are necessary, because they usually don't change between instances. If we want to customize them for certain child class, the child class can always override the method json_key_type or json_description. Regarding payload, it becomes redundant once the logic is merged to CBORSerializable. The default implementation can directly use the class name as the key type, something like below:

class CBORSerializable:
    ....
    
    
    @property
    def json_key_type(self) -> str:
        return self.__class__.__name__

    @property
    def json_description(self) -> str:
        return self.__class__.__doc__
    
    def to_json(self) -> str:
        return json.dumps(
            {
                "type": self.json_key_type,
                "description": self.json_description,
                "cborHex": self.to_cbor_hex(),
            }
        )

Basic usage

Screenshot 2025-06-08 at 8 06 30 AM

Custom key type
Screenshot 2025-06-08 at 8 12 56 AM


KEY_TYPE = ""
DESCRIPTION = ""

def __init__(
self,
payload: Optional[bytes] = None,
key_type: Optional[str] = None,
description: Optional[str] = None,
):
self._payload = payload
self._key_type = key_type or self.KEY_TYPE
self._description = description or self.DESCRIPTION

@property
def payload(self) -> bytes:
if self._payload is None:
self._payload = self.to_cbor()
return self._payload

@property
def key_type(self) -> str:
return self._key_type

@property
def description(self) -> str:
return self._description

def to_json(self) -> str:
"""Serialize the key to JSON.

The json output has three fields: "type", "description", and "cborHex".

Returns:
str: JSON representation of the key.
"""
return json.dumps(
{
"type": self.key_type,
"description": self.description,
"cborHex": self.to_cbor_hex(),
}
)

@classmethod
def from_json(
cls: Type[TextEnvelope], data: str, validate_type=False
) -> TextEnvelope:
"""Restore a TextEnvelope from a JSON string.

Args:
data (str): JSON string.
validate_type (bool): Checks whether the type specified in json object is the same
as the class's default type.

Returns:
Key: The key restored from JSON.

Raises:
InvalidKeyTypeException: When `validate_type=True` and the type in json is not equal to the default type
of the Key class used.
"""
obj = json.loads(data)

if validate_type and obj["type"] != cls.KEY_TYPE:
raise InvalidKeyTypeException(
f"Expect key type: {cls.KEY_TYPE}, got {obj['type']} instead."
)

k = cls.from_cbor(obj["cborHex"])

assert isinstance(k, cls)

k._key_type = obj["type"]
k._description = obj["description"]
k._payload = k.to_cbor()

return k

def save(self, path: str):
if os.path.isfile(path):
if os.stat(path).st_size > 0:
raise IOError(f"File {path} already exists!")
with open(path, "w") as f:
f.write(self.to_json())

@classmethod
def load(cls, path: str):
with open(path) as f:
return cls.from_json(f.read())
43 changes: 41 additions & 2 deletions pycardano/transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@

from copy import deepcopy
from dataclasses import dataclass, field
from pprint import pformat
from typing import Any, Callable, List, Optional, Type, Union

import cbor2
from cbor2 import CBORTag
from nacl.encoding import RawEncoder
from nacl.hash import blake2b
from pprintpp import pformat

from pycardano.address import Address
from pycardano.certificate import Certificate
Expand Down Expand Up @@ -39,6 +39,7 @@
NonEmptyOrderedSet,
OrderedSet,
Primitive,
TextEnvelope,
default_encoder,
limit_primitive_type,
list_hook,
Expand Down Expand Up @@ -685,7 +686,7 @@ def id(self) -> TransactionId:


@dataclass(repr=False)
class Transaction(ArrayCBORSerializable):
class Transaction(ArrayCBORSerializable, TextEnvelope):
transaction_body: TransactionBody

transaction_witness_set: TransactionWitnessSet
Expand All @@ -694,6 +695,44 @@ class Transaction(ArrayCBORSerializable):

auxiliary_data: Optional[AuxiliaryData] = None

def __init__(
self,
transaction_body: TransactionBody,
transaction_witness_set: TransactionWitnessSet,
valid: bool = True,
auxiliary_data: Optional[AuxiliaryData] = None,
payload: Optional[bytes] = None,
key_type: Optional[str] = None,
description: Optional[str] = None,
):
super().__init__()
self.transaction_body = transaction_body
self.transaction_witness_set = transaction_witness_set
self.valid = valid
self.auxiliary_data = auxiliary_data
self._payload = payload
self._key_type = key_type or self.KEY_TYPE
self._description = description or self.DESCRIPTION

def __repr__(self):
fields = vars(self)
fields.pop("_payload", None)
fields.pop("_key_type", None)
fields.pop("_description", None)
return pformat(vars(self), indent=2)

@property
def DESCRIPTION(self):
return "Ledger Cddl Format"

@property
def KEY_TYPE(self):
return (
"Unwitnessed Tx ConwayEra"
if self.transaction_witness_set.is_empty()
else "Signed Tx ConwayEra"
)

@property
def id(self) -> TransactionId:
return self.transaction_body.id
4 changes: 2 additions & 2 deletions pycardano/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ def fee(
"""Calculate fee based on the length of a transaction's CBOR bytes and script execution.

Args:
context (ChainConext): A chain context.
context (ChainContext): A chain context.
length (int): The length of CBOR bytes, which could usually be derived
by `len(tx.to_cbor())`.
exec_steps (int): Number of execution steps run by plutus scripts in the transaction.
Expand Down Expand Up @@ -201,7 +201,7 @@ def min_lovelace_pre_alonzo(
def min_lovelace_post_alonzo(output: TransactionOutput, context: ChainContext) -> int:
"""Calculate minimum lovelace a transaction output needs to hold post alonzo.

This implementation is copied from the origianl Haskell implementation:
This implementation is copied from the original Haskell implementation:
https://github.com/input-output-hk/cardano-ledger/blob/eb053066c1d3bb51fb05978eeeab88afc0b049b2/eras/babbage/impl/src/Cardano/Ledger/Babbage/Rules/Utxo.hs#L242-L265

Args:
Expand Down
61 changes: 57 additions & 4 deletions pycardano/witness.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from dataclasses import dataclass, field
from typing import Any, List, Optional, Type, Union

from pprintpp import pformat

from pycardano.key import ExtendedVerificationKey, VerificationKey
from pycardano.nativescript import NativeScript
from pycardano.plutus import (
Expand All @@ -18,6 +20,7 @@
ArrayCBORSerializable,
MapCBORSerializable,
NonEmptyOrderedSet,
TextEnvelope,
limit_primitive_type,
list_hook,
)
Expand All @@ -26,15 +29,32 @@


@dataclass(repr=False)
class VerificationKeyWitness(ArrayCBORSerializable):
class VerificationKeyWitness(ArrayCBORSerializable, TextEnvelope):
vkey: Union[VerificationKey, ExtendedVerificationKey]
signature: bytes

def __post_init__(self):
KEY_TYPE = "TxWitness ConwayEra"
DESCRIPTION = "Key Witness ShelleyEra"

def __init__(
self,
vkey: Union[VerificationKey, ExtendedVerificationKey],
signature: bytes,
payload: Optional[bytes] = None,
key_type: Optional[str] = None,
description: Optional[str] = None,
):
# When vkey is in extended format, we need to convert it to non-extended, so it can match the
# key hash of the input address we are trying to spend.
if isinstance(self.vkey, ExtendedVerificationKey):
self.vkey = self.vkey.to_non_extended()
super().__init__()
if isinstance(vkey, ExtendedVerificationKey):
self.vkey = vkey.to_non_extended()

Check warning on line 51 in pycardano/witness.py

View check run for this annotation

Codecov / codecov/patch

pycardano/witness.py#L51

Added line #L51 was not covered by tests
else:
self.vkey = vkey
self.signature = signature
self._payload = payload
self._key_type = key_type or self.KEY_TYPE
self._description = description or self.DESCRIPTION

@classmethod
@limit_primitive_type(list, tuple)
Expand All @@ -46,6 +66,26 @@
signature=values[1],
)

def to_shallow_primitive(self) -> Union[list, tuple]:
"""Convert to a shallow primitive representation."""
return [self.vkey.to_primitive(), self.signature]

def __eq__(self, other):
if not isinstance(other, VerificationKeyWitness):
return False
else:
return (
self.vkey.payload == other.vkey.payload
and self.signature == other.signature
)

def __repr__(self):
fields = {
"vkey": self.vkey.payload.hex(),
"signature": self.signature.hex(),
}
return pformat(fields, indent=2)


@dataclass(repr=False)
class TransactionWitnessSet(MapCBORSerializable):
Expand Down Expand Up @@ -126,3 +166,16 @@
self.plutus_v2_script = NonEmptyOrderedSet(self.plutus_v2_script)
if isinstance(self.plutus_v3_script, list):
self.plutus_v3_script = NonEmptyOrderedSet(self.plutus_v3_script)

def is_empty(self) -> bool:
"""Check if the witness set is empty."""
return (
not self.vkey_witnesses
and not self.native_scripts
and not self.bootstrap_witness
and not self.plutus_v1_script
and not self.plutus_data
and not self.redeemer
and not self.plutus_v2_script
and not self.plutus_v3_script
)
Loading