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()) 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 diff --git a/pycardano/serialization.py b/pycardano/serialization.py index 0c00dbe6..2ab5f685 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 @@ -535,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 diff --git a/pycardano/transaction.py b/pycardano/transaction.py index e2099966..d9f5515f 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 @@ -694,6 +694,18 @@ class Transaction(ArrayCBORSerializable): auxiliary_data: Optional[AuxiliaryData] = None + @property + 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/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: diff --git a/pycardano/witness.py b/pycardano/witness.py index 49e4b22a..618f3532 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 ( @@ -30,6 +32,14 @@ class VerificationKeyWitness(ArrayCBORSerializable): vkey: Union[VerificationKey, ExtendedVerificationKey] signature: bytes + @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. @@ -46,6 +56,22 @@ def from_primitive( signature=values[1], ) + 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): @@ -126,3 +152,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 + ) 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 diff --git a/test/pycardano/backend/test_cardano_cli.py b/test/pycardano/backend/test_cardano_cli.py index a81dc3b4..66d7ea89 100644 --- a/test/pycardano/backend/test_cardano_cli.py +++ b/test/pycardano/backend/test_cardano_cli.py @@ -20,6 +20,7 @@ TransactionFailedException, TransactionInput, ) +from pycardano.backend.cardano_cli import network_magic QUERY_TIP_RESULT = { "block": 1460093, @@ -646,6 +647,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_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_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_serialization.py b/test/pycardano/test_serialization.py index d7878717..c75503c5 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 @@ -24,13 +26,18 @@ CBORBase, Datum, MultiAsset, + Primitive, RawPlutusData, Transaction, TransactionWitnessSet, VerificationKey, VerificationKeyWitness, ) -from pycardano.exception import DeserializeException, SerializeException +from pycardano.exception import ( + DeserializeException, + InvalidKeyTypeException, + SerializeException, +) from pycardano.plutus import PlutusData, PlutusV1Script, PlutusV2Script from pycardano.serialization import ( ArrayCBORSerializable, @@ -982,3 +989,44 @@ class TestData(MapCBORSerializable): s_copy[0].value = 100 assert s[0].value == 1 assert s_copy[0].value == 100 + + +def test_save_load(): + @dataclass + class Test1(CBORSerializable): + a: str + b: Union[str, None] = None + + @property + def json_type(self) -> str: + return "Test Type" + + @property + def json_description(self) -> str: + return "Test Description" + + @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")) + + 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_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) + loaded = Test1.load(f.name) + assert test1 == loaded + + with pytest.raises(IOError): + test1.save(f.name) diff --git a/test/pycardano/test_transaction.py b/test/pycardano/test_transaction.py index 30925d6d..c14312b1 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 @@ -213,6 +214,7 @@ def test_full_tx(): "3e646e616d656a3c72657175697265643e667368613235366a3c72657175697265643e64747970656a3c72657175697265643e" ) tx = Transaction.from_cbor(tx_cbor) + tx.__repr__() check_two_way_cbor(tx) @@ -243,6 +245,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..65752c72 --- /dev/null +++ b/test/pycardano/test_witness.py @@ -0,0 +1,19 @@ +import tempfile + +from pycardano import 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 + + assert witness != vk