From 20f554e204ac9920c42049bbe83c86270527621c Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Sat, 7 Oct 2023 09:44:26 -0500 Subject: [PATCH 01/28] feat: add cardano-cli chain context --- pycardano/backend/__init__.py | 1 + pycardano/backend/cardano_cli.py | 406 +++++++++++++++++++++++++++++++ pycardano/exception.py | 4 + 3 files changed, 411 insertions(+) create mode 100644 pycardano/backend/cardano_cli.py diff --git a/pycardano/backend/__init__.py b/pycardano/backend/__init__.py index 3ca43b7c..515b7cde 100644 --- a/pycardano/backend/__init__.py +++ b/pycardano/backend/__init__.py @@ -2,4 +2,5 @@ from .base import * from .blockfrost import * +from .cardano_cli import * from .ogmios import * diff --git a/pycardano/backend/cardano_cli.py b/pycardano/backend/cardano_cli.py new file mode 100644 index 00000000..3a78a00b --- /dev/null +++ b/pycardano/backend/cardano_cli.py @@ -0,0 +1,406 @@ +""" +Cardano CLI Chain Context +""" +import json +import os +import subprocess +import tempfile +import time +from enum import Enum +from functools import partial +from pathlib import Path +from typing import Optional, List, Dict, Union + +from cachetools import Cache, LRUCache, TTLCache, func + +from pycardano.address import Address +from pycardano.backend.base import ( + ALONZO_COINS_PER_UTXO_WORD, + ChainContext, + GenesisParameters, + ProtocolParameters, +) +from pycardano.exception import TransactionFailedException, CardanoCliError, PyCardanoException +from pycardano.hash import DatumHash, ScriptHash +from pycardano.transaction import ( + Asset, + AssetName, + MultiAsset, + TransactionInput, + TransactionOutput, + UTxO, + Value, +) +from pycardano.types import JsonDict + +__all__ = ["CardanoCliChainContext", "CardanoCliNetwork"] + + +class Mode(str, Enum): + """ + Mode enumeration. + """ + + ONLINE = "online" + OFFLINE = "offline" + + +def network_magic(magic_number: int) -> List[str]: + """ + Returns the network magic number for the cardano-cli + Args: + magic_number: The network magic number + + Returns: + The network magic number arguments + """ + return ["--testnet-magic", str(magic_number)] + + +class CardanoCliNetwork(Enum): + """ + Enum class for Cardano Era + """ + + MAINNET = ["--mainnet"] + TESTNET = ["--testnet-magic", str(1097911063)] + PREVIEW = ["--testnet-magic", str(2)] + PREPROD = ["--testnet-magic", str(1)] + GUILDNET = ["--testnet-magic", str(141)] + CUSTOM = partial(network_magic) + + +class CardanoCliChainContext(ChainContext): + _binary: Optional[Path] + _socket: Optional[Path] + _config_file: Optional[Path] + _mode: Mode + _network: CardanoCliNetwork + _last_known_block_slot: int + _last_chain_tip_fetch: float + _genesis_param: Optional[GenesisParameters] + _protocol_param: Optional[ProtocolParameters] + _utxo_cache: Cache + _datum_cache: Cache + + def __init__( + self, + binary: Path, + socket: Path, + config_file: Path, + network: CardanoCliNetwork, + refetch_chain_tip_interval: Optional[float] = None, + utxo_cache_size: int = 10000, + datum_cache_size: int = 10000, + ): + if not binary.exists() or not binary.is_file(): + raise CardanoCliError(f"cardano-cli binary file not found: {binary}") + + # Check the socket path file and set the CARDANO_NODE_SOCKET_PATH environment variable + try: + if not socket.exists(): + raise CardanoCliError(f"cardano-cli binary file not found: {binary}") + elif not socket.is_socket(): + raise CardanoCliError(f"{socket} is not a socket file") + + self._socket = socket + os.environ["CARDANO_NODE_SOCKET_PATH"] = self._socket.as_posix() + self._mode = Mode.ONLINE + except CardanoCliError: + self._socket = None + self._mode = Mode.OFFLINE + + self._binary = binary + self._network = network + self._config_file = config_file + self._last_known_block_slot = 0 + self._refetch_chain_tip_interval = ( + refetch_chain_tip_interval + if refetch_chain_tip_interval is not None + else 1000 + ) + self._last_chain_tip_fetch = 0 + self._genesis_param = None + self._protocol_param = None + if refetch_chain_tip_interval is None: + self._refetch_chain_tip_interval = ( + self.genesis_param.slot_length + / self.genesis_param.active_slots_coefficient + ) + + self._utxo_cache = TTLCache( + ttl=self._refetch_chain_tip_interval, maxsize=utxo_cache_size + ) + self._datum_cache = LRUCache(maxsize=datum_cache_size) + + def _run_command(self, cmd: List[str]) -> str: + """ + Runs the command in the cardano-cli + + :param cmd: Command as a list of strings + :return: The stdout if the command runs successfully + """ + try: + result = subprocess.run( + [self._binary.as_posix()] + cmd, capture_output=True, check=True + ) + return result.stdout.decode().strip() + except subprocess.CalledProcessError as err: + raise CardanoCliError(err.stderr.decode()) from err + + def _query_chain_tip(self) -> JsonDict: + result = self._run_command(["query", "tip"] + self._network.value) + return json.loads(result) + + def _query_current_protocol_params(self) -> JsonDict: + result = self._run_command(["query", "protocol-parameters"] + self._network.value) + return json.loads(result) + + def _query_genesis_config(self) -> JsonDict: + if not self._config_file.exists() or not self._config_file.is_file(): + raise CardanoCliError(f"Cardano config file not found: {self._config_file}") + with open(self._config_file, encoding="utf-8") as config_file: + config_json = json.load(config_file) + shelly_genesis_file = self._config_file.parent / config_json["ShelleyGenesisFile"] + if not shelly_genesis_file.exists() or not shelly_genesis_file.is_file(): + raise CardanoCliError(f"Shelly Genesis file not found: {shelly_genesis_file}") + with open(shelly_genesis_file, encoding="utf-8") as genesis_file: + genesis_json = json.load(genesis_file) + return genesis_json + + def _get_min_utxo(self) -> int: + params = self._query_genesis_config() + if "minUTxOValue" in params: + return params["minUTxOValue"] + elif "lovelacePerUTxOWord" in params: + return params["lovelacePerUTxOWord"] + elif "utxoCostPerWord" in params: + return params["utxoCostPerWord"] + elif "utxoCostPerByte" in params: + return params["utxoCostPerByte"] + + def _parse_cost_models(self, cli_result: JsonDict) -> Dict[str, Dict[str, int]]: + cli_cost_models = cli_result.get("costModels", {}) + + cost_models = {} + if "PlutusScriptV1" in cli_cost_models: + cost_models["PlutusScriptV1"] = cli_cost_models["PlutusScriptV1"].copy() + elif "PlutusV1" in cli_cost_models: + cost_models["PlutusV1"] = cli_cost_models["PlutusV1"].copy() + + if "PlutusScriptV2" in cli_cost_models: + cost_models["PlutusScriptV2"] = cli_cost_models["PlutusScriptV2"].copy() + elif "PlutusV2" in cli_cost_models: + cost_models["PlutusV2"] = cli_cost_models["PlutusV2"].copy() + + return cost_models + + def _is_chain_tip_updated(self): + # fetch at most every twenty seconds! + if time.time() - self._last_chain_tip_fetch < self._refetch_chain_tip_interval: + return False + self._last_chain_tip_fetch = time.time() + result = self._query_chain_tip() + return float(result["syncProgress"]) != 100.0 + + def _fetch_protocol_param(self) -> ProtocolParameters: + result = self._query_current_protocol_params() + return ProtocolParameters( + min_fee_constant=result["minFeeConstant"] if "minFeeConstant" in result else result["txFeeFixed"], + min_fee_coefficient=result["minFeeCoefficient"] if "minFeeCoefficient" in result else result["txFeePerByte"], + max_block_size=result["maxBlockBodySize"], + max_tx_size=result["maxTxSize"], + max_block_header_size=result["maxBlockHeaderSize"], + key_deposit=result["stakeAddressDeposit"], + pool_deposit=result["stakePoolDeposit"], + pool_influence=result["poolPledgeInfluence"], + monetary_expansion=result["monetaryExpansion"], + treasury_expansion=result["treasuryCut"], + decentralization_param=result.get("decentralization", 0), + extra_entropy=result.get("extraPraosEntropy", ""), + protocol_major_version=result["protocolVersion"]["major"], + protocol_minor_version=result["protocolVersion"]["minor"], + min_utxo=self._get_min_utxo(), + min_pool_cost=result["minPoolCost"], + price_mem=result["executionUnitPrices"]["priceMemory"] if "executionUnitPrices" in result else result["executionPrices"]["priceMemory"], + price_step=result["executionUnitPrices"]["priceSteps"] if "executionUnitPrices" in result else result["executionPrices"]["priceSteps"], + max_tx_ex_mem=result["maxTxExecutionUnits"]["memory"], + max_tx_ex_steps=result["maxTxExecutionUnits"]["steps"], + max_block_ex_mem=result["maxBlockExecutionUnits"]["memory"], + max_block_ex_steps=result["maxBlockExecutionUnits"]["steps"], + max_val_size=result["maxValueSize"], + collateral_percent=result["collateralPercentage"], + max_collateral_inputs=result["maxCollateralInputs"], + coins_per_utxo_word=result.get( + "coinsPerUtxoWord", ALONZO_COINS_PER_UTXO_WORD + ), + coins_per_utxo_byte=result.get("coinsPerUtxoByte", 0), + cost_models=self._parse_cost_models(result), + ) + + @staticmethod + def _fraction_parser(fraction: str) -> float: + x, y = fraction.split("/") + return int(x) / int(y) + + @property + def protocol_param(self) -> ProtocolParameters: + """Get current protocol parameters""" + if not self._protocol_param or self._is_chain_tip_updated(): + self._protocol_param = self._fetch_protocol_param() + return self._protocol_param + + @property + def genesis_param(self) -> GenesisParameters: + """Get chain genesis parameters""" + genesis_params = self._query_genesis_config() + return GenesisParameters( + active_slots_coefficient=genesis_params["activeSlotsCoeff"], + update_quorum=genesis_params["updateQuorum"], + max_lovelace_supply=genesis_params["maxLovelaceSupply"], + network_magic=genesis_params["networkMagic"], + epoch_length=genesis_params["epochLength"], + system_start=genesis_params["systemStart"], + slots_per_kes_period=genesis_params["slotsPerKESPeriod"], + slot_length=genesis_params["slotLength"], + max_kes_evolutions=genesis_params["maxKESEvolutions"], + security_param=genesis_params["securityParam"], + ) + + @property + def network(self) -> CardanoCliNetwork: + """Cet current network""" + return self._network + + @property + def epoch(self) -> int: + """Current epoch number""" + result = self._query_chain_tip() + return result["epoch"] + + @property + def era(self) -> int: + """Current Cardano era""" + result = self._query_chain_tip() + return result["era"] + + @property + @func.ttl_cache(ttl=1) + def last_block_slot(self) -> int: + result = self._query_chain_tip() + return result["slot"] + + def version(self): + """ + Gets the cardano-cli version + """ + return self._run_command(["version"]) + + def _utxos(self, address: str) -> List[UTxO]: + """Get all UTxOs associated with an address. + + Args: + address (str): An address encoded with bech32. + + Returns: + List[UTxO]: A list of UTxOs. + """ + key = (self.last_block_slot, address) + if key in self._utxo_cache: + return self._utxo_cache[key] + + result = self._run_command(["query", "utxo", "--address", address] + self._network.value) + raw_utxos = result.split("\n")[2:] + + # Parse the UTXOs into a list of dict objects + utxos = [] + for utxo_line in raw_utxos: + if len(utxo_line) == 0: + continue + + vals = utxo_line.split() + utxo_dict = { + "tx_hash": vals[0], + "tx_ix": vals[1], + "lovelaces": int(vals[2]), + "type": vals[3], + } + + tx_in = TransactionInput.from_primitive([utxo_dict["tx_hash"], int(utxo_dict["tx_ix"])]) + lovelace_amount = utxo_dict["lovelaces"] + + tx_out = TransactionOutput( + Address.from_primitive(address), + amount=Value(coin=int(lovelace_amount)) + ) + + extra = [i for i, j in enumerate(vals) if j == "+"] + for i in extra: + if "TxOutDatumNone" in vals[i + 1]: + continue + elif "TxOutDatumHash" in vals[i + 1] and "Data" in vals[i + 2]: + datum_hash = DatumHash.from_primitive(vals[i + 3]) + tx_out.datum_hash = datum_hash + else: + multi_assets = MultiAsset() + + policy_id = vals[i + 2].split(".")[0] + asset_hex_name = vals[i + 2].split(".")[1] + quantity = vals[i + 1] + + policy = ScriptHash.from_primitive(policy_id) + asset_name = AssetName.from_primitive(asset_hex_name) + + multi_assets.setdefault(policy, Asset())[ + asset_name + ] = quantity + + tx_out.amount = Value(lovelace_amount, multi_assets) + + utxo = UTxO(input=tx_in, output=tx_out) + + utxos.append(utxo) + + self._utxo_cache[key] = utxos + + return utxos + + def submit_tx_cbor(self, cbor: Union[bytes, str]) -> str: + """Submit a transaction to the blockchain. + + Args: + cbor (Union[bytes, str]): The transaction to be submitted. + + Returns: + str: The transaction hash. + + Raises: + :class:`TransactionFailedException`: When fails to submit the transaction to blockchain. + :class:`PyCardanoException`: When fails to retrieve the transaction hash. + """ + if isinstance(cbor, bytes): + cbor = cbor.hex() + + with tempfile.NamedTemporaryFile(mode="w") as tmp_tx_file: + tx_json = { + "type": f"Witnessed Tx {self.era}Era", + "description": "Generated by PyCardano", + "cborHex": cbor + } + + tmp_tx_file.write(json.dumps(tx_json)) + + tmp_tx_file.flush() + + try: + self._run_command(["transaction", "submit", "--tx-file", tmp_tx_file.name] + self._network.value) + except CardanoCliError as err: + raise TransactionFailedException("Failed to submit transaction") from err + + # Get the transaction ID + try: + txid = self._run_command(["transaction", "txid", "--tx-file", tmp_tx_file.name]) + except CardanoCliError as err: + raise PyCardanoException(f"Unable to get transaction id for {tmp_tx_file.name}") from err + + return txid diff --git a/pycardano/exception.py b/pycardano/exception.py index fdcdf498..d487b209 100644 --- a/pycardano/exception.py +++ b/pycardano/exception.py @@ -60,3 +60,7 @@ class MaxInputCountExceededException(UTxOSelectionException): class InputUTxODepletedException(UTxOSelectionException): pass + + +class CardanoCliError(PyCardanoException): + pass \ No newline at end of file From d1266d135d156afdba727cbac7a39e3ae089b3a9 Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Sat, 7 Oct 2023 09:45:35 -0500 Subject: [PATCH 02/28] fix: allow instances of str to submit_tx_cbor --- pycardano/backend/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pycardano/backend/base.py b/pycardano/backend/base.py index 63aa09b7..1a2bfd4a 100644 --- a/pycardano/backend/base.py +++ b/pycardano/backend/base.py @@ -171,7 +171,7 @@ def submit_tx(self, tx: Union[Transaction, bytes, str]): """ if isinstance(tx, Transaction): return self.submit_tx_cbor(tx.to_cbor()) - elif isinstance(tx, bytes): + elif isinstance(tx, bytes) or isinstance(tx, str): return self.submit_tx_cbor(tx) else: raise InvalidArgumentException( From bcd683b5fcc505d599e58c581dd5ea34c20e887a Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Sat, 7 Oct 2023 19:17:49 -0500 Subject: [PATCH 03/28] fix: cast to int for asset amount and check for None in get_min_utxo --- pycardano/backend/cardano_cli.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pycardano/backend/cardano_cli.py b/pycardano/backend/cardano_cli.py index 3a78a00b..40035139 100644 --- a/pycardano/backend/cardano_cli.py +++ b/pycardano/backend/cardano_cli.py @@ -169,14 +169,14 @@ def _query_genesis_config(self) -> JsonDict: return genesis_json def _get_min_utxo(self) -> int: - params = self._query_genesis_config() - if "minUTxOValue" in params: + params = self._query_current_protocol_params() + if "minUTxOValue" in params and params["minUTxOValue"] is not None: return params["minUTxOValue"] - elif "lovelacePerUTxOWord" in params: + elif "lovelacePerUTxOWord" in params and params["lovelacePerUTxOWord"] is not None: return params["lovelacePerUTxOWord"] - elif "utxoCostPerWord" in params: + elif "utxoCostPerWord" in params and params["utxoCostPerWord"] is not None: return params["utxoCostPerWord"] - elif "utxoCostPerByte" in params: + elif "utxoCostPerByte" in params and params["utxoCostPerByte"] is not None: return params["utxoCostPerByte"] def _parse_cost_models(self, cli_result: JsonDict) -> Dict[str, Dict[str, int]]: @@ -346,7 +346,7 @@ def _utxos(self, address: str) -> List[UTxO]: policy_id = vals[i + 2].split(".")[0] asset_hex_name = vals[i + 2].split(".")[1] - quantity = vals[i + 1] + quantity = int(vals[i + 1]) policy = ScriptHash.from_primitive(policy_id) asset_name = AssetName.from_primitive(asset_hex_name) From e1fc6c632282d7a46908d8e8faf61565a6b70edb Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Sat, 7 Oct 2023 19:18:31 -0500 Subject: [PATCH 04/28] test: add test for cardano-cli chain context --- test/pycardano/backend/conftest.py | 220 +++++++++ test/pycardano/backend/test_cardano_cli.py | 543 +++++++++++++++++++++ 2 files changed, 763 insertions(+) create mode 100644 test/pycardano/backend/conftest.py create mode 100644 test/pycardano/backend/test_cardano_cli.py diff --git a/test/pycardano/backend/conftest.py b/test/pycardano/backend/conftest.py new file mode 100644 index 00000000..6398958b --- /dev/null +++ b/test/pycardano/backend/conftest.py @@ -0,0 +1,220 @@ +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +@pytest.fixture(scope="session") +def genesis_json(): + return { + "activeSlotsCoeff": 0.05, + "epochLength": 432000, + "genDelegs": { + "637f2e950b0fd8f8e3e811c5fbeb19e411e7a2bf37272b84b29c1a0b": { + "delegate": "aae9293510344ddd636364c2673e34e03e79e3eefa8dbaa70e326f7d", + "vrf": "227116365af2ed943f1a8b5e6557bfaa34996f1578eec667a5e2b361c51e4ce7" + }, + "8a4b77c4f534f8b8cc6f269e5ebb7ba77fa63a476e50e05e66d7051c": { + "delegate": "d15422b2e8b60e500a82a8f4ceaa98b04e55a0171d1125f6c58f8758", + "vrf": "0ada6c25d62db5e1e35d3df727635afa943b9e8a123ab83785e2281605b09ce2" + }, + "b00470cd193d67aac47c373602fccd4195aad3002c169b5570de1126": { + "delegate": "b3b539e9e7ed1b32fbf778bf2ebf0a6b9f980eac90ac86623d11881a", + "vrf": "0ff0ce9b820376e51c03b27877cd08f8ba40318f1a9f85a3db0b60dd03f71a7a" + }, + "b260ffdb6eba541fcf18601923457307647dce807851b9d19da133ab": { + "delegate": "7c64eb868b4ef566391a321c85323f41d2b95480d7ce56ad2abcb022", + "vrf": "7fb22abd39d550c9a022ec8104648a26240a9ff9c88b8b89a6e20d393c03098e" + }, + "ced1599fd821a39593e00592e5292bdc1437ae0f7af388ef5257344a": { + "delegate": "de7ca985023cf892f4de7f5f1d0a7181668884752d9ebb9e96c95059", + "vrf": "c301b7fc4d1b57fb60841bcec5e3d2db89602e5285801e522fce3790987b1124" + }, + "dd2a7d71a05bed11db61555ba4c658cb1ce06c8024193d064f2a66ae": { + "delegate": "1e113c218899ee7807f4028071d0e108fc790dade9fd1a0d0b0701ee", + "vrf": "faf2702aa4893c877c622ab22dfeaf1d0c8aab98b837fe2bf667314f0d043822" + }, + "f3b9e74f7d0f24d2314ea5dfbca94b65b2059d1ff94d97436b82d5b4": { + "delegate": "fd637b08cc379ef7b99c83b416458fcda8a01a606041779331008fb9", + "vrf": "37f2ea7c843a688159ddc2c38a2f997ab465150164a9136dca69564714b73268" + } + }, + "initialFunds": {}, + "maxKESEvolutions": 62, + "maxLovelaceSupply": 45000000000000000, + "networkId": "Testnet", + "networkMagic": 1, + "protocolParams": { + "protocolVersion": { + "minor": 0, + "major": 2 + }, + "decentralisationParam": 1, + "eMax": 18, + "extraEntropy": { + "tag": "NeutralNonce" + }, + "maxTxSize": 16384, + "maxBlockBodySize": 65536, + "maxBlockHeaderSize": 1100, + "minFeeA": 44, + "minFeeB": 155381, + "minUTxOValue": 1000000, + "poolDeposit": 500000000, + "minPoolCost": 340000000, + "keyDeposit": 2000000, + "nOpt": 150, + "rho": 0.003, + "tau": 0.20, + "a0": 0.3 + }, + "securityParam": 2160, + "slotLength": 1, + "slotsPerKESPeriod": 129600, + "staking": { + "pools": {}, + "stake": {} + }, + "systemStart": "2022-06-01T00:00:00Z", + "updateQuorum": 5 + } + + +@pytest.fixture(autouse=True) +def mock_check_socket(): + with patch("pathlib.Path.exists", return_value=True), patch( + "pathlib.Path.is_socket", return_value=True + ), patch("pathlib.Path.is_file", return_value=True): + yield + + +@pytest.fixture(scope="session") +def genesis_file(genesis_json): + genesis_file_path = Path.cwd() / "shelley-genesis.json" + + with open(genesis_file_path, "w", encoding="utf-8") as file: + file.write(json.dumps(genesis_json, indent=4)) + + yield genesis_file_path + + genesis_file_path.unlink() + + +@pytest.fixture(scope="session") +def config_file(): + config_file_path = Path.cwd() / "config.json" + + config_json = { + "AlonzoGenesisFile": "alonzo-genesis.json", + "AlonzoGenesisHash": "7e94a15f55d1e82d10f09203fa1d40f8eede58fd8066542cf6566008068ed874", + "ApplicationName": "cardano-sl", + "ApplicationVersion": 0, + "ByronGenesisFile": "byron-genesis.json", + "ByronGenesisHash": "d4b8de7a11d929a323373cbab6c1a9bdc931beffff11db111cf9d57356ee1937", + "ConwayGenesisFile": "conway-genesis.json", + "ConwayGenesisHash": "f28f1c1280ea0d32f8cd3143e268650d6c1a8e221522ce4a7d20d62fc09783e1", + "EnableP2P": True, + "LastKnownBlockVersion-Alt": 0, + "LastKnownBlockVersion-Major": 2, + "LastKnownBlockVersion-Minor": 0, + "Protocol": "Cardano", + "RequiresNetworkMagic": "RequiresMagic", + "ShelleyGenesisFile": "shelley-genesis.json", + "ShelleyGenesisHash": "162d29c4e1cf6b8a84f2d692e67a3ac6bc7851bc3e6e4afe64d15778bed8bd86", + "TargetNumberOfActivePeers": 20, + "TargetNumberOfEstablishedPeers": 50, + "TargetNumberOfKnownPeers": 100, + "TargetNumberOfRootPeers": 100, + "TraceAcceptPolicy": True, + "TraceBlockFetchClient": False, + "TraceBlockFetchDecisions": False, + "TraceBlockFetchProtocol": False, + "TraceBlockFetchProtocolSerialised": False, + "TraceBlockFetchServer": False, + "TraceChainDb": True, + "TraceChainSyncBlockServer": False, + "TraceChainSyncClient": False, + "TraceChainSyncHeaderServer": False, + "TraceChainSyncProtocol": False, + "TraceConnectionManager": True, + "TraceDNSResolver": True, + "TraceDNSSubscription": True, + "TraceDiffusionInitialization": True, + "TraceErrorPolicy": True, + "TraceForge": True, + "TraceHandshake": False, + "TraceInboundGovernor": True, + "TraceIpSubscription": True, + "TraceLedgerPeers": True, + "TraceLocalChainSyncProtocol": False, + "TraceLocalErrorPolicy": True, + "TraceLocalHandshake": False, + "TraceLocalRootPeers": True, + "TraceLocalTxSubmissionProtocol": False, + "TraceLocalTxSubmissionServer": False, + "TraceMempool": True, + "TraceMux": False, + "TracePeerSelection": True, + "TracePeerSelectionActions": True, + "TracePublicRootPeers": True, + "TraceServer": True, + "TraceTxInbound": False, + "TraceTxOutbound": False, + "TraceTxSubmissionProtocol": False, + "TracingVerbosity": "NormalVerbosity", + "TurnOnLogMetrics": True, + "TurnOnLogging": True, + "defaultBackends": [ + "KatipBK" + ], + "defaultScribes": [ + [ + "StdoutSK", + "stdout" + ] + ], + "hasEKG": 12788, + "hasPrometheus": [ + "0.0.0.0", + 12798 + ], + "minSeverity": "Info", + "options": { + "mapBackends": { + "cardano.node.metrics": [ + "EKGViewBK" + ], + "cardano.node.resources": [ + "EKGViewBK" + ] + }, + "mapSubtrace": { + "cardano.node.metrics": { + "subtrace": "Neutral" + } + } + }, + "rotation": { + "rpKeepFilesNum": 10, + "rpLogLimitBytes": 5000000, + "rpMaxAgeHours": 24 + }, + "setupBackends": [ + "KatipBK" + ], + "setupScribes": [ + { + "scFormat": "ScText", + "scKind": "StdoutSK", + "scName": "stdout", + "scRotation": None + } + ] + } + + with open(config_file_path, "w", encoding="utf-8") as file: + file.write(json.dumps(config_json, indent=4)) + + yield config_file_path + + config_file_path.unlink() diff --git a/test/pycardano/backend/test_cardano_cli.py b/test/pycardano/backend/test_cardano_cli.py new file mode 100644 index 00000000..9b85e624 --- /dev/null +++ b/test/pycardano/backend/test_cardano_cli.py @@ -0,0 +1,543 @@ +import json +from pathlib import Path +from typing import List +from unittest.mock import patch + +import pytest + +from pycardano import CardanoCliChainContext, ProtocolParameters, ALONZO_COINS_PER_UTXO_WORD, CardanoCliNetwork, \ + GenesisParameters, TransactionInput, MultiAsset + +QUERY_TIP_RESULT = { + "block": 1460093, + "epoch": 98, + "era": "Babbage", + "hash": "c1bda7b2975dd3bf9969a57d92528ba7d60383b6e1c4a37b68379c4f4330e790", + "slot": 41008115, + "slotInEpoch": 313715, + "slotsToEpochEnd": 118285, + "syncProgress": "100.00" +} + +QUERY_PROTOCOL_PARAMETERS_RESULT = { + "collateralPercentage": 150, + "costModels": { + "PlutusV1": [ + 205665, + 812, + 1, + 1, + 1000, + 571, + 0, + 1, + 1000, + 24177, + 4, + 1, + 1000, + 32, + 117366, + 10475, + 4, + 23000, + 100, + 23000, + 100, + 23000, + 100, + 23000, + 100, + 23000, + 100, + 23000, + 100, + 100, + 100, + 23000, + 100, + 19537, + 32, + 175354, + 32, + 46417, + 4, + 221973, + 511, + 0, + 1, + 89141, + 32, + 497525, + 14068, + 4, + 2, + 196500, + 453240, + 220, + 0, + 1, + 1, + 1000, + 28662, + 4, + 2, + 245000, + 216773, + 62, + 1, + 1060367, + 12586, + 1, + 208512, + 421, + 1, + 187000, + 1000, + 52998, + 1, + 80436, + 32, + 43249, + 32, + 1000, + 32, + 80556, + 1, + 57667, + 4, + 1000, + 10, + 197145, + 156, + 1, + 197145, + 156, + 1, + 204924, + 473, + 1, + 208896, + 511, + 1, + 52467, + 32, + 64832, + 32, + 65493, + 32, + 22558, + 32, + 16563, + 32, + 76511, + 32, + 196500, + 453240, + 220, + 0, + 1, + 1, + 69522, + 11687, + 0, + 1, + 60091, + 32, + 196500, + 453240, + 220, + 0, + 1, + 1, + 196500, + 453240, + 220, + 0, + 1, + 1, + 806990, + 30482, + 4, + 1927926, + 82523, + 4, + 265318, + 0, + 4, + 0, + 85931, + 32, + 205665, + 812, + 1, + 1, + 41182, + 32, + 212342, + 32, + 31220, + 32, + 32696, + 32, + 43357, + 32, + 32247, + 32, + 38314, + 32, + 57996947, + 18975, + 10 + ], + "PlutusV2": [ + 205665, + 812, + 1, + 1, + 1000, + 571, + 0, + 1, + 1000, + 24177, + 4, + 1, + 1000, + 32, + 117366, + 10475, + 4, + 23000, + 100, + 23000, + 100, + 23000, + 100, + 23000, + 100, + 23000, + 100, + 23000, + 100, + 100, + 100, + 23000, + 100, + 19537, + 32, + 175354, + 32, + 46417, + 4, + 221973, + 511, + 0, + 1, + 89141, + 32, + 497525, + 14068, + 4, + 2, + 196500, + 453240, + 220, + 0, + 1, + 1, + 1000, + 28662, + 4, + 2, + 245000, + 216773, + 62, + 1, + 1060367, + 12586, + 1, + 208512, + 421, + 1, + 187000, + 1000, + 52998, + 1, + 80436, + 32, + 43249, + 32, + 1000, + 32, + 80556, + 1, + 57667, + 4, + 1000, + 10, + 197145, + 156, + 1, + 197145, + 156, + 1, + 204924, + 473, + 1, + 208896, + 511, + 1, + 52467, + 32, + 64832, + 32, + 65493, + 32, + 22558, + 32, + 16563, + 32, + 76511, + 32, + 196500, + 453240, + 220, + 0, + 1, + 1, + 69522, + 11687, + 0, + 1, + 60091, + 32, + 196500, + 453240, + 220, + 0, + 1, + 1, + 196500, + 453240, + 220, + 0, + 1, + 1, + 1159724, + 392670, + 0, + 2, + 806990, + 30482, + 4, + 1927926, + 82523, + 4, + 265318, + 0, + 4, + 0, + 85931, + 32, + 205665, + 812, + 1, + 1, + 41182, + 32, + 212342, + 32, + 31220, + 32, + 32696, + 32, + 43357, + 32, + 32247, + 32, + 38314, + 32, + 35892428, + 10, + 57996947, + 18975, + 10, + 38887044, + 32947, + 10 + ] + }, + "decentralization": None, + "executionUnitPrices": { + "priceMemory": 5.77e-2, + "priceSteps": 7.21e-5 + }, + "extraPraosEntropy": None, + "maxBlockBodySize": 90112, + "maxBlockExecutionUnits": { + "memory": 62000000, + "steps": 20000000000 + }, + "maxBlockHeaderSize": 1100, + "maxCollateralInputs": 3, + "maxTxExecutionUnits": { + "memory": 14000000, + "steps": 10000000000 + }, + "maxTxSize": 16384, + "maxValueSize": 5000, + "minPoolCost": 340000000, + "minUTxOValue": None, + "monetaryExpansion": 3.0e-3, + "poolPledgeInfluence": 0.3, + "poolRetireMaxEpoch": 18, + "protocolVersion": { + "major": 8, + "minor": 0 + }, + "stakeAddressDeposit": 2000000, + "stakePoolDeposit": 500000000, + "stakePoolTargetNum": 500, + "treasuryCut": 0.2, + "txFeeFixed": 155381, + "txFeePerByte": 44, + "utxoCostPerByte": 4310, + "utxoCostPerWord": None +} + +QUERY_UTXO_RESULT = """ TxHash TxIx Amount +-------------------------------------------------------------------------------------- +270be16fa17cdb3ef683bf2c28259c978d4b7088792074f177c8efda247e23f7 0 1000000 lovelace + TxOutDatumNone +270be16fa17cdb3ef683bf2c28259c978d4b7088792074f177c8efda247e23f7 1 9498624998 lovelace + 1000000000 328a60495759e0d8e244eca5b85b2467d142c8a755d6cd0592dff47b.6d656c636f696e +""" + + +def override_run_command(cmd: List[str]): + """ + Override the run_command method of CardanoCliChainContext to return a mock result + Args: + cmd: The command to run + + Returns: + The mock result + """ + if "tip" in cmd: + return json.dumps(QUERY_TIP_RESULT) + if "protocol-parameters" in cmd: + return json.dumps(QUERY_PROTOCOL_PARAMETERS_RESULT) + if "utxo" in cmd: + return QUERY_UTXO_RESULT + if "txid" in cmd: + return "270be16fa17cdb3ef683bf2c28259c978d4b7088792074f177c8efda247e23f7" + else: + return None + + +@pytest.fixture +def chain_context(genesis_file, config_file): + """ + Create a CardanoCliChainContext with a mock run_command method + Args: + genesis_file: The genesis file + config_file: The config file + + Returns: + The CardanoCliChainContext + """ + with patch( + "pycardano.backend.cardano_cli.CardanoCliChainContext._run_command", + side_effect=override_run_command, + ): + context = CardanoCliChainContext( + binary=Path("cardano-cli"), + socket=Path("node.socket"), + config_file=config_file, + network=CardanoCliNetwork.PREPROD) + context._run_command = override_run_command + return context + + +class TestCardanoCliChainContext: + def test_protocol_param(self, chain_context): + assert ( + ProtocolParameters( + min_fee_constant=QUERY_PROTOCOL_PARAMETERS_RESULT["txFeeFixed"], + min_fee_coefficient=QUERY_PROTOCOL_PARAMETERS_RESULT["txFeePerByte"], + max_block_size=QUERY_PROTOCOL_PARAMETERS_RESULT["maxBlockBodySize"], + max_tx_size=QUERY_PROTOCOL_PARAMETERS_RESULT["maxTxSize"], + max_block_header_size=QUERY_PROTOCOL_PARAMETERS_RESULT["maxBlockHeaderSize"], + key_deposit=QUERY_PROTOCOL_PARAMETERS_RESULT["stakeAddressDeposit"], + pool_deposit=QUERY_PROTOCOL_PARAMETERS_RESULT["stakePoolDeposit"], + pool_influence=QUERY_PROTOCOL_PARAMETERS_RESULT["poolPledgeInfluence"], + monetary_expansion=QUERY_PROTOCOL_PARAMETERS_RESULT["monetaryExpansion"], + treasury_expansion=QUERY_PROTOCOL_PARAMETERS_RESULT["treasuryCut"], + decentralization_param=QUERY_PROTOCOL_PARAMETERS_RESULT.get("decentralization", 0), + extra_entropy=QUERY_PROTOCOL_PARAMETERS_RESULT.get("extraPraosEntropy", ""), + protocol_major_version=int(QUERY_PROTOCOL_PARAMETERS_RESULT["protocolVersion"]["major"]), + protocol_minor_version=int(QUERY_PROTOCOL_PARAMETERS_RESULT["protocolVersion"]["minor"]), + min_utxo=QUERY_PROTOCOL_PARAMETERS_RESULT["utxoCostPerByte"], + min_pool_cost=QUERY_PROTOCOL_PARAMETERS_RESULT["minPoolCost"], + price_mem=float(QUERY_PROTOCOL_PARAMETERS_RESULT["executionUnitPrices"]["priceMemory"]), + price_step=float(QUERY_PROTOCOL_PARAMETERS_RESULT["executionUnitPrices"]["priceSteps"]), + max_tx_ex_mem=int(QUERY_PROTOCOL_PARAMETERS_RESULT["maxTxExecutionUnits"]["memory"]), + max_tx_ex_steps=int(QUERY_PROTOCOL_PARAMETERS_RESULT["maxTxExecutionUnits"]["steps"]), + max_block_ex_mem=int(QUERY_PROTOCOL_PARAMETERS_RESULT["maxBlockExecutionUnits"]["memory"]), + max_block_ex_steps=int(QUERY_PROTOCOL_PARAMETERS_RESULT["maxBlockExecutionUnits"]["steps"]), + max_val_size=QUERY_PROTOCOL_PARAMETERS_RESULT["maxValueSize"], + collateral_percent=QUERY_PROTOCOL_PARAMETERS_RESULT["collateralPercentage"], + max_collateral_inputs=QUERY_PROTOCOL_PARAMETERS_RESULT["maxCollateralInputs"], + coins_per_utxo_word=QUERY_PROTOCOL_PARAMETERS_RESULT.get( + "coinsPerUtxoWord", ALONZO_COINS_PER_UTXO_WORD + ), + coins_per_utxo_byte=QUERY_PROTOCOL_PARAMETERS_RESULT.get("coinsPerUtxoByte", 0), + cost_models=QUERY_PROTOCOL_PARAMETERS_RESULT["costModels"], + ) + == chain_context.protocol_param + ) + + def test_genesis(self, chain_context, genesis_json): + assert ( + GenesisParameters( + active_slots_coefficient=genesis_json["activeSlotsCoeff"], + update_quorum=genesis_json["updateQuorum"], + max_lovelace_supply=genesis_json["maxLovelaceSupply"], + network_magic=genesis_json["networkMagic"], + epoch_length=genesis_json["epochLength"], + system_start=genesis_json["systemStart"], + slots_per_kes_period=genesis_json["slotsPerKESPeriod"], + slot_length=genesis_json["slotLength"], + max_kes_evolutions=genesis_json["maxKESEvolutions"], + security_param=genesis_json["securityParam"], + ) + == chain_context.genesis_param + ) + + def test_utxo(self, chain_context): + results = chain_context.utxos( + "addr_test1qqmnh90jyfaajul4h2mawrxz4rfx04hpaadstm6y8wr90kyhf4dqfm247jlvna83g5wx9veaymzl6g9t833grknh3yhqxhzh4n" + ) + + assert results[0].input == TransactionInput.from_primitive( + ["270be16fa17cdb3ef683bf2c28259c978d4b7088792074f177c8efda247e23f7", 0] + ) + assert results[0].output.amount == 1000000 + + assert results[1].input == TransactionInput.from_primitive( + ["270be16fa17cdb3ef683bf2c28259c978d4b7088792074f177c8efda247e23f7", 1] + ) + assert results[1].output.amount.coin == 9498624998 + assert results[1].output.amount.multi_asset == MultiAsset.from_primitive( + { + "328a60495759e0d8e244eca5b85b2467d142c8a755d6cd0592dff47b": { + "6d656c636f696e": 1000000000 + } + } + ) + + def test_submit_tx(self, chain_context): + results = chain_context.submit_tx( + "testcborhexfromtransaction" + ) + + assert results == "270be16fa17cdb3ef683bf2c28259c978d4b7088792074f177c8efda247e23f7" From eaa41662b110a124b4e77e13566b799d49e20b61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20M=C3=BCndler?= Date: Mon, 30 Oct 2023 14:02:48 +0100 Subject: [PATCH 05/28] Black formatting --- pycardano/backend/cardano_cli.py | 93 +++++++---- pycardano/exception.py | 2 +- test/pycardano/backend/conftest.py | 77 +++------ test/pycardano/backend/test_cardano_cli.py | 183 ++++++++++++--------- 4 files changed, 195 insertions(+), 160 deletions(-) diff --git a/pycardano/backend/cardano_cli.py b/pycardano/backend/cardano_cli.py index 40035139..8e79c3eb 100644 --- a/pycardano/backend/cardano_cli.py +++ b/pycardano/backend/cardano_cli.py @@ -20,7 +20,11 @@ GenesisParameters, ProtocolParameters, ) -from pycardano.exception import TransactionFailedException, CardanoCliError, PyCardanoException +from pycardano.exception import ( + TransactionFailedException, + CardanoCliError, + PyCardanoException, +) from pycardano.hash import DatumHash, ScriptHash from pycardano.transaction import ( Asset, @@ -84,14 +88,14 @@ class CardanoCliChainContext(ChainContext): _datum_cache: Cache def __init__( - self, - binary: Path, - socket: Path, - config_file: Path, - network: CardanoCliNetwork, - refetch_chain_tip_interval: Optional[float] = None, - utxo_cache_size: int = 10000, - datum_cache_size: int = 10000, + self, + binary: Path, + socket: Path, + config_file: Path, + network: CardanoCliNetwork, + refetch_chain_tip_interval: Optional[float] = None, + utxo_cache_size: int = 10000, + datum_cache_size: int = 10000, ): if not binary.exists() or not binary.is_file(): raise CardanoCliError(f"cardano-cli binary file not found: {binary}") @@ -124,8 +128,8 @@ def __init__( self._protocol_param = None if refetch_chain_tip_interval is None: self._refetch_chain_tip_interval = ( - self.genesis_param.slot_length - / self.genesis_param.active_slots_coefficient + self.genesis_param.slot_length + / self.genesis_param.active_slots_coefficient ) self._utxo_cache = TTLCache( @@ -153,7 +157,9 @@ def _query_chain_tip(self) -> JsonDict: return json.loads(result) def _query_current_protocol_params(self) -> JsonDict: - result = self._run_command(["query", "protocol-parameters"] + self._network.value) + result = self._run_command( + ["query", "protocol-parameters"] + self._network.value + ) return json.loads(result) def _query_genesis_config(self) -> JsonDict: @@ -161,9 +167,13 @@ def _query_genesis_config(self) -> JsonDict: raise CardanoCliError(f"Cardano config file not found: {self._config_file}") with open(self._config_file, encoding="utf-8") as config_file: config_json = json.load(config_file) - shelly_genesis_file = self._config_file.parent / config_json["ShelleyGenesisFile"] + shelly_genesis_file = ( + self._config_file.parent / config_json["ShelleyGenesisFile"] + ) if not shelly_genesis_file.exists() or not shelly_genesis_file.is_file(): - raise CardanoCliError(f"Shelly Genesis file not found: {shelly_genesis_file}") + raise CardanoCliError( + f"Shelly Genesis file not found: {shelly_genesis_file}" + ) with open(shelly_genesis_file, encoding="utf-8") as genesis_file: genesis_json = json.load(genesis_file) return genesis_json @@ -172,7 +182,10 @@ def _get_min_utxo(self) -> int: params = self._query_current_protocol_params() if "minUTxOValue" in params and params["minUTxOValue"] is not None: return params["minUTxOValue"] - elif "lovelacePerUTxOWord" in params and params["lovelacePerUTxOWord"] is not None: + elif ( + "lovelacePerUTxOWord" in params + and params["lovelacePerUTxOWord"] is not None + ): return params["lovelacePerUTxOWord"] elif "utxoCostPerWord" in params and params["utxoCostPerWord"] is not None: return params["utxoCostPerWord"] @@ -206,8 +219,12 @@ def _is_chain_tip_updated(self): def _fetch_protocol_param(self) -> ProtocolParameters: result = self._query_current_protocol_params() return ProtocolParameters( - min_fee_constant=result["minFeeConstant"] if "minFeeConstant" in result else result["txFeeFixed"], - min_fee_coefficient=result["minFeeCoefficient"] if "minFeeCoefficient" in result else result["txFeePerByte"], + min_fee_constant=result["minFeeConstant"] + if "minFeeConstant" in result + else result["txFeeFixed"], + min_fee_coefficient=result["minFeeCoefficient"] + if "minFeeCoefficient" in result + else result["txFeePerByte"], max_block_size=result["maxBlockBodySize"], max_tx_size=result["maxTxSize"], max_block_header_size=result["maxBlockHeaderSize"], @@ -222,8 +239,12 @@ def _fetch_protocol_param(self) -> ProtocolParameters: protocol_minor_version=result["protocolVersion"]["minor"], min_utxo=self._get_min_utxo(), min_pool_cost=result["minPoolCost"], - price_mem=result["executionUnitPrices"]["priceMemory"] if "executionUnitPrices" in result else result["executionPrices"]["priceMemory"], - price_step=result["executionUnitPrices"]["priceSteps"] if "executionUnitPrices" in result else result["executionPrices"]["priceSteps"], + price_mem=result["executionUnitPrices"]["priceMemory"] + if "executionUnitPrices" in result + else result["executionPrices"]["priceMemory"], + price_step=result["executionUnitPrices"]["priceSteps"] + if "executionUnitPrices" in result + else result["executionPrices"]["priceSteps"], max_tx_ex_mem=result["maxTxExecutionUnits"]["memory"], max_tx_ex_steps=result["maxTxExecutionUnits"]["steps"], max_block_ex_mem=result["maxBlockExecutionUnits"]["memory"], @@ -309,7 +330,9 @@ def _utxos(self, address: str) -> List[UTxO]: if key in self._utxo_cache: return self._utxo_cache[key] - result = self._run_command(["query", "utxo", "--address", address] + self._network.value) + result = self._run_command( + ["query", "utxo", "--address", address] + self._network.value + ) raw_utxos = result.split("\n")[2:] # Parse the UTXOs into a list of dict objects @@ -326,12 +349,13 @@ def _utxos(self, address: str) -> List[UTxO]: "type": vals[3], } - tx_in = TransactionInput.from_primitive([utxo_dict["tx_hash"], int(utxo_dict["tx_ix"])]) + tx_in = TransactionInput.from_primitive( + [utxo_dict["tx_hash"], int(utxo_dict["tx_ix"])] + ) lovelace_amount = utxo_dict["lovelaces"] tx_out = TransactionOutput( - Address.from_primitive(address), - amount=Value(coin=int(lovelace_amount)) + Address.from_primitive(address), amount=Value(coin=int(lovelace_amount)) ) extra = [i for i, j in enumerate(vals) if j == "+"] @@ -351,9 +375,7 @@ def _utxos(self, address: str) -> List[UTxO]: policy = ScriptHash.from_primitive(policy_id) asset_name = AssetName.from_primitive(asset_hex_name) - multi_assets.setdefault(policy, Asset())[ - asset_name - ] = quantity + multi_assets.setdefault(policy, Asset())[asset_name] = quantity tx_out.amount = Value(lovelace_amount, multi_assets) @@ -385,7 +407,7 @@ def submit_tx_cbor(self, cbor: Union[bytes, str]) -> str: tx_json = { "type": f"Witnessed Tx {self.era}Era", "description": "Generated by PyCardano", - "cborHex": cbor + "cborHex": cbor, } tmp_tx_file.write(json.dumps(tx_json)) @@ -393,14 +415,23 @@ def submit_tx_cbor(self, cbor: Union[bytes, str]) -> str: tmp_tx_file.flush() try: - self._run_command(["transaction", "submit", "--tx-file", tmp_tx_file.name] + self._network.value) + self._run_command( + ["transaction", "submit", "--tx-file", tmp_tx_file.name] + + self._network.value + ) except CardanoCliError as err: - raise TransactionFailedException("Failed to submit transaction") from err + raise TransactionFailedException( + "Failed to submit transaction" + ) from err # Get the transaction ID try: - txid = self._run_command(["transaction", "txid", "--tx-file", tmp_tx_file.name]) + txid = self._run_command( + ["transaction", "txid", "--tx-file", tmp_tx_file.name] + ) except CardanoCliError as err: - raise PyCardanoException(f"Unable to get transaction id for {tmp_tx_file.name}") from err + raise PyCardanoException( + f"Unable to get transaction id for {tmp_tx_file.name}" + ) from err return txid diff --git a/pycardano/exception.py b/pycardano/exception.py index d487b209..c8772142 100644 --- a/pycardano/exception.py +++ b/pycardano/exception.py @@ -63,4 +63,4 @@ class InputUTxODepletedException(UTxOSelectionException): class CardanoCliError(PyCardanoException): - pass \ No newline at end of file + pass diff --git a/test/pycardano/backend/conftest.py b/test/pycardano/backend/conftest.py index 6398958b..23571df5 100644 --- a/test/pycardano/backend/conftest.py +++ b/test/pycardano/backend/conftest.py @@ -4,6 +4,7 @@ import pytest + @pytest.fixture(scope="session") def genesis_json(): return { @@ -12,32 +13,32 @@ def genesis_json(): "genDelegs": { "637f2e950b0fd8f8e3e811c5fbeb19e411e7a2bf37272b84b29c1a0b": { "delegate": "aae9293510344ddd636364c2673e34e03e79e3eefa8dbaa70e326f7d", - "vrf": "227116365af2ed943f1a8b5e6557bfaa34996f1578eec667a5e2b361c51e4ce7" + "vrf": "227116365af2ed943f1a8b5e6557bfaa34996f1578eec667a5e2b361c51e4ce7", }, "8a4b77c4f534f8b8cc6f269e5ebb7ba77fa63a476e50e05e66d7051c": { "delegate": "d15422b2e8b60e500a82a8f4ceaa98b04e55a0171d1125f6c58f8758", - "vrf": "0ada6c25d62db5e1e35d3df727635afa943b9e8a123ab83785e2281605b09ce2" + "vrf": "0ada6c25d62db5e1e35d3df727635afa943b9e8a123ab83785e2281605b09ce2", }, "b00470cd193d67aac47c373602fccd4195aad3002c169b5570de1126": { "delegate": "b3b539e9e7ed1b32fbf778bf2ebf0a6b9f980eac90ac86623d11881a", - "vrf": "0ff0ce9b820376e51c03b27877cd08f8ba40318f1a9f85a3db0b60dd03f71a7a" + "vrf": "0ff0ce9b820376e51c03b27877cd08f8ba40318f1a9f85a3db0b60dd03f71a7a", }, "b260ffdb6eba541fcf18601923457307647dce807851b9d19da133ab": { "delegate": "7c64eb868b4ef566391a321c85323f41d2b95480d7ce56ad2abcb022", - "vrf": "7fb22abd39d550c9a022ec8104648a26240a9ff9c88b8b89a6e20d393c03098e" + "vrf": "7fb22abd39d550c9a022ec8104648a26240a9ff9c88b8b89a6e20d393c03098e", }, "ced1599fd821a39593e00592e5292bdc1437ae0f7af388ef5257344a": { "delegate": "de7ca985023cf892f4de7f5f1d0a7181668884752d9ebb9e96c95059", - "vrf": "c301b7fc4d1b57fb60841bcec5e3d2db89602e5285801e522fce3790987b1124" + "vrf": "c301b7fc4d1b57fb60841bcec5e3d2db89602e5285801e522fce3790987b1124", }, "dd2a7d71a05bed11db61555ba4c658cb1ce06c8024193d064f2a66ae": { "delegate": "1e113c218899ee7807f4028071d0e108fc790dade9fd1a0d0b0701ee", - "vrf": "faf2702aa4893c877c622ab22dfeaf1d0c8aab98b837fe2bf667314f0d043822" + "vrf": "faf2702aa4893c877c622ab22dfeaf1d0c8aab98b837fe2bf667314f0d043822", }, "f3b9e74f7d0f24d2314ea5dfbca94b65b2059d1ff94d97436b82d5b4": { "delegate": "fd637b08cc379ef7b99c83b416458fcda8a01a606041779331008fb9", - "vrf": "37f2ea7c843a688159ddc2c38a2f997ab465150164a9136dca69564714b73268" - } + "vrf": "37f2ea7c843a688159ddc2c38a2f997ab465150164a9136dca69564714b73268", + }, }, "initialFunds": {}, "maxKESEvolutions": 62, @@ -45,15 +46,10 @@ def genesis_json(): "networkId": "Testnet", "networkMagic": 1, "protocolParams": { - "protocolVersion": { - "minor": 0, - "major": 2 - }, + "protocolVersion": {"minor": 0, "major": 2}, "decentralisationParam": 1, "eMax": 18, - "extraEntropy": { - "tag": "NeutralNonce" - }, + "extraEntropy": {"tag": "NeutralNonce"}, "maxTxSize": 16384, "maxBlockBodySize": 65536, "maxBlockHeaderSize": 1100, @@ -66,24 +62,21 @@ def genesis_json(): "nOpt": 150, "rho": 0.003, "tau": 0.20, - "a0": 0.3 + "a0": 0.3, }, "securityParam": 2160, "slotLength": 1, "slotsPerKESPeriod": 129600, - "staking": { - "pools": {}, - "stake": {} - }, + "staking": {"pools": {}, "stake": {}}, "systemStart": "2022-06-01T00:00:00Z", - "updateQuorum": 5 + "updateQuorum": 5, } @pytest.fixture(autouse=True) def mock_check_socket(): with patch("pathlib.Path.exists", return_value=True), patch( - "pathlib.Path.is_socket", return_value=True + "pathlib.Path.is_socket", return_value=True ), patch("pathlib.Path.is_file", return_value=True): yield @@ -164,52 +157,32 @@ def config_file(): "TracingVerbosity": "NormalVerbosity", "TurnOnLogMetrics": True, "TurnOnLogging": True, - "defaultBackends": [ - "KatipBK" - ], - "defaultScribes": [ - [ - "StdoutSK", - "stdout" - ] - ], + "defaultBackends": ["KatipBK"], + "defaultScribes": [["StdoutSK", "stdout"]], "hasEKG": 12788, - "hasPrometheus": [ - "0.0.0.0", - 12798 - ], + "hasPrometheus": ["0.0.0.0", 12798], "minSeverity": "Info", "options": { "mapBackends": { - "cardano.node.metrics": [ - "EKGViewBK" - ], - "cardano.node.resources": [ - "EKGViewBK" - ] + "cardano.node.metrics": ["EKGViewBK"], + "cardano.node.resources": ["EKGViewBK"], }, - "mapSubtrace": { - "cardano.node.metrics": { - "subtrace": "Neutral" - } - } + "mapSubtrace": {"cardano.node.metrics": {"subtrace": "Neutral"}}, }, "rotation": { "rpKeepFilesNum": 10, "rpLogLimitBytes": 5000000, - "rpMaxAgeHours": 24 + "rpMaxAgeHours": 24, }, - "setupBackends": [ - "KatipBK" - ], + "setupBackends": ["KatipBK"], "setupScribes": [ { "scFormat": "ScText", "scKind": "StdoutSK", "scName": "stdout", - "scRotation": None + "scRotation": None, } - ] + ], } with open(config_file_path, "w", encoding="utf-8") as file: diff --git a/test/pycardano/backend/test_cardano_cli.py b/test/pycardano/backend/test_cardano_cli.py index 9b85e624..8f02c84c 100644 --- a/test/pycardano/backend/test_cardano_cli.py +++ b/test/pycardano/backend/test_cardano_cli.py @@ -5,8 +5,15 @@ import pytest -from pycardano import CardanoCliChainContext, ProtocolParameters, ALONZO_COINS_PER_UTXO_WORD, CardanoCliNetwork, \ - GenesisParameters, TransactionInput, MultiAsset +from pycardano import ( + CardanoCliChainContext, + ProtocolParameters, + ALONZO_COINS_PER_UTXO_WORD, + CardanoCliNetwork, + GenesisParameters, + TransactionInput, + MultiAsset, +) QUERY_TIP_RESULT = { "block": 1460093, @@ -16,7 +23,7 @@ "slot": 41008115, "slotInEpoch": 313715, "slotsToEpochEnd": 118285, - "syncProgress": "100.00" + "syncProgress": "100.00", } QUERY_PROTOCOL_PARAMETERS_RESULT = { @@ -188,7 +195,7 @@ 32, 57996947, 18975, - 10 + 10, ], "PlutusV2": [ 205665, @@ -365,26 +372,17 @@ 10, 38887044, 32947, - 10 - ] + 10, + ], }, "decentralization": None, - "executionUnitPrices": { - "priceMemory": 5.77e-2, - "priceSteps": 7.21e-5 - }, + "executionUnitPrices": {"priceMemory": 5.77e-2, "priceSteps": 7.21e-5}, "extraPraosEntropy": None, "maxBlockBodySize": 90112, - "maxBlockExecutionUnits": { - "memory": 62000000, - "steps": 20000000000 - }, + "maxBlockExecutionUnits": {"memory": 62000000, "steps": 20000000000}, "maxBlockHeaderSize": 1100, "maxCollateralInputs": 3, - "maxTxExecutionUnits": { - "memory": 14000000, - "steps": 10000000000 - }, + "maxTxExecutionUnits": {"memory": 14000000, "steps": 10000000000}, "maxTxSize": 16384, "maxValueSize": 5000, "minPoolCost": 340000000, @@ -392,10 +390,7 @@ "monetaryExpansion": 3.0e-3, "poolPledgeInfluence": 0.3, "poolRetireMaxEpoch": 18, - "protocolVersion": { - "major": 8, - "minor": 0 - }, + "protocolVersion": {"major": 8, "minor": 0}, "stakeAddressDeposit": 2000000, "stakePoolDeposit": 500000000, "stakePoolTargetNum": 500, @@ -403,7 +398,7 @@ "txFeeFixed": 155381, "txFeePerByte": 44, "utxoCostPerByte": 4310, - "utxoCostPerWord": None + "utxoCostPerWord": None, } QUERY_UTXO_RESULT = """ TxHash TxIx Amount @@ -446,14 +441,15 @@ def chain_context(genesis_file, config_file): The CardanoCliChainContext """ with patch( - "pycardano.backend.cardano_cli.CardanoCliChainContext._run_command", - side_effect=override_run_command, + "pycardano.backend.cardano_cli.CardanoCliChainContext._run_command", + side_effect=override_run_command, ): context = CardanoCliChainContext( binary=Path("cardano-cli"), socket=Path("node.socket"), config_file=config_file, - network=CardanoCliNetwork.PREPROD) + network=CardanoCliNetwork.PREPROD, + ) context._run_command = override_run_command return context @@ -461,56 +457,90 @@ def chain_context(genesis_file, config_file): class TestCardanoCliChainContext: def test_protocol_param(self, chain_context): assert ( - ProtocolParameters( - min_fee_constant=QUERY_PROTOCOL_PARAMETERS_RESULT["txFeeFixed"], - min_fee_coefficient=QUERY_PROTOCOL_PARAMETERS_RESULT["txFeePerByte"], - max_block_size=QUERY_PROTOCOL_PARAMETERS_RESULT["maxBlockBodySize"], - max_tx_size=QUERY_PROTOCOL_PARAMETERS_RESULT["maxTxSize"], - max_block_header_size=QUERY_PROTOCOL_PARAMETERS_RESULT["maxBlockHeaderSize"], - key_deposit=QUERY_PROTOCOL_PARAMETERS_RESULT["stakeAddressDeposit"], - pool_deposit=QUERY_PROTOCOL_PARAMETERS_RESULT["stakePoolDeposit"], - pool_influence=QUERY_PROTOCOL_PARAMETERS_RESULT["poolPledgeInfluence"], - monetary_expansion=QUERY_PROTOCOL_PARAMETERS_RESULT["monetaryExpansion"], - treasury_expansion=QUERY_PROTOCOL_PARAMETERS_RESULT["treasuryCut"], - decentralization_param=QUERY_PROTOCOL_PARAMETERS_RESULT.get("decentralization", 0), - extra_entropy=QUERY_PROTOCOL_PARAMETERS_RESULT.get("extraPraosEntropy", ""), - protocol_major_version=int(QUERY_PROTOCOL_PARAMETERS_RESULT["protocolVersion"]["major"]), - protocol_minor_version=int(QUERY_PROTOCOL_PARAMETERS_RESULT["protocolVersion"]["minor"]), - min_utxo=QUERY_PROTOCOL_PARAMETERS_RESULT["utxoCostPerByte"], - min_pool_cost=QUERY_PROTOCOL_PARAMETERS_RESULT["minPoolCost"], - price_mem=float(QUERY_PROTOCOL_PARAMETERS_RESULT["executionUnitPrices"]["priceMemory"]), - price_step=float(QUERY_PROTOCOL_PARAMETERS_RESULT["executionUnitPrices"]["priceSteps"]), - max_tx_ex_mem=int(QUERY_PROTOCOL_PARAMETERS_RESULT["maxTxExecutionUnits"]["memory"]), - max_tx_ex_steps=int(QUERY_PROTOCOL_PARAMETERS_RESULT["maxTxExecutionUnits"]["steps"]), - max_block_ex_mem=int(QUERY_PROTOCOL_PARAMETERS_RESULT["maxBlockExecutionUnits"]["memory"]), - max_block_ex_steps=int(QUERY_PROTOCOL_PARAMETERS_RESULT["maxBlockExecutionUnits"]["steps"]), - max_val_size=QUERY_PROTOCOL_PARAMETERS_RESULT["maxValueSize"], - collateral_percent=QUERY_PROTOCOL_PARAMETERS_RESULT["collateralPercentage"], - max_collateral_inputs=QUERY_PROTOCOL_PARAMETERS_RESULT["maxCollateralInputs"], - coins_per_utxo_word=QUERY_PROTOCOL_PARAMETERS_RESULT.get( - "coinsPerUtxoWord", ALONZO_COINS_PER_UTXO_WORD - ), - coins_per_utxo_byte=QUERY_PROTOCOL_PARAMETERS_RESULT.get("coinsPerUtxoByte", 0), - cost_models=QUERY_PROTOCOL_PARAMETERS_RESULT["costModels"], - ) - == chain_context.protocol_param + ProtocolParameters( + min_fee_constant=QUERY_PROTOCOL_PARAMETERS_RESULT["txFeeFixed"], + min_fee_coefficient=QUERY_PROTOCOL_PARAMETERS_RESULT["txFeePerByte"], + max_block_size=QUERY_PROTOCOL_PARAMETERS_RESULT["maxBlockBodySize"], + max_tx_size=QUERY_PROTOCOL_PARAMETERS_RESULT["maxTxSize"], + max_block_header_size=QUERY_PROTOCOL_PARAMETERS_RESULT[ + "maxBlockHeaderSize" + ], + key_deposit=QUERY_PROTOCOL_PARAMETERS_RESULT["stakeAddressDeposit"], + pool_deposit=QUERY_PROTOCOL_PARAMETERS_RESULT["stakePoolDeposit"], + pool_influence=QUERY_PROTOCOL_PARAMETERS_RESULT["poolPledgeInfluence"], + monetary_expansion=QUERY_PROTOCOL_PARAMETERS_RESULT[ + "monetaryExpansion" + ], + treasury_expansion=QUERY_PROTOCOL_PARAMETERS_RESULT["treasuryCut"], + decentralization_param=QUERY_PROTOCOL_PARAMETERS_RESULT.get( + "decentralization", 0 + ), + extra_entropy=QUERY_PROTOCOL_PARAMETERS_RESULT.get( + "extraPraosEntropy", "" + ), + protocol_major_version=int( + QUERY_PROTOCOL_PARAMETERS_RESULT["protocolVersion"]["major"] + ), + protocol_minor_version=int( + QUERY_PROTOCOL_PARAMETERS_RESULT["protocolVersion"]["minor"] + ), + min_utxo=QUERY_PROTOCOL_PARAMETERS_RESULT["utxoCostPerByte"], + min_pool_cost=QUERY_PROTOCOL_PARAMETERS_RESULT["minPoolCost"], + price_mem=float( + QUERY_PROTOCOL_PARAMETERS_RESULT["executionUnitPrices"][ + "priceMemory" + ] + ), + price_step=float( + QUERY_PROTOCOL_PARAMETERS_RESULT["executionUnitPrices"][ + "priceSteps" + ] + ), + max_tx_ex_mem=int( + QUERY_PROTOCOL_PARAMETERS_RESULT["maxTxExecutionUnits"]["memory"] + ), + max_tx_ex_steps=int( + QUERY_PROTOCOL_PARAMETERS_RESULT["maxTxExecutionUnits"]["steps"] + ), + max_block_ex_mem=int( + QUERY_PROTOCOL_PARAMETERS_RESULT["maxBlockExecutionUnits"]["memory"] + ), + max_block_ex_steps=int( + QUERY_PROTOCOL_PARAMETERS_RESULT["maxBlockExecutionUnits"]["steps"] + ), + max_val_size=QUERY_PROTOCOL_PARAMETERS_RESULT["maxValueSize"], + collateral_percent=QUERY_PROTOCOL_PARAMETERS_RESULT[ + "collateralPercentage" + ], + max_collateral_inputs=QUERY_PROTOCOL_PARAMETERS_RESULT[ + "maxCollateralInputs" + ], + coins_per_utxo_word=QUERY_PROTOCOL_PARAMETERS_RESULT.get( + "coinsPerUtxoWord", ALONZO_COINS_PER_UTXO_WORD + ), + coins_per_utxo_byte=QUERY_PROTOCOL_PARAMETERS_RESULT.get( + "coinsPerUtxoByte", 0 + ), + cost_models=QUERY_PROTOCOL_PARAMETERS_RESULT["costModels"], + ) + == chain_context.protocol_param ) def test_genesis(self, chain_context, genesis_json): assert ( - GenesisParameters( - active_slots_coefficient=genesis_json["activeSlotsCoeff"], - update_quorum=genesis_json["updateQuorum"], - max_lovelace_supply=genesis_json["maxLovelaceSupply"], - network_magic=genesis_json["networkMagic"], - epoch_length=genesis_json["epochLength"], - system_start=genesis_json["systemStart"], - slots_per_kes_period=genesis_json["slotsPerKESPeriod"], - slot_length=genesis_json["slotLength"], - max_kes_evolutions=genesis_json["maxKESEvolutions"], - security_param=genesis_json["securityParam"], - ) - == chain_context.genesis_param + GenesisParameters( + active_slots_coefficient=genesis_json["activeSlotsCoeff"], + update_quorum=genesis_json["updateQuorum"], + max_lovelace_supply=genesis_json["maxLovelaceSupply"], + network_magic=genesis_json["networkMagic"], + epoch_length=genesis_json["epochLength"], + system_start=genesis_json["systemStart"], + slots_per_kes_period=genesis_json["slotsPerKESPeriod"], + slot_length=genesis_json["slotLength"], + max_kes_evolutions=genesis_json["maxKESEvolutions"], + security_param=genesis_json["securityParam"], + ) + == chain_context.genesis_param ) def test_utxo(self, chain_context): @@ -536,8 +566,9 @@ def test_utxo(self, chain_context): ) def test_submit_tx(self, chain_context): - results = chain_context.submit_tx( - "testcborhexfromtransaction" - ) + results = chain_context.submit_tx("testcborhexfromtransaction") - assert results == "270be16fa17cdb3ef683bf2c28259c978d4b7088792074f177c8efda247e23f7" + assert ( + results + == "270be16fa17cdb3ef683bf2c28259c978d4b7088792074f177c8efda247e23f7" + ) From 45fa2660383937f2da7dafd47039b2d10d49b219 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20M=C3=BCndler?= Date: Mon, 30 Oct 2023 14:07:21 +0100 Subject: [PATCH 06/28] Fix some QA issues --- pycardano/backend/cardano_cli.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/pycardano/backend/cardano_cli.py b/pycardano/backend/cardano_cli.py index 8e79c3eb..4d8c8909 100644 --- a/pycardano/backend/cardano_cli.py +++ b/pycardano/backend/cardano_cli.py @@ -13,6 +13,7 @@ from cachetools import Cache, LRUCache, TTLCache, func +from pycardano import Network from pycardano.address import Address from pycardano.backend.base import ( ALONZO_COINS_PER_UTXO_WORD, @@ -75,9 +76,9 @@ class CardanoCliNetwork(Enum): class CardanoCliChainContext(ChainContext): - _binary: Optional[Path] + _binary: Path _socket: Optional[Path] - _config_file: Optional[Path] + _config_file: Path _mode: Mode _network: CardanoCliNetwork _last_known_block_slot: int @@ -191,6 +192,7 @@ def _get_min_utxo(self) -> int: return params["utxoCostPerWord"] elif "utxoCostPerByte" in params and params["utxoCostPerByte"] is not None: return params["utxoCostPerByte"] + raise ValueError("Cannot determine minUTxOValue, invalid protocol params") def _parse_cost_models(self, cli_result: JsonDict) -> Dict[str, Dict[str, int]]: cli_cost_models = cli_result.get("costModels", {}) @@ -289,9 +291,11 @@ def genesis_param(self) -> GenesisParameters: ) @property - def network(self) -> CardanoCliNetwork: + def network(self) -> Network: """Cet current network""" - return self._network + if self._network == CardanoCliNetwork.MAINNET: + return Network.MAINNET + return Network.TESTNET @property def epoch(self) -> int: From 20b544f4f0f29e1471568b05fa343abe4f5220da Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Thu, 2 Nov 2023 11:36:17 -0500 Subject: [PATCH 07/28] refactor: use `--out-file /dev/stdout` to get utxo data as json --- pycardano/backend/cardano_cli.py | 116 +++++++++++++-------- test/pycardano/backend/test_cardano_cli.py | 33 +++--- 2 files changed, 93 insertions(+), 56 deletions(-) diff --git a/pycardano/backend/cardano_cli.py b/pycardano/backend/cardano_cli.py index 4d8c8909..48b8d81c 100644 --- a/pycardano/backend/cardano_cli.py +++ b/pycardano/backend/cardano_cli.py @@ -13,7 +13,11 @@ from cachetools import Cache, LRUCache, TTLCache, func -from pycardano import Network +from pycardano.serialization import RawCBOR +from pycardano.nativescript import NativeScript +from pycardano.plutus import PlutusV2Script, PlutusV1Script + +from pycardano.network import Network from pycardano.address import Address from pycardano.backend.base import ( ALONZO_COINS_PER_UTXO_WORD, @@ -194,7 +198,8 @@ def _get_min_utxo(self) -> int: return params["utxoCostPerByte"] raise ValueError("Cannot determine minUTxOValue, invalid protocol params") - def _parse_cost_models(self, cli_result: JsonDict) -> Dict[str, Dict[str, int]]: + @staticmethod + def _parse_cost_models(cli_result: JsonDict) -> Dict[str, Dict[str, int]]: cli_cost_models = cli_result.get("costModels", {}) cost_models = {} @@ -211,7 +216,7 @@ def _parse_cost_models(self, cli_result: JsonDict) -> Dict[str, Dict[str, int]]: return cost_models def _is_chain_tip_updated(self): - # fetch at most every twenty seconds! + # fetch at almost every twenty seconds! if time.time() - self._last_chain_tip_fetch < self._refetch_chain_tip_interval: return False self._last_chain_tip_fetch = time.time() @@ -321,6 +326,29 @@ def version(self): """ return self._run_command(["version"]) + @staticmethod + def _get_script( + reference_script: dict, + ) -> Union[PlutusV1Script, PlutusV2Script, NativeScript]: + """ + Get a script object from a reference script dictionary. + Args: + reference_script: + + Returns: + + """ + script_type = reference_script["script"]["type"] + script_json: JsonDict = reference_script["script"] + if script_type == "PlutusScriptV1": + v1script = PlutusV1Script(bytes.fromhex(script_json["cborHex"])) + return v1script + elif script_type == "PlutusScriptV2": + v2script = PlutusV2Script(bytes.fromhex(script_json["cborHex"])) + return v2script + else: + return NativeScript.from_dict(script_json) + def _utxos(self, address: str) -> List[UTxO]: """Get all UTxOs associated with an address. @@ -335,57 +363,61 @@ def _utxos(self, address: str) -> List[UTxO]: return self._utxo_cache[key] result = self._run_command( - ["query", "utxo", "--address", address] + self._network.value + ["query", "utxo", "--address", address, "--out-file", "/dev/stdout"] + + self._network.value ) - raw_utxos = result.split("\n")[2:] - # Parse the UTXOs into a list of dict objects + raw_utxos = json.loads(result) + utxos = [] - for utxo_line in raw_utxos: - if len(utxo_line) == 0: - continue - - vals = utxo_line.split() - utxo_dict = { - "tx_hash": vals[0], - "tx_ix": vals[1], - "lovelaces": int(vals[2]), - "type": vals[3], - } + for tx_hash in raw_utxos.keys(): + tx_id, tx_idx = tx_hash.split("#") + utxo = raw_utxos[tx_hash] + tx_in = TransactionInput.from_primitive([tx_id, int(tx_idx)]) + + value = Value() + multi_asset = MultiAsset() + for asset in utxo["value"].keys(): + if asset == "lovelace": + value.coin = utxo["value"][asset] + else: + policy_id = asset + policy = ScriptHash.from_primitive(policy_id) - tx_in = TransactionInput.from_primitive( - [utxo_dict["tx_hash"], int(utxo_dict["tx_ix"])] - ) - lovelace_amount = utxo_dict["lovelaces"] + for asset_hex_name in utxo["value"][asset].keys(): + asset_name = AssetName.from_primitive(asset_hex_name) + amount = utxo["value"][asset][asset_hex_name] + multi_asset.setdefault(policy, Asset())[asset_name] = amount - tx_out = TransactionOutput( - Address.from_primitive(address), amount=Value(coin=int(lovelace_amount)) - ) + value.multi_asset = multi_asset - extra = [i for i, j in enumerate(vals) if j == "+"] - for i in extra: - if "TxOutDatumNone" in vals[i + 1]: - continue - elif "TxOutDatumHash" in vals[i + 1] and "Data" in vals[i + 2]: - datum_hash = DatumHash.from_primitive(vals[i + 3]) - tx_out.datum_hash = datum_hash - else: - multi_assets = MultiAsset() + datum_hash = ( + DatumHash.from_primitive(utxo["datumhash"]) + if utxo.get("datumhash") and utxo.get("inlineDatum") is None + else None + ) - policy_id = vals[i + 2].split(".")[0] - asset_hex_name = vals[i + 2].split(".")[1] - quantity = int(vals[i + 1]) + datum = None - policy = ScriptHash.from_primitive(policy_id) - asset_name = AssetName.from_primitive(asset_hex_name) + if utxo.get("datum"): + datum = RawCBOR(bytes.fromhex(utxo["datum"])) + elif utxo.get("inlineDatumhash"): + datum = utxo["inlineDatum"] - multi_assets.setdefault(policy, Asset())[asset_name] = quantity + script = None - tx_out.amount = Value(lovelace_amount, multi_assets) + if utxo.get("referenceScript"): + script = self._get_script(utxo["referenceScript"]) - utxo = UTxO(input=tx_in, output=tx_out) + tx_out = TransactionOutput( + Address.from_primitive(utxo["address"]), + amount=value, + datum_hash=datum_hash, + datum=datum, + script=script, + ) - utxos.append(utxo) + utxos.append(UTxO(tx_in, tx_out)) self._utxo_cache[key] = utxos diff --git a/test/pycardano/backend/test_cardano_cli.py b/test/pycardano/backend/test_cardano_cli.py index 8f02c84c..a4470519 100644 --- a/test/pycardano/backend/test_cardano_cli.py +++ b/test/pycardano/backend/test_cardano_cli.py @@ -401,11 +401,7 @@ "utxoCostPerWord": None, } -QUERY_UTXO_RESULT = """ TxHash TxIx Amount --------------------------------------------------------------------------------------- -270be16fa17cdb3ef683bf2c28259c978d4b7088792074f177c8efda247e23f7 0 1000000 lovelace + TxOutDatumNone -270be16fa17cdb3ef683bf2c28259c978d4b7088792074f177c8efda247e23f7 1 9498624998 lovelace + 1000000000 328a60495759e0d8e244eca5b85b2467d142c8a755d6cd0592dff47b.6d656c636f696e -""" +QUERY_UTXO_RESULT = '{"fbaa018740241abb935240051134914389c3f94647d8bd6c30cb32d3fdb799bf#0": {"address": "addr1x8nz307k3sr60gu0e47cmajssy4fmld7u493a4xztjrll0aj764lvrxdayh2ux30fl0ktuh27csgmpevdu89jlxppvrswgxsta", "datum": null, "inlineDatum": {"constructor": 0, "fields": [{"constructor": 0, "fields": [{"bytes": "2e11e7313e00ccd086cfc4f1c3ebed4962d31b481b6a153c23601c0f"}, {"bytes": "636861726c69335f6164615f6e6674"}]}, {"constructor": 0, "fields": [{"bytes": ""}, {"bytes": ""}]}, {"constructor": 0, "fields": [{"bytes": "8e51398904a5d3fc129fbf4f1589701de23c7824d5c90fdb9490e15a"}, {"bytes": "434841524c4933"}]}, {"constructor": 0, "fields": [{"bytes": "d8d46a3e430fab5dc8c5a0a7fc82abbf4339a89034a8c804bb7e6012"}, {"bytes": "636861726c69335f6164615f6c71"}]}, {"int": 997}, {"list": [{"bytes": "4dd98a2ef34bc7ac3858bbcfdf94aaa116bb28ca7e01756140ba4d19"}]}, {"int": 10000000000}]}, "inlineDatumhash": "c56003cba9cfcf2f73cf6a5f4d6354d03c281bcd2bbd7a873d7475faa10a7123", "referenceScript": null, "value": {"2e11e7313e00ccd086cfc4f1c3ebed4962d31b481b6a153c23601c0f": {"636861726c69335f6164615f6e6674": 1}, "8e51398904a5d3fc129fbf4f1589701de23c7824d5c90fdb9490e15a": {"434841524c4933": 1367726755}, "d8d46a3e430fab5dc8c5a0a7fc82abbf4339a89034a8c804bb7e6012": {"636861726c69335f6164615f6c71": 9223372035870126880}, "lovelace": 708864940}}}' def override_run_command(cmd: List[str]): @@ -549,19 +545,28 @@ def test_utxo(self, chain_context): ) assert results[0].input == TransactionInput.from_primitive( - ["270be16fa17cdb3ef683bf2c28259c978d4b7088792074f177c8efda247e23f7", 0] + ["fbaa018740241abb935240051134914389c3f94647d8bd6c30cb32d3fdb799bf", 0] ) - assert results[0].output.amount == 1000000 + assert results[0].output.amount.coin == 708864940 - assert results[1].input == TransactionInput.from_primitive( - ["270be16fa17cdb3ef683bf2c28259c978d4b7088792074f177c8efda247e23f7", 1] + assert ( + str(results[0].output.address) + == "addr1x8nz307k3sr60gu0e47cmajssy4fmld7u493a4xztjrll0aj764lvrxdayh2ux30fl0ktuh27csgmpevdu89jlxppvrswgxsta" ) - assert results[1].output.amount.coin == 9498624998 - assert results[1].output.amount.multi_asset == MultiAsset.from_primitive( + + assert isinstance(results[0].output.datum, dict) + + assert results[0].output.amount.multi_asset == MultiAsset.from_primitive( { - "328a60495759e0d8e244eca5b85b2467d142c8a755d6cd0592dff47b": { - "6d656c636f696e": 1000000000 - } + "2e11e7313e00ccd086cfc4f1c3ebed4962d31b481b6a153c23601c0f": { + "636861726c69335f6164615f6e6674": 1 + }, + "8e51398904a5d3fc129fbf4f1589701de23c7824d5c90fdb9490e15a": { + "434841524c4933": 1367726755 + }, + "d8d46a3e430fab5dc8c5a0a7fc82abbf4339a89034a8c804bb7e6012": { + "636861726c69335f6164615f6c71": 9223372035870126880 + }, } ) From efe16980a58670ac6fbffdc188ca34b0dd5cdc03 Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Thu, 2 Nov 2023 11:43:46 -0500 Subject: [PATCH 08/28] fix: remove unused offline/online mode code --- pycardano/backend/cardano_cli.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/pycardano/backend/cardano_cli.py b/pycardano/backend/cardano_cli.py index 48b8d81c..846aff7e 100644 --- a/pycardano/backend/cardano_cli.py +++ b/pycardano/backend/cardano_cli.py @@ -45,15 +45,6 @@ __all__ = ["CardanoCliChainContext", "CardanoCliNetwork"] -class Mode(str, Enum): - """ - Mode enumeration. - """ - - ONLINE = "online" - OFFLINE = "offline" - - def network_magic(magic_number: int) -> List[str]: """ Returns the network magic number for the cardano-cli @@ -83,7 +74,6 @@ class CardanoCliChainContext(ChainContext): _binary: Path _socket: Optional[Path] _config_file: Path - _mode: Mode _network: CardanoCliNetwork _last_known_block_slot: int _last_chain_tip_fetch: float @@ -114,10 +104,8 @@ def __init__( self._socket = socket os.environ["CARDANO_NODE_SOCKET_PATH"] = self._socket.as_posix() - self._mode = Mode.ONLINE except CardanoCliError: self._socket = None - self._mode = Mode.OFFLINE self._binary = binary self._network = network From 6ae4e642b12ab2195ba436573126232f17b1d9df Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Thu, 2 Nov 2023 11:45:56 -0500 Subject: [PATCH 09/28] fix: remove unused fraction parser method --- pycardano/backend/cardano_cli.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/pycardano/backend/cardano_cli.py b/pycardano/backend/cardano_cli.py index 846aff7e..788aa95a 100644 --- a/pycardano/backend/cardano_cli.py +++ b/pycardano/backend/cardano_cli.py @@ -254,11 +254,6 @@ def _fetch_protocol_param(self) -> ProtocolParameters: cost_models=self._parse_cost_models(result), ) - @staticmethod - def _fraction_parser(fraction: str) -> float: - x, y = fraction.split("/") - return int(x) / int(y) - @property def protocol_param(self) -> ProtocolParameters: """Get current protocol parameters""" From 57d4f2e2bd825c2b7f413b714efcca95b7a11362 Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Thu, 2 Nov 2023 20:42:50 -0500 Subject: [PATCH 10/28] fix: add docker configuration to use cardano-cli in a Docker container and network args method to use custom networks --- poetry.lock | 50 ++++++++++++++- pycardano/backend/cardano_cli.py | 101 ++++++++++++++++++++++++------- pyproject.toml | 1 + 3 files changed, 128 insertions(+), 24 deletions(-) diff --git a/poetry.lock b/poetry.lock index 53b35dcd..ab80fded 100644 --- a/poetry.lock +++ b/poetry.lock @@ -547,6 +547,28 @@ files = [ {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, ] +[[package]] +name = "docker" +version = "6.1.3" +description = "A Python library for the Docker Engine API." +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "docker-6.1.3-py3-none-any.whl", hash = "sha256:aecd2277b8bf8e506e484f6ab7aec39abe0038e29fa4a6d3ba86c3fe01844ed9"}, + {file = "docker-6.1.3.tar.gz", hash = "sha256:aa6d17830045ba5ef0168d5eaa34d37beeb113948c413affe1d5991fc11f9a20"}, +] + +[package.dependencies] +packaging = ">=14.0" +pywin32 = {version = ">=304", markers = "sys_platform == \"win32\""} +requests = ">=2.26.0" +urllib3 = ">=1.26.0" +websocket-client = ">=0.32.0" + +[package.extras] +ssh = ["paramiko (>=2.4.3)"] + [[package]] name = "docutils" version = "0.17.1" @@ -1059,7 +1081,7 @@ asn1crypto = ">=1.5.1" name = "packaging" version = "23.1" description = "Core utilities for Python packages" -category = "dev" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1295,6 +1317,30 @@ files = [ {file = "pytz-2023.3.tar.gz", hash = "sha256:1d8ce29db189191fb55338ee6d0387d82ab59f3d00eac103412d64e0ebd0c588"}, ] +[[package]] +name = "pywin32" +version = "306" +description = "Python for Window Extensions" +category = "main" +optional = false +python-versions = "*" +files = [ + {file = "pywin32-306-cp310-cp310-win32.whl", hash = "sha256:06d3420a5155ba65f0b72f2699b5bacf3109f36acbe8923765c22938a69dfc8d"}, + {file = "pywin32-306-cp310-cp310-win_amd64.whl", hash = "sha256:84f4471dbca1887ea3803d8848a1616429ac94a4a8d05f4bc9c5dcfd42ca99c8"}, + {file = "pywin32-306-cp311-cp311-win32.whl", hash = "sha256:e65028133d15b64d2ed8f06dd9fbc268352478d4f9289e69c190ecd6818b6407"}, + {file = "pywin32-306-cp311-cp311-win_amd64.whl", hash = "sha256:a7639f51c184c0272e93f244eb24dafca9b1855707d94c192d4a0b4c01e1100e"}, + {file = "pywin32-306-cp311-cp311-win_arm64.whl", hash = "sha256:70dba0c913d19f942a2db25217d9a1b726c278f483a919f1abfed79c9cf64d3a"}, + {file = "pywin32-306-cp312-cp312-win32.whl", hash = "sha256:383229d515657f4e3ed1343da8be101000562bf514591ff383ae940cad65458b"}, + {file = "pywin32-306-cp312-cp312-win_amd64.whl", hash = "sha256:37257794c1ad39ee9be652da0462dc2e394c8159dfd913a8a4e8eb6fd346da0e"}, + {file = "pywin32-306-cp312-cp312-win_arm64.whl", hash = "sha256:5821ec52f6d321aa59e2db7e0a35b997de60c201943557d108af9d4ae1ec7040"}, + {file = "pywin32-306-cp37-cp37m-win32.whl", hash = "sha256:1c73ea9a0d2283d889001998059f5eaaba3b6238f767c9cf2833b13e6a685f65"}, + {file = "pywin32-306-cp37-cp37m-win_amd64.whl", hash = "sha256:72c5f621542d7bdd4fdb716227be0dd3f8565c11b280be6315b06ace35487d36"}, + {file = "pywin32-306-cp38-cp38-win32.whl", hash = "sha256:e4c092e2589b5cf0d365849e73e02c391c1349958c5ac3e9d5ccb9a28e017b3a"}, + {file = "pywin32-306-cp38-cp38-win_amd64.whl", hash = "sha256:e8ac1ae3601bee6ca9f7cb4b5363bf1c0badb935ef243c4733ff9a393b1690c0"}, + {file = "pywin32-306-cp39-cp39-win32.whl", hash = "sha256:e25fd5b485b55ac9c057f67d94bc203f3f6595078d1fb3b458c9c28b7153a802"}, + {file = "pywin32-306-cp39-cp39-win_amd64.whl", hash = "sha256:39b61c15272833b5c329a2989999dcae836b1eed650252ab1b7bfbe1d59f30f4"}, +] + [[package]] name = "requests" version = "2.28.2" @@ -1704,4 +1750,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = "^3.7" -content-hash = "6d227be4b5f57497cf7b45d18e4e666faf87b53b0936f340be9521861d238868" +content-hash = "10d50e3a827a03b07829571b5dfcdb04270bcf07ed9f652a8d02d53ecdbbddae" diff --git a/pycardano/backend/cardano_cli.py b/pycardano/backend/cardano_cli.py index 788aa95a..04ba7c90 100644 --- a/pycardano/backend/cardano_cli.py +++ b/pycardano/backend/cardano_cli.py @@ -11,7 +11,9 @@ from pathlib import Path from typing import Optional, List, Dict, Union +import docker from cachetools import Cache, LRUCache, TTLCache, func +from docker.errors import APIError from pycardano.serialization import RawCBOR from pycardano.nativescript import NativeScript @@ -42,7 +44,7 @@ ) from pycardano.types import JsonDict -__all__ = ["CardanoCliChainContext", "CardanoCliNetwork"] +__all__ = ["CardanoCliChainContext", "CardanoCliNetwork", "DockerConfig"] def network_magic(magic_number: int) -> List[str]: @@ -70,6 +72,22 @@ class CardanoCliNetwork(Enum): CUSTOM = partial(network_magic) +class DockerConfig: + """ + Docker configuration to use the cardano-cli in a Docker container + """ + + container_name: str + """ The name of the Docker container containing the cardano-cli""" + + host_socket: Optional[Path] + """ The path to the Docker host socket file""" + + def __init__(self, container_name: str, host_socket: Optional[Path] = None): + self.container_name = container_name + self.host_socket = host_socket + + class CardanoCliChainContext(ChainContext): _binary: Path _socket: Optional[Path] @@ -81,6 +99,8 @@ class CardanoCliChainContext(ChainContext): _protocol_param: Optional[ProtocolParameters] _utxo_cache: Cache _datum_cache: Cache + _docker_config: Optional[DockerConfig] + _network_magic_number: Optional[int] def __init__( self, @@ -91,21 +111,24 @@ def __init__( refetch_chain_tip_interval: Optional[float] = None, utxo_cache_size: int = 10000, datum_cache_size: int = 10000, + docker_config: Optional[DockerConfig] = None, + network_magic_number: Optional[int] = None, ): - if not binary.exists() or not binary.is_file(): - raise CardanoCliError(f"cardano-cli binary file not found: {binary}") - - # Check the socket path file and set the CARDANO_NODE_SOCKET_PATH environment variable - try: - if not socket.exists(): + if docker_config is None: + if not binary.exists() or not binary.is_file(): raise CardanoCliError(f"cardano-cli binary file not found: {binary}") - elif not socket.is_socket(): - raise CardanoCliError(f"{socket} is not a socket file") - self._socket = socket - os.environ["CARDANO_NODE_SOCKET_PATH"] = self._socket.as_posix() - except CardanoCliError: - self._socket = None + # Check the socket path file and set the CARDANO_NODE_SOCKET_PATH environment variable + try: + if not socket.exists(): + raise CardanoCliError(f"cardano-node socket not found: {socket}") + elif not socket.is_socket(): + raise CardanoCliError(f"{socket} is not a socket file") + + self._socket = socket + os.environ["CARDANO_NODE_SOCKET_PATH"] = self._socket.as_posix() + except CardanoCliError: + self._socket = None self._binary = binary self._network = network @@ -129,29 +152,63 @@ def __init__( ttl=self._refetch_chain_tip_interval, maxsize=utxo_cache_size ) self._datum_cache = LRUCache(maxsize=datum_cache_size) + self._docker_config = docker_config + self._network_magic_number = network_magic_number + + @property + def _network_args(self) -> List[str]: + if self._network is CardanoCliNetwork.CUSTOM: + return self._network.value(self._network_magic_number) + else: + return self._network.value def _run_command(self, cmd: List[str]) -> str: """ - Runs the command in the cardano-cli + Runs the command in the cardano-cli. If the docker configuration is set, it will run the command in the + docker container. :param cmd: Command as a list of strings :return: The stdout if the command runs successfully """ try: - result = subprocess.run( - [self._binary.as_posix()] + cmd, capture_output=True, check=True - ) - return result.stdout.decode().strip() + if self._docker_config: + docker_config = self._docker_config + if docker_config.host_socket is None: + client = docker.from_env() + else: + client = docker.DockerClient( + base_url=docker_config.host_socket.as_posix() + ) + + container = client.containers.get(docker_config.container_name) + + exec_result = container.exec_run( + [self._binary.as_posix()] + cmd, stdout=True, stderr=True + ) + + if exec_result.exit_code == 0: + output = exec_result.output.decode() + return output + else: + error = exec_result.output.decode() + raise CardanoCliError(error) + else: + result = subprocess.run( + [self._binary.as_posix()] + cmd, capture_output=True, check=True + ) + return result.stdout.decode().strip() except subprocess.CalledProcessError as err: raise CardanoCliError(err.stderr.decode()) from err + except APIError as err: + raise CardanoCliError(err) from err def _query_chain_tip(self) -> JsonDict: - result = self._run_command(["query", "tip"] + self._network.value) + result = self._run_command(["query", "tip"] + self._network_args) return json.loads(result) def _query_current_protocol_params(self) -> JsonDict: result = self._run_command( - ["query", "protocol-parameters"] + self._network.value + ["query", "protocol-parameters"] + self._network_args ) return json.loads(result) @@ -347,7 +404,7 @@ def _utxos(self, address: str) -> List[UTxO]: result = self._run_command( ["query", "utxo", "--address", address, "--out-file", "/dev/stdout"] - + self._network.value + + self._network_args ) raw_utxos = json.loads(result) @@ -436,7 +493,7 @@ def submit_tx_cbor(self, cbor: Union[bytes, str]) -> str: try: self._run_command( ["transaction", "submit", "--tx-file", tmp_tx_file.name] - + self._network.value + + self._network_args ) except CardanoCliError as err: raise TransactionFailedException( diff --git a/pyproject.toml b/pyproject.toml index e57f6393..60da629b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ ECPy = "^1.2.5" frozendict = "^2.3.8" frozenlist = "^1.3.3" cachetools = "^5.3.0" +docker = "^6.1.3" [tool.poetry.dev-dependencies] Sphinx = "^4.3.2" From 9a549dda6b128da65082fa00415bb6a9701a9c7b Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Thu, 2 Nov 2023 20:50:04 -0500 Subject: [PATCH 11/28] test: add integration tests for cardano-cli --- integration-test/docker-compose.yml | 1 + integration-test/test/test_cardano_cli.py | 68 +++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 integration-test/test/test_cardano_cli.py diff --git a/integration-test/docker-compose.yml b/integration-test/docker-compose.yml index fb6fce14..e79fe347 100644 --- a/integration-test/docker-compose.yml +++ b/integration-test/docker-compose.yml @@ -13,6 +13,7 @@ services: entrypoint: bash environment: NETWORK: "${NETWORK:-local-alonzo}" + CARDANO_NODE_SOCKET_PATH: "/ipc/node.socket" command: /code/run_node.sh networks: diff --git a/integration-test/test/test_cardano_cli.py b/integration-test/test/test_cardano_cli.py new file mode 100644 index 00000000..4192f502 --- /dev/null +++ b/integration-test/test/test_cardano_cli.py @@ -0,0 +1,68 @@ +import os +from pathlib import Path + + +from pycardano import ( + CardanoCliChainContext, + CardanoCliNetwork, + ProtocolParameters, + GenesisParameters, + Network, +) +from pycardano.backend.cardano_cli import DockerConfig + + +class TestCardanoCli: + network_env = os.getenv("NETWORK", "local-alonzo") + host_socket = os.getenv("DOCKER_HOST", None) + network_magic = os.getenv("NETWORK_MAGIC", 42) + + configs_dir = Path(__file__).parent.parent / "configs" + + chain_context = CardanoCliChainContext( + binary=Path("cardano-cli"), + socket=Path("/ipc/node.socket"), + config_file=configs_dir / network_env / "config.json", + network=CardanoCliNetwork.CUSTOM, + docker_config=DockerConfig( + container_name="cardano-node", + host_socket=Path(host_socket) if host_socket else None, + ), + network_magic_number=int(network_magic), + ) + + def test_protocol_param(self): + protocol_param = self.chain_context.protocol_param + + assert protocol_param is not None + assert isinstance(protocol_param, ProtocolParameters) + + cost_models = protocol_param.cost_models + for _, cost_model in cost_models.items(): + assert "addInteger-cpu-arguments-intercept" in cost_model + assert "addInteger-cpu-arguments-slope" in cost_model + + def test_genesis_param(self): + genesis_param = self.chain_context.genesis_param + + assert genesis_param is not None + assert isinstance(genesis_param, GenesisParameters) + + def test_network(self): + network = self.chain_context.network + + assert network is not None + assert isinstance(network, Network) + + def test_epoch(self): + epoch = self.chain_context.epoch + + assert epoch is not None + assert isinstance(epoch, int) + assert epoch > 0 + + def test_last_block_slot(self): + last_block_slot = self.chain_context.last_block_slot + + assert isinstance(last_block_slot, int) + assert last_block_slot > 0 From 3e72507d390bf86b9a6348eb1cde2d45e65987e7 Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Mon, 6 Nov 2023 09:56:31 -0500 Subject: [PATCH 12/28] test: fix cardano-node container name --- integration-test/test/test_cardano_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration-test/test/test_cardano_cli.py b/integration-test/test/test_cardano_cli.py index 4192f502..ff0a07f1 100644 --- a/integration-test/test/test_cardano_cli.py +++ b/integration-test/test/test_cardano_cli.py @@ -25,7 +25,7 @@ class TestCardanoCli: config_file=configs_dir / network_env / "config.json", network=CardanoCliNetwork.CUSTOM, docker_config=DockerConfig( - container_name="cardano-node", + container_name="integration-test_cardano-node_1", host_socket=Path(host_socket) if host_socket else None, ), network_magic_number=int(network_magic), From e31f9d83aa659605c0de2eeaeaee7c37c40829a8 Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Sat, 11 Nov 2023 18:33:18 -0500 Subject: [PATCH 13/28] feat: add initial functionality for pool certificates --- pycardano/certificate.py | 126 +++++++++++++++++++++++- pycardano/hash.py | 25 +++++ pycardano/stake_pool.py | 202 +++++++++++++++++++++++++++++++++++++++ pycardano/transaction.py | 9 +- pycardano/witness.py | 71 +++++++++++++- 5 files changed, 426 insertions(+), 7 deletions(-) create mode 100644 pycardano/stake_pool.py diff --git a/pycardano/certificate.py b/pycardano/certificate.py index 11b8892a..8db96367 100644 --- a/pycardano/certificate.py +++ b/pycardano/certificate.py @@ -1,8 +1,10 @@ +from __future__ import annotations + from dataclasses import dataclass, field -from typing import Optional, Union +from typing import Optional, Union, Tuple, Type from pycardano.hash import PoolKeyHash, ScriptHash, VerificationKeyHash -from pycardano.serialization import ArrayCBORSerializable +from pycardano.serialization import ArrayCBORSerializable, limit_primitive_type __all__ = [ "Certificate", @@ -10,8 +12,15 @@ "StakeRegistration", "StakeDeregistration", "StakeDelegation", + "PoolRegistration", + "PoolRetirement", + "CertificateCBORSerializer" ] +from pycardano.stake_pool import PoolParams + +unit_interval = Tuple[int, int] + @dataclass(repr=False) class StakeCredential(ArrayCBORSerializable): @@ -25,6 +34,16 @@ def __post_init__(self): else: self._CODE = 1 + @classmethod + @limit_primitive_type(list) + def from_primitive( + cls: Type[StakeCredential], values: Union[list, tuple] + ) -> StakeCredential: + if values[0] == 0: + return cls(VerificationKeyHash(values[1])) + else: + return cls(ScriptHash(values[1])) + @dataclass(repr=False) class StakeRegistration(ArrayCBORSerializable): @@ -32,6 +51,16 @@ class StakeRegistration(ArrayCBORSerializable): stake_credential: StakeCredential + def __post_init__(self): + self._CODE = 0 + + @classmethod + @limit_primitive_type(list) + def from_primitive( + cls: Type[StakeRegistration], values: Union[list, tuple] + ) -> StakeRegistration: + return cls(stake_credential=StakeCredential.from_primitive(values[1])) + @dataclass(repr=False) class StakeDeregistration(ArrayCBORSerializable): @@ -39,6 +68,16 @@ class StakeDeregistration(ArrayCBORSerializable): stake_credential: StakeCredential + def __post_init__(self): + self._CODE = 1 + + @classmethod + @limit_primitive_type(list) + def from_primitive( + cls: Type[StakeDeregistration], values: Union[list, tuple] + ) -> StakeDeregistration: + return cls(StakeCredential.from_primitive(values[1])) + @dataclass(repr=False) class StakeDelegation(ArrayCBORSerializable): @@ -48,5 +87,86 @@ class StakeDelegation(ArrayCBORSerializable): pool_keyhash: PoolKeyHash + def __post_init__(self): + self._CODE = 2 + + @classmethod + @limit_primitive_type(list) + def from_primitive( + cls: Type[StakeDelegation], values: Union[list, tuple] + ) -> StakeDelegation: + return cls( + stake_credential=StakeCredential.from_primitive(values[1]), + pool_keyhash=PoolKeyHash.from_primitive(values[2]), + ) + + +@dataclass(repr=False) +class PoolRegistration(ArrayCBORSerializable): + _CODE: int = field(init=False, default=3) + + pool_params: PoolParams + + def __post_init__(self): + self._CODE = 3 + + @classmethod + @limit_primitive_type(list) + def from_primitive( + cls: Type[PoolRegistration], values: Union[list, tuple] + ) -> PoolRegistration: + if isinstance(values[1], list): + return cls( + pool_params=PoolParams.from_primitive(values[1]), + ) + else: + return cls( + pool_params=PoolParams.from_primitive(values[1:]), + ) + + +@dataclass(repr=False) +class PoolRetirement(ArrayCBORSerializable): + _CODE: int = field(init=False, default=4) + + pool_keyhash: PoolKeyHash + epoch: int + + def __post_init__(self): + self._CODE = 4 + + @classmethod + @limit_primitive_type(list) + def from_primitive( + cls: Type[PoolRetirement], values: Union[list, tuple] + ) -> PoolRetirement: + return cls(pool_keyhash=PoolKeyHash.from_primitive(values[1]), epoch=values[2]) + + +Certificate = Union[ + StakeRegistration, + StakeDeregistration, + StakeDelegation, + PoolRegistration, + PoolRetirement, +] + -Certificate = Union[StakeRegistration, StakeDeregistration, StakeDelegation] +@dataclass(repr=False) +class CertificateCBORSerializer(ArrayCBORSerializable): + + @classmethod + @limit_primitive_type(list) + def from_primitive( + cls: Type[Certificate], values: Union[list, tuple] + ) -> Certificate: + if values[0] == 0: + return StakeRegistration.from_primitive(values) + elif values[0] == 1: + return StakeDeregistration.from_primitive(values) + elif values[0] == 2: + return StakeDelegation.from_primitive(values) + elif values[0] == 3: + return PoolRegistration.from_primitive(values) + elif values[0] == 4: + return PoolRetirement.from_primitive(values) diff --git a/pycardano/hash.py b/pycardano/hash.py index f8034fd5..88915521 100644 --- a/pycardano/hash.py +++ b/pycardano/hash.py @@ -12,6 +12,8 @@ "AUXILIARY_DATA_HASH_SIZE", "POOL_KEY_HASH_SIZE", "SCRIPT_DATA_HASH_SIZE", + "VRF_KEY_HASH_SIZE", + "POOL_METADATA_HASH_SIZE", "ConstrainedBytes", "VerificationKeyHash", "ScriptHash", @@ -20,6 +22,8 @@ "DatumHash", "AuxiliaryDataHash", "PoolKeyHash", + "PoolMetadataHash", + "VrfKeyHash", ] VERIFICATION_KEY_HASH_SIZE = 28 @@ -29,6 +33,9 @@ DATUM_HASH_SIZE = 32 AUXILIARY_DATA_HASH_SIZE = 32 POOL_KEY_HASH_SIZE = 28 +POOL_METADATA_HASH_SIZE = 32 +VRF_KEY_HASH_SIZE = 32 +REWARD_ACCOUNT_HASH_SIZE = 29 T = TypeVar("T", bound="ConstrainedBytes") @@ -128,3 +135,21 @@ class PoolKeyHash(ConstrainedBytes): """Hash of a stake pool""" MAX_SIZE = MIN_SIZE = POOL_KEY_HASH_SIZE + + +class PoolMetadataHash(ConstrainedBytes): + """Hash of a stake pool metadata""" + + MAX_SIZE = MIN_SIZE = POOL_METADATA_HASH_SIZE + + +class VrfKeyHash(ConstrainedBytes): + """Hash of a Cardano VRF key.""" + + MAX_SIZE = MIN_SIZE = VRF_KEY_HASH_SIZE + + +class RewardAccountHash(ConstrainedBytes): + """Hash of a Cardano VRF key.""" + + MAX_SIZE = MIN_SIZE = REWARD_ACCOUNT_HASH_SIZE diff --git a/pycardano/stake_pool.py b/pycardano/stake_pool.py new file mode 100644 index 00000000..77515c87 --- /dev/null +++ b/pycardano/stake_pool.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +import fractions +import re +from dataclasses import dataclass, field +from typing import Optional, Union, List, Type + +from pycardano.hash import ( + PoolKeyHash, + VerificationKeyHash, + VrfKeyHash, + PoolMetadataHash, + RewardAccountHash, +) +from pycardano.serialization import ( + CBORSerializable, + ArrayCBORSerializable, + limit_primitive_type, + list_hook, +) + + +def is_bech32_cardano_pool_id(s: str) -> bool: + """Check if a string is a valid Cardano stake pool ID in bech32 format.""" + # Regex for Cardano bech32 stake pool ID format with the correct HRP "pool1" + # pattern = r'^pool1[02-9ac-hj-np-z]{57}$' + pattern = r"^pool1[02-9ac-hj-np-z]+$" + return re.match(pattern, s) is not None + + +@dataclass(frozen=True) +class PoolId(CBORSerializable): + value: str + + def __post_init__(self): + if not is_bech32_cardano_pool_id(self.value): + raise ValueError( + "Invalid PoolId format. The PoolId should be a valid Cardano stake pool ID in bech32 format." + ) + + if not self.value.startswith("pool1") and len(self.value) != 56: + raise ValueError("The pool id bech32 is not valid!") + + def __str__(self): + return self.value + + def __repr__(self): + return self.value + + def to_primitive(self) -> str: + return self.value + + @classmethod + @limit_primitive_type(str) + def from_primitive(cls: Type[PoolId], value: str) -> PoolId: + return cls(value) + + +@dataclass(repr=False) +class Fraction(CBORSerializable): + numerator: int + denominator: int + + def __str__(self): + return f"{self.numerator}/{self.denominator}" + + def __repr__(self): + return f"Fraction({self.numerator}, {self.denominator})" + + def to_primitive(self) -> str: + return f"{self.numerator}/{self.denominator}" + + @classmethod + @limit_primitive_type(fractions.Fraction, str) + def from_primitive( + cls: Type[Fraction], fraction: Union[fractions.Fraction, str] + ) -> Fraction: + if isinstance(fraction, fractions.Fraction): + return cls(int(fraction.numerator), int(fraction.denominator)) + elif isinstance(fraction, str): + numerator, denominator = fraction.split("/") + return cls(int(numerator), int(denominator)) + + +@dataclass(repr=False) +class SingleHostAddr(ArrayCBORSerializable): + _CODE: int = field(init=False, default=0) + + port: Optional[int] + ipv4: Optional[bytes] + ipv6: Optional[bytes] + + def __post_init__(self): + self._CODE = 0 + + @classmethod + @limit_primitive_type(list) + def from_primitive( + cls: Type[SingleHostAddr], values: Union[list, tuple] + ) -> SingleHostAddr: + return cls( + port=values[1], + ipv4=values[2], + ipv6=values[3], + ) + + +@dataclass(repr=False) +class SingleHostName(ArrayCBORSerializable): + _CODE: int = field(init=False, default=1) + + port: Optional[int] + dns_name: Optional[str] + + def __post_init__(self): + self._CODE = 1 + + @classmethod + @limit_primitive_type(list) + def from_primitive( + cls: Type[SingleHostName], values: Union[list, tuple] + ) -> SingleHostName: + return cls( + port=values[1], + dns_name=values[2], + ) + + +@dataclass(repr=False) +class MultiHostName(ArrayCBORSerializable): + _CODE: int = field(init=False, default=2) + + dns_name: Optional[str] + + def __post_init__(self): + self._CODE = 2 + + @classmethod + @limit_primitive_type(list) + def from_primitive( + cls: Type[MultiHostName], values: Union[list, tuple] + ) -> MultiHostName: + return cls( + dns_name=values[1], + ) + + +Relay = Union[SingleHostAddr, SingleHostName, MultiHostName] + + +@dataclass(repr=False) +class PoolMetadata(ArrayCBORSerializable): + url: str + pool_metadata_hash: PoolMetadataHash + + +@dataclass(repr=False) +class RelayCBORSerializer(ArrayCBORSerializable): + # relay: Relay + + @classmethod + @limit_primitive_type(list) + def from_primitive(cls: Type[Relay], values: Union[list, tuple]) -> Relay: + if values[0] == 0: + return SingleHostAddr.from_primitive(values) + elif values[0] == 1: + return SingleHostName.from_primitive(values) + elif values[0] == 2: + return MultiHostName.from_primitive(values) + + +@dataclass(repr=False) +class PoolParams(ArrayCBORSerializable): + operator: PoolKeyHash + vrf_keyhash: VrfKeyHash + pledge: int + cost: int + margin: Fraction + reward_account: RewardAccountHash + pool_owners: List[VerificationKeyHash] + relays: Optional[List[Relay]] = field( + default=None, + metadata={"optional": True, "object_hook": list_hook(RelayCBORSerializer)}, + ) + pool_metadata: Optional[PoolMetadata] = None + id: Optional[PoolId] = field(default=None, metadata={"optional": True}) + + # @classmethod + # @limit_primitive_type(list) + # def from_primitive(cls: Type[PoolParams], values: Union[list, tuple]) -> PoolParams: + # return cls( + # operator=PoolKeyHash.from_primitive(values[1]), + # vrf_keyhash=VrfKeyHash.from_primitive(values[2]), + # pledge=values[3], + # cost=values[4], + # margin=Fraction.from_primitive(fractions.Fraction(values[5][0], values[5][1])), + # reward_account=RewardAccountHash.from_primitive(values[6]), + # pool_owners=[VerificationKeyHash.from_primitive(value) for value in values[7]], + # relays=[RelayCBORSerializer.from_primitive(value) for value in values[8]], + # pool_metadata=PoolMetadata.from_primitive(values[9]), + # id=PoolId.from_primitive(values[10]), + # ) diff --git a/pycardano/transaction.py b/pycardano/transaction.py index fa324059..88801c48 100644 --- a/pycardano/transaction.py +++ b/pycardano/transaction.py @@ -13,7 +13,7 @@ from nacl.hash import blake2b from pycardano.address import Address -from pycardano.certificate import Certificate +from pycardano.certificate import Certificate, CertificateCBORSerializer from pycardano.exception import InvalidDataException, InvalidOperationException from pycardano.hash import ( TRANSACTION_HASH_SIZE, @@ -504,7 +504,12 @@ class TransactionBody(MapCBORSerializable): ttl: Optional[int] = field(default=None, metadata={"key": 3, "optional": True}) certificates: Optional[List[Certificate]] = field( - default=None, metadata={"key": 4, "optional": True} + default=None, + metadata={ + "key": 4, + "optional": True, + "object_hook": list_hook(CertificateCBORSerializer), + }, ) withdraws: Optional[Withdrawals] = field( diff --git a/pycardano/witness.py b/pycardano/witness.py index 227cdefc..ea10c6ab 100644 --- a/pycardano/witness.py +++ b/pycardano/witness.py @@ -1,7 +1,7 @@ """Transaction witness.""" - +from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, List, Optional, Union +from typing import Any, List, Optional, Union, Type from pycardano.key import ExtendedVerificationKey, VerificationKey from pycardano.nativescript import NativeScript @@ -10,6 +10,7 @@ ArrayCBORSerializable, MapCBORSerializable, list_hook, + limit_primitive_type, ) __all__ = ["VerificationKeyWitness", "TransactionWitnessSet"] @@ -26,6 +27,16 @@ def __post_init__(self): if isinstance(self.vkey, ExtendedVerificationKey): self.vkey = self.vkey.to_non_extended() + @classmethod + @limit_primitive_type(list) + def from_primitive( + cls: Type[VerificationKeyWitness], values: Union[list, tuple] + ) -> VerificationKeyWitness: + return cls( + vkey=VerificationKey.from_primitive(values[0]), + signature=values[1], + ) + @dataclass(repr=False) class TransactionWitnessSet(MapCBORSerializable): @@ -65,3 +76,59 @@ class TransactionWitnessSet(MapCBORSerializable): default=None, metadata={"optional": True, "key": 5, "object_hook": list_hook(Redeemer)}, ) + + @classmethod + @limit_primitive_type(dict, list) + def from_primitive( + cls: Type[TransactionWitnessSet], values: Union[dict, list, tuple] + ) -> TransactionWitnessSet: + if isinstance(values, dict): + return cls( + vkey_witnesses=[ + VerificationKeyWitness.from_primitive(witness) + for witness in values.get(0) + ] + if values.get(0) + else None, + native_scripts=[ + NativeScript.from_primitive(script) for script in values.get(1) + ] + if values.get(1) + else None, + bootstrap_witness=values.get(2), + plutus_v1_script=[PlutusV1Script(script) for script in values.get(3)] + if values.get(3) + else None, + plutus_data=values.get(4), + redeemer=[ + Redeemer.from_primitive(redeemer) for redeemer in values.get(5) + ] + if values.get(5) + else None, + ) + elif isinstance(values, list): + # TODO: May need to handle this differently + values = dict(values) + return cls( + vkey_witnesses=[ + VerificationKeyWitness.from_primitive(witness) + for witness in values.get(0) + ] + if values.get(0) + else None, + native_scripts=[ + NativeScript.from_primitive(script) for script in values.get(1) + ] + if values.get(1) + else None, + bootstrap_witness=values.get(2), + plutus_v1_script=[PlutusV1Script(script) for script in values.get(3)] + if values.get(3) + else None, + plutus_data=values.get(4), + redeemer=[ + Redeemer.from_primitive(redeemer) for redeemer in values.get(5) + ] + if values.get(5) + else None, + ) From 5a1b2a43df5eed56fa5318896057e05280709a7b Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Sat, 11 Nov 2023 18:33:39 -0500 Subject: [PATCH 14/28] test: add some tests for pool certificates --- test/pycardano/test_certificate.py | 113 +++++++++++++++++++++++++++-- 1 file changed, 106 insertions(+), 7 deletions(-) diff --git a/test/pycardano/test_certificate.py b/test/pycardano/test_certificate.py index 2c30717d..dc1558c6 100644 --- a/test/pycardano/test_certificate.py +++ b/test/pycardano/test_certificate.py @@ -1,46 +1,91 @@ from pycardano.address import Address from pycardano.certificate import ( - PoolKeyHash, StakeCredential, StakeDelegation, StakeDeregistration, StakeRegistration, + PoolRegistration, + PoolRetirement, +) +from pycardano.hash import ( + POOL_KEY_HASH_SIZE, + VRF_KEY_HASH_SIZE, + VrfKeyHash, + PoolKeyHash, + VerificationKeyHash, + VERIFICATION_KEY_HASH_SIZE, + PoolMetadataHash, + POOL_METADATA_HASH_SIZE, + ScriptHash, + SCRIPT_HASH_SIZE, + RewardAccountHash, + REWARD_ACCOUNT_HASH_SIZE, ) -from pycardano.hash import POOL_KEY_HASH_SIZE +from pycardano.stake_pool import PoolParams, PoolMetadata, MultiHostName, Fraction TEST_ADDR = Address.from_primitive( "stake_test1upyz3gk6mw5he20apnwfn96cn9rscgvmmsxc9r86dh0k66gswf59n" ) -def test_stake_credential(): +def test_stake_credential_verification_key_hash(): stake_credential = StakeCredential(TEST_ADDR.staking_part) + stake_credential_cbor_hex = stake_credential.to_cbor_hex() + assert ( - stake_credential.to_cbor_hex() + stake_credential_cbor_hex == "8200581c4828a2dadba97ca9fd0cdc99975899470c219bdc0d828cfa6ddf6d69" ) + assert StakeCredential.from_cbor(stake_credential_cbor_hex) == stake_credential + + +def test_stake_credential_script_hash(): + stake_credential = StakeCredential(ScriptHash(b"1" * SCRIPT_HASH_SIZE)) + + stake_credential_cbor_hex = stake_credential.to_cbor_hex() + + assert ( + stake_credential_cbor_hex + == "8201581c31313131313131313131313131313131313131313131313131313131" + ) + + assert StakeCredential.from_cbor(stake_credential_cbor_hex) == stake_credential + def test_stake_registration(): stake_credential = StakeCredential(TEST_ADDR.staking_part) stake_registration = StakeRegistration(stake_credential) + stake_registration_cbor_hex = stake_registration.to_cbor_hex() + assert ( - stake_registration.to_cbor_hex() + stake_registration_cbor_hex == "82008200581c4828a2dadba97ca9fd0cdc99975899470c219bdc0d828cfa6ddf6d69" ) + assert ( + StakeRegistration.from_cbor(stake_registration_cbor_hex) == stake_registration + ) + def test_stake_deregistration(): stake_credential = StakeCredential(TEST_ADDR.staking_part) stake_deregistration = StakeDeregistration(stake_credential) + stake_deregistration_cbor_hex = stake_deregistration.to_cbor_hex() + assert ( - stake_deregistration.to_cbor_hex() + stake_deregistration_cbor_hex == "82018200581c4828a2dadba97ca9fd0cdc99975899470c219bdc0d828cfa6ddf6d69" ) + assert ( + StakeDeregistration.from_cbor(stake_deregistration_cbor_hex) + == stake_deregistration + ) + def test_stake_delegation(): stake_credential = StakeCredential(TEST_ADDR.staking_part) @@ -48,8 +93,62 @@ def test_stake_delegation(): stake_credential, PoolKeyHash(b"1" * POOL_KEY_HASH_SIZE) ) + stake_delegation_cbor_hex = stake_delegation.to_cbor_hex() + assert ( - stake_delegation.to_cbor_hex() + stake_delegation_cbor_hex == "83028200581c4828a2dadba97ca9fd0cdc99975899470c219bdc0d828cfa6ddf" "6d69581c31313131313131313131313131313131313131313131313131313131" ) + + assert StakeDelegation.from_cbor(stake_delegation_cbor_hex) == stake_delegation + + +def test_pool_registration(): + pool_keyhash = PoolKeyHash(b"1" * POOL_KEY_HASH_SIZE) + vrf_keyhash = VrfKeyHash(b"1" * VRF_KEY_HASH_SIZE) + pool_owner = VerificationKeyHash(b"1" * VERIFICATION_KEY_HASH_SIZE) + reward_account = RewardAccountHash(b"1" * REWARD_ACCOUNT_HASH_SIZE) + pool_params = PoolParams( + operator=pool_keyhash, + vrf_keyhash=vrf_keyhash, + pledge=100_000_000, + cost=340_000_000, + margin=Fraction(1, 50), + reward_account=reward_account, + pool_owners=[pool_owner], + relays=[MultiHostName(dns_name="relay1.example.com")], + pool_metadata=PoolMetadata( + url="https://meta1.example.com", + pool_metadata_hash=PoolMetadataHash(b"1" * POOL_METADATA_HASH_SIZE), + ), + ) + pool_registration = PoolRegistration(pool_params) + + pool_registration_cbor_hex = pool_registration.to_cbor_hex() + + assert ( + pool_registration_cbor_hex + == "820389581c3131313131313131313131313131313131313131313131313131313158203131313131313131313131313131313" + "1313131313131313131313131313131311a05f5e1001a1443fd0064312f3530581d3131313131313131313131313131313131" + "31313131313131313131313181581c313131313131313131313131313131313131313131313131313131318182027272656c6" + "179312e6578616d706c652e636f6d82781968747470733a2f2f6d657461312e6578616d706c652e636f6d5820313131313131" + "3131313131313131313131313131313131313131313131313131" + ) + + assert PoolRegistration.from_cbor(pool_registration_cbor_hex) == pool_registration + + +def test_pool_retirement(): + pool_keyhash = PoolKeyHash(b"1" * POOL_KEY_HASH_SIZE) + epoch = 700 + pool_retirement = PoolRetirement(pool_keyhash, epoch) + + pool_retirement_cbor_hex = pool_retirement.to_cbor_hex() + + assert ( + pool_retirement_cbor_hex + == "8304581c313131313131313131313131313131313131313131313131313131311902bc" + ) + + assert PoolRetirement.from_cbor(pool_retirement_cbor_hex) == pool_retirement From c064bb01b5490f46d74728d637507f3350c3487f Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Thu, 16 Nov 2023 16:30:02 -0500 Subject: [PATCH 15/28] refactor: use built in fractions module --- pycardano/pool_params.py | 263 +++++++++++++++++++++++++++++++++++++++ pycardano/stake_pool.py | 202 ------------------------------ 2 files changed, 263 insertions(+), 202 deletions(-) create mode 100644 pycardano/pool_params.py delete mode 100644 pycardano/stake_pool.py diff --git a/pycardano/pool_params.py b/pycardano/pool_params.py new file mode 100644 index 00000000..a2c65a84 --- /dev/null +++ b/pycardano/pool_params.py @@ -0,0 +1,263 @@ +""" +Pool parameters for stake pool registration certificate. +""" +from __future__ import annotations + +import socket +from abc import ABC +from dataclasses import dataclass, field +from fractions import Fraction +from typing import Optional, Union, List, Type + +from pycardano.crypto.bech32 import bech32_decode +from pycardano.hash import ( + PoolKeyHash, + VerificationKeyHash, + VrfKeyHash, + PoolMetadataHash, + RewardAccountHash, +) +from pycardano.serialization import ( + CBORSerializable, + ArrayCBORSerializable, + limit_primitive_type, + list_hook, +) + + +def is_bech32_cardano_pool_id(pool_id: str) -> bool: + """Check if a string is a valid Cardano stake pool ID in bech32 format.""" + if pool_id is None or not pool_id.startswith("pool"): + return False + return bech32_decode(pool_id) != (None, None, None) + + +@dataclass(frozen=True) +class PoolId(CBORSerializable): + value: str + + def __post_init__(self): + if not is_bech32_cardano_pool_id(self.value): + raise ValueError( + "Invalid PoolId format. The PoolId should be a valid Cardano stake pool ID in bech32 format." + ) + + def __str__(self): + return self.value + + def __repr__(self): + return self.value + + def to_primitive(self) -> str: + return self.value + + @classmethod + @limit_primitive_type(str) + def from_primitive(cls: Type[PoolId], value: str) -> PoolId: + return cls(value) + + +@dataclass(repr=False) +class SingleHostAddr(ArrayCBORSerializable): + _CODE: int = field(init=False, default=0) + + port: Optional[int] + ipv4: Optional[str | bytes] + ipv6: Optional[str | bytes] + + def __init__( + self, + port: Optional[int] = None, + ipv4: Optional[str | bytes] = None, + ipv6: Optional[str | bytes] = None, + ): + super().__init__() + + self._CODE = 0 + self.port = port + + self.ipv4 = self.bytes_to_ipv4(ipv4) + self.ipv6 = self.bytes_to_ipv6(ipv6) + + @staticmethod + def ipv4_to_bytes(ip_address: Optional[str | bytes] = None) -> bytes | None: + """ + Convert IPv4 address to bytes format. + Args: + ip_address: The IPv4 address in human-readable format. + + Returns: + bytes: IPv4 address in bytes format. + """ + if isinstance(ip_address, str): + return socket.inet_aton(ip_address) + elif isinstance(ip_address, bytes): + return ip_address + else: + return None + + @staticmethod + def ipv6_to_bytes(ip_address: Optional[str | bytes] = None) -> bytes | None: + """ + Convert IPv6 address to bytes format. + Args: + ip_address: The IPv6 address in human-readable format. + + Returns: + The IPv6 address in bytes format. + """ + if isinstance(ip_address, str): + return socket.inet_pton(socket.AF_INET6, ip_address) + elif isinstance(ip_address, bytes): + return ip_address + else: + return None + + @staticmethod + def bytes_to_ipv4(bytes_ip_address: Optional[str | bytes] = None) -> str | None: + """ + Convert IPv4 address in bytes to human-readable format. + Args: + bytes_ip_address: The IPv4 address in bytes format. + Returns: + The IPv4 address in human-readable format. + """ + if isinstance(bytes_ip_address, str): + return bytes_ip_address + elif isinstance(bytes_ip_address, bytes): + return socket.inet_ntoa(bytes_ip_address) + else: + return None + + @staticmethod + def bytes_to_ipv6(bytes_ip_address: Optional[str | bytes] = None) -> str | None: + """ + Convert IPv6 address in bytes to human-readable format. + Args: + bytes_ip_address: The IPv6 address in bytes format. + Returns: + The IPv6 address in human-readable format. + """ + if isinstance(bytes_ip_address, str): + return bytes_ip_address + elif isinstance(bytes_ip_address, bytes): + return socket.inet_ntop(socket.AF_INET6, bytes_ip_address) + else: + return None + + def to_primitive(self) -> list: + return [ + self._CODE, + self.port, + self.ipv4_to_bytes(self.ipv4), + self.ipv6_to_bytes(self.ipv6), + ] + + @classmethod + @limit_primitive_type(list) + def from_primitive( + cls: Type[SingleHostAddr], values: Union[list, tuple] + ) -> SingleHostAddr: + return cls( + port=values[1], + ipv4=values[2], + ipv6=values[3], + ) + + +@dataclass(repr=False) +class SingleHostName(ArrayCBORSerializable): + _CODE: int = field(init=False, default=1) + + port: Optional[int] + dns_name: Optional[str] + + def __post_init__(self): + self._CODE = 1 + + @classmethod + @limit_primitive_type(list) + def from_primitive( + cls: Type[SingleHostName], values: Union[list, tuple] + ) -> SingleHostName: + return cls( + port=values[1], + dns_name=values[2], + ) + + +@dataclass(repr=False) +class MultiHostName(ArrayCBORSerializable): + _CODE: int = field(init=False, default=2) + + dns_name: Optional[str] + + def __post_init__(self): + self._CODE = 2 + + @classmethod + @limit_primitive_type(list) + def from_primitive( + cls: Type[MultiHostName], values: Union[list, tuple] + ) -> MultiHostName: + return cls( + dns_name=values[1], + ) + + +Relay = Union[SingleHostAddr, SingleHostName, MultiHostName] + + +@dataclass(repr=False) +class PoolMetadata(ArrayCBORSerializable): + url: str + pool_metadata_hash: PoolMetadataHash + + +@dataclass(repr=False) +class FractionSerializer(CBORSerializable, Fraction, ABC): + @classmethod + @limit_primitive_type(Fraction, str, list) + def from_primitive( + cls: Type[Fraction], fraction: Union[Fraction, str, list] + ) -> Fraction: + if isinstance(fraction, Fraction): + return Fraction(int(fraction.numerator), int(fraction.denominator)) + elif isinstance(fraction, str): + numerator, denominator = fraction.split("/") + return Fraction(int(numerator), int(denominator)) + elif isinstance(fraction, list): + numerator, denominator = fraction[1] + return Fraction(int(numerator), int(denominator)) + + +@dataclass(repr=False) +class RelayCBORSerializer(ArrayCBORSerializable): + @classmethod + @limit_primitive_type(list) + def from_primitive(cls: Type[Relay], values: Union[list, tuple]) -> Relay | None: + if values[0] == 0: + return SingleHostAddr.from_primitive(values) + elif values[0] == 1: + return SingleHostName.from_primitive(values) + elif values[0] == 2: + return MultiHostName.from_primitive(values) + return None + + +@dataclass(repr=False) +class PoolParams(ArrayCBORSerializable): + operator: PoolKeyHash + vrf_keyhash: VrfKeyHash + pledge: int + cost: int + margin: Fraction = field( + metadata={"object_hook": FractionSerializer.from_primitive} + ) + reward_account: RewardAccountHash + pool_owners: List[VerificationKeyHash] + relays: Optional[List[Relay]] = field( + metadata={"object_hook": list_hook(RelayCBORSerializer)}, + ) + pool_metadata: Optional[PoolMetadata] = None + id: Optional[PoolId] = field(default=None, metadata={"optional": True}) diff --git a/pycardano/stake_pool.py b/pycardano/stake_pool.py deleted file mode 100644 index 77515c87..00000000 --- a/pycardano/stake_pool.py +++ /dev/null @@ -1,202 +0,0 @@ -from __future__ import annotations - -import fractions -import re -from dataclasses import dataclass, field -from typing import Optional, Union, List, Type - -from pycardano.hash import ( - PoolKeyHash, - VerificationKeyHash, - VrfKeyHash, - PoolMetadataHash, - RewardAccountHash, -) -from pycardano.serialization import ( - CBORSerializable, - ArrayCBORSerializable, - limit_primitive_type, - list_hook, -) - - -def is_bech32_cardano_pool_id(s: str) -> bool: - """Check if a string is a valid Cardano stake pool ID in bech32 format.""" - # Regex for Cardano bech32 stake pool ID format with the correct HRP "pool1" - # pattern = r'^pool1[02-9ac-hj-np-z]{57}$' - pattern = r"^pool1[02-9ac-hj-np-z]+$" - return re.match(pattern, s) is not None - - -@dataclass(frozen=True) -class PoolId(CBORSerializable): - value: str - - def __post_init__(self): - if not is_bech32_cardano_pool_id(self.value): - raise ValueError( - "Invalid PoolId format. The PoolId should be a valid Cardano stake pool ID in bech32 format." - ) - - if not self.value.startswith("pool1") and len(self.value) != 56: - raise ValueError("The pool id bech32 is not valid!") - - def __str__(self): - return self.value - - def __repr__(self): - return self.value - - def to_primitive(self) -> str: - return self.value - - @classmethod - @limit_primitive_type(str) - def from_primitive(cls: Type[PoolId], value: str) -> PoolId: - return cls(value) - - -@dataclass(repr=False) -class Fraction(CBORSerializable): - numerator: int - denominator: int - - def __str__(self): - return f"{self.numerator}/{self.denominator}" - - def __repr__(self): - return f"Fraction({self.numerator}, {self.denominator})" - - def to_primitive(self) -> str: - return f"{self.numerator}/{self.denominator}" - - @classmethod - @limit_primitive_type(fractions.Fraction, str) - def from_primitive( - cls: Type[Fraction], fraction: Union[fractions.Fraction, str] - ) -> Fraction: - if isinstance(fraction, fractions.Fraction): - return cls(int(fraction.numerator), int(fraction.denominator)) - elif isinstance(fraction, str): - numerator, denominator = fraction.split("/") - return cls(int(numerator), int(denominator)) - - -@dataclass(repr=False) -class SingleHostAddr(ArrayCBORSerializable): - _CODE: int = field(init=False, default=0) - - port: Optional[int] - ipv4: Optional[bytes] - ipv6: Optional[bytes] - - def __post_init__(self): - self._CODE = 0 - - @classmethod - @limit_primitive_type(list) - def from_primitive( - cls: Type[SingleHostAddr], values: Union[list, tuple] - ) -> SingleHostAddr: - return cls( - port=values[1], - ipv4=values[2], - ipv6=values[3], - ) - - -@dataclass(repr=False) -class SingleHostName(ArrayCBORSerializable): - _CODE: int = field(init=False, default=1) - - port: Optional[int] - dns_name: Optional[str] - - def __post_init__(self): - self._CODE = 1 - - @classmethod - @limit_primitive_type(list) - def from_primitive( - cls: Type[SingleHostName], values: Union[list, tuple] - ) -> SingleHostName: - return cls( - port=values[1], - dns_name=values[2], - ) - - -@dataclass(repr=False) -class MultiHostName(ArrayCBORSerializable): - _CODE: int = field(init=False, default=2) - - dns_name: Optional[str] - - def __post_init__(self): - self._CODE = 2 - - @classmethod - @limit_primitive_type(list) - def from_primitive( - cls: Type[MultiHostName], values: Union[list, tuple] - ) -> MultiHostName: - return cls( - dns_name=values[1], - ) - - -Relay = Union[SingleHostAddr, SingleHostName, MultiHostName] - - -@dataclass(repr=False) -class PoolMetadata(ArrayCBORSerializable): - url: str - pool_metadata_hash: PoolMetadataHash - - -@dataclass(repr=False) -class RelayCBORSerializer(ArrayCBORSerializable): - # relay: Relay - - @classmethod - @limit_primitive_type(list) - def from_primitive(cls: Type[Relay], values: Union[list, tuple]) -> Relay: - if values[0] == 0: - return SingleHostAddr.from_primitive(values) - elif values[0] == 1: - return SingleHostName.from_primitive(values) - elif values[0] == 2: - return MultiHostName.from_primitive(values) - - -@dataclass(repr=False) -class PoolParams(ArrayCBORSerializable): - operator: PoolKeyHash - vrf_keyhash: VrfKeyHash - pledge: int - cost: int - margin: Fraction - reward_account: RewardAccountHash - pool_owners: List[VerificationKeyHash] - relays: Optional[List[Relay]] = field( - default=None, - metadata={"optional": True, "object_hook": list_hook(RelayCBORSerializer)}, - ) - pool_metadata: Optional[PoolMetadata] = None - id: Optional[PoolId] = field(default=None, metadata={"optional": True}) - - # @classmethod - # @limit_primitive_type(list) - # def from_primitive(cls: Type[PoolParams], values: Union[list, tuple]) -> PoolParams: - # return cls( - # operator=PoolKeyHash.from_primitive(values[1]), - # vrf_keyhash=VrfKeyHash.from_primitive(values[2]), - # pledge=values[3], - # cost=values[4], - # margin=Fraction.from_primitive(fractions.Fraction(values[5][0], values[5][1])), - # reward_account=RewardAccountHash.from_primitive(values[6]), - # pool_owners=[VerificationKeyHash.from_primitive(value) for value in values[7]], - # relays=[RelayCBORSerializer.from_primitive(value) for value in values[8]], - # pool_metadata=PoolMetadata.from_primitive(values[9]), - # id=PoolId.from_primitive(values[10]), - # ) From dad69929de81e7bf486f7ab1264d356a7e5fa2ec Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Thu, 16 Nov 2023 16:30:37 -0500 Subject: [PATCH 16/28] fix: output PoolRegistration as flat list --- pycardano/certificate.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/pycardano/certificate.py b/pycardano/certificate.py index 8db96367..2e52272a 100644 --- a/pycardano/certificate.py +++ b/pycardano/certificate.py @@ -14,10 +14,10 @@ "StakeDelegation", "PoolRegistration", "PoolRetirement", - "CertificateCBORSerializer" + "CertificateCBORSerializer", ] -from pycardano.stake_pool import PoolParams +from pycardano.pool_params import PoolParams unit_interval = Tuple[int, int] @@ -110,6 +110,15 @@ class PoolRegistration(ArrayCBORSerializable): def __post_init__(self): self._CODE = 3 + def to_primitive(self): + pool_params = self.pool_params.to_primitive() + if isinstance(pool_params, list): + return [ + self._CODE, + *pool_params + ] + return super().to_primitive() + @classmethod @limit_primitive_type(list) def from_primitive( @@ -154,12 +163,11 @@ def from_primitive( @dataclass(repr=False) class CertificateCBORSerializer(ArrayCBORSerializable): - @classmethod @limit_primitive_type(list) def from_primitive( cls: Type[Certificate], values: Union[list, tuple] - ) -> Certificate: + ) -> Certificate | None: if values[0] == 0: return StakeRegistration.from_primitive(values) elif values[0] == 1: @@ -170,3 +178,4 @@ def from_primitive( return PoolRegistration.from_primitive(values) elif values[0] == 4: return PoolRetirement.from_primitive(values) + return None From af4edbca2eeb0f2a8bbc2199fffd94ffbdcbbc1c Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Thu, 16 Nov 2023 16:31:21 -0500 Subject: [PATCH 17/28] fix: clean up some code --- pycardano/hash.py | 2 + pycardano/witness.py | 87 +++++++++++++++++++++----------------------- 2 files changed, 43 insertions(+), 46 deletions(-) diff --git a/pycardano/hash.py b/pycardano/hash.py index 88915521..54cdd798 100644 --- a/pycardano/hash.py +++ b/pycardano/hash.py @@ -14,6 +14,7 @@ "SCRIPT_DATA_HASH_SIZE", "VRF_KEY_HASH_SIZE", "POOL_METADATA_HASH_SIZE", + "REWARD_ACCOUNT_HASH_SIZE", "ConstrainedBytes", "VerificationKeyHash", "ScriptHash", @@ -24,6 +25,7 @@ "PoolKeyHash", "PoolMetadataHash", "VrfKeyHash", + "RewardAccountHash", ] VERIFICATION_KEY_HASH_SIZE = 28 diff --git a/pycardano/witness.py b/pycardano/witness.py index ea10c6ab..f7ca440c 100644 --- a/pycardano/witness.py +++ b/pycardano/witness.py @@ -81,54 +81,49 @@ class TransactionWitnessSet(MapCBORSerializable): @limit_primitive_type(dict, list) def from_primitive( cls: Type[TransactionWitnessSet], values: Union[dict, list, tuple] - ) -> TransactionWitnessSet: - if isinstance(values, dict): + ) -> TransactionWitnessSet | None: + + def _get_vkey_witnesses(data: Any): + return [ + VerificationKeyWitness.from_primitive(witness) + for witness in data + ] if data else None + + def _get_native_scripts(data: Any): + return [ + NativeScript.from_primitive(script) for script in data + ] if data else None + + def _get_plutus_v1_scripts(data: Any): + return [ + PlutusV1Script(script) for script in data + ] if data else None + + def _get_plutus_v2_scripts(data: Any): + return [ + PlutusV2Script(script) for script in data + ] if data else None + + def _get_redeemers(data: Any): + return [ + Redeemer.from_primitive(redeemer) for redeemer in data + ] if data else None + + def _get_cls(data: Any): return cls( - vkey_witnesses=[ - VerificationKeyWitness.from_primitive(witness) - for witness in values.get(0) - ] - if values.get(0) - else None, - native_scripts=[ - NativeScript.from_primitive(script) for script in values.get(1) - ] - if values.get(1) - else None, - bootstrap_witness=values.get(2), - plutus_v1_script=[PlutusV1Script(script) for script in values.get(3)] - if values.get(3) - else None, - plutus_data=values.get(4), - redeemer=[ - Redeemer.from_primitive(redeemer) for redeemer in values.get(5) - ] - if values.get(5) - else None, + vkey_witnesses=_get_vkey_witnesses(data.get(0)), + native_scripts=_get_native_scripts(data.get(1)), + bootstrap_witness=data.get(2), + plutus_v1_script=_get_plutus_v1_scripts(data.get(3)), + plutus_data=data.get(4), + redeemer=_get_redeemers(data.get(5)), + plutus_v2_script=_get_plutus_v2_scripts(data.get(6)), ) + + if isinstance(values, dict): + return _get_cls(values) elif isinstance(values, list): # TODO: May need to handle this differently values = dict(values) - return cls( - vkey_witnesses=[ - VerificationKeyWitness.from_primitive(witness) - for witness in values.get(0) - ] - if values.get(0) - else None, - native_scripts=[ - NativeScript.from_primitive(script) for script in values.get(1) - ] - if values.get(1) - else None, - bootstrap_witness=values.get(2), - plutus_v1_script=[PlutusV1Script(script) for script in values.get(3)] - if values.get(3) - else None, - plutus_data=values.get(4), - redeemer=[ - Redeemer.from_primitive(redeemer) for redeemer in values.get(5) - ] - if values.get(5) - else None, - ) + return _get_cls(values) + return None From f497d5b8cbd0a9552a55546ba74d16c7d294b849 Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Thu, 16 Nov 2023 16:31:48 -0500 Subject: [PATCH 18/28] test: add tests for pool params --- test/pycardano/test_certificate.py | 27 ++- test/pycardano/test_pool_params.py | 311 +++++++++++++++++++++++++++++ 2 files changed, 331 insertions(+), 7 deletions(-) create mode 100644 test/pycardano/test_pool_params.py diff --git a/test/pycardano/test_certificate.py b/test/pycardano/test_certificate.py index dc1558c6..37c689b8 100644 --- a/test/pycardano/test_certificate.py +++ b/test/pycardano/test_certificate.py @@ -1,3 +1,4 @@ +from fractions import Fraction from pycardano.address import Address from pycardano.certificate import ( StakeCredential, @@ -21,7 +22,14 @@ RewardAccountHash, REWARD_ACCOUNT_HASH_SIZE, ) -from pycardano.stake_pool import PoolParams, PoolMetadata, MultiHostName, Fraction +from pycardano.pool_params import ( + PoolParams, + PoolMetadata, + MultiHostName, + # Fraction, + SingleHostAddr, + SingleHostName, +) TEST_ADDR = Address.from_primitive( "stake_test1upyz3gk6mw5he20apnwfn96cn9rscgvmmsxc9r86dh0k66gswf59n" @@ -117,7 +125,11 @@ def test_pool_registration(): margin=Fraction(1, 50), reward_account=reward_account, pool_owners=[pool_owner], - relays=[MultiHostName(dns_name="relay1.example.com")], + relays=[ + SingleHostAddr(port=3001, ipv4="192.168.0.1", ipv6="::1"), + SingleHostName(port=3001, dns_name="relay1.example.com"), + MultiHostName(dns_name="relay1.example.com"), + ], pool_metadata=PoolMetadata( url="https://meta1.example.com", pool_metadata_hash=PoolMetadataHash(b"1" * POOL_METADATA_HASH_SIZE), @@ -129,11 +141,12 @@ def test_pool_registration(): assert ( pool_registration_cbor_hex - == "820389581c3131313131313131313131313131313131313131313131313131313158203131313131313131313131313131313" - "1313131313131313131313131313131311a05f5e1001a1443fd0064312f3530581d3131313131313131313131313131313131" - "31313131313131313131313181581c313131313131313131313131313131313131313131313131313131318182027272656c6" - "179312e6578616d706c652e636f6d82781968747470733a2f2f6d657461312e6578616d706c652e636f6d5820313131313131" - "3131313131313131313131313131313131313131313131313131" + == "8a03581c31313131313131313131313131313131313131313131313131313131582031313131313131313131313131313131313131" + "313131313131313131313131311a05f5e1001a1443fd00d81e82011832581d31313131313131313131313131313131313131313131" + "3131313131313181581c31313131313131313131313131313131313131313131313131313131838400190bb944c0a8000150000000" + "000000000000000000000000018301190bb97272656c6179312e6578616d706c652e636f6d82027272656c6179312e6578616d706c" + "652e636f6d82781968747470733a2f2f6d657461312e6578616d706c652e636f6d5820313131313131313131313131313131313131" + "3131313131313131313131313131" ) assert PoolRegistration.from_cbor(pool_registration_cbor_hex) == pool_registration diff --git a/test/pycardano/test_pool_params.py b/test/pycardano/test_pool_params.py new file mode 100644 index 00000000..946076ff --- /dev/null +++ b/test/pycardano/test_pool_params.py @@ -0,0 +1,311 @@ +from fractions import Fraction + +import pytest + +from pycardano import ( + PoolMetadataHash, + POOL_METADATA_HASH_SIZE, + VRF_KEY_HASH_SIZE, + POOL_KEY_HASH_SIZE, + VERIFICATION_KEY_HASH_SIZE, +) +from pycardano.hash import ( + REWARD_ACCOUNT_HASH_SIZE, + PoolKeyHash, + VrfKeyHash, + RewardAccountHash, + VerificationKeyHash, +) +from pycardano.pool_params import ( + # Fraction, + SingleHostAddr, + SingleHostName, + MultiHostName, + PoolMetadata, + RelayCBORSerializer, + PoolParams, + FractionSerializer, +) +from pycardano.pool_params import is_bech32_cardano_pool_id, PoolId + +TEST_POOL_ID = "pool1mt8sdg37f2h3rypyuc77k7vxrjshtvjw04zdjlae9vdzyt9uu34" + + +# Parametrized test for happy path cases +@pytest.mark.parametrize( + "pool_id, expected", + [ + (TEST_POOL_ID, True), + ("pool1234567890abcdef", False), + ("pool1abcdefghijklmnopqrstuvwxyz", False), + ("pool1", False), + ("pool11234567890abcdef", False), + ("pool1abcdefghijklmnopqrstuvwxyz1234", False), + ("pool1!@#$%^&*()-_+={}[]|\\:;\"'<>,.?/", False), + ( + "pool1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", + False, + ), # One character short + ( + "pool1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", + False, + ), # One character too long + ("pool1!@#$%^&*()", False), # Invalid characters + ("pool1", False), # Too short + ("", False), # Empty string + (None, False), # None input + ("pool019", False), # Invalid character '1' + ( + "stake1uxtr5m6kygt77399zxqrykkluqr0grr4yrjtl5xplza6k8q5fghrp", + False, + ), # Incorrect HRP + ], +) +def test_is_bech32_cardano_pool_id(pool_id: str, expected: bool): + assert is_bech32_cardano_pool_id(pool_id) == expected + + +def test_pool_id(): + # Act + pool_id = PoolId(TEST_POOL_ID) + + # Assert + assert str(pool_id) == TEST_POOL_ID + assert pool_id.to_primitive() == TEST_POOL_ID + assert str(PoolId.from_primitive(TEST_POOL_ID)) == TEST_POOL_ID + + +# Parametrized test cases for error cases +@pytest.mark.parametrize( + "test_id, pool_id_str, expected_exception", + [ + ("ERR-1", "", ValueError), # Empty string + ("ERR-2", "1234567890", ValueError), # Not a bech32 format + ("ERR-3", "pool123", ValueError), # Too short to be valid + # Add more error cases as needed + ], +) +def test_pool_id_error_cases(test_id, pool_id_str, expected_exception): + # Act & Assert + with pytest.raises(expected_exception): + PoolId(pool_id_str) + + +@pytest.mark.parametrize( + "port, ipv4, ipv6", + [ + ( + 3001, + b"\xC0\xA8\x00\x01", + b" \x01\r\xb8\x85\xa3\x00\x00\x14-\x00\x00\x08\x01\r\xb8", + ), # IPv4 and IPv6 + (None, b"\xC0\xA8\x00\x01", None), # Only IPv4 + ( + None, + None, + b" \x01\r\xb8\x85\xa3\x00\x00\x14-\x00\x00\x08\x01\r\xb8", + ), # Only IPv6 + ], +) +def test_single_host_addr(port, ipv4, ipv6): + # Act + single_host_addr = SingleHostAddr.from_primitive([0, port, ipv4, ipv6]) + + # Assert + assert single_host_addr.port == port + assert single_host_addr.ipv4 == SingleHostAddr.bytes_to_ipv4(ipv4) + assert single_host_addr.ipv6 == SingleHostAddr.bytes_to_ipv6(ipv6) + assert single_host_addr.to_primitive() == [0, port, ipv4, ipv6] + + +@pytest.mark.parametrize( + "port, dns_name", + [ + (80, "example.com"), + (443, "secure.example.com"), + (None, "noport.example.com"), + ], +) +def test_single_host_name(port, dns_name): + # Arrange + primitive_values = [1, port, dns_name] + + # Act + single_host_name = SingleHostName.from_primitive(primitive_values) + + # Assert + assert single_host_name.port == port + assert single_host_name.dns_name == dns_name + assert single_host_name._CODE == 1 + assert single_host_name.to_primitive() == [1, port, dns_name] + + +@pytest.mark.parametrize( + "dns_name", + [ + "example.com", + "secure.example.com", + "noport.example.com", + ], +) +def test_multi_host_name(dns_name): + # Arrange + primitive_values = [2, dns_name] + + # Act + multi_host_name = MultiHostName.from_primitive(primitive_values) + + # Assert + assert multi_host_name.dns_name == dns_name + assert multi_host_name._CODE == 2 + assert multi_host_name.to_primitive() == [2, dns_name] + + +@pytest.mark.parametrize( + "url, pool_metadata_hash", + [ + ( + "https://example.com/metadata.json", + b"1" * POOL_METADATA_HASH_SIZE, + ), + ( + "https://pooldata.info/api/metadata", + b"2" * POOL_METADATA_HASH_SIZE, + ), + ( + "http://metadata.pool/endpoint", + b"3" * POOL_METADATA_HASH_SIZE, + ), + ], +) +def test_pool_metadata(url, pool_metadata_hash): + # Arrange + primitive_values = [url, pool_metadata_hash] + + # Act + pool_metadata = PoolMetadata.from_primitive(primitive_values) + + # Assert + assert pool_metadata.url == url + assert pool_metadata.pool_metadata_hash == PoolMetadataHash(pool_metadata_hash) + assert isinstance(pool_metadata, PoolMetadata) + assert pool_metadata.to_primitive() == primitive_values + + +@pytest.mark.parametrize( + "input_value", + [ + [30, [1, 2]], + "1/2", + "3/4", + "0/1", + "1/1", + Fraction(123456, 1), + Fraction(5, 6), + Fraction(7, 8), + Fraction(5, 6), + ], +) +def test_fraction_serializer(input_value): + # Act + result = FractionSerializer.from_primitive(input_value) + + # Assert + assert isinstance(result, Fraction) + + +@pytest.mark.parametrize( + "test_id, input_value, expected_output", + [ + ("HP-1", [0, 3001, "10.20.30.40", "::1"], SingleHostAddr), + ("HP-2", [1, 3001, "example.com"], SingleHostName), + ("HP-3", [2, "example.com"], MultiHostName), + ], +) +def test_relay_cbor_serializer(test_id, input_value, expected_output): + # Act + result = RelayCBORSerializer.from_primitive(input_value) + + # Assert + assert isinstance( + result, expected_output + ), f"Test {test_id} failed: {result} != {expected_output}" + + +@pytest.mark.parametrize( + "operator, vrf_keyhash, pledge, cost, margin, reward_account, pool_owners, relays, pool_metadata", + [ + # Test case ID: HP-1 + ( + b"1" * POOL_KEY_HASH_SIZE, + b"1" * VRF_KEY_HASH_SIZE, + 10_000_000, + 340_000_000, + "1/10", + b"1" * REWARD_ACCOUNT_HASH_SIZE, + [b"1" * VERIFICATION_KEY_HASH_SIZE], + [ + [0, 3001, "10.20.30.40", None], + [1, 3001, "example.com"], + [2, "example.com"], + ], + [ + "https://example.com/metadata.json", + b"1" * POOL_METADATA_HASH_SIZE, + ], + ), + # Add more test cases with different realistic values + ], + ids=["test_pool_params-1"], +) # Add more IDs for each test case +def test_pool_params( + operator, + vrf_keyhash, + pledge, + cost, + margin, + reward_account, + pool_owners, + relays, + pool_metadata, +): + # Arrange + primitive_values = [ + operator, + vrf_keyhash, + pledge, + cost, + margin, + reward_account, + pool_owners, + relays, + pool_metadata, + ] + primitive_out = [ + operator, + vrf_keyhash, + pledge, + cost, + FractionSerializer.from_primitive(margin), + reward_account, + pool_owners, + [RelayCBORSerializer.from_primitive(x).to_primitive() for x in relays], + pool_metadata, + ] + + # Act + pool_params = PoolParams.from_primitive(primitive_values) + + # Assert + assert isinstance(pool_params, PoolParams) + assert pool_params.operator == PoolKeyHash(operator) + assert pool_params.vrf_keyhash == VrfKeyHash(vrf_keyhash) + assert pool_params.pledge == pledge + assert pool_params.cost == cost + assert pool_params.margin == Fraction(margin) + assert pool_params.reward_account == RewardAccountHash(reward_account) + assert pool_params.pool_owners == [VerificationKeyHash(x) for x in pool_owners] + assert pool_params.relays == [RelayCBORSerializer.from_primitive(x) for x in relays] + assert pool_params.pool_metadata == PoolMetadata.from_primitive(pool_metadata) + + assert pool_params.to_primitive() == primitive_out From ac4a29bb77aaa44fb97dc63165ab94207548a8ab Mon Sep 17 00:00:00 2001 From: Jerry Date: Mon, 1 Jan 2024 09:47:04 -0800 Subject: [PATCH 19/28] Add more integration tests for cardano cli context --- integration-test/docker-compose.yml | 1 + integration-test/test/test_cardano_cli.py | 3 +-- integration-test/test/test_plutus.py | 22 ++++++++++++++++++++++ pycardano/backend/cardano_cli.py | 22 +++++++++++++--------- test/pycardano/backend/test_cardano_cli.py | 6 +++--- 5 files changed, 40 insertions(+), 14 deletions(-) diff --git a/integration-test/docker-compose.yml b/integration-test/docker-compose.yml index e79fe347..ccdd13ee 100644 --- a/integration-test/docker-compose.yml +++ b/integration-test/docker-compose.yml @@ -22,6 +22,7 @@ services: volumes: - .:/code + - /tmp:/tmp - node-db:/data/db - node-ipc:/ipc ports: diff --git a/integration-test/test/test_cardano_cli.py b/integration-test/test/test_cardano_cli.py index ff0a07f1..b3e82fbe 100644 --- a/integration-test/test/test_cardano_cli.py +++ b/integration-test/test/test_cardano_cli.py @@ -1,13 +1,12 @@ import os from pathlib import Path - from pycardano import ( CardanoCliChainContext, CardanoCliNetwork, - ProtocolParameters, GenesisParameters, Network, + ProtocolParameters, ) from pycardano.backend.cardano_cli import DockerConfig diff --git a/integration-test/test/test_plutus.py b/integration-test/test/test_plutus.py index 784790ff..e33ea198 100644 --- a/integration-test/test/test_plutus.py +++ b/integration-test/test/test_plutus.py @@ -1,4 +1,6 @@ +import collections import time +from typing import Dict, Union import cbor2 import pytest @@ -7,6 +9,7 @@ from pycardano import * from .base import TEST_RETRIES, TestBase +from .test_cardano_cli import TestCardanoCli class TestPlutus(TestBase): @@ -371,3 +374,22 @@ class TestPlutusOgmiosOnly(TestPlutus): @classmethod def setup_class(cls): cls.chain_context._kupo_url = None + + +def evaluate_tx(tx: Transaction) -> Dict[str, ExecutionUnits]: + redeemers = tx.transaction_witness_set.redeemer + execution_units = {} + + if redeemers: + for r in redeemers: + k = f"{r.tag.name.lower()}:{r.index}" + execution_units[k] = ExecutionUnits(1000000, 1000000000) + + return execution_units + + +class TestPlutusCardanoCLI(TestPlutus): + @classmethod + def setup_class(cls): + cls.chain_context = TestCardanoCli.chain_context + cls.chain_context.evaluate_tx = evaluate_tx diff --git a/pycardano/backend/cardano_cli.py b/pycardano/backend/cardano_cli.py index 04ba7c90..5f506f6b 100644 --- a/pycardano/backend/cardano_cli.py +++ b/pycardano/backend/cardano_cli.py @@ -9,17 +9,13 @@ from enum import Enum from functools import partial from pathlib import Path -from typing import Optional, List, Dict, Union +from typing import Dict, List, Optional, Union +import cbor2 import docker from cachetools import Cache, LRUCache, TTLCache, func from docker.errors import APIError -from pycardano.serialization import RawCBOR -from pycardano.nativescript import NativeScript -from pycardano.plutus import PlutusV2Script, PlutusV1Script - -from pycardano.network import Network from pycardano.address import Address from pycardano.backend.base import ( ALONZO_COINS_PER_UTXO_WORD, @@ -28,11 +24,15 @@ ProtocolParameters, ) from pycardano.exception import ( - TransactionFailedException, CardanoCliError, PyCardanoException, + TransactionFailedException, ) from pycardano.hash import DatumHash, ScriptHash +from pycardano.nativescript import NativeScript +from pycardano.network import Network +from pycardano.plutus import PlutusV1Script, PlutusV2Script +from pycardano.serialization import RawCBOR from pycardano.transaction import ( Asset, AssetName, @@ -381,10 +381,14 @@ def _get_script( script_type = reference_script["script"]["type"] script_json: JsonDict = reference_script["script"] if script_type == "PlutusScriptV1": - v1script = PlutusV1Script(bytes.fromhex(script_json["cborHex"])) + v1script = PlutusV1Script( + cbor2.loads(bytes.fromhex(script_json["cborHex"])) + ) return v1script elif script_type == "PlutusScriptV2": - v2script = PlutusV2Script(bytes.fromhex(script_json["cborHex"])) + v2script = PlutusV2Script( + cbor2.loads(bytes.fromhex(script_json["cborHex"])) + ) return v2script else: return NativeScript.from_dict(script_json) diff --git a/test/pycardano/backend/test_cardano_cli.py b/test/pycardano/backend/test_cardano_cli.py index a4470519..785181e6 100644 --- a/test/pycardano/backend/test_cardano_cli.py +++ b/test/pycardano/backend/test_cardano_cli.py @@ -6,13 +6,13 @@ import pytest from pycardano import ( - CardanoCliChainContext, - ProtocolParameters, ALONZO_COINS_PER_UTXO_WORD, + CardanoCliChainContext, CardanoCliNetwork, GenesisParameters, - TransactionInput, MultiAsset, + ProtocolParameters, + TransactionInput, ) QUERY_TIP_RESULT = { From 8f1df056b0869aeb226bd79c96d39b09f64e2edc Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Sun, 25 Feb 2024 18:09:58 -0500 Subject: [PATCH 20/28] feat: add stake pool key pairs --- pycardano/key.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/pycardano/key.py b/pycardano/key.py index 335a5649..35cce25a 100644 --- a/pycardano/key.py +++ b/pycardano/key.py @@ -32,6 +32,9 @@ "StakeSigningKey", "StakeVerificationKey", "StakeKeyPair", + "StakePoolSigningKey", + "StakePoolVerificationKey", + "StakePoolKeyPair", ] @@ -314,3 +317,37 @@ def from_signing_key( cls: Type[StakeKeyPair], signing_key: SigningKey ) -> StakeKeyPair: return cls(signing_key, StakeVerificationKey.from_signing_key(signing_key)) + + +class StakePoolSigningKey(SigningKey): + KEY_TYPE = "StakePoolSigningKey_ed25519" + DESCRIPTION = "Stake Pool Operator Signing Key" + + +class StakePoolVerificationKey(VerificationKey): + KEY_TYPE = "StakePoolVerificationKey_ed25519" + DESCRIPTION = "Stake Pool Operator Verification Key" + + +class StakePoolKeyPair: + def __init__(self, signing_key: SigningKey, verification_key: VerificationKey): + self.signing_key = signing_key + self.verification_key = verification_key + + @classmethod + def generate(cls: Type[StakePoolKeyPair]) -> StakePoolKeyPair: + signing_key = StakePoolSigningKey.generate() + return cls.from_signing_key(signing_key) + + @classmethod + def from_signing_key( + cls: Type[StakePoolKeyPair], signing_key: SigningKey + ) -> StakePoolKeyPair: + return cls(signing_key, StakePoolVerificationKey.from_signing_key(signing_key)) + + def __eq__(self, other): + if isinstance(other, StakePoolKeyPair): + return ( + other.signing_key == self.signing_key + and other.verification_key == self.verification_key + ) From 6d3ce1db56f46aae4355273ea6fcc998574a88cf Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Sun, 25 Feb 2024 18:14:22 -0500 Subject: [PATCH 21/28] fix: resolve mypy and black linting issues --- pycardano/certificate.py | 7 ++----- pycardano/hash.py | 2 +- pycardano/pool_params.py | 4 +++- pycardano/witness.py | 34 +++++++++++++++++----------------- 4 files changed, 23 insertions(+), 24 deletions(-) diff --git a/pycardano/certificate.py b/pycardano/certificate.py index 2e52272a..d4b28d79 100644 --- a/pycardano/certificate.py +++ b/pycardano/certificate.py @@ -113,10 +113,7 @@ def __post_init__(self): def to_primitive(self): pool_params = self.pool_params.to_primitive() if isinstance(pool_params, list): - return [ - self._CODE, - *pool_params - ] + return [self._CODE, *pool_params] return super().to_primitive() @classmethod @@ -166,7 +163,7 @@ class CertificateCBORSerializer(ArrayCBORSerializable): @classmethod @limit_primitive_type(list) def from_primitive( - cls: Type[Certificate], values: Union[list, tuple] + cls: Type[CertificateCBORSerializer], values: Union[list, tuple] ) -> Certificate | None: if values[0] == 0: return StakeRegistration.from_primitive(values) diff --git a/pycardano/hash.py b/pycardano/hash.py index 54cdd798..e4023b8a 100644 --- a/pycardano/hash.py +++ b/pycardano/hash.py @@ -133,7 +133,7 @@ class AuxiliaryDataHash(ConstrainedBytes): MAX_SIZE = MIN_SIZE = AUXILIARY_DATA_HASH_SIZE -class PoolKeyHash(ConstrainedBytes): +class PoolKeyHash(VerificationKeyHash): """Hash of a stake pool""" MAX_SIZE = MIN_SIZE = POOL_KEY_HASH_SIZE diff --git a/pycardano/pool_params.py b/pycardano/pool_params.py index a2c65a84..7a98d1ad 100644 --- a/pycardano/pool_params.py +++ b/pycardano/pool_params.py @@ -235,7 +235,9 @@ def from_primitive( class RelayCBORSerializer(ArrayCBORSerializable): @classmethod @limit_primitive_type(list) - def from_primitive(cls: Type[Relay], values: Union[list, tuple]) -> Relay | None: + def from_primitive( + cls: Type[RelayCBORSerializer], values: Union[list, tuple] + ) -> Relay | None: if values[0] == 0: return SingleHostAddr.from_primitive(values) elif values[0] == 1: diff --git a/pycardano/witness.py b/pycardano/witness.py index f7ca440c..d8410d2b 100644 --- a/pycardano/witness.py +++ b/pycardano/witness.py @@ -82,32 +82,32 @@ class TransactionWitnessSet(MapCBORSerializable): def from_primitive( cls: Type[TransactionWitnessSet], values: Union[dict, list, tuple] ) -> TransactionWitnessSet | None: - def _get_vkey_witnesses(data: Any): - return [ - VerificationKeyWitness.from_primitive(witness) - for witness in data - ] if data else None + return ( + [VerificationKeyWitness.from_primitive(witness) for witness in data] + if data + else None + ) def _get_native_scripts(data: Any): - return [ - NativeScript.from_primitive(script) for script in data - ] if data else None + return ( + [NativeScript.from_primitive(script) for script in data] + if data + else None + ) def _get_plutus_v1_scripts(data: Any): - return [ - PlutusV1Script(script) for script in data - ] if data else None + return [PlutusV1Script(script) for script in data] if data else None def _get_plutus_v2_scripts(data: Any): - return [ - PlutusV2Script(script) for script in data - ] if data else None + return [PlutusV2Script(script) for script in data] if data else None def _get_redeemers(data: Any): - return [ - Redeemer.from_primitive(redeemer) for redeemer in data - ] if data else None + return ( + [Redeemer.from_primitive(redeemer) for redeemer in data] + if data + else None + ) def _get_cls(data: Any): return cls( From daa85c3ef2d9b63bbb9e9dc2fcfe31669d5e529d Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Sun, 25 Feb 2024 18:18:28 -0500 Subject: [PATCH 22/28] feat: add witness count override for fee estimation add initial stake pool registration flag and deposit add pool vkey hashes if certificate exists --- pycardano/txbuilder.py | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/pycardano/txbuilder.py b/pycardano/txbuilder.py index e3e68471..ef724f05 100644 --- a/pycardano/txbuilder.py +++ b/pycardano/txbuilder.py @@ -12,6 +12,8 @@ StakeDelegation, StakeDeregistration, StakeRegistration, + PoolRegistration, + PoolRetirement, ) from pycardano.coinselection import ( LargestFirstSelector, @@ -110,6 +112,10 @@ class TransactionBuilder: init=False, default_factory=lambda: set() ) + witness_override: Optional[int] = field(default=None) + + initial_stake_pool_registration: Optional[bool] = field(default=False) + _inputs: List[UTxO] = field(init=False, default_factory=lambda: []) _potential_inputs: List[UTxO] = field(init=False, default_factory=lambda: []) @@ -688,15 +694,35 @@ def _check_and_add_vkey(stake_credential: StakeCredential): cert, (StakeRegistration, StakeDeregistration, StakeDelegation) ): _check_and_add_vkey(cert.stake_credential) + elif isinstance(cert, PoolRegistration): + results.add(cert.pool_params.operator) + elif isinstance(cert, PoolRetirement): + results.add(cert.pool_keyhash) return results def _get_total_key_deposit(self): - results = set() + stake_registration_certs = set() + stake_pool_registration_certs = set() + + protocol_params = self.context.protocol_param + if self.certificates: for cert in self.certificates: if isinstance(cert, StakeRegistration): - results.add(cert.stake_credential.credential) - return self.context.protocol_param.key_deposit * len(results) + stake_registration_certs.add(cert.stake_credential.credential) + elif ( + isinstance(cert, PoolRegistration) + and self.initial_stake_pool_registration + ): + stake_pool_registration_certs.add(cert.pool_params.operator) + + stake_registration_deposit = protocol_params.key_deposit * len( + stake_registration_certs + ) + stake_pool_registration_deposit = protocol_params.pool_deposit * len( + stake_pool_registration_certs + ) + return stake_registration_deposit + stake_pool_registration_deposit def _withdrawal_vkey_hashes(self) -> Set[VerificationKeyHash]: results = set() @@ -791,8 +817,12 @@ def _build_fake_vkey_witnesses(self) -> List[VerificationKeyWitness]: vkey_hashes.update(self._native_scripts_vkey_hashes()) vkey_hashes.update(self._certificate_vkey_hashes()) vkey_hashes.update(self._withdrawal_vkey_hashes()) + + witness_count = self.witness_override or len(vkey_hashes) + return [ - VerificationKeyWitness(FAKE_VKEY, FAKE_TX_SIGNATURE) for _ in vkey_hashes + VerificationKeyWitness(FAKE_VKEY, FAKE_TX_SIGNATURE) + for _ in range(witness_count) ] def _build_fake_witness_set(self) -> TransactionWitnessSet: From b185559cbe1423829c095d413ee710bc59227145 Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Sun, 25 Feb 2024 18:19:22 -0500 Subject: [PATCH 23/28] chore: add integration test temporary folders to ignore --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 88a8b689..b5e58dea 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ dist # IDE .idea -.code \ No newline at end of file +.code +/integration-test/.env +/integration-test/tmp_configs/* From c72b493afedcba5714a7c5563ae34289c6ddc99d Mon Sep 17 00:00:00 2001 From: Hareem Adderley Date: Sun, 25 Feb 2024 18:19:59 -0500 Subject: [PATCH 24/28] test: add test for pool certificate related code --- test/conftest.py | 51 +++++++++++++++ test/pycardano/test_certificate.py | 41 +----------- test/pycardano/test_key.py | 81 ++++++++++++++++++++++- test/pycardano/test_txbuilder.py | 101 +++++++++++++++++++++++------ test/pycardano/util.py | 6 +- test/resources/keys/cold.skey | 5 ++ test/resources/keys/cold.vkey | 5 ++ 7 files changed, 227 insertions(+), 63 deletions(-) create mode 100644 test/conftest.py create mode 100644 test/resources/keys/cold.skey create mode 100644 test/resources/keys/cold.vkey diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 00000000..51bd371c --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,51 @@ +from fractions import Fraction + +import pytest + +from pycardano import ( + PoolKeyHash, + POOL_KEY_HASH_SIZE, + VRF_KEY_HASH_SIZE, + VrfKeyHash, + VerificationKeyHash, + RewardAccountHash, + REWARD_ACCOUNT_HASH_SIZE, + VERIFICATION_KEY_HASH_SIZE, + PoolMetadataHash, + POOL_METADATA_HASH_SIZE, +) +from pycardano.pool_params import ( + PoolParams, + SingleHostAddr, + SingleHostName, + MultiHostName, + PoolMetadata, +) +from test.pycardano.util import FixedChainContext + + +@pytest.fixture +def chain_context(): + return FixedChainContext() + + +@pytest.fixture +def pool_params(): + return PoolParams( + operator=PoolKeyHash(b"1" * POOL_KEY_HASH_SIZE), + vrf_keyhash=VrfKeyHash(b"1" * VRF_KEY_HASH_SIZE), + pledge=100_000_000, + cost=340_000_000, + margin=Fraction(1, 50), + reward_account=RewardAccountHash(b"1" * REWARD_ACCOUNT_HASH_SIZE), + pool_owners=[VerificationKeyHash(b"1" * VERIFICATION_KEY_HASH_SIZE)], + relays=[ + SingleHostAddr(port=3001, ipv4="192.168.0.1", ipv6="::1"), + SingleHostName(port=3001, dns_name="relay1.example.com"), + MultiHostName(dns_name="relay1.example.com"), + ], + pool_metadata=PoolMetadata( + url="https://meta1.example.com", + pool_metadata_hash=PoolMetadataHash(b"1" * POOL_METADATA_HASH_SIZE), + ), + ) diff --git a/test/pycardano/test_certificate.py b/test/pycardano/test_certificate.py index 37c689b8..9bc551d2 100644 --- a/test/pycardano/test_certificate.py +++ b/test/pycardano/test_certificate.py @@ -1,4 +1,3 @@ -from fractions import Fraction from pycardano.address import Address from pycardano.certificate import ( StakeCredential, @@ -10,25 +9,9 @@ ) from pycardano.hash import ( POOL_KEY_HASH_SIZE, - VRF_KEY_HASH_SIZE, - VrfKeyHash, PoolKeyHash, - VerificationKeyHash, - VERIFICATION_KEY_HASH_SIZE, - PoolMetadataHash, - POOL_METADATA_HASH_SIZE, ScriptHash, SCRIPT_HASH_SIZE, - RewardAccountHash, - REWARD_ACCOUNT_HASH_SIZE, -) -from pycardano.pool_params import ( - PoolParams, - PoolMetadata, - MultiHostName, - # Fraction, - SingleHostAddr, - SingleHostName, ) TEST_ADDR = Address.from_primitive( @@ -112,29 +95,7 @@ def test_stake_delegation(): assert StakeDelegation.from_cbor(stake_delegation_cbor_hex) == stake_delegation -def test_pool_registration(): - pool_keyhash = PoolKeyHash(b"1" * POOL_KEY_HASH_SIZE) - vrf_keyhash = VrfKeyHash(b"1" * VRF_KEY_HASH_SIZE) - pool_owner = VerificationKeyHash(b"1" * VERIFICATION_KEY_HASH_SIZE) - reward_account = RewardAccountHash(b"1" * REWARD_ACCOUNT_HASH_SIZE) - pool_params = PoolParams( - operator=pool_keyhash, - vrf_keyhash=vrf_keyhash, - pledge=100_000_000, - cost=340_000_000, - margin=Fraction(1, 50), - reward_account=reward_account, - pool_owners=[pool_owner], - relays=[ - SingleHostAddr(port=3001, ipv4="192.168.0.1", ipv6="::1"), - SingleHostName(port=3001, dns_name="relay1.example.com"), - MultiHostName(dns_name="relay1.example.com"), - ], - pool_metadata=PoolMetadata( - url="https://meta1.example.com", - pool_metadata_hash=PoolMetadataHash(b"1" * POOL_METADATA_HASH_SIZE), - ), - ) +def test_pool_registration(pool_params): pool_registration = PoolRegistration(pool_params) pool_registration_cbor_hex = pool_registration.to_cbor_hex() diff --git a/test/pycardano/test_key.py b/test/pycardano/test_key.py index 3801f246..ddc8bfee 100644 --- a/test/pycardano/test_key.py +++ b/test/pycardano/test_key.py @@ -7,6 +7,9 @@ PaymentKeyPair, PaymentSigningKey, PaymentVerificationKey, + StakePoolSigningKey, + StakePoolVerificationKey, + StakePoolKeyPair, ) SK = PaymentSigningKey.from_json( @@ -25,6 +28,22 @@ }""" ) +SPSK = StakePoolSigningKey.from_json( + """{ + "type": "StakePoolSigningKey_ed25519", + "description": "StakePoolSigningKey_ed25519", + "cborHex": "582044181bd0e6be21cea5b0751b8c6d4f88a5cb2d5dfec31a271add617f7ce559a9" + }""" +) + +SPVK = StakePoolVerificationKey.from_json( + """{ + "type": "StakePoolVerificationKey_ed25519", + "description": "StakePoolVerificationKey_ed25519", + "cborHex": "5820354ce32da92e7116f6c70e9be99a3a601d33137d0685ab5b7e2ff5b656989299" + }""" +) + EXTENDED_SK = ExtendedSigningKey.from_json( """{ "type": "PaymentExtendedSigningKeyShelley_ed25519_bip32", @@ -58,6 +77,24 @@ def test_payment_key(): assert PaymentKeyPair.from_signing_key(SK).verification_key.payload == VK.payload +def test_stake_pool_key(): + assert ( + SPSK.payload + == b"D\x18\x1b\xd0\xe6\xbe!\xce\xa5\xb0u\x1b\x8cmO\x88\xa5\xcb-]\xfe\xc3\x1a'\x1a\xdda\x7f|\xe5Y\xa9" + ) + assert ( + SPVK.payload + == b"5L\xe3-\xa9.q\x16\xf6\xc7\x0e\x9b\xe9\x9a:`\x1d3\x13}\x06\x85\xab[~/\xf5\xb6V\x98\x92\x99" + ) + assert ( + SPVK.hash().payload + == b'3/\x13v\xecJi\xe3\x93\xe1\x88`1\x80\xa6\r"\n\x10\xf0<1\xb6)|\xa4c\xb5' + ) + assert ( + StakePoolKeyPair.from_signing_key(SPSK).verification_key.payload == SPVK.payload + ) + + def test_extended_payment_key(): assert EXTENDED_VK == ExtendedVerificationKey.from_signing_key(EXTENDED_SK) @@ -86,12 +123,29 @@ def test_key_pair(): assert PaymentKeyPair(sk, vk) == PaymentKeyPair.from_signing_key(sk) +def test_stake_pool_key_pair(): + sk = StakePoolSigningKey.generate() + vk = StakePoolVerificationKey.from_signing_key(sk) + assert StakePoolKeyPair(sk, vk) == StakePoolKeyPair.from_signing_key(sk) + + def test_key_load(): - sk = PaymentSigningKey.load( + PaymentSigningKey.load( str(pathlib.Path(__file__).parent / "../resources/keys/payment.skey") ) +def test_stake_pool_key_load(): + sk = StakePoolSigningKey.load( + str(pathlib.Path(__file__).parent / "../resources/keys/cold.skey") + ) + vk = StakePoolVerificationKey.load( + str(pathlib.Path(__file__).parent / "../resources/keys/cold.vkey") + ) + assert sk == StakePoolSigningKey.from_json(sk.to_json()) + assert vk == StakePoolVerificationKey.from_json(vk.to_json()) + + def test_key_save(): with tempfile.NamedTemporaryFile() as f: SK.save(f.name) @@ -99,6 +153,16 @@ def test_key_save(): assert SK == sk +def test_stake_pool_key_save(): + with tempfile.NamedTemporaryFile() as skf, tempfile.NamedTemporaryFile() as vkf: + SPSK.save(skf.name) + sk = StakePoolSigningKey.load(skf.name) + SPVK.save(vkf.name) + vk = StakePoolSigningKey.load(vkf.name) + assert SPSK == sk + assert SPVK == vk + + def test_key_hash(): sk = PaymentSigningKey.generate() vk = PaymentVerificationKey.from_signing_key(sk) @@ -112,3 +176,18 @@ def test_key_hash(): assert len(sk_set) == 1 assert len(vk_set) == 1 + + +def test_stake_pool_key_hash(): + sk = StakePoolSigningKey.generate() + vk = StakePoolVerificationKey.from_signing_key(sk) + + sk_set = set() + vk_set = set() + + for _ in range(2): + sk_set.add(sk) + vk_set.add(vk) + + assert len(sk_set) == 1 + assert len(vk_set) == 1 diff --git a/test/pycardano/test_txbuilder.py b/test/pycardano/test_txbuilder.py index 3060a908..7a02f89f 100644 --- a/test/pycardano/test_txbuilder.py +++ b/test/pycardano/test_txbuilder.py @@ -1,13 +1,17 @@ import copy from dataclasses import replace -from test.pycardano.test_key import SK -from test.pycardano.util import chain_context +from fractions import Fraction from unittest.mock import patch import pytest from pycardano.address import Address -from pycardano.certificate import StakeCredential, StakeDelegation, StakeRegistration +from pycardano.certificate import ( + StakeCredential, + StakeDelegation, + StakeRegistration, + PoolRegistration, +) from pycardano.coinselection import RandomImproveMultiAsset from pycardano.exception import ( InsufficientUTxOBalanceException, @@ -48,6 +52,7 @@ from pycardano.txbuilder import TransactionBuilder from pycardano.utils import fee from pycardano.witness import VerificationKeyWitness +from test.pycardano.test_key import SK def test_tx_builder(chain_context): @@ -79,14 +84,14 @@ def test_tx_builder(chain_context): def test_tx_builder_no_change(chain_context): tx_builder = TransactionBuilder(chain_context, [RandomImproveMultiAsset([0, 0])]) sender = "addr_test1vrm9x2zsux7va6w892g38tvchnzahvcd9tykqf3ygnmwtaqyfg52x" - sender_address = Address.from_primitive(sender) + Address.from_primitive(sender) # Add sender address as input tx_builder.add_input_address(sender).add_output( TransactionOutput.from_primitive([sender, 500000]) ) - tx_body = tx_builder.build() + tx_builder.build() def test_tx_builder_with_certain_input(chain_context): @@ -206,7 +211,7 @@ def test_tx_builder_raises_utxo_selection(chain_context): ) with pytest.raises(UTxOSelectionException) as e: - tx_body = tx_builder.build( + tx_builder.build( change_address=sender_address, ) @@ -226,7 +231,7 @@ def test_tx_too_big_exception(chain_context): tx_builder.add_output(TransactionOutput.from_primitive([sender, 10])) with pytest.raises(InvalidTransactionException): - tx_body = tx_builder.build(change_address=sender_address) + tx_builder.build(change_address=sender_address) def test_tx_small_utxo_precise_fee(chain_context): @@ -278,7 +283,7 @@ def test_tx_small_utxo_balance_fail(chain_context): # Balance is smaller than minimum ada required in change # No more UTxO is available, throwing UTxO selection exception with pytest.raises(UTxOSelectionException): - tx_body = tx_builder.build(change_address=sender_address) + tx_builder.build(change_address=sender_address) def test_tx_small_utxo_balance_pass(chain_context): @@ -486,7 +491,7 @@ def test_tx_add_change_split_nfts_not_enough_add(chain_context): tx_builder.ttl = 123456789 with pytest.raises(InsufficientUTxOBalanceException): - tx_body = tx_builder.build(change_address=sender_address) + tx_builder.build(change_address=sender_address) def test_not_enough_input_amount(chain_context): @@ -502,7 +507,7 @@ def test_not_enough_input_amount(chain_context): with pytest.raises(UTxOSelectionException): # Tx builder must fail here because there is not enough amount of input ADA to pay tx fee - tx_body = tx_builder.build(change_address=sender_address) + tx_builder.build(change_address=sender_address) def test_add_script_input(chain_context): @@ -521,7 +526,7 @@ def test_add_script_input(chain_context): tx_in1, TransactionOutput(script_address, 10000000, datum_hash=datum.hash()) ) mint = MultiAsset.from_primitive({script_hash.payload: {b"TestToken": 1}}) - utxo2 = UTxO( + UTxO( tx_in2, TransactionOutput( script_address, Value(10000000, mint), datum_hash=datum.hash() @@ -536,7 +541,7 @@ def test_add_script_input(chain_context): "addr_test1vrm9x2zsux7va6w892g38tvchnzahvcd9tykqf3ygnmwtaqyfg52x" ) tx_builder.add_output(TransactionOutput(receiver, 5000000)) - tx_body = tx_builder.build(change_address=receiver) + tx_builder.build(change_address=receiver) witness = tx_builder.build_witness_set() assert [datum] == witness.plutus_data assert [redeemer1, redeemer2] == witness.redeemer @@ -564,7 +569,7 @@ def test_add_script_input_no_script(chain_context): "addr_test1vrm9x2zsux7va6w892g38tvchnzahvcd9tykqf3ygnmwtaqyfg52x" ) tx_builder.add_output(TransactionOutput(receiver, 5000000)) - tx_body = tx_builder.build(change_address=receiver) + tx_builder.build(change_address=receiver) witness = tx_builder.build_witness_set() assert [datum] == witness.plutus_data assert [redeemer] == witness.redeemer @@ -835,7 +840,7 @@ def test_wrong_redeemer_execution_units(chain_context): tx_in1, TransactionOutput(script_address, 10000000, datum_hash=datum.hash()) ) mint = MultiAsset.from_primitive({script_hash.payload: {b"TestToken": 1}}) - utxo2 = UTxO( + UTxO( tx_in2, TransactionOutput( script_address, Value(10000000, mint), datum_hash=datum.hash() @@ -856,7 +861,7 @@ def test_all_redeemer_should_provide_execution_units(chain_context): tx_in1 = TransactionInput.from_primitive( ["18cbe6cadecd3f89b60e08e68e5e6c7d72d730aaa1ad21431590f7e6643438ef", 0] ) - tx_in2 = TransactionInput.from_primitive( + TransactionInput.from_primitive( ["18cbe6cadecd3f89b60e08e68e5e6c7d72d730aaa1ad21431590f7e6643438ef", 1] ) plutus_script = PlutusV1Script(b"dummy test script") @@ -893,7 +898,7 @@ def test_add_minting_script(chain_context): "addr_test1vrm9x2zsux7va6w892g38tvchnzahvcd9tykqf3ygnmwtaqyfg52x" ) tx_builder.add_output(TransactionOutput(receiver, Value(5000000, mint))) - tx_body = tx_builder.build(change_address=receiver) + tx_builder.build(change_address=receiver) witness = tx_builder.build_witness_set() assert [plutus_script] == witness.plutus_v1_script @@ -915,7 +920,7 @@ def test_add_minting_script_only(chain_context): "addr_test1vrm9x2zsux7va6w892g38tvchnzahvcd9tykqf3ygnmwtaqyfg52x" ) tx_builder.add_output(TransactionOutput(receiver, Value(5000000, mint))) - tx_body = tx_builder.build(change_address=receiver) + tx_builder.build(change_address=receiver) witness = tx_builder.build_witness_set() assert [plutus_script] == witness.plutus_v1_script @@ -1019,7 +1024,7 @@ def test_estimate_execution_unit(chain_context): "addr_test1vrm9x2zsux7va6w892g38tvchnzahvcd9tykqf3ygnmwtaqyfg52x" ) tx_builder.add_output(TransactionOutput(receiver, 5000000)) - tx_body = tx_builder.build(change_address=receiver) + tx_builder.build(change_address=receiver) witness = tx_builder.build_witness_set() assert [datum] == witness.plutus_data assert [redeemer1] == witness.redeemer @@ -1112,6 +1117,62 @@ def test_tx_builder_certificates(chain_context): assert expected == tx_body.to_primitive() +def test_tx_builder_stake_pool_registration(chain_context, pool_params): + tx_builder = TransactionBuilder(chain_context, [RandomImproveMultiAsset([0, 0])]) + sender = "addr_test1vrm9x2zsux7va6w892g38tvchnzahvcd9tykqf3ygnmwtaqyfg52x" + sender_address = Address.from_primitive(sender) + + pool_registration = PoolRegistration(pool_params) + + tx_in3 = TransactionInput.from_primitive([b"2" * 32, 2]) + tx_out3 = TransactionOutput.from_primitive([sender, 505000000]) + utxo = UTxO(tx_in3, tx_out3) + + tx_builder.add_input(utxo) + + tx_builder.initial_stake_pool_registration = True + + tx_builder.certificates = [pool_registration] + + tx_body = tx_builder.build(change_address=sender_address) + + expected = { + 0: [[b"22222222222222222222222222222222", 2]], + 1: [ + [ + b"`\xf6S(P\xe1\xbc\xce\xe9\xc7*\x91\x13\xad\x98\xbc\xc5\xdb\xb3\r*\xc9`&$D\xf6\xe5\xf4", + 4819407, + ] + ], + 2: 180593, + 4: [ + [ + 3, + b"1111111111111111111111111111", + b"11111111111111111111111111111111", + 100000000, + 340000000, + Fraction(1, 50), + b"11111111111111111111111111111", + [b"1111111111111111111111111111"], + [ + [ + 0, + 3001, + b"\xc0\xa8\x00\x01", + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01", + ], + [1, 3001, "relay1.example.com"], + [2, "relay1.example.com"], + ], + ["https://meta1.example.com", b"11111111111111111111111111111111"], + ] + ], + } + + assert expected == tx_body.to_primitive() + + def test_tx_builder_withdrawal(chain_context): tx_builder = TransactionBuilder(chain_context, [RandomImproveMultiAsset([0, 0])]) sender = "addr_test1vrm9x2zsux7va6w892g38tvchnzahvcd9tykqf3ygnmwtaqyfg52x" @@ -1339,7 +1400,7 @@ def test_tx_builder_small_utxo_input(chain_context): ), ) ) - signed_tx = builder.build(change_address=address) + builder.build(change_address=address) def test_tx_builder_small_utxo_input_2(chain_context): @@ -1408,7 +1469,7 @@ def test_tx_builder_small_utxo_input_2(chain_context): ), ) ) - signed_tx = builder.build(change_address=address) + builder.build(change_address=address) def test_tx_builder_merge_change_to_output_3(chain_context): diff --git a/test/pycardano/util.py b/test/pycardano/util.py index 9334ff7f..63a4f17e 100644 --- a/test/pycardano/util.py +++ b/test/pycardano/util.py @@ -2,11 +2,13 @@ import pytest -from pycardano import ExecutionUnits +from pycardano import ( + ExecutionUnits, +) from pycardano.backend.base import ChainContext, GenesisParameters, ProtocolParameters from pycardano.network import Network from pycardano.serialization import CBORSerializable -from pycardano.transaction import TransactionInput, TransactionOutput, UTxO, Value +from pycardano.transaction import TransactionInput, TransactionOutput, UTxO TEST_ADDR = "addr_test1vr2p8st5t5cxqglyjky7vk98k7jtfhdpvhl4e97cezuhn0cqcexl7" diff --git a/test/resources/keys/cold.skey b/test/resources/keys/cold.skey new file mode 100644 index 00000000..a4eb4881 --- /dev/null +++ b/test/resources/keys/cold.skey @@ -0,0 +1,5 @@ +{ + "type": "StakePoolSigningKey_ed25519", + "description": "StakePoolSigningKey_ed25519", + "cborHex": "582044181bd0e6be21cea5b0751b8c6d4f88a5cb2d5dfec31a271add617f7ce559a9" +} diff --git a/test/resources/keys/cold.vkey b/test/resources/keys/cold.vkey new file mode 100644 index 00000000..87a811b4 --- /dev/null +++ b/test/resources/keys/cold.vkey @@ -0,0 +1,5 @@ +{ + "type": "StakePoolVerificationKey_ed25519", + "description": "StakePoolVerificationKey_ed25519", + "cborHex": "5820354ce32da92e7116f6c70e9be99a3a601d33137d0685ab5b7e2ff5b656989299" +} From abf7182f858aefc7274916ffa12b8675f2d5067d Mon Sep 17 00:00:00 2001 From: Jerry Date: Sun, 25 Feb 2024 17:02:03 -0800 Subject: [PATCH 25/28] Simplify Certificate deserialization --- pycardano/backend/cardano_cli.py | 2 +- pycardano/certificate.py | 68 ++++++++++------------ pycardano/cip/cip14.py | 1 + pycardano/pool_params.py | 8 +-- pycardano/transaction.py | 3 +- pycardano/txbuilder.py | 4 +- pycardano/witness.py | 5 +- test/conftest.py | 18 +++--- test/pycardano/backend/test_cardano_cli.py | 6 +- test/pycardano/test_certificate.py | 11 +--- test/pycardano/test_key.py | 2 +- test/pycardano/test_pool_params.py | 22 +++---- test/pycardano/test_serialization.py | 3 +- test/pycardano/test_txbuilder.py | 4 +- test/pycardano/util.py | 4 +- 15 files changed, 75 insertions(+), 86 deletions(-) diff --git a/pycardano/backend/cardano_cli.py b/pycardano/backend/cardano_cli.py index cba4f3b6..5215adcc 100644 --- a/pycardano/backend/cardano_cli.py +++ b/pycardano/backend/cardano_cli.py @@ -31,7 +31,7 @@ from pycardano.hash import DatumHash, ScriptHash from pycardano.nativescript import NativeScript from pycardano.network import Network -from pycardano.plutus import PlutusV1Script, PlutusV2Script, RawPlutusData, Datum +from pycardano.plutus import Datum, PlutusV1Script, PlutusV2Script, RawPlutusData from pycardano.serialization import RawCBOR from pycardano.transaction import ( Asset, diff --git a/pycardano/certificate.py b/pycardano/certificate.py index d4b28d79..b866055a 100644 --- a/pycardano/certificate.py +++ b/pycardano/certificate.py @@ -1,8 +1,9 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Optional, Union, Tuple, Type +from typing import Optional, Tuple, Type, Union +from pycardano.exception import DeserializeException from pycardano.hash import PoolKeyHash, ScriptHash, VerificationKeyHash from pycardano.serialization import ArrayCBORSerializable, limit_primitive_type @@ -14,7 +15,6 @@ "StakeDelegation", "PoolRegistration", "PoolRetirement", - "CertificateCBORSerializer", ] from pycardano.pool_params import PoolParams @@ -41,8 +41,10 @@ def from_primitive( ) -> StakeCredential: if values[0] == 0: return cls(VerificationKeyHash(values[1])) - else: + elif values[0] == 1: return cls(ScriptHash(values[1])) + else: + raise DeserializeException(f"Invalid StakeCredential type {values[0]}") @dataclass(repr=False) @@ -76,7 +78,10 @@ def __post_init__(self): def from_primitive( cls: Type[StakeDeregistration], values: Union[list, tuple] ) -> StakeDeregistration: - return cls(StakeCredential.from_primitive(values[1])) + if values[0] == 1: + return cls(StakeCredential.from_primitive(values[1])) + else: + raise DeserializeException(f"Invalid StakeDeregistration type {values[0]}") @dataclass(repr=False) @@ -95,10 +100,13 @@ def __post_init__(self): def from_primitive( cls: Type[StakeDelegation], values: Union[list, tuple] ) -> StakeDelegation: - return cls( - stake_credential=StakeCredential.from_primitive(values[1]), - pool_keyhash=PoolKeyHash.from_primitive(values[2]), - ) + if values[0] == 2: + return cls( + stake_credential=StakeCredential.from_primitive(values[1]), + pool_keyhash=PoolKeyHash.from_primitive(values[2]), + ) + else: + raise DeserializeException(f"Invalid StakeDelegation type {values[0]}") @dataclass(repr=False) @@ -121,14 +129,17 @@ def to_primitive(self): def from_primitive( cls: Type[PoolRegistration], values: Union[list, tuple] ) -> PoolRegistration: - if isinstance(values[1], list): - return cls( - pool_params=PoolParams.from_primitive(values[1]), - ) + if values[0] == 3: + if isinstance(values[1], list): + return cls( + pool_params=PoolParams.from_primitive(values[1]), + ) + else: + return cls( + pool_params=PoolParams.from_primitive(values[1:]), + ) else: - return cls( - pool_params=PoolParams.from_primitive(values[1:]), - ) + raise DeserializeException(f"Invalid PoolRegistration type {values[0]}") @dataclass(repr=False) @@ -146,7 +157,12 @@ def __post_init__(self): def from_primitive( cls: Type[PoolRetirement], values: Union[list, tuple] ) -> PoolRetirement: - return cls(pool_keyhash=PoolKeyHash.from_primitive(values[1]), epoch=values[2]) + if values[0] == 4: + return cls( + pool_keyhash=PoolKeyHash.from_primitive(values[1]), epoch=values[2] + ) + else: + raise DeserializeException(f"Invalid PoolRetirement type {values[0]}") Certificate = Union[ @@ -156,23 +172,3 @@ def from_primitive( PoolRegistration, PoolRetirement, ] - - -@dataclass(repr=False) -class CertificateCBORSerializer(ArrayCBORSerializable): - @classmethod - @limit_primitive_type(list) - def from_primitive( - cls: Type[CertificateCBORSerializer], values: Union[list, tuple] - ) -> Certificate | None: - if values[0] == 0: - return StakeRegistration.from_primitive(values) - elif values[0] == 1: - return StakeDeregistration.from_primitive(values) - elif values[0] == 2: - return StakeDelegation.from_primitive(values) - elif values[0] == 3: - return PoolRegistration.from_primitive(values) - elif values[0] == 4: - return PoolRetirement.from_primitive(values) - return None diff --git a/pycardano/cip/cip14.py b/pycardano/cip/cip14.py index 6126f23a..88951452 100644 --- a/pycardano/cip/cip14.py +++ b/pycardano/cip/cip14.py @@ -2,6 +2,7 @@ from nacl.encoding import RawEncoder from nacl.hash import blake2b + from pycardano.crypto.bech32 import encode from pycardano.hash import ScriptHash from pycardano.transaction import AssetName diff --git a/pycardano/pool_params.py b/pycardano/pool_params.py index 7a98d1ad..d3aad19a 100644 --- a/pycardano/pool_params.py +++ b/pycardano/pool_params.py @@ -7,19 +7,19 @@ from abc import ABC from dataclasses import dataclass, field from fractions import Fraction -from typing import Optional, Union, List, Type +from typing import List, Optional, Type, Union from pycardano.crypto.bech32 import bech32_decode from pycardano.hash import ( PoolKeyHash, - VerificationKeyHash, - VrfKeyHash, PoolMetadataHash, RewardAccountHash, + VerificationKeyHash, + VrfKeyHash, ) from pycardano.serialization import ( - CBORSerializable, ArrayCBORSerializable, + CBORSerializable, limit_primitive_type, list_hook, ) diff --git a/pycardano/transaction.py b/pycardano/transaction.py index 88801c48..3b961e93 100644 --- a/pycardano/transaction.py +++ b/pycardano/transaction.py @@ -13,7 +13,7 @@ from nacl.hash import blake2b from pycardano.address import Address -from pycardano.certificate import Certificate, CertificateCBORSerializer +from pycardano.certificate import Certificate from pycardano.exception import InvalidDataException, InvalidOperationException from pycardano.hash import ( TRANSACTION_HASH_SIZE, @@ -508,7 +508,6 @@ class TransactionBody(MapCBORSerializable): metadata={ "key": 4, "optional": True, - "object_hook": list_hook(CertificateCBORSerializer), }, ) diff --git a/pycardano/txbuilder.py b/pycardano/txbuilder.py index db313fbc..52a0f989 100644 --- a/pycardano/txbuilder.py +++ b/pycardano/txbuilder.py @@ -8,12 +8,12 @@ from pycardano.backend.base import ChainContext from pycardano.certificate import ( Certificate, + PoolRegistration, + PoolRetirement, StakeCredential, StakeDelegation, StakeDeregistration, StakeRegistration, - PoolRegistration, - PoolRetirement, ) from pycardano.coinselection import ( LargestFirstSelector, diff --git a/pycardano/witness.py b/pycardano/witness.py index d8410d2b..4fca9dae 100644 --- a/pycardano/witness.py +++ b/pycardano/witness.py @@ -1,7 +1,8 @@ """Transaction witness.""" from __future__ import annotations + from dataclasses import dataclass, field -from typing import Any, List, Optional, Union, Type +from typing import Any, List, Optional, Type, Union from pycardano.key import ExtendedVerificationKey, VerificationKey from pycardano.nativescript import NativeScript @@ -9,8 +10,8 @@ from pycardano.serialization import ( ArrayCBORSerializable, MapCBORSerializable, - list_hook, limit_primitive_type, + list_hook, ) __all__ = ["VerificationKeyWitness", "TransactionWitnessSet"] diff --git a/test/conftest.py b/test/conftest.py index 51bd371c..aff5d585 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,27 +1,27 @@ from fractions import Fraction +from test.pycardano.util import FixedChainContext import pytest from pycardano import ( - PoolKeyHash, POOL_KEY_HASH_SIZE, - VRF_KEY_HASH_SIZE, - VrfKeyHash, - VerificationKeyHash, - RewardAccountHash, + POOL_METADATA_HASH_SIZE, REWARD_ACCOUNT_HASH_SIZE, VERIFICATION_KEY_HASH_SIZE, + VRF_KEY_HASH_SIZE, + PoolKeyHash, PoolMetadataHash, - POOL_METADATA_HASH_SIZE, + RewardAccountHash, + VerificationKeyHash, + VrfKeyHash, ) from pycardano.pool_params import ( + MultiHostName, + PoolMetadata, PoolParams, SingleHostAddr, SingleHostName, - MultiHostName, - PoolMetadata, ) -from test.pycardano.util import FixedChainContext @pytest.fixture diff --git a/test/pycardano/backend/test_cardano_cli.py b/test/pycardano/backend/test_cardano_cli.py index 2d890731..5805ad58 100644 --- a/test/pycardano/backend/test_cardano_cli.py +++ b/test/pycardano/backend/test_cardano_cli.py @@ -9,13 +9,13 @@ ALONZO_COINS_PER_UTXO_WORD, CardanoCliChainContext, CardanoCliNetwork, + DatumHash, GenesisParameters, MultiAsset, - ProtocolParameters, - TransactionInput, PlutusV2Script, + ProtocolParameters, RawPlutusData, - DatumHash, + TransactionInput, ) QUERY_TIP_RESULT = { diff --git a/test/pycardano/test_certificate.py b/test/pycardano/test_certificate.py index 9bc551d2..d534de22 100644 --- a/test/pycardano/test_certificate.py +++ b/test/pycardano/test_certificate.py @@ -1,18 +1,13 @@ from pycardano.address import Address from pycardano.certificate import ( + PoolRegistration, + PoolRetirement, StakeCredential, StakeDelegation, StakeDeregistration, StakeRegistration, - PoolRegistration, - PoolRetirement, -) -from pycardano.hash import ( - POOL_KEY_HASH_SIZE, - PoolKeyHash, - ScriptHash, - SCRIPT_HASH_SIZE, ) +from pycardano.hash import POOL_KEY_HASH_SIZE, SCRIPT_HASH_SIZE, PoolKeyHash, ScriptHash TEST_ADDR = Address.from_primitive( "stake_test1upyz3gk6mw5he20apnwfn96cn9rscgvmmsxc9r86dh0k66gswf59n" diff --git a/test/pycardano/test_key.py b/test/pycardano/test_key.py index ddc8bfee..ccd34589 100644 --- a/test/pycardano/test_key.py +++ b/test/pycardano/test_key.py @@ -7,9 +7,9 @@ PaymentKeyPair, PaymentSigningKey, PaymentVerificationKey, + StakePoolKeyPair, StakePoolSigningKey, StakePoolVerificationKey, - StakePoolKeyPair, ) SK = PaymentSigningKey.from_json( diff --git a/test/pycardano/test_pool_params.py b/test/pycardano/test_pool_params.py index 946076ff..461aa970 100644 --- a/test/pycardano/test_pool_params.py +++ b/test/pycardano/test_pool_params.py @@ -3,30 +3,30 @@ import pytest from pycardano import ( - PoolMetadataHash, - POOL_METADATA_HASH_SIZE, - VRF_KEY_HASH_SIZE, POOL_KEY_HASH_SIZE, + POOL_METADATA_HASH_SIZE, VERIFICATION_KEY_HASH_SIZE, + VRF_KEY_HASH_SIZE, + PoolMetadataHash, ) from pycardano.hash import ( REWARD_ACCOUNT_HASH_SIZE, PoolKeyHash, - VrfKeyHash, RewardAccountHash, VerificationKeyHash, + VrfKeyHash, ) -from pycardano.pool_params import ( - # Fraction, - SingleHostAddr, - SingleHostName, +from pycardano.pool_params import ( # Fraction, + FractionSerializer, MultiHostName, + PoolId, PoolMetadata, - RelayCBORSerializer, PoolParams, - FractionSerializer, + RelayCBORSerializer, + SingleHostAddr, + SingleHostName, + is_bech32_cardano_pool_id, ) -from pycardano.pool_params import is_bech32_cardano_pool_id, PoolId TEST_POOL_ID = "pool1mt8sdg37f2h3rypyuc77k7vxrjshtvjw04zdjlae9vdzyt9uu34" diff --git a/test/pycardano/test_serialization.py b/test/pycardano/test_serialization.py index 987921d2..890eb16a 100644 --- a/test/pycardano/test_serialization.py +++ b/test/pycardano/test_serialization.py @@ -1,12 +1,11 @@ from dataclasses import dataclass, field - -import pycardano from test.pycardano.util import check_two_way_cbor from typing import Any, Dict, List, Optional, Set, Tuple, Union import cbor2 import pytest +import pycardano from pycardano import Datum, RawPlutusData from pycardano.exception import DeserializeException from pycardano.plutus import PlutusV1Script, PlutusV2Script diff --git a/test/pycardano/test_txbuilder.py b/test/pycardano/test_txbuilder.py index 7a02f89f..7ba70819 100644 --- a/test/pycardano/test_txbuilder.py +++ b/test/pycardano/test_txbuilder.py @@ -1,16 +1,17 @@ import copy from dataclasses import replace from fractions import Fraction +from test.pycardano.test_key import SK from unittest.mock import patch import pytest from pycardano.address import Address from pycardano.certificate import ( + PoolRegistration, StakeCredential, StakeDelegation, StakeRegistration, - PoolRegistration, ) from pycardano.coinselection import RandomImproveMultiAsset from pycardano.exception import ( @@ -52,7 +53,6 @@ from pycardano.txbuilder import TransactionBuilder from pycardano.utils import fee from pycardano.witness import VerificationKeyWitness -from test.pycardano.test_key import SK def test_tx_builder(chain_context): diff --git a/test/pycardano/util.py b/test/pycardano/util.py index 63a4f17e..892b16eb 100644 --- a/test/pycardano/util.py +++ b/test/pycardano/util.py @@ -2,9 +2,7 @@ import pytest -from pycardano import ( - ExecutionUnits, -) +from pycardano import ExecutionUnits from pycardano.backend.base import ChainContext, GenesisParameters, ProtocolParameters from pycardano.network import Network from pycardano.serialization import CBORSerializable From c845908b25b759583aa6c158bac6de4bb7ed433d Mon Sep 17 00:00:00 2001 From: Jerry Date: Sun, 25 Feb 2024 17:18:31 -0800 Subject: [PATCH 26/28] Fix failing test cases for python<3.10 Syntax "Optional[type1 | type2]" is not supported in version <= 3.9 --- pycardano/pool_params.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pycardano/pool_params.py b/pycardano/pool_params.py index d3aad19a..94955f99 100644 --- a/pycardano/pool_params.py +++ b/pycardano/pool_params.py @@ -62,14 +62,14 @@ class SingleHostAddr(ArrayCBORSerializable): _CODE: int = field(init=False, default=0) port: Optional[int] - ipv4: Optional[str | bytes] - ipv6: Optional[str | bytes] + ipv4: Optional[Union[str, bytes]] + ipv6: Optional[Union[str, bytes]] def __init__( self, port: Optional[int] = None, - ipv4: Optional[str | bytes] = None, - ipv6: Optional[str | bytes] = None, + ipv4: Optional[Union[str, bytes]] = None, + ipv6: Optional[Union[str, bytes]] = None, ): super().__init__() From e6c1093097a7b001093dc11a99e67e0946adb561 Mon Sep 17 00:00:00 2001 From: Jerry Date: Sun, 25 Feb 2024 21:30:05 -0800 Subject: [PATCH 27/28] Simplify relay parsing --- pycardano/pool_params.py | 97 +++++++++++++++--------------- test/pycardano/backend/conftest.py | 10 ++- test/pycardano/test_pool_params.py | 46 ++------------ 3 files changed, 61 insertions(+), 92 deletions(-) diff --git a/pycardano/pool_params.py b/pycardano/pool_params.py index 94955f99..e3121ce1 100644 --- a/pycardano/pool_params.py +++ b/pycardano/pool_params.py @@ -10,6 +10,7 @@ from typing import List, Optional, Type, Union from pycardano.crypto.bech32 import bech32_decode +from pycardano.exception import DeserializeException from pycardano.hash import ( PoolKeyHash, PoolMetadataHash, @@ -21,9 +22,20 @@ ArrayCBORSerializable, CBORSerializable, limit_primitive_type, - list_hook, ) +__all__ = [ + "PoolId", + "PoolMetadata", + "PoolParams", + "Relay", + "SingleHostAddr", + "SingleHostName", + "MultiHostName", + "RelayCBORSerializer", + "is_bech32_cardano_pool_id", +] + def is_bech32_cardano_pool_id(pool_id: str) -> bool: """Check if a string is a valid Cardano stake pool ID in bech32 format.""" @@ -158,11 +170,14 @@ def to_primitive(self) -> list: def from_primitive( cls: Type[SingleHostAddr], values: Union[list, tuple] ) -> SingleHostAddr: - return cls( - port=values[1], - ipv4=values[2], - ipv6=values[3], - ) + if values[0] == 0: + return cls( + port=values[1], + ipv4=values[2], + ipv6=values[3], + ) + else: + raise DeserializeException(f"Invalid SingleHostAddr type {values[0]}") @dataclass(repr=False) @@ -180,10 +195,13 @@ def __post_init__(self): def from_primitive( cls: Type[SingleHostName], values: Union[list, tuple] ) -> SingleHostName: - return cls( - port=values[1], - dns_name=values[2], - ) + if values[0] == 1: + return cls( + port=values[1], + dns_name=values[2], + ) + else: + raise DeserializeException(f"Invalid SingleHostName type {values[0]}") @dataclass(repr=False) @@ -200,9 +218,12 @@ def __post_init__(self): def from_primitive( cls: Type[MultiHostName], values: Union[list, tuple] ) -> MultiHostName: - return cls( - dns_name=values[1], - ) + if values[0] == 2: + return cls( + dns_name=values[1], + ) + else: + raise DeserializeException(f"Invalid MultiHostName type {values[0]}") Relay = Union[SingleHostAddr, SingleHostName, MultiHostName] @@ -214,37 +235,17 @@ class PoolMetadata(ArrayCBORSerializable): pool_metadata_hash: PoolMetadataHash -@dataclass(repr=False) -class FractionSerializer(CBORSerializable, Fraction, ABC): - @classmethod - @limit_primitive_type(Fraction, str, list) - def from_primitive( - cls: Type[Fraction], fraction: Union[Fraction, str, list] - ) -> Fraction: - if isinstance(fraction, Fraction): - return Fraction(int(fraction.numerator), int(fraction.denominator)) - elif isinstance(fraction, str): - numerator, denominator = fraction.split("/") - return Fraction(int(numerator), int(denominator)) - elif isinstance(fraction, list): - numerator, denominator = fraction[1] - return Fraction(int(numerator), int(denominator)) - - -@dataclass(repr=False) -class RelayCBORSerializer(ArrayCBORSerializable): - @classmethod - @limit_primitive_type(list) - def from_primitive( - cls: Type[RelayCBORSerializer], values: Union[list, tuple] - ) -> Relay | None: - if values[0] == 0: - return SingleHostAddr.from_primitive(values) - elif values[0] == 1: - return SingleHostName.from_primitive(values) - elif values[0] == 2: - return MultiHostName.from_primitive(values) - return None +def fraction_parser(fraction: Union[Fraction, str, list]) -> Fraction: + if isinstance(fraction, Fraction): + return Fraction(int(fraction.numerator), int(fraction.denominator)) + elif isinstance(fraction, str): + numerator, denominator = fraction.split("/") + return Fraction(int(numerator), int(denominator)) + elif isinstance(fraction, list): + numerator, denominator = fraction[1] + return Fraction(int(numerator), int(denominator)) + else: + raise ValueError(f"Invalid fraction type {fraction}") @dataclass(repr=False) @@ -253,13 +254,9 @@ class PoolParams(ArrayCBORSerializable): vrf_keyhash: VrfKeyHash pledge: int cost: int - margin: Fraction = field( - metadata={"object_hook": FractionSerializer.from_primitive} - ) + margin: Fraction = field(metadata={"object_hook": fraction_parser}) reward_account: RewardAccountHash pool_owners: List[VerificationKeyHash] - relays: Optional[List[Relay]] = field( - metadata={"object_hook": list_hook(RelayCBORSerializer)}, - ) + relays: Optional[List[Relay]] = None pool_metadata: Optional[PoolMetadata] = None id: Optional[PoolId] = field(default=None, metadata={"optional": True}) diff --git a/test/pycardano/backend/conftest.py b/test/pycardano/backend/conftest.py index 23571df5..ae878e7e 100644 --- a/test/pycardano/backend/conftest.py +++ b/test/pycardano/backend/conftest.py @@ -90,7 +90,10 @@ def genesis_file(genesis_json): yield genesis_file_path - genesis_file_path.unlink() + try: + genesis_file_path.unlink() + except FileNotFoundError: + pass @pytest.fixture(scope="session") @@ -190,4 +193,7 @@ def config_file(): yield config_file_path - config_file_path.unlink() + try: + config_file_path.unlink() + except FileNotFoundError: + pass diff --git a/test/pycardano/test_pool_params.py b/test/pycardano/test_pool_params.py index 461aa970..0f038ccf 100644 --- a/test/pycardano/test_pool_params.py +++ b/test/pycardano/test_pool_params.py @@ -17,14 +17,13 @@ VrfKeyHash, ) from pycardano.pool_params import ( # Fraction, - FractionSerializer, MultiHostName, PoolId, PoolMetadata, PoolParams, - RelayCBORSerializer, SingleHostAddr, SingleHostName, + fraction_parser, is_bech32_cardano_pool_id, ) @@ -208,30 +207,12 @@ def test_pool_metadata(url, pool_metadata_hash): ) def test_fraction_serializer(input_value): # Act - result = FractionSerializer.from_primitive(input_value) + result = fraction_parser(input_value) # Assert assert isinstance(result, Fraction) -@pytest.mark.parametrize( - "test_id, input_value, expected_output", - [ - ("HP-1", [0, 3001, "10.20.30.40", "::1"], SingleHostAddr), - ("HP-2", [1, 3001, "example.com"], SingleHostName), - ("HP-3", [2, "example.com"], MultiHostName), - ], -) -def test_relay_cbor_serializer(test_id, input_value, expected_output): - # Act - result = RelayCBORSerializer.from_primitive(input_value) - - # Assert - assert isinstance( - result, expected_output - ), f"Test {test_id} failed: {result} != {expected_output}" - - @pytest.mark.parametrize( "operator, vrf_keyhash, pledge, cost, margin, reward_account, pool_owners, relays, pool_metadata", [ @@ -245,7 +226,7 @@ def test_relay_cbor_serializer(test_id, input_value, expected_output): b"1" * REWARD_ACCOUNT_HASH_SIZE, [b"1" * VERIFICATION_KEY_HASH_SIZE], [ - [0, 3001, "10.20.30.40", None], + [0, 3001, SingleHostAddr.ipv4_to_bytes("10.20.30.40"), None], [1, 3001, "example.com"], [2, "example.com"], ], @@ -286,26 +267,11 @@ def test_pool_params( vrf_keyhash, pledge, cost, - FractionSerializer.from_primitive(margin), + fraction_parser(margin), reward_account, pool_owners, - [RelayCBORSerializer.from_primitive(x).to_primitive() for x in relays], + relays, pool_metadata, ] - # Act - pool_params = PoolParams.from_primitive(primitive_values) - - # Assert - assert isinstance(pool_params, PoolParams) - assert pool_params.operator == PoolKeyHash(operator) - assert pool_params.vrf_keyhash == VrfKeyHash(vrf_keyhash) - assert pool_params.pledge == pledge - assert pool_params.cost == cost - assert pool_params.margin == Fraction(margin) - assert pool_params.reward_account == RewardAccountHash(reward_account) - assert pool_params.pool_owners == [VerificationKeyHash(x) for x in pool_owners] - assert pool_params.relays == [RelayCBORSerializer.from_primitive(x) for x in relays] - assert pool_params.pool_metadata == PoolMetadata.from_primitive(pool_metadata) - - assert pool_params.to_primitive() == primitive_out + assert PoolParams.from_primitive(primitive_values).to_primitive() == primitive_out From d79d8cfd05edc4e5b775e1aaf06d6a536e3b52ec Mon Sep 17 00:00:00 2001 From: Jerry Date: Sun, 25 Feb 2024 21:33:52 -0800 Subject: [PATCH 28/28] Remove unused import --- pycardano/pool_params.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pycardano/pool_params.py b/pycardano/pool_params.py index e3121ce1..126a5d58 100644 --- a/pycardano/pool_params.py +++ b/pycardano/pool_params.py @@ -4,7 +4,6 @@ from __future__ import annotations import socket -from abc import ABC from dataclasses import dataclass, field from fractions import Fraction from typing import List, Optional, Type, Union @@ -32,7 +31,6 @@ "SingleHostAddr", "SingleHostName", "MultiHostName", - "RelayCBORSerializer", "is_bech32_cardano_pool_id", ]