From 313a62619f3c0c9e92f211fab029150a93ea0cd4 Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Tue, 3 Jun 2025 09:54:57 -0500 Subject: [PATCH 01/16] test: add cardano-cli tests for latest --- test/pycardano/backend/test_cardano_cli.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/pycardano/backend/test_cardano_cli.py b/test/pycardano/backend/test_cardano_cli.py index a81dc3b4..cd08e434 100644 --- a/test/pycardano/backend/test_cardano_cli.py +++ b/test/pycardano/backend/test_cardano_cli.py @@ -861,6 +861,14 @@ def test_submit_tx_latest(self, chain_context_latest): == "270be16fa17cdb3ef683bf2c28259c978d4b7088792074f177c8efda247e23f7" ) + def test_submit_tx_latest(self, chain_context_latest): + results = chain_context_latest.submit_tx("testcborhexfromtransaction") + + assert ( + results + == "270be16fa17cdb3ef683bf2c28259c978d4b7088792074f177c8efda247e23f7" + ) + def test_submit_tx_fail(self, chain_context_tx_fail): with pytest.raises(TransactionFailedException) as exc_info: chain_context_tx_fail.submit_tx("testcborhexfromtransaction") From bc363a2d68392378c8d436f9fdc0bc09cf3b5686 Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Tue, 3 Jun 2025 14:21:47 -0500 Subject: [PATCH 02/16] test: add fixtures for transaction failure scenarios in cardano-cli tests --- test/pycardano/backend/test_cardano_cli.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/test/pycardano/backend/test_cardano_cli.py b/test/pycardano/backend/test_cardano_cli.py index cd08e434..664a224c 100644 --- a/test/pycardano/backend/test_cardano_cli.py +++ b/test/pycardano/backend/test_cardano_cli.py @@ -19,6 +19,7 @@ RawPlutusData, TransactionFailedException, TransactionInput, + CardanoCliError, ) QUERY_TIP_RESULT = { @@ -861,14 +862,6 @@ def test_submit_tx_latest(self, chain_context_latest): == "270be16fa17cdb3ef683bf2c28259c978d4b7088792074f177c8efda247e23f7" ) - def test_submit_tx_latest(self, chain_context_latest): - results = chain_context_latest.submit_tx("testcborhexfromtransaction") - - assert ( - results - == "270be16fa17cdb3ef683bf2c28259c978d4b7088792074f177c8efda247e23f7" - ) - def test_submit_tx_fail(self, chain_context_tx_fail): with pytest.raises(TransactionFailedException) as exc_info: chain_context_tx_fail.submit_tx("testcborhexfromtransaction") From 0cf1cd735004a44ed325dc64a07d3d72374bbd24 Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Fri, 6 Jun 2025 15:27:06 -0500 Subject: [PATCH 03/16] feat: add TextEnvelope class for JSON serialization and deserialization --- pycardano/serialization.py | 104 ++++++++++++++++++++++++++++++++++++- pycardano/transaction.py | 32 +++++++++++- pycardano/witness.py | 45 +++++++++++++++- 3 files changed, 178 insertions(+), 3 deletions(-) diff --git a/pycardano/serialization.py b/pycardano/serialization.py index 0c00dbe6..eb549061 100644 --- a/pycardano/serialization.py +++ b/pycardano/serialization.py @@ -2,6 +2,8 @@ from __future__ import annotations +import json +import os import re import typing from collections import OrderedDict, UserList, defaultdict @@ -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__ = [ @@ -63,6 +69,7 @@ "OrderedSet", "NonEmptyOrderedSet", "CodedSerializable", + "TextEnvelope", ] T = TypeVar("T") @@ -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.""" + + 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()) diff --git a/pycardano/transaction.py b/pycardano/transaction.py index e2099966..765322cd 100644 --- a/pycardano/transaction.py +++ b/pycardano/transaction.py @@ -39,6 +39,7 @@ NonEmptyOrderedSet, OrderedSet, Primitive, + TextEnvelope, default_encoder, limit_primitive_type, list_hook, @@ -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 @@ -694,6 +695,35 @@ 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, + ): + self.transaction_body = transaction_body + self.transaction_witness_set = transaction_witness_set + self.valid = valid + self.auxiliary_data = auxiliary_data + ArrayCBORSerializable.__init__(self) + TextEnvelope.__init__(self, payload, key_type, description) + + @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 diff --git a/pycardano/witness.py b/pycardano/witness.py index 49e4b22a..c1e04ee3 100644 --- a/pycardano/witness.py +++ b/pycardano/witness.py @@ -18,6 +18,7 @@ ArrayCBORSerializable, MapCBORSerializable, NonEmptyOrderedSet, + TextEnvelope, limit_primitive_type, list_hook, ) @@ -26,10 +27,26 @@ @dataclass(repr=False) -class VerificationKeyWitness(ArrayCBORSerializable): +class VerificationKeyWitness(ArrayCBORSerializable, TextEnvelope): vkey: Union[VerificationKey, ExtendedVerificationKey] signature: bytes + 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, + ): + self.vkey = vkey + self.signature = signature + ArrayCBORSerializable.__init__(self) + TextEnvelope.__init__(self, payload, key_type, description) + def __post_init__(self): # 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. @@ -46,6 +63,19 @@ def from_primitive( 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 + ) + @dataclass(repr=False) class TransactionWitnessSet(MapCBORSerializable): @@ -126,3 +156,16 @@ def __post_init__(self): 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 + ) From 564aed7f73c43bede267aae5be7276901b6d71f4 Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Fri, 6 Jun 2025 15:27:43 -0500 Subject: [PATCH 04/16] test: add tests for network magic and DRep serialization and TextEnvelope save and load --- test/pycardano/backend/test_cardano_cli.py | 5 + test/pycardano/test_certificate.py | 102 +++++++++++++++++++++ test/pycardano/test_transaction.py | 27 ++++++ test/pycardano/test_witness.py | 22 +++++ 4 files changed, 156 insertions(+) create mode 100644 test/pycardano/test_witness.py diff --git a/test/pycardano/backend/test_cardano_cli.py b/test/pycardano/backend/test_cardano_cli.py index 664a224c..5309be42 100644 --- a/test/pycardano/backend/test_cardano_cli.py +++ b/test/pycardano/backend/test_cardano_cli.py @@ -21,6 +21,7 @@ TransactionInput, CardanoCliError, ) +from pycardano.backend.cardano_cli import network_magic QUERY_TIP_RESULT = { "block": 1460093, @@ -647,6 +648,10 @@ def override_run_command_fail(cmd: List[str]): return context +def test_network_magic(): + assert network_magic(1) == ["--testnet-magic", "1"] + + class TestCardanoCliChainContext: def test_protocol_param(self, chain_context): assert ( diff --git a/test/pycardano/test_certificate.py b/test/pycardano/test_certificate.py index cd14e9e1..322b5af1 100644 --- a/test/pycardano/test_certificate.py +++ b/test/pycardano/test_certificate.py @@ -7,7 +7,9 @@ from pycardano.certificate import ( Anchor, AuthCommitteeHotCertificate, + DRep, DRepCredential, + DRepKind, PoolRegistration, PoolRetirement, ResignCommitteeColdCertificate, @@ -22,8 +24,10 @@ from pycardano.hash import ( # plutus_script_hash, POOL_KEY_HASH_SIZE, SCRIPT_HASH_SIZE, + VERIFICATION_KEY_HASH_SIZE, PoolKeyHash, ScriptHash, + VerificationKeyHash, ) TEST_ADDR = Address.from_primitive( @@ -197,6 +201,104 @@ def test_drep_credential(): assert DRepCredential.from_cbor(drep_credential_cbor_hex) == drep_credential +@pytest.mark.parametrize( + "input_values,expected_kind,expected_credential,expected_exception,case_id", + [ + # Happy path: VERIFICATION_KEY_HASH + ( + [DRepKind.VERIFICATION_KEY_HASH.value, b"1" * VERIFICATION_KEY_HASH_SIZE], + DRepKind.VERIFICATION_KEY_HASH, + VerificationKeyHash(b"1" * VERIFICATION_KEY_HASH_SIZE), + None, + "verification_key_hash", + ), + # Happy path: SCRIPT_HASH + ( + [DRepKind.SCRIPT_HASH.value, b"1" * SCRIPT_HASH_SIZE], + DRepKind.SCRIPT_HASH, + ScriptHash(b"1" * SCRIPT_HASH_SIZE), + None, + "script_hash", + ), + # Happy path: ALWAYS_ABSTAIN + ( + [DRepKind.ALWAYS_ABSTAIN.value], + DRepKind.ALWAYS_ABSTAIN, + None, + None, + "always_abstain", + ), + # Happy path: ALWAYS_NO_CONFIDENCE + ( + [DRepKind.ALWAYS_NO_CONFIDENCE.value], + DRepKind.ALWAYS_NO_CONFIDENCE, + None, + None, + "always_no_confidence", + ), + # Error: invalid DRepKind value (not in enum) + ([99], None, None, DeserializeException, "invalid_drep_kind"), + # Error: valid kind but missing credential for VERIFICATION_KEY_HASH + ( + [DRepKind.VERIFICATION_KEY_HASH.value], + None, + None, + IndexError, + "missing_credential_verification_key_hash", + ), + # Error: valid kind but missing credential for SCRIPT_HASH + ( + [DRepKind.SCRIPT_HASH.value], + None, + None, + IndexError, + "missing_credential_script_hash", + ), + # Error: valid kind but extra credential for ALWAYS_ABSTAIN + ( + [DRepKind.ALWAYS_ABSTAIN.value, b"extra"], + DRepKind.ALWAYS_ABSTAIN, + None, + None, + "abstain_with_extra", + ), + # Error: valid kind but extra credential for ALWAYS_NO_CONFIDENCE + ( + [DRepKind.ALWAYS_NO_CONFIDENCE.value, b"extra"], + DRepKind.ALWAYS_NO_CONFIDENCE, + None, + None, + "no_confidence_with_extra", + ), + # Error: input is empty + ([], None, None, IndexError, "empty_input"), + # Error: input is not a list or tuple + ("notalist", None, None, DeserializeException, "input_not_list"), + ], + ids=lambda p: p if isinstance(p, str) else None, +) +def test_drep_from_primitive( + input_values, expected_kind, expected_credential, expected_exception, case_id +): + + # Arrange + # (All input values are provided via test parameters) + + # Act / Assert + if expected_exception: + with pytest.raises(expected_exception): + DRep.from_primitive(input_values) + else: + result = DRep.from_primitive(input_values) + # Assert + assert result.kind == expected_kind + if expected_credential is not None: + assert isinstance(result.credential, type(expected_credential)) + assert result.credential.payload == expected_credential.payload + else: + assert result.credential is None + + def test_unreg_drep_certificate(): staking_key = StakeSigningKey.from_cbor( "5820ff3a330df8859e4e5f42a97fcaee73f6a00d0cf864f4bca902bd106d423f02c0" diff --git a/test/pycardano/test_transaction.py b/test/pycardano/test_transaction.py index 30925d6d..184bbe4a 100644 --- a/test/pycardano/test_transaction.py +++ b/test/pycardano/test_transaction.py @@ -1,3 +1,4 @@ +import tempfile from dataclasses import dataclass from fractions import Fraction from test.pycardano.util import check_two_way_cbor @@ -243,6 +244,32 @@ def test_transaction(): assert expected_tx_id == signed_tx.id +def test_transaction_save_load(): + tx_cbor = ( + "84a70081825820b35a4ba9ef3ce21adcd6879d08553642224304704d206c74d3ffb3e6eed3ca28000d80018182581d60cc" + "30497f4ff962f4c1dca54cceefe39f86f1d7179668009f8eb71e598200a1581cec8b7d1dd0b124e8333d3fa8d818f6eac0" + "68231a287554e9ceae490ea24f5365636f6e6454657374746f6b656e1a009896804954657374746f6b656e1a0098968002" + "1a000493e00e8009a1581cec8b7d1dd0b124e8333d3fa8d818f6eac068231a287554e9ceae490ea24f5365636f6e645465" + "7374746f6b656e1a009896804954657374746f6b656e1a00989680075820592a2df0e091566969b3044626faa8023dabe6" + "f39c78f33bed9e105e55159221a200828258206443a101bdb948366fc87369336224595d36d8b0eee5602cba8b81a024e5" + "84735840846f408dee3b101fda0f0f7ca89e18b724b7ca6266eb29775d3967d6920cae7457accb91def9b77571e15dd2ed" + "e38b12cf92496ce7382fa19eb90ab7f73e49008258205797dc2cc919dfec0bb849551ebdf30d96e5cbe0f33f734a87fe82" + "6db30f7ef95840bdc771aa7b8c86a8ffcbe1b7a479c68503c8aa0ffde8059443055bf3e54b92f4fca5e0b9ca5bb11ab23b" + "1390bb9ffce414fa398fc0b17f4dc76fe9f7e2c99c09018182018482051a075bcd1582041a075bcd0c8200581c9139e5c0" + "a42f0f2389634c3dd18dc621f5594c5ba825d9a8883c66278200581c835600a2be276a18a4bebf0225d728f090f724f4c0" + "acd591d066fa6ff5d90103a100a11902d1a16b7b706f6c6963795f69647da16d7b706f6c6963795f6e616d657da66b6465" + "736372697074696f6e6a3c6f7074696f6e616c3e65696d6167656a3c72657175697265643e686c6f636174696f6ea36761" + "7277656176656a3c6f7074696f6e616c3e6568747470736a3c6f7074696f6e616c3e64697066736a3c7265717569726564" + "3e646e616d656a3c72657175697265643e667368613235366a3c72657175697265643e64747970656a3c72657175697265643e" + ) + tx = Transaction.from_cbor(tx_cbor) + + with tempfile.NamedTemporaryFile() as f: + tx.save(f.name) + loaded_tx = Transaction.load(f.name) + assert tx == loaded_tx + + def test_multi_asset(): serialized_value = [ 100, diff --git a/test/pycardano/test_witness.py b/test/pycardano/test_witness.py new file mode 100644 index 00000000..2fdfbb15 --- /dev/null +++ b/test/pycardano/test_witness.py @@ -0,0 +1,22 @@ +import tempfile + +from pycardano import ( + PaymentKeyPair, + PaymentSigningKey, + PaymentVerificationKey, + VerificationKeyWitness, +) + + +def test_witness_save_load(): + sk = PaymentSigningKey.generate() + vk = PaymentVerificationKey.from_signing_key(sk) + witness = VerificationKeyWitness( + vkey=vk, + signature=sk.sign(b"test"), + ) + + with tempfile.NamedTemporaryFile() as f: + witness.save(f.name) + loaded_witness = VerificationKeyWitness.load(f.name) + assert witness == loaded_witness From 82dd14006f6fdb9f703c828e5e5a0215a4fa90c4 Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Fri, 6 Jun 2025 20:08:32 -0500 Subject: [PATCH 05/16] test: add tests for TextEnvelope and witness --- test/pycardano/test_serialization.py | 52 +++++++++++++++++++++++++++- test/pycardano/test_witness.py | 3 +- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/test/pycardano/test_serialization.py b/test/pycardano/test_serialization.py index d7878717..b98ccb5c 100644 --- a/test/pycardano/test_serialization.py +++ b/test/pycardano/test_serialization.py @@ -1,3 +1,5 @@ +import json +import tempfile from collections import defaultdict, deque from copy import deepcopy from dataclasses import dataclass, field @@ -30,7 +32,11 @@ VerificationKey, VerificationKeyWitness, ) -from pycardano.exception import DeserializeException, SerializeException +from pycardano.exception import ( + DeserializeException, + SerializeException, + InvalidKeyTypeException, +) from pycardano.plutus import PlutusData, PlutusV1Script, PlutusV2Script from pycardano.serialization import ( ArrayCBORSerializable, @@ -45,6 +51,7 @@ RawCBOR, default_encoder, limit_primitive_type, + TextEnvelope, ) @@ -982,3 +989,46 @@ class TestData(MapCBORSerializable): s_copy[0].value = 100 assert s[0].value == 1 assert s_copy[0].value == 100 + + +def test_text_envelope(): + @dataclass + class Test1(ArrayCBORSerializable, TextEnvelope): + a: str + b: Union[str, None] = None + + KEY_TYPE = "Test1" + DESCRIPTION = "A test class for TextEnvelope serialization" + + def __init__( + self, + a: str, + b: Union[str, None] = None, + payload: Optional[bytes] = None, + key_type: Optional[str] = None, + description: Optional[str] = None, + ): + self.a = a + self.b = b + TextEnvelope.__init__(self, payload, key_type, description) + + test1 = Test1(a="a") + + wrong_type = { + "type": "Test2", + "description": "A test class for TextEnvelope serialization", + "cborHex": "826161f6", + } + + with pytest.raises(InvalidKeyTypeException): + invalid_test1 = Test1.from_json(json.dumps(wrong_type), validate_type=True) + + assert test1.payload == b"\x82aa\xf6" + + with tempfile.NamedTemporaryFile() as f: + test1.save(f.name) + loaded = Test1.load(f.name) + assert test1 == loaded + + with pytest.raises(IOError): + test1.save(f.name) diff --git a/test/pycardano/test_witness.py b/test/pycardano/test_witness.py index 2fdfbb15..19d82a91 100644 --- a/test/pycardano/test_witness.py +++ b/test/pycardano/test_witness.py @@ -1,7 +1,6 @@ import tempfile from pycardano import ( - PaymentKeyPair, PaymentSigningKey, PaymentVerificationKey, VerificationKeyWitness, @@ -20,3 +19,5 @@ def test_witness_save_load(): witness.save(f.name) loaded_witness = VerificationKeyWitness.load(f.name) assert witness == loaded_witness + + assert witness != vk From a39bea18e13b15f35423d803bd08b684c4c89475 Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Fri, 6 Jun 2025 21:34:57 -0500 Subject: [PATCH 06/16] test: add unit tests for BlockFrostChainContext methods to improve coverage --- test/pycardano/backend/test_blockfrost.py | 325 ++++++++++++++++++++++ 1 file changed, 325 insertions(+) diff --git a/test/pycardano/backend/test_blockfrost.py b/test/pycardano/backend/test_blockfrost.py index 5dcb2cbb..4cc16f39 100644 --- a/test/pycardano/backend/test_blockfrost.py +++ b/test/pycardano/backend/test_blockfrost.py @@ -1,7 +1,11 @@ +from fractions import Fraction from unittest.mock import MagicMock, patch from blockfrost import ApiUrls +from blockfrost.utils import convert_json_to_object +from requests import Response +from pycardano import ALONZO_COINS_PER_UTXO_WORD, GenesisParameters, ProtocolParameters from pycardano.backend.blockfrost import BlockFrostChainContext from pycardano.network import Network @@ -20,3 +24,324 @@ def test_blockfrost_chain_context(mock_api): chain_context = BlockFrostChainContext("project_id", base_url=ApiUrls.preview.value) assert chain_context.network == Network.TESTNET + + +def test_epoch_property(): + with patch( + "blockfrost.api.BlockFrostApi.epoch_latest", + return_value=convert_json_to_object( + { + "epoch": 225, + "start_time": 1603403091, + "end_time": 1603835086, + "first_block_time": 1603403092, + "last_block_time": 1603835084, + "block_count": 21298, + "tx_count": 17856, + "output": "7849943934049314", + "fees": "4203312194", + "active_stake": "784953934049314", + } + ), + ): + chain_context = BlockFrostChainContext( + "project_id", base_url=ApiUrls.preprod.value + ) + chain_context._check_epoch_and_update() + assert chain_context.epoch == 225 + + +def test_last_block_slot(): + with patch( + "blockfrost.api.BlockFrostApi.epoch_latest", + return_value=convert_json_to_object( + { + "epoch": 225, + } + ), + ), patch( + "blockfrost.api.BlockFrostApi.block_latest", + return_value=convert_json_to_object( + { + "time": 1641338934, + "height": 15243593, + "hash": "4ea1ba291e8eef538635a53e59fddba7810d1679631cc3aed7c8e6c4091a516a", + "slot": 412162133, + "epoch": 425, + "epoch_slot": 12, + "slot_leader": "pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2qnikdy", + "size": 3, + "tx_count": 1, + "output": "128314491794", + "fees": "592661", + "block_vrf": "vrf_vk1wf2k6lhujezqcfe00l6zetxpnmh9n6mwhpmhm0dvfh3fxgmdnrfqkms8ty", + "op_cert": "da905277534faf75dae41732650568af545134ee08a3c0392dbefc8096ae177c", + "op_cert_counter": "18", + "previous_block": "43ebccb3ac72c7cebd0d9b755a4b08412c9f5dcb81b8a0ad1e3c197d29d47b05", + "next_block": "8367f026cf4b03e116ff8ee5daf149b55ba5a6ec6dec04803b8dc317721d15fa", + "confirmations": 4698, + } + ), + ): + chain_context = BlockFrostChainContext( + "project_id", base_url=ApiUrls.preprod.value + ) + assert chain_context.last_block_slot == 412162133 + + +def test_genesis_param(): + genesis_json = { + "active_slots_coefficient": 0.05, + "update_quorum": 5, + "max_lovelace_supply": "45000000000000000", + "network_magic": 764824073, + "epoch_length": 432000, + "system_start": 1506203091, + "slots_per_kes_period": 129600, + "slot_length": 1, + "max_kes_evolutions": 62, + "security_param": 2160, + } + + with patch( + "blockfrost.api.BlockFrostApi.epoch_latest", + return_value=convert_json_to_object( + { + "epoch": 225, + } + ), + ), patch( + "blockfrost.api.BlockFrostApi.genesis", + return_value=convert_json_to_object(genesis_json), + ): + chain_context = BlockFrostChainContext( + "project_id", base_url=ApiUrls.preprod.value + ) + assert chain_context.genesis_param == GenesisParameters(**genesis_json) + + +def test_protocol_param(): + protocol_param_json = { + "epoch": 225, + "min_fee_a": 44, + "min_fee_b": 155381, + "max_block_size": 65536, + "max_tx_size": 16384, + "max_block_header_size": 1100, + "key_deposit": "2000000", + "pool_deposit": "500000000", + "e_max": 18, + "n_opt": 150, + "a0": 0.3, + "rho": 0.003, + "tau": 0.2, + "decentralisation_param": 0.5, + "extra_entropy": None, + "protocol_major_ver": 2, + "protocol_minor_ver": 0, + "min_utxo": "1000000", + "min_pool_cost": "340000000", + "nonce": "1a3be38bcbb7911969283716ad7aa550250226b76a61fc51cc9a9a35d9276d81", + "cost_models": { + "PlutusV1": { + "addInteger-cpu-arguments-intercept": 197209, + "addInteger-cpu-arguments-slope": 0, + }, + "PlutusV2": { + "addInteger-cpu-arguments-intercept": 197209, + "addInteger-cpu-arguments-slope": 0, + }, + }, + "cost_models_raw": {"PlutusV1": [197209, 0], "PlutusV2": [197209, 0]}, + "price_mem": 0.0577, + "price_step": 0.0000721, + "max_tx_ex_mem": "10000000", + "max_tx_ex_steps": "10000000000", + "max_block_ex_mem": "50000000", + "max_block_ex_steps": "40000000000", + "max_val_size": "5000", + "collateral_percent": 150, + "max_collateral_inputs": 3, + "coins_per_utxo_size": "34482", + "coins_per_utxo_word": "34482", + "pvt_motion_no_confidence": 1, + "pvt_committee_normal": 1, + "pvt_committee_no_confidence": 1, + "pvt_hard_fork_initiation": 1, + "dvt_motion_no_confidence": 1, + "dvt_committee_normal": 1, + "dvt_committee_no_confidence": 1, + "dvt_update_to_constitution": 1, + "dvt_hard_fork_initiation": 1, + "dvt_p_p_network_group": 1, + "dvt_p_p_economic_group": 1, + "dvt_p_p_technical_group": 1, + "dvt_p_p_gov_group": 1, + "dvt_treasury_withdrawal": 1, + "committee_min_size": "…", + "committee_max_term_length": "…", + "gov_action_lifetime": "…", + "gov_action_deposit": "…", + "drep_deposit": "…", + "drep_activity": "…", + "pvtpp_security_group": 1, + "pvt_p_p_security_group": 1, + "min_fee_ref_script_cost_per_byte": 1, + } + + with patch( + "blockfrost.api.BlockFrostApi.epoch_latest", + return_value=convert_json_to_object( + { + "epoch": 225, + } + ), + ), patch( + "blockfrost.api.BlockFrostApi.epoch_latest_parameters", + return_value=convert_json_to_object(protocol_param_json), + ): + chain_context = BlockFrostChainContext( + "project_id", base_url=ApiUrls.preprod.value + ) + + params = convert_json_to_object(protocol_param_json) + assert chain_context.protocol_param == ProtocolParameters( + min_fee_constant=int(params.min_fee_b), + min_fee_coefficient=int(params.min_fee_a), + max_block_size=int(params.max_block_size), + max_tx_size=int(params.max_tx_size), + max_block_header_size=int(params.max_block_header_size), + key_deposit=int(params.key_deposit), + pool_deposit=int(params.pool_deposit), + pool_influence=Fraction(params.a0), + monetary_expansion=Fraction(params.rho), + treasury_expansion=Fraction(params.tau), + decentralization_param=Fraction(params.decentralisation_param), + extra_entropy=params.extra_entropy, + protocol_major_version=int(params.protocol_major_ver), + protocol_minor_version=int(params.protocol_minor_ver), + min_utxo=int(params.min_utxo), + min_pool_cost=int(params.min_pool_cost), + price_mem=Fraction(params.price_mem), + price_step=Fraction(params.price_step), + max_tx_ex_mem=int(params.max_tx_ex_mem), + max_tx_ex_steps=int(params.max_tx_ex_steps), + max_block_ex_mem=int(params.max_block_ex_mem), + max_block_ex_steps=int(params.max_block_ex_steps), + max_val_size=int(params.max_val_size), + collateral_percent=int(params.collateral_percent), + max_collateral_inputs=int(params.max_collateral_inputs), + coins_per_utxo_word=int(params.coins_per_utxo_word) + or ALONZO_COINS_PER_UTXO_WORD, + coins_per_utxo_byte=int(params.coins_per_utxo_size), + cost_models={ + k: v.to_dict() for k, v in params.cost_models.to_dict().items() + }, + maximum_reference_scripts_size={"bytes": 200000}, + min_fee_reference_scripts={ + "base": params.min_fee_ref_script_cost_per_byte, + "range": 200000, + "multiplier": 1, + }, + ) + + +def test_utxos(): + utxos_json = [ + { + "address": "addr1qxqs59lphg8g6qndelq8xwqn60ag3aeyfcp33c2kdp46a09re5df3pzwwmyq946axfcejy5n4x0y99wqpgtp2gd0k09qsgy6pz", + "tx_hash": "39a7a284c2a0948189dc45dec670211cd4d72f7b66c5726c08d9b3df11e44d58", + "output_index": 0, + "amount": [{"unit": "lovelace", "quantity": "42000000"}], + "block": "7eb8e27d18686c7db9a18f8bbcfe34e3fed6e047afaa2d969904d15e934847e6", + "data_hash": "9e478573ab81ea7a8e31891ce0648b81229f408d596a3483e6f4f9b92d3cf710", + "inline_datum": None, + "reference_script_hash": None, + }, + { + "address": "addr1qxqs59lphg8g6qndelq8xwqn60ag3aeyfcp33c2kdp46a09re5df3pzwwmyq946axfcejy5n4x0y99wqpgtp2gd0k09qsgy6pz", + "tx_hash": "4c4e67bafa15e742c13c592b65c8f74c769cd7d9af04c848099672d1ba391b49", + "output_index": 0, + "amount": [{"unit": "lovelace", "quantity": "729235000"}], + "block": "953f1b80eb7c11a7ffcd67cbd4fde66e824a451aca5a4065725e5174b81685b7", + "data_hash": None, + "inline_datum": None, + "reference_script_hash": None, + }, + { + "address": "addr1qxqs59lphg8g6qndelq8xwqn60ag3aeyfcp33c2kdp46a09re5df3pzwwmyq946axfcejy5n4x0y99wqpgtp2gd0k09qsgy6pz", + "tx_hash": "768c63e27a1c816a83dc7b07e78af673b2400de8849ea7e7b734ae1333d100d2", + "output_index": 1, + "amount": [ + {"unit": "lovelace", "quantity": "42000000"}, + { + "unit": "b0d07d45fe9514f80213f4020e5a61241458be626841cde717cb38a76e7574636f696e", + "quantity": "12", + }, + ], + "block": "5c571f83fe6c784d3fbc223792627ccf0eea96773100f9aedecf8b1eda4544d7", + "data_hash": None, + "inline_datum": None, + "reference_script_hash": None, + }, + ] + + with patch( + "blockfrost.api.BlockFrostApi.epoch_latest", + return_value=convert_json_to_object( + { + "epoch": 225, + } + ), + ), patch( + "blockfrost.api.BlockFrostApi.address_utxos", + return_value=convert_json_to_object(utxos_json), + ): + chain_context = BlockFrostChainContext( + "project_id", base_url=ApiUrls.preprod.value + ) + + utxos = chain_context.utxos( + "addr1qxqs59lphg8g6qndelq8xwqn60ag3aeyfcp33c2kdp46a09re5df3pzwwmyq946axfcejy5n4x0y99wqpgtp2gd0k09qsgy6pz" + ) + assert len(utxos) == 3 + + +def test_submit_tx_cbor(): + response = Response() + response.status_code = 200 + with patch( + "blockfrost.api.BlockFrostApi.epoch_latest", + return_value=convert_json_to_object( + { + "epoch": 225, + } + ), + ), patch( + "blockfrost.api.BlockFrostApi.transaction_submit", + return_value=response, + ): + chain_context = BlockFrostChainContext( + "project_id", base_url=ApiUrls.preprod.value + ) + + tx_cbor = ( + "84a70081825820b35a4ba9ef3ce21adcd6879d08553642224304704d206c74d3ffb3e6eed3ca28000d80018182581d60cc" + "30497f4ff962f4c1dca54cceefe39f86f1d7179668009f8eb71e598200a1581cec8b7d1dd0b124e8333d3fa8d818f6eac0" + "68231a287554e9ceae490ea24f5365636f6e6454657374746f6b656e1a009896804954657374746f6b656e1a0098968002" + "1a000493e00e8009a1581cec8b7d1dd0b124e8333d3fa8d818f6eac068231a287554e9ceae490ea24f5365636f6e645465" + "7374746f6b656e1a009896804954657374746f6b656e1a00989680075820592a2df0e091566969b3044626faa8023dabe6" + "f39c78f33bed9e105e55159221a200828258206443a101bdb948366fc87369336224595d36d8b0eee5602cba8b81a024e5" + "84735840846f408dee3b101fda0f0f7ca89e18b724b7ca6266eb29775d3967d6920cae7457accb91def9b77571e15dd2ed" + "e38b12cf92496ce7382fa19eb90ab7f73e49008258205797dc2cc919dfec0bb849551ebdf30d96e5cbe0f33f734a87fe82" + "6db30f7ef95840bdc771aa7b8c86a8ffcbe1b7a479c68503c8aa0ffde8059443055bf3e54b92f4fca5e0b9ca5bb11ab23b" + "1390bb9ffce414fa398fc0b17f4dc76fe9f7e2c99c09018182018482051a075bcd1582041a075bcd0c8200581c9139e5c0" + "a42f0f2389634c3dd18dc621f5594c5ba825d9a8883c66278200581c835600a2be276a18a4bebf0225d728f090f724f4c0" + "acd591d066fa6ff5d90103a100a11902d1a16b7b706f6c6963795f69647da16d7b706f6c6963795f6e616d657da66b6465" + "736372697074696f6e6a3c6f7074696f6e616c3e65696d6167656a3c72657175697265643e686c6f636174696f6ea36761" + "7277656176656a3c6f7074696f6e616c3e6568747470736a3c6f7074696f6e616c3e64697066736a3c7265717569726564" + "3e646e616d656a3c72657175697265643e667368613235366a3c72657175697265643e64747970656a3c72657175697265643e" + ) + + resp = chain_context.submit_tx_cbor(tx_cbor) + assert resp.status_code == 200 From 72c3b808877bfe89fdb09104026badbe8b3794b0 Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Fri, 6 Jun 2025 21:35:26 -0500 Subject: [PATCH 07/16] lint: clean up imports --- test/pycardano/backend/test_cardano_cli.py | 1 - test/pycardano/test_serialization.py | 4 ++-- test/pycardano/test_witness.py | 6 +----- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/test/pycardano/backend/test_cardano_cli.py b/test/pycardano/backend/test_cardano_cli.py index 5309be42..66d7ea89 100644 --- a/test/pycardano/backend/test_cardano_cli.py +++ b/test/pycardano/backend/test_cardano_cli.py @@ -19,7 +19,6 @@ RawPlutusData, TransactionFailedException, TransactionInput, - CardanoCliError, ) from pycardano.backend.cardano_cli import network_magic diff --git a/test/pycardano/test_serialization.py b/test/pycardano/test_serialization.py index b98ccb5c..bc1cb4cf 100644 --- a/test/pycardano/test_serialization.py +++ b/test/pycardano/test_serialization.py @@ -34,8 +34,8 @@ ) from pycardano.exception import ( DeserializeException, - SerializeException, InvalidKeyTypeException, + SerializeException, ) from pycardano.plutus import PlutusData, PlutusV1Script, PlutusV2Script from pycardano.serialization import ( @@ -49,9 +49,9 @@ NonEmptyOrderedSet, OrderedSet, RawCBOR, + TextEnvelope, default_encoder, limit_primitive_type, - TextEnvelope, ) diff --git a/test/pycardano/test_witness.py b/test/pycardano/test_witness.py index 19d82a91..65752c72 100644 --- a/test/pycardano/test_witness.py +++ b/test/pycardano/test_witness.py @@ -1,10 +1,6 @@ import tempfile -from pycardano import ( - PaymentSigningKey, - PaymentVerificationKey, - VerificationKeyWitness, -) +from pycardano import PaymentSigningKey, PaymentVerificationKey, VerificationKeyWitness def test_witness_save_load(): From 2cb86995f15e0be8277c79a1489dfe63c2ad7a14 Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Sat, 7 Jun 2025 10:13:55 -0500 Subject: [PATCH 08/16] fix: remove private properties from repr --- pycardano/transaction.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/pycardano/transaction.py b/pycardano/transaction.py index 765322cd..9ad0e232 100644 --- a/pycardano/transaction.py +++ b/pycardano/transaction.py @@ -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 @@ -705,12 +705,21 @@ def __init__( 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 - ArrayCBORSerializable.__init__(self) - TextEnvelope.__init__(self, payload, key_type, description) + 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): From b4e6c3af0748a27ac2a88f7faf459c0b8cdd1ffc Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Sat, 7 Jun 2025 10:14:13 -0500 Subject: [PATCH 09/16] fix: update vkey handling and improve repr --- pycardano/witness.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/pycardano/witness.py b/pycardano/witness.py index c1e04ee3..e7324bcb 100644 --- a/pycardano/witness.py +++ b/pycardano/witness.py @@ -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 ( @@ -42,16 +44,17 @@ def __init__( key_type: Optional[str] = None, description: Optional[str] = None, ): - self.vkey = vkey - self.signature = signature - ArrayCBORSerializable.__init__(self) - TextEnvelope.__init__(self, payload, key_type, description) - - def __post_init__(self): # 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() + 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) @@ -76,6 +79,13 @@ def __eq__(self, other): 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): From f30331f8ae7580c06456491124fad09efc033e72 Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Sat, 7 Jun 2025 10:14:30 -0500 Subject: [PATCH 10/16] test: add repr call in Transaction test for verification --- test/pycardano/test_transaction.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/pycardano/test_transaction.py b/test/pycardano/test_transaction.py index 184bbe4a..c14312b1 100644 --- a/test/pycardano/test_transaction.py +++ b/test/pycardano/test_transaction.py @@ -214,6 +214,7 @@ def test_full_tx(): "3e646e616d656a3c72657175697265643e667368613235366a3c72657175697265643e64747970656a3c72657175697265643e" ) tx = Transaction.from_cbor(tx_cbor) + tx.__repr__() check_two_way_cbor(tx) From 04f1d4805515432ccb8d0a1f9b7c8136d5f7658d Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Sat, 7 Jun 2025 16:24:35 -0500 Subject: [PATCH 11/16] style: correct typos in function docstrings --- pycardano/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pycardano/utils.py b/pycardano/utils.py index 97364fd4..7c912160 100644 --- a/pycardano/utils.py +++ b/pycardano/utils.py @@ -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. @@ -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: From 675f7fe1973715f590ed43f54a5da95e5a7022c6 Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Wed, 11 Jun 2025 13:46:54 -0500 Subject: [PATCH 12/16] refactor: add JSON save and load methods to CBORSerializable --- pycardano/serialization.py | 214 ++++++++++++++++++++----------------- 1 file changed, 113 insertions(+), 101 deletions(-) diff --git a/pycardano/serialization.py b/pycardano/serialization.py index eb549061..2ab5f685 100644 --- a/pycardano/serialization.py +++ b/pycardano/serialization.py @@ -47,11 +47,7 @@ from frozenlist import FrozenList from pprintpp import pformat -from pycardano.exception import ( - DeserializeException, - InvalidKeyTypeException, - SerializeException, -) +from pycardano.exception import DeserializeException, SerializeException from pycardano.types import check_type, typechecked __all__ = [ @@ -69,7 +65,6 @@ "OrderedSet", "NonEmptyOrderedSet", "CodedSerializable", - "TextEnvelope", ] T = TypeVar("T") @@ -542,6 +537,118 @@ def from_cbor(cls: Type[CBORBase], payload: Union[str, bytes]) -> CBORBase: def __repr__(self): return pformat(vars(self), indent=2) + @property + def json_type(self) -> str: + """ + Return the class name of the CBORSerializable object. + + This property provides a default string representing the type of the object for use in JSON serialization. + + Returns: + str: The class name of the object. + """ + return self.__class__.__name__ + + @property + def json_description(self) -> str: + """ + Return the docstring of the CBORSerializable object's class. + + This property provides a default string description of the object for use in JSON serialization. + + Returns: + str: The docstring of the object's class. + """ + return "Generated with PyCardano" + + def to_json(self, **kwargs) -> str: + """ + Convert the CBORSerializable object to a JSON string containing type, description, and CBOR hex. + + This method returns a JSON representation of the object, including its type, description, and CBOR hex encoding. + + Args: + **kwargs: Additional keyword arguments that can include: + - key_type (str): The type to use in the JSON output. Defaults to the class name. + - description (str): The description to use in the JSON output. Defaults to the class docstring. + + Returns: + str: The JSON string representation of the object. + """ + key_type = kwargs.pop("key_type", self.json_type) + description = kwargs.pop("description", self.json_description) + return json.dumps( + { + "type": key_type, + "description": description, + "cborHex": self.to_cbor_hex(), + } + ) + + @classmethod + def from_json(cls: Type[CBORSerializable], data: str) -> CBORSerializable: + """ + Load a CBORSerializable object from a JSON string containing its CBOR hex representation. + + Args: + data (str): The JSON string to load the object from. + + Returns: + CBORSerializable: The loaded CBORSerializable object. + + Raises: + DeserializeException: If the loaded object is not of the expected type. + """ + obj = json.loads(data) + + k = cls.from_cbor(obj["cborHex"]) + + if not isinstance(k, cls): + raise DeserializeException( + f"Expected type {cls.__name__} but got {type(k).__name__}." + ) + + return k + + def save( + self, + path: str, + key_type: Optional[str] = None, + description: Optional[str] = None, + ): + """ + Save the CBORSerializable object to a file in JSON format. + + This method writes the object's JSON representation to the specified file path. + It raises an error if the file already exists and is not empty. + + Args: + path (str): The file path to save the object to. + key_type (str, optional): The type to use in the JSON output. + description (str, optional): The description to use in the JSON output. + + Raises: + IOError: If the file already exists and is not empty. + """ + if os.path.isfile(path) and os.stat(path).st_size > 0: + raise IOError(f"File {path} already exists!") + with open(path, "w") as f: + f.write(self.to_json(key_type=key_type, description=description)) + + @classmethod + def load(cls, path: str): + """ + Load a CBORSerializable object from a file containing its JSON representation. + + Args: + path (str): The file path to load the object from. + + Returns: + CBORSerializable: The loaded CBORSerializable object. + """ + with open(path) as f: + return cls.from_json(f.read()) + def _restore_dataclass_field( f: Field, v: Primitive @@ -1149,98 +1256,3 @@ 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.""" - - 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()) From 5340723430ed0952a25d5b5692c04bf0aecdb262 Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Wed, 11 Jun 2025 13:51:50 -0500 Subject: [PATCH 13/16] refactor: override add save and load methods for Address object --- pycardano/address.py | 45 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/pycardano/address.py b/pycardano/address.py index 185ec092..ba7a6dd7 100644 --- a/pycardano/address.py +++ b/pycardano/address.py @@ -8,8 +8,11 @@ from __future__ import annotations +import os from enum import Enum -from typing import Type, Union +from typing import Optional, Type, Union + +from typing_extensions import override from pycardano.crypto.bech32 import decode, encode from pycardano.exception import ( @@ -406,3 +409,43 @@ def __eq__(self, other): def __repr__(self): return f"{self.encode()}" + + @override + def save( + self, + path: str, + key_type: Optional[str] = None, + description: Optional[str] = None, + ): + """ + Save the Address object to a file. + + This method writes the object's JSON representation to the specified file path. + It raises an error if the file already exists and is not empty. + + Args: + path (str): The file path to save the object to. + key_type (str, optional): Not used in this context, but can be included for consistency. + description (str, optional): Not used in this context, but can be included for consistency. + + Raises: + IOError: If the file already exists and is not empty. + """ + if os.path.isfile(path) and os.stat(path).st_size > 0: + raise IOError(f"File {path} already exists!") + with open(path, "w") as f: + f.write(self.encode()) + + @classmethod + def load(cls, path: str) -> Address: + """ + Load an Address object from a file. + + Args: + path (str): The file path to load the object from. + + Returns: + Address: The loaded Address object. + """ + with open(path) as f: + return cls.decode(f.read()) From cce6dbe6ebaeb493c64f5191d9ce301cc04b5e6c Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Wed, 11 Jun 2025 13:52:50 -0500 Subject: [PATCH 14/16] refactor: remove duplicate save and load methods from Key class --- pycardano/key.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/pycardano/key.py b/pycardano/key.py index 77c64b7d..e8bce690 100644 --- a/pycardano/key.py +++ b/pycardano/key.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import os from typing import Optional, Type from nacl.encoding import RawEncoder @@ -74,7 +73,7 @@ def to_primitive(self) -> bytes: def from_primitive(cls: Type["Key"], value: bytes) -> Key: return cls(value) - def to_json(self) -> str: + def to_json(self, **kwargs) -> str: """Serialize the key to JSON. The json output has three fields: "type", "description", and "cborHex". @@ -123,18 +122,6 @@ def from_json(cls: Type[Key], data: str, validate_type=False) -> Key: description=obj["description"], ) - 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()) - def __bytes__(self): return self.payload From 971db5fd7401464462aed5541593643e1ebf0638 Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Wed, 11 Jun 2025 13:55:38 -0500 Subject: [PATCH 15/16] refactor: revert to previous version and add properties for to_json --- pycardano/transaction.py | 39 ++++++--------------------------------- pycardano/witness.py | 38 ++++++++++++-------------------------- 2 files changed, 18 insertions(+), 59 deletions(-) diff --git a/pycardano/transaction.py b/pycardano/transaction.py index 9ad0e232..d9f5515f 100644 --- a/pycardano/transaction.py +++ b/pycardano/transaction.py @@ -39,7 +39,6 @@ NonEmptyOrderedSet, OrderedSet, Primitive, - TextEnvelope, default_encoder, limit_primitive_type, list_hook, @@ -686,7 +685,7 @@ def id(self) -> TransactionId: @dataclass(repr=False) -class Transaction(ArrayCBORSerializable, TextEnvelope): +class Transaction(ArrayCBORSerializable): transaction_body: TransactionBody transaction_witness_set: TransactionWitnessSet @@ -695,44 +694,18 @@ class Transaction(ArrayCBORSerializable, TextEnvelope): 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): + def json_type(self) -> str: return ( "Unwitnessed Tx ConwayEra" if self.transaction_witness_set.is_empty() else "Signed Tx ConwayEra" ) + @property + def json_description(self) -> str: + return "Ledger Cddl Format" + @property def id(self) -> TransactionId: return self.transaction_body.id diff --git a/pycardano/witness.py b/pycardano/witness.py index e7324bcb..618f3532 100644 --- a/pycardano/witness.py +++ b/pycardano/witness.py @@ -20,7 +20,6 @@ ArrayCBORSerializable, MapCBORSerializable, NonEmptyOrderedSet, - TextEnvelope, limit_primitive_type, list_hook, ) @@ -29,32 +28,23 @@ @dataclass(repr=False) -class VerificationKeyWitness(ArrayCBORSerializable, TextEnvelope): +class VerificationKeyWitness(ArrayCBORSerializable): vkey: Union[VerificationKey, ExtendedVerificationKey] signature: bytes - 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, - ): + @property + def json_type(self) -> str: + return "TxWitness ConwayEra" + + @property + def json_description(self) -> str: + return "Key Witness ShelleyEra" + + def __post_init__(self): # 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. - super().__init__() - if isinstance(vkey, ExtendedVerificationKey): - self.vkey = vkey.to_non_extended() - 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 + if isinstance(self.vkey, ExtendedVerificationKey): + self.vkey = self.vkey.to_non_extended() @classmethod @limit_primitive_type(list, tuple) @@ -66,10 +56,6 @@ def from_primitive( 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 From e48aee5eb771207514224325b583c4a96db8b427 Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Wed, 11 Jun 2025 13:55:52 -0500 Subject: [PATCH 16/16] test: add save and load tests for Address and CBORSerializable classes --- test/pycardano/test_address.py | 12 +++++++ test/pycardano/test_serialization.py | 50 +++++++++++++--------------- 2 files changed, 36 insertions(+), 26 deletions(-) diff --git a/test/pycardano/test_address.py b/test/pycardano/test_address.py index b9ba8ddf..0ee21e49 100644 --- a/test/pycardano/test_address.py +++ b/test/pycardano/test_address.py @@ -1,3 +1,5 @@ +import tempfile + import pytest from pycardano.address import Address, AddressType, PointerAddress @@ -211,3 +213,13 @@ def test_from_primitive_invalid_type_addr(): with pytest.raises(DeserializeException): Address.from_primitive(value) + + +def test_save_load_address(): + address_string = "addr_test1vr2p8st5t5cxqglyjky7vk98k7jtfhdpvhl4e97cezuhn0cqcexl7" + address = Address.from_primitive(address_string) + + with tempfile.NamedTemporaryFile() as f: + address.save(f.name) + loaded_address = Address.load(f.name) + assert address == loaded_address diff --git a/test/pycardano/test_serialization.py b/test/pycardano/test_serialization.py index bc1cb4cf..c75503c5 100644 --- a/test/pycardano/test_serialization.py +++ b/test/pycardano/test_serialization.py @@ -26,6 +26,7 @@ CBORBase, Datum, MultiAsset, + Primitive, RawPlutusData, Transaction, TransactionWitnessSet, @@ -49,7 +50,6 @@ NonEmptyOrderedSet, OrderedSet, RawCBOR, - TextEnvelope, default_encoder, limit_primitive_type, ) @@ -991,39 +991,37 @@ class TestData(MapCBORSerializable): assert s_copy[0].value == 100 -def test_text_envelope(): +def test_save_load(): @dataclass - class Test1(ArrayCBORSerializable, TextEnvelope): + class Test1(CBORSerializable): a: str b: Union[str, None] = None - KEY_TYPE = "Test1" - DESCRIPTION = "A test class for TextEnvelope serialization" - - def __init__( - self, - a: str, - b: Union[str, None] = None, - payload: Optional[bytes] = None, - key_type: Optional[str] = None, - description: Optional[str] = None, - ): - self.a = a - self.b = b - TextEnvelope.__init__(self, payload, key_type, description) + @property + def json_type(self) -> str: + return "Test Type" - test1 = Test1(a="a") + @property + def json_description(self) -> str: + return "Test Description" - wrong_type = { - "type": "Test2", - "description": "A test class for TextEnvelope serialization", - "cborHex": "826161f6", - } + @classmethod + def from_primitive( + cls: Type[CBORSerializable], value: Any, type_args: Optional[tuple] = None + ) -> CBORSerializable: + if not isinstance(value, dict): + raise DeserializeException(f"Expected dict, got {type(value)}") + return Test1(a=value["a"], b=value.get("b")) - with pytest.raises(InvalidKeyTypeException): - invalid_test1 = Test1.from_json(json.dumps(wrong_type), validate_type=True) + def to_shallow_primitive(self) -> Union[Primitive, CBORSerializable]: + return {"a": self.a, "b": self.b} + + test1 = Test1(a="a") + test1_json = json.loads(test1.to_json()) - assert test1.payload == b"\x82aa\xf6" + assert test1_json["type"] == "Test Type" + assert test1_json["description"] == "Test Description" + assert test1_json["cborHex"] == test1.to_cbor_hex() with tempfile.NamedTemporaryFile() as f: test1.save(f.name)