diff --git a/docs/requirements.txt b/docs/requirements.txt index 868ca6d9..f51cc886 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,24 +1,24 @@ alabaster==0.7.13 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" annotated-types==0.7.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" asn1crypto==1.5.1 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" -attrs==24.2.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" -babel==2.16.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" +attrs==25.1.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" +babel==2.17.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" black==24.8.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" blinker==1.8.2 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" blockfrost-python==0.6.0 ; python_full_version >= "3.8.1" and python_version < "4" -cachetools==5.5.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" +cachetools==5.5.1 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" cardano-tools==2.1.0 ; python_full_version >= "3.8.1" and python_version < "4.0" -cbor2==5.6.4 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" -certifi==2024.8.30 ; python_full_version >= "3.8.1" and python_version < "4" +cbor2==5.6.5 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" +certifi==2025.1.31 ; python_full_version >= "3.8.1" and python_version < "4" certvalidator==0.11.1 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" cffi==1.17.1 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" -charset-normalizer==3.3.2 ; python_full_version >= "3.8.1" and python_version < "4" -click==8.1.7 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" +charset-normalizer==3.4.1 ; python_full_version >= "3.8.1" and python_version < "4" +click==8.1.8 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" colorama==0.4.6 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" and (sys_platform == "win32" or platform_system == "Windows") coloredlogs==15.0.1 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" cose==0.9.dev8 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" coverage[toml]==7.6.1 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" -cryptography==43.0.1 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" +cryptography==43.0.3 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" decorator==5.1.1 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" docker==7.1.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" docutils==0.19 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" @@ -26,10 +26,10 @@ ecdsa==0.19.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" ecpy==1.2.5 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" exceptiongroup==1.2.2 ; python_full_version >= "3.8.1" and python_version < "3.11" execnet==2.1.1 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" -flake8==7.1.1 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" +flake8==7.1.2 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" flask==2.3.3 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" -frozendict==2.4.4 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" -frozenlist==1.4.1 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" +frozendict==2.4.6 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" +frozenlist==1.5.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" humanfriendly==10.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" idna==3.10 ; python_full_version >= "3.8.1" and python_version < "4" imagesize==1.4.1 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" @@ -37,16 +37,16 @@ importlib-metadata==8.5.0 ; python_full_version >= "3.8.1" and python_version < iniconfig==2.0.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" isort==5.13.2 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" itsdangerous==2.2.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" -jinja2==3.1.4 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" +jinja2==3.1.5 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" markupsafe==2.1.5 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" mccabe==0.7.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" mnemonic==0.21 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" mypy-extensions==1.0.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" mypy==1.11.2 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" -ogmios==1.2.1 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" -orjson==3.10.7 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" +ogmios==1.3.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" +orjson==3.10.15 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" oscrypto==1.3.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" -packaging==24.1 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" +packaging==24.2 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" pathspec==0.12.1 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" pexpect==4.9.0 ; python_full_version >= "3.8.1" and python_version < "4.0" platformdirs==4.3.6 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" @@ -56,21 +56,21 @@ ptyprocess==0.7.0 ; python_full_version >= "3.8.1" and python_version < "4.0" py==1.11.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" pycodestyle==2.12.1 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" pycparser==2.22 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" -pydantic-core==2.23.4 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" -pydantic==2.9.2 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" +pydantic-core==2.27.2 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" +pydantic==2.10.6 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" pyflakes==3.2.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" -pygments==2.18.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" +pygments==2.19.1 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" pynacl==1.5.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" pyreadline3==3.5.4 ; sys_platform == "win32" and python_full_version >= "3.8.1" and python_full_version < "4.0.0" pytest-cov==5.0.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" pytest-xdist==3.6.1 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" -pytest==8.3.3 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" -pytz==2024.2 ; python_full_version >= "3.8.1" and python_version < "3.9" -pywin32==306 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" and sys_platform == "win32" +pytest==8.3.4 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" +pytz==2025.1 ; python_full_version >= "3.8.1" and python_version < "3.9" +pywin32==308 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" and sys_platform == "win32" requests==2.32.3 ; python_full_version >= "3.8.1" and python_version < "4" retry==0.9.2 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" -setuptools==75.1.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" -six==1.16.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" +setuptools==75.3.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" +six==1.17.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" snowballstemmer==2.2.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" sphinx-copybutton==0.5.2 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" sphinx-rtd-theme==2.0.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" @@ -82,11 +82,12 @@ sphinxcontrib-jquery==4.1 ; python_full_version >= "3.8.1" and python_full_versi sphinxcontrib-jsmath==1.0.1 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" sphinxcontrib-qthelp==1.0.3 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" sphinxcontrib-serializinghtml==1.1.5 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" -tomli==2.0.1 ; python_full_version >= "3.8.1" and python_full_version <= "3.11.0a6" -typeguard==4.3.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" +standard-imghdr==3.13.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" +tomli==2.2.1 ; python_full_version >= "3.8.1" and python_full_version <= "3.11.0a6" +typeguard==4.4.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" typing-extensions==4.12.2 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" urllib3==2.2.3 ; python_full_version >= "3.8.1" and python_version < "4" websocket-client==1.8.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" websockets==13.1 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" -werkzeug==3.0.4 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" +werkzeug==3.0.6 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" zipp==3.20.2 ; python_full_version >= "3.8.1" and python_version < "3.10" diff --git a/docs/source/api/pycardano.governance.rst b/docs/source/api/pycardano.governance.rst new file mode 100644 index 00000000..4ddce47a --- /dev/null +++ b/docs/source/api/pycardano.governance.rst @@ -0,0 +1,7 @@ +Governance +============================== + +.. automodule:: pycardano.governance + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/api/pycardano.poolparams.rst b/docs/source/api/pycardano.poolparams.rst new file mode 100644 index 00000000..595c8a50 --- /dev/null +++ b/docs/source/api/pycardano.poolparams.rst @@ -0,0 +1,7 @@ +Pool parameters +======================== + +.. automodule:: pycardano.pool_params + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/index.rst b/docs/source/index.rst index f43ecbbd..6e839ee1 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -38,12 +38,14 @@ making it a light-weight library that is easy and fast to set up in all kinds of api/pycardano.crypto api/pycardano.coinselection api/pycardano.exception + api/pycardano.governance api/pycardano.hash api/pycardano.key api/pycardano.metadata api/pycardano.nativescript api/pycardano.network api/pycardano.plutus + api/pycardano.poolparams api/pycardano.serialization api/pycardano.transaction api/pycardano.utils diff --git a/integration-test/configs/local-chang/conway-genesis.json b/integration-test/configs/local-chang/conway-genesis.json index 2de0f672..67bcacdc 100644 --- a/integration-test/configs/local-chang/conway-genesis.json +++ b/integration-test/configs/local-chang/conway-genesis.json @@ -288,8 +288,7 @@ "anchor": { "url": "ipfs://QmQq5hWDNzvDR1ForEktAHrdCQmfSL2u5yctNpzDwoSBu4", "dataHash": "23b43bebac48a4acc39e578715aa06635d6d900fa3ea7441dfffd6e43b914f7b" - }, - "script": "edcd84c10e36ae810dc50847477083069db796219b39ccde790484e0" + } }, "committee": { "members": { diff --git a/integration-test/run_tests.sh b/integration-test/run_tests.sh index 253ff187..b9119b15 100755 --- a/integration-test/run_tests.sh +++ b/integration-test/run_tests.sh @@ -72,6 +72,9 @@ docker compose -f docker-compose-chang.yml up -d export PAYMENT_KEY="$ROOT"/configs/local-chang/shelley/utxo-keys/utxo1.skey export EXTENDED_PAYMENT_KEY="$ROOT"/keys/extended.skey +export POOL_COLD_KEY="$ROOT"/keys/pool/cold.skey +export POOL_PAYMENT_KEY="$ROOT"/keys/pool/payment.skey +export POOL_STAKE_KEY="$ROOT"/keys/pool/stake.skey export POOL_ID=$(cat "$ROOT"/keys/pool/pool.id) sleep 10 diff --git a/integration-test/test/base.py b/integration-test/test/base.py index 8e2dff3b..7c174529 100644 --- a/integration-test/test/base.py +++ b/integration-test/test/base.py @@ -34,6 +34,9 @@ class TestBase: payment_key_path = os.environ.get("PAYMENT_KEY") extended_key_path = os.environ.get("EXTENDED_PAYMENT_KEY") + pool_cold_key_path = os.environ.get("POOL_COLD_KEY") + pool_payment_key_path = os.environ.get("POOL_PAYMENT_KEY") + pool_stake_key_path = os.environ.get("POOL_STAKE_KEY") if not payment_key_path or not extended_key_path: raise Exception( "Cannot find payment key. Please specify environment variable PAYMENT_KEY and extended_key_path" @@ -44,6 +47,12 @@ class TestBase: extended_payment_vkey = PaymentExtendedVerificationKey.from_signing_key( extended_payment_skey ) + pool_cold_skey = PaymentSigningKey.load(pool_cold_key_path) + pool_cold_vkey = PaymentVerificationKey.from_signing_key(pool_cold_skey) + pool_payment_skey = PaymentSigningKey.load(pool_payment_key_path) + pool_payment_vkey = PaymentVerificationKey.from_signing_key(pool_payment_skey) + pool_stake_skey = StakeSigningKey.load(pool_stake_key_path) + pool_stake_vkey = StakeVerificationKey.from_signing_key(pool_stake_skey) payment_key_pair = PaymentKeyPair.generate() stake_key_pair = StakeKeyPair.generate() diff --git a/integration-test/test/test_certificate.py b/integration-test/test/test_certificate.py index b7196a0e..cb3e8165 100644 --- a/integration-test/test/test_certificate.py +++ b/integration-test/test/test_certificate.py @@ -40,9 +40,7 @@ def test_stake_delegation(self): stake_credential = StakeCredential( self.stake_key_pair.verification_key.hash() ) - stake_registration = StakeRegistration(stake_credential) pool_hash = PoolKeyHash(bytes.fromhex(os.environ.get("POOL_ID").strip())) - # stake_delegation = StakeDelegation(stake_credential, pool_keyhash=pool_hash) drep = DRep( DRepKind.VERIFICATION_KEY_HASH, @@ -99,9 +97,11 @@ def test_stake_delegation(self): keys=[stake_address.encode()] ) - stake_address_reward = rewards[stake_address.staking_part.payload.hex()][ - "rewards" - ]["ada"]["lovelace"] + stake_address_reward = 0 + if stake_address.staking_part.payload.hex() in rewards: + stake_address_reward = rewards[stake_address.staking_part.payload.hex()][ + "rewards" + ]["ada"]["lovelace"] builder.withdrawals = Withdrawals({bytes(stake_address): stake_address_reward}) diff --git a/integration-test/test/test_governance.py b/integration-test/test/test_governance.py new file mode 100644 index 00000000..f55600c0 --- /dev/null +++ b/integration-test/test/test_governance.py @@ -0,0 +1,185 @@ +import os +import time + +from retry import retry + +from pycardano import * + +from .base import TEST_RETRIES, TestBase + + +class TestGovernanceAction(TestBase): + @retry(tries=TEST_RETRIES, backoff=1.3, delay=2, jitter=(0, 10)) + def test_governance_action_and_voting(self): + # Create new stake key pair + stake_key_pair = StakeKeyPair.generate() + + # Create addresses for testing + address = Address( + self.payment_vkey.hash(), + stake_key_pair.verification_key.hash(), + self.NETWORK, + ) + + # Load pool cold key for signing + # pool_cold_skey = PaymentSigningKey.load(self.pool_cold_key_path) + + # First, ensure we have enough funds + utxos = self.chain_context.utxos(address) + + if not utxos: + giver_address = Address(self.payment_vkey.hash(), network=self.NETWORK) + + builder = TransactionBuilder(self.chain_context) + builder.add_input_address(giver_address) + builder.add_output(TransactionOutput(address, 110000000000)) + + signed_tx = builder.build_and_sign([self.payment_skey], giver_address) + print("############### Funding Transaction created ###############") + print(signed_tx) + print(signed_tx.to_cbor_hex()) + print("############### Submitting funding transaction ###############") + self.chain_context.submit_tx(signed_tx) + time.sleep(5) + + # Step 1: Register as a DRep first + drep_credential = DRepCredential(stake_key_pair.verification_key.hash()) + anchor = Anchor( + url="https://test-drep.com", + data_hash=AnchorDataHash(bytes.fromhex("0" * 64)), + ) + + drep_registration = RegDRepCert( + drep_credential=drep_credential, + coin=500000000, + anchor=anchor, + ) + + stake_credential = StakeCredential(stake_key_pair.verification_key.hash()) + pool_hash = PoolKeyHash(bytes.fromhex(os.environ.get("POOL_ID").strip())) + + drep = DRep( + DRepKind.VERIFICATION_KEY_HASH, + stake_key_pair.verification_key.hash(), + ) + + all_in_one_cert = StakeRegistrationAndDelegationAndVoteDelegation( + stake_credential, pool_hash, drep, 1000000 + ) + + # Create transaction for DRep registration + builder = TransactionBuilder(self.chain_context) + builder.add_input_address(address) + builder.add_output(TransactionOutput(address, 35000000)) + builder.certificates = [drep_registration, all_in_one_cert] + + signed_tx = builder.build_and_sign( + [stake_key_pair.signing_key, self.payment_skey], + address, + ) + print("############### DRep Registration Transaction created ###############") + print(signed_tx) + print(signed_tx.to_cbor_hex()) + print("############### Submitting DRep registration ###############") + self.chain_context.submit_tx(signed_tx) + time.sleep(5) + + # Step 2: Create and submit parameter change action + param_update = ProtocolParamUpdate( + max_block_body_size=75536, + max_transaction_size=26384, + ) + + parameter_change_action = ParameterChangeAction(None, param_update, None) + + # Create transaction for parameter change + builder = TransactionBuilder(self.chain_context) + builder.add_input_address(address) + builder.add_output(TransactionOutput(address, 35000000)) + reward_account = Address( + staking_part=stake_key_pair.verification_key.hash(), network=self.NETWORK + ) + builder.add_proposal( + 100000000000, + bytes(reward_account), + parameter_change_action, + Anchor( + url="https://test-param-update.com", + data_hash=AnchorDataHash(bytes.fromhex("0" * 64)), + ), + ) + + # Sign with both payment key and pool cold key for governance action + signed_tx = builder.build_and_sign( + [self.payment_skey, stake_key_pair.signing_key], + address, + ) + print("############### Gov Action Transaction created ###############") + print(signed_tx) + print(signed_tx.to_cbor_hex()) + print("############### Submitting gov action transaction ###############") + self.chain_context.submit_tx(signed_tx) + time.sleep(5) + + # Get the governance action ID from the transaction + gov_action_id = GovActionId( + transaction_id=signed_tx.id, + gov_action_index=0, # First governance action in the transaction + ) + + # Step 3: Vote for the action as a DRep + drep_voter = Voter( + credential=stake_key_pair.verification_key.hash(), + voter_type=VoterType.DREP, + ) + + # Step 4: Vote for the action as a stake pool + pool_id = os.environ.get("POOL_ID").strip() + + # Create transaction for voting + builder = TransactionBuilder(self.chain_context) + builder.add_input_address(address) + builder.add_output(TransactionOutput(address, 35000000)) + + # Add DRep vote using the helper method + builder.add_vote( + voter=drep_voter, + gov_action_id=gov_action_id, + vote=Vote.YES, + anchor=Anchor( + url="https://test-drep.com", + data_hash=AnchorDataHash(bytes.fromhex("0" * 64)), + ), + ) + + # Add pool vote using the helper method + pool_voter = Voter( + credential=VerificationKeyHash(bytes.fromhex(pool_id)), + voter_type=VoterType.STAKING_POOL, + ) + builder.add_vote( + voter=pool_voter, + gov_action_id=gov_action_id, + vote=Vote.YES, + anchor=Anchor( + url="https://test-pool.com", + data_hash=AnchorDataHash(bytes.fromhex("0" * 64)), + ), + ) + + # Sign with all required keys + signed_tx = builder.build_and_sign( + [ + stake_key_pair.signing_key, + self.payment_skey, + self.pool_cold_skey, + ], + address, + ) + print("############### Voting Transaction created ###############") + print(signed_tx) + print(signed_tx.to_cbor_hex()) + print("############### Submitting voting transaction ###############") + self.chain_context.submit_tx(signed_tx) + + print("############### Test completed successfully ###############") diff --git a/poetry.lock b/poetry.lock index 58a45ce6..298fd53e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -38,13 +38,13 @@ files = [ [[package]] name = "attrs" -version = "24.3.0" +version = "25.1.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.8" files = [ - {file = "attrs-24.3.0-py3-none-any.whl", hash = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308"}, - {file = "attrs-24.3.0.tar.gz", hash = "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff"}, + {file = "attrs-25.1.0-py3-none-any.whl", hash = "sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a"}, + {file = "attrs-25.1.0.tar.gz", hash = "sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e"}, ] [package.extras] @@ -57,20 +57,20 @@ tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] [[package]] name = "babel" -version = "2.16.0" +version = "2.17.0" description = "Internationalization utilities" optional = false python-versions = ">=3.8" files = [ - {file = "babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b"}, - {file = "babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316"}, + {file = "babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2"}, + {file = "babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d"}, ] [package.dependencies] pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} [package.extras] -dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] +dev = ["backports.zoneinfo", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata"] [[package]] name = "black" @@ -145,13 +145,13 @@ requests = "*" [[package]] name = "cachetools" -version = "5.5.0" +version = "5.5.1" description = "Extensible memoizing collections and decorators" optional = false python-versions = ">=3.7" files = [ - {file = "cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292"}, - {file = "cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a"}, + {file = "cachetools-5.5.1-py3-none-any.whl", hash = "sha256:b76651fdc3b24ead3c648bbdeeb940c1b04d365b38b4af66788f9ec4a81d42bb"}, + {file = "cachetools-5.5.1.tar.gz", hash = "sha256:70f238fbba50383ef62e55c6aff6d9673175fe59f7c6782c7a0b9e38f4a9df95"}, ] [[package]] @@ -229,13 +229,13 @@ test = ["coverage (>=7)", "hypothesis", "pytest"] [[package]] name = "certifi" -version = "2024.12.14" +version = "2025.1.31" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56"}, - {file = "certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db"}, + {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, + {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, ] [[package]] @@ -732,13 +732,13 @@ testing = ["hatch", "pre-commit", "pytest", "tox"] [[package]] name = "flake8" -version = "7.1.1" +version = "7.1.2" description = "the modular source code checker: pep8 pyflakes and co" optional = false python-versions = ">=3.8.1" files = [ - {file = "flake8-7.1.1-py2.py3-none-any.whl", hash = "sha256:597477df7860daa5aa0fdd84bf5208a043ab96b8e96ab708770ae0364dd03213"}, - {file = "flake8-7.1.1.tar.gz", hash = "sha256:049d058491e228e03e67b390f311bbf88fce2dbaa8fa673e7aea87b7198b8d38"}, + {file = "flake8-7.1.2-py2.py3-none-any.whl", hash = "sha256:1cbc62e65536f65e6d754dfe6f1bada7f5cf392d6f5db3c2b85892466c3e7c1a"}, + {file = "flake8-7.1.2.tar.gz", hash = "sha256:c586ffd0b41540951ae41af572e6790dbd49fc12b3aa2541685d253d9bd504bd"}, ] [package.dependencies] @@ -1432,13 +1432,13 @@ files = [ [[package]] name = "pydantic" -version = "2.10.5" +version = "2.10.6" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic-2.10.5-py3-none-any.whl", hash = "sha256:4dd4e322dbe55472cb7ca7e73f4b63574eecccf2835ffa2af9021ce113c83c53"}, - {file = "pydantic-2.10.5.tar.gz", hash = "sha256:278b38dbbaec562011d659ee05f63346951b3a248a6f3642e1bc68894ea2b4ff"}, + {file = "pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584"}, + {file = "pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236"}, ] [package.dependencies] @@ -1689,13 +1689,13 @@ testing = ["filelock"] [[package]] name = "pytz" -version = "2024.2" +version = "2025.1" description = "World timezone definitions, modern and historical" optional = false python-versions = "*" files = [ - {file = "pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725"}, - {file = "pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a"}, + {file = "pytz-2025.1-py2.py3-none-any.whl", hash = "sha256:89dd22dca55b46eac6eda23b2d72721bf1bdfef212645d81513ef5d03038de57"}, + {file = "pytz-2025.1.tar.gz", hash = "sha256:c2db42be2a2518b28e65f9207c4d05e6ff547d1efa4086469ef855e4ab70178e"}, ] [[package]] @@ -1978,6 +1978,17 @@ files = [ lint = ["docutils-stubs", "flake8", "mypy"] test = ["pytest"] +[[package]] +name = "standard-imghdr" +version = "3.13.0" +description = "Standard library imghdr redistribution. \"dead battery\"." +optional = false +python-versions = "*" +files = [ + {file = "standard_imghdr-3.13.0-py3-none-any.whl", hash = "sha256:30a1bff5465605bb496f842a6ac3cc1f2131bf3025b0da28d4877d6d4b7cc8e9"}, + {file = "standard_imghdr-3.13.0.tar.gz", hash = "sha256:8d9c68058d882f6fc3542a8d39ef9ff94d2187dc90bd0c851e0902776b7b7a42"}, +] + [[package]] name = "tomli" version = "2.2.1" @@ -2216,4 +2227,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.8.1" -content-hash = "eb08a306a62b6c8f50a03d5382e903fdb1ed271889622feff8121cc45b3b9626" +content-hash = "1a67fefdf92b7e688cedd4c46b8ba19fe0eed44340b2d407f79fd96567396eaa" diff --git a/pycardano/__init__.py b/pycardano/__init__.py index 5fd5fe05..46d24da2 100644 --- a/pycardano/__init__.py +++ b/pycardano/__init__.py @@ -7,12 +7,14 @@ from .coinselection import * from .crypto import * from .exception import * +from .governance import * from .hash import * from .key import * from .metadata import * from .nativescript import * from .network import * from .plutus import * +from .pool_params import * from .serialization import * from .transaction import * from .txbuilder import * diff --git a/pycardano/certificate.py b/pycardano/certificate.py index efbf1137..84aa04d1 100644 --- a/pycardano/certificate.py +++ b/pycardano/certificate.py @@ -6,7 +6,11 @@ from pycardano.exception import DeserializeException from pycardano.hash import AnchorDataHash, PoolKeyHash, ScriptHash, VerificationKeyHash -from pycardano.serialization import ArrayCBORSerializable, limit_primitive_type +from pycardano.serialization import ( + ArrayCBORSerializable, + CodedSerializable, + limit_primitive_type, +) __all__ = [ "Certificate", @@ -47,7 +51,10 @@ class Anchor(ArrayCBORSerializable): """ url: str + """The URL pointing to the anchor's resource location""" + data_hash: AnchorDataHash + """The hash of the data associated with this anchor""" @dataclass(repr=False) @@ -61,6 +68,7 @@ class StakeCredential(ArrayCBORSerializable): _CODE: Optional[int] = field(init=False, default=None) credential: Union[VerificationKeyHash, ScriptHash] + """The actual credential, either a verification key hash or script hash""" def __post_init__(self): if isinstance(self.credential, VerificationKeyHash): @@ -80,104 +88,115 @@ def from_primitive( else: raise DeserializeException(f"Invalid StakeCredential type {values[0]}") + def __hash__(self): + return hash(self.to_cbor()) + @dataclass(repr=False) -class StakeRegistration(ArrayCBORSerializable): - """Represents a stake address registration certificate in the Cardano blockchain. +class DRepCredential(StakeCredential): + """Represents a Delegate Representative (DRep) credential. - This certificate is used to register a stake address on the blockchain, - enabling participation in staking and delegation. + This credential type is specifically used for DReps in the governance system, + inheriting from StakeCredential. """ - _CODE: int = field(init=False, default=0) + pass - stake_credential: StakeCredential - def __post_init__(self): - self._CODE = 0 +@unique +class DRepKind(Enum): + """Enumerates the different types of Delegate Representatives (DReps). - @classmethod - @limit_primitive_type(list, tuple) - def from_primitive( - cls: Type[StakeRegistration], values: Union[list, tuple] - ) -> StakeRegistration: - if values[0] == 0: - return cls(stake_credential=StakeCredential.from_primitive(values[1])) - else: - raise DeserializeException(f"Invalid StakeRegistration type {values[0]}") + Defines the possible kinds of DReps in the Cardano governance system: + - VERIFICATION_KEY_HASH: A DRep identified by a verification key hash + - SCRIPT_HASH: A DRep identified by a script hash + - ALWAYS_ABSTAIN: A special DRep that always abstains from voting + - ALWAYS_NO_CONFIDENCE: A special DRep that always votes no confidence + """ + + VERIFICATION_KEY_HASH = 0 + SCRIPT_HASH = 1 + ALWAYS_ABSTAIN = 2 + ALWAYS_NO_CONFIDENCE = 3 @dataclass(repr=False) -class StakeDeregistration(ArrayCBORSerializable): - """Represents a stake address deregistration certificate in the Cardano blockchain. +class DRep(ArrayCBORSerializable): + """Represents a Delegate Representative (DRep) in the Cardano governance system. - This certificate is used to deregister a stake address, withdrawing it from - participation in staking and delegation. + DReps are entities that can represent stake holders in governance decisions. """ - _CODE: int = field(init=False, default=1) - - stake_credential: StakeCredential + kind: DRepKind + """The type of DRep (verification key, script hash, always abstain, or always no confidence)""" - def __post_init__(self): - self._CODE = 1 + credential: Optional[Union[VerificationKeyHash, ScriptHash]] = field( + default=None, metadata={"optional": True} + ) + """The credential associated with this DRep, if applicable""" @classmethod - @limit_primitive_type(list, tuple) - def from_primitive( - cls: Type[StakeDeregistration], values: Union[list, tuple] - ) -> StakeDeregistration: - if values[0] == 1: - return cls(StakeCredential.from_primitive(values[1])) + @limit_primitive_type(list) + def from_primitive(cls: Type[DRep], values: Union[list, tuple]) -> DRep: + try: + kind = DRepKind(values[0]) + except ValueError: + raise DeserializeException(f"Invalid DRep type {values[0]}") + + if kind == DRepKind.VERIFICATION_KEY_HASH: + return cls(kind=kind, credential=VerificationKeyHash(values[1])) + elif kind == DRepKind.SCRIPT_HASH: + return cls(kind=kind, credential=ScriptHash(values[1])) + elif kind == DRepKind.ALWAYS_ABSTAIN: + return cls(kind=kind) + elif kind == DRepKind.ALWAYS_NO_CONFIDENCE: + return cls(kind=kind) else: - raise DeserializeException(f"Invalid StakeDeregistration type {values[0]}") + raise DeserializeException(f"Invalid DRep type {values[0]}") + + def to_primitive(self): + if self.credential is not None: + return [self.kind.value, self.credential.to_primitive()] + return [self.kind.value] @dataclass(repr=False) -class StakeDelegation(ArrayCBORSerializable): - """Represents a stake delegation certificate in the Cardano blockchain. +class StakeRegistration(CodedSerializable): + """Certificate for registering a stake credential.""" - This certificate is used to delegate stake to a specific stake pool, - allowing participation in the proof-of-stake protocol. - """ + _CODE: int = field(init=False, default=0) + stake_credential: StakeCredential + """The stake credential being registered""" - _CODE: int = field(init=False, default=2) +@dataclass(repr=False) +class StakeDeregistration(CodedSerializable): + """Certificate for deregistering a stake credential.""" + + _CODE: int = field(init=False, default=1) stake_credential: StakeCredential + """The stake credential being deregistered""" - pool_keyhash: PoolKeyHash - def __post_init__(self): - self._CODE = 2 +@dataclass(repr=False) +class StakeDelegation(CodedSerializable): + """Certificate for delegating stake to a stake pool.""" - @classmethod - @limit_primitive_type(list, tuple) - def from_primitive( - cls: Type[StakeDelegation], values: Union[list, tuple] - ) -> StakeDelegation: - 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]}") + _CODE: int = field(init=False, default=2) + stake_credential: StakeCredential + """The stake credential being delegated""" + + pool_keyhash: PoolKeyHash + """The hash of the pool's key to delegate to""" @dataclass(repr=False) -class PoolRegistration(ArrayCBORSerializable): - """Represents a stake pool registration certificate in the Cardano blockchain. - - This certificate is used to register a new stake pool on the network, - including all its operational parameters and metadata. - """ +class PoolRegistration(CodedSerializable): + """Certificate for registering a stake pool.""" _CODE: int = field(init=False, default=3) - pool_params: PoolParams - - def __post_init__(self): - self._CODE = 3 + """The parameters defining the stake pool's configuration""" def to_primitive(self): pool_params = self.pool_params.to_primitive() @@ -204,483 +223,177 @@ def from_primitive( @dataclass(repr=False) -class PoolRetirement(ArrayCBORSerializable): - """Represents a stake pool retirement certificate in the Cardano blockchain. - - This certificate is used to signal that a stake pool will cease operations - at a specified epoch. - """ +class PoolRetirement(CodedSerializable): + """Certificate for retiring a stake pool.""" _CODE: int = field(init=False, default=4) - pool_keyhash: PoolKeyHash - epoch: int + """The hash of the pool's key that is being retired""" - def __post_init__(self): - self._CODE = 4 - - @classmethod - @limit_primitive_type(list, tuple) - def from_primitive( - cls: Type[PoolRetirement], values: Union[list, tuple] - ) -> PoolRetirement: - 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]}") + epoch: int + """The epoch number when the pool will retire""" @dataclass(repr=False) -class StakeRegistrationConway(ArrayCBORSerializable): - """Represents a stake registration certificate in the Conway era of Cardano. - - This certificate is used to register stake rights with an associated deposit - amount in the Conway era. - """ +class StakeRegistrationConway(CodedSerializable): + """Certificate for registering a stake credential in the Conway era.""" _CODE: int = field(init=False, default=7) - stake_credential: StakeCredential - coin: int + """The stake credential being registered""" - def __post_init__(self): - self._CODE = 7 - - @classmethod - @limit_primitive_type(list) - def from_primitive( - cls: Type[StakeRegistrationConway], values: Union[list, tuple] - ) -> StakeRegistrationConway: - if values[0] == 7: - return cls( - stake_credential=StakeCredential.from_primitive(values[1]), - coin=values[2], - ) - else: - raise DeserializeException( - f"Invalid StakeRegistrationConway type {values[0]}" - ) + coin: int + """The amount of coins associated with this registration""" @dataclass(repr=False) -class StakeDeregistrationConway(ArrayCBORSerializable): - """Represents a stake deregistration certificate in the Conway era of Cardano. - - This certificate is used to deregister stake rights and reclaim the deposit - in the Conway era. - """ +class StakeDeregistrationConway(CodedSerializable): + """Certificate for deregistering a stake credential in the Conway era.""" _CODE: int = field(init=False, default=8) - stake_credential: StakeCredential - coin: int + """The stake credential being deregistered""" - def __post_init__(self): - self._CODE = 8 - - @classmethod - @limit_primitive_type(list) - def from_primitive( - cls: Type[StakeDeregistrationConway], values: Union[list, tuple] - ) -> StakeDeregistrationConway: - if values[0] == 8: - return cls( - stake_credential=StakeCredential.from_primitive(values[1]), - coin=values[2], - ) - else: - raise DeserializeException( - f"Invalid StakeDeregistrationConway type {values[0]}" - ) + coin: int + """The amount of coins associated with this deregistration""" @dataclass(repr=False) -class VoteDelegation(ArrayCBORSerializable): - """Represents a vote delegation certificate in the Conway era of Cardano. - - This certificate is used to delegate voting rights to a DRep (Delegate Representative). - """ +class VoteDelegation(CodedSerializable): + """Certificate for delegating voting power to a DRep.""" _CODE: int = field(init=False, default=9) - stake_credential: StakeCredential - drep: DRep + """The stake credential delegating its voting power""" - def __post_init__(self): - self._CODE = 9 - - @classmethod - @limit_primitive_type(list) - def from_primitive( - cls: Type[VoteDelegation], values: Union[list, tuple] - ) -> VoteDelegation: - if values[0] == 9: - return cls( - stake_credential=StakeCredential.from_primitive(values[1]), - drep=DRep.from_primitive(values[2]), - ) - else: - raise DeserializeException(f"Invalid VoteDelegation type {values[0]}") + drep: DRep + """The DRep receiving the voting power delegation""" @dataclass(repr=False) -class StakeAndVoteDelegation(ArrayCBORSerializable): - """Represents a combined stake and vote delegation certificate. - - This certificate allows simultaneous delegation of both staking rights to a pool - and voting rights to a DRep. - """ +class StakeAndVoteDelegation(CodedSerializable): + """Certificate for delegating both stake and voting power.""" _CODE: int = field(init=False, default=10) - stake_credential: StakeCredential - pool_keyhash: PoolKeyHash - drep: DRep + """The stake credential being delegated""" - def __post_init__(self): - self._CODE = 10 + pool_keyhash: PoolKeyHash + """The hash of the pool's key receiving the stake delegation""" - @classmethod - @limit_primitive_type(list) - def from_primitive( - cls: Type[StakeAndVoteDelegation], values: Union[list, tuple] - ) -> StakeAndVoteDelegation: - if values[0] == 10: - return cls( - stake_credential=StakeCredential.from_primitive(values[1]), - pool_keyhash=PoolKeyHash.from_primitive(values[2]), - drep=DRep.from_primitive(values[3]), - ) - else: - raise DeserializeException( - f"Invalid StakeAndVoteDelegation type {values[0]}" - ) + drep: DRep + """The DRep receiving the voting power delegation""" @dataclass(repr=False) -class StakeRegistrationAndDelegation(ArrayCBORSerializable): - """Represents a combined stake registration and delegation certificate. - - This certificate allows simultaneous registration of stake rights and - delegation to a stake pool. - """ +class StakeRegistrationAndDelegation(CodedSerializable): + """Certificate for registering stake and delegating to a pool.""" _CODE: int = field(init=False, default=11) - stake_credential: StakeCredential - pool_keyhash: PoolKeyHash - coin: int + """The stake credential being registered and delegated""" - def __post_init__(self): - self._CODE = 11 + pool_keyhash: PoolKeyHash + """The hash of the pool's key receiving the delegation""" - @classmethod - @limit_primitive_type(list) - def from_primitive( - cls: Type[StakeRegistrationAndDelegation], values: Union[list, tuple] - ) -> StakeRegistrationAndDelegation: - if values[0] == 11: - return cls( - stake_credential=StakeCredential.from_primitive(values[1]), - pool_keyhash=PoolKeyHash.from_primitive(values[2]), - coin=values[3], - ) - else: - raise DeserializeException(f"Invalid {cls.__name__} type {values[0]}") + coin: int + """The amount of coins associated with this registration""" @dataclass(repr=False) -class StakeRegistrationAndVoteDelegation(ArrayCBORSerializable): - """Represents a combined stake registration and vote delegation certificate. - - This certificate allows simultaneous registration of stake rights and - delegation of voting rights to a DRep. - """ +class StakeRegistrationAndVoteDelegation(CodedSerializable): + """Certificate for registering stake and delegating voting power.""" _CODE: int = field(init=False, default=12) - stake_credential: StakeCredential - drep: DRep - coin: int + """The stake credential being registered""" - def __post_init__(self): - self._CODE = 12 + drep: DRep + """The DRep receiving the voting power delegation""" - @classmethod - @limit_primitive_type(list) - def from_primitive( - cls: Type[StakeRegistrationAndVoteDelegation], values: Union[list, tuple] - ) -> StakeRegistrationAndVoteDelegation: - if values[0] == 12: - return cls( - stake_credential=StakeCredential.from_primitive(values[1]), - drep=DRep.from_primitive(values[2]), - coin=values[3], - ) - else: - raise DeserializeException(f"Invalid {cls.__name__} type {values[0]}") + coin: int + """The amount of coins associated with this registration""" @dataclass(repr=False) -class StakeRegistrationAndDelegationAndVoteDelegation(ArrayCBORSerializable): - """Represents a certificate combining stake registration, stake delegation, and vote delegation. - - This certificate allows simultaneous registration of stake rights, delegation to a - stake pool, and delegation of voting rights to a DRep. - """ +class StakeRegistrationAndDelegationAndVoteDelegation(CodedSerializable): + """Certificate for registering stake and delegating both stake and voting power.""" _CODE: int = field(init=False, default=13) - stake_credential: StakeCredential - pool_keyhash: PoolKeyHash - drep: DRep - coin: int - - def __post_init__(self): - self._CODE = 13 - - @classmethod - @limit_primitive_type(list) - def from_primitive( - cls: Type[StakeRegistrationAndDelegationAndVoteDelegation], - values: Union[list, tuple], - ) -> StakeRegistrationAndDelegationAndVoteDelegation: - if values[0] == 13: - return cls( - stake_credential=StakeCredential.from_primitive(values[1]), - pool_keyhash=PoolKeyHash.from_primitive(values[2]), - drep=DRep.from_primitive(values[3]), - coin=values[4], - ) - else: - raise DeserializeException(f"Invalid {cls.__name__} type {values[0]}") - - -@dataclass(repr=False) -class DRepCredential(StakeCredential): - """Represents a Delegate Representative (DRep) credential. - - This credential type is specifically used for DReps in the governance system, - inheriting from StakeCredential. - """ + """The stake credential being registered and delegated""" - pass - - -@unique -class DRepKind(Enum): - """Enumerates the different types of Delegate Representatives (DReps). - - Defines the possible kinds of DReps in the Cardano governance system: - - VERIFICATION_KEY_HASH: A DRep identified by a verification key hash - - SCRIPT_HASH: A DRep identified by a script hash - - ALWAYS_ABSTAIN: A special DRep that always abstains from voting - - ALWAYS_NO_CONFIDENCE: A special DRep that always votes no confidence - """ - - VERIFICATION_KEY_HASH = 0 - SCRIPT_HASH = 1 - ALWAYS_ABSTAIN = 2 - ALWAYS_NO_CONFIDENCE = 3 - - -@dataclass(repr=False) -class DRep(ArrayCBORSerializable): - """Represents a Delegate Representative (DRep) in the Cardano governance system. - - DReps are entities that can represent stake holders in governance decisions. - """ - - kind: DRepKind - credential: Optional[Union[VerificationKeyHash, ScriptHash]] = field( - default=None, metadata={"optional": True} - ) - - @classmethod - @limit_primitive_type(list) - def from_primitive(cls: Type[DRep], values: Union[list, tuple]) -> DRep: - try: - kind = DRepKind(values[0]) - except ValueError: - raise DeserializeException(f"Invalid DRep type {values[0]}") + pool_keyhash: PoolKeyHash + """The hash of the pool's key receiving the stake delegation""" - if kind == DRepKind.VERIFICATION_KEY_HASH: - return cls(kind=kind, credential=VerificationKeyHash(values[1])) - elif kind == DRepKind.SCRIPT_HASH: - return cls(kind=kind, credential=ScriptHash(values[1])) - elif kind == DRepKind.ALWAYS_ABSTAIN: - return cls(kind=kind) - elif kind == DRepKind.ALWAYS_NO_CONFIDENCE: - return cls(kind=kind) - else: - raise DeserializeException(f"Invalid DRep type {values[0]}") + drep: DRep + """The DRep receiving the voting power delegation""" - def to_primitive(self): - if self.credential is not None: - return [self.kind.value, self.credential.to_primitive()] - return [self.kind.value] + coin: int + """The amount of coins associated with this registration""" @dataclass(repr=False) -class AuthCommitteeHotCertificate(ArrayCBORSerializable): - """Represents an authorization certificate for a Constitutional Committee hot key. - - This certificate authorizes a hot key for use by a Constitutional Committee member. - """ +class AuthCommitteeHotCertificate(CodedSerializable): + """Certificate for authorizing a committee hot key.""" _CODE: int = field(init=False, default=14) - committee_cold_credential: StakeCredential - committee_hot_credential: StakeCredential - - def __post_init__(self): - self._CODE = 14 + """The cold credential of the committee member""" - @classmethod - @limit_primitive_type(list) - def from_primitive( - cls: Type[AuthCommitteeHotCertificate], values: Union[list, tuple] - ) -> AuthCommitteeHotCertificate: - if values[0] == 14: - return cls( - committee_cold_credential=StakeCredential.from_primitive(values[1]), - committee_hot_credential=StakeCredential.from_primitive(values[2]), - ) - else: - raise DeserializeException( - f"Invalid AuthCommitteeHotCertificate type {values[0]}" - ) + committee_hot_credential: StakeCredential + """The hot credential being authorized""" @dataclass(repr=False) -class ResignCommitteeColdCertificate(ArrayCBORSerializable): - """Represents a resignation certificate for a Constitutional Committee member. - - This certificate is used when a committee member wishes to resign their position. - """ +class ResignCommitteeColdCertificate(CodedSerializable): + """Certificate for resigning from the constitutional committee.""" _CODE: int = field(init=False, default=15) - committee_cold_credential: StakeCredential - anchor: Optional[Anchor] + """The cold credential of the resigning committee member""" - def __post_init__(self): - self._CODE = 15 - - @classmethod - @limit_primitive_type(list) - def from_primitive( - cls: Type[ResignCommitteeColdCertificate], values: Union[list, tuple] - ) -> ResignCommitteeColdCertificate: - if values[0] == 15: - return cls( - committee_cold_credential=StakeCredential.from_primitive(values[1]), - anchor=( - Anchor.from_primitive(values[2]) if values[2] is not None else None - ), - ) - else: - raise DeserializeException( - f"Invalid ResignCommitteeColdCertificate type {values[0]}" - ) + anchor: Optional[Anchor] + """Optional anchor containing additional metadata about the resignation""" @dataclass(repr=False) -class RegDRepCert(ArrayCBORSerializable): - """Represents a certificate for registering a new Delegate Representative (DRep). - - This certificate is used to register a new DRep in the governance system with an - associated deposit amount and optional metadata. - """ +class RegDRepCert(CodedSerializable): + """Certificate for registering as a delegate representative (DRep).""" _CODE: int = field(init=False, default=16) - drep_credential: DRepCredential - coin: int - anchor: Optional[Anchor] = field(default=None) + """The credential of the DRep being registered""" - def __post_init__(self): - self._CODE = 16 + coin: int + """The amount of coins associated with this registration""" - @classmethod - @limit_primitive_type(list) - def from_primitive( - cls: Type[RegDRepCert], values: Union[list, tuple] - ) -> RegDRepCert: - if values[0] == 16: - return cls( - drep_credential=DRepCredential.from_primitive(values[1]), - coin=values[2], - anchor=( - Anchor.from_primitive(values[3]) if values[3] is not None else None - ), - ) - else: - raise DeserializeException(f"Invalid RegDRepCert type {values[0]}") + anchor: Optional[Anchor] = field(default=None) + """Optional anchor containing additional metadata about the DRep""" @dataclass(repr=False) -class UnregDRepCertificate(ArrayCBORSerializable): - """Represents a certificate for unregistering a Delegate Representative (DRep). - - This certificate is used to remove a DRep from the governance system. - """ +class UnregDRepCertificate(CodedSerializable): + """Certificate for unregistering as a delegate representative (DRep).""" _CODE: int = field(init=False, default=17) - drep_credential: DRepCredential - coin: int + """The credential of the DRep being unregistered""" - def __post_init__(self): - self._CODE = 17 - - @classmethod - @limit_primitive_type(list) - def from_primitive( - cls: Type[UnregDRepCertificate], values: Union[list, tuple] - ) -> UnregDRepCertificate: - if values[0] == 17: - return cls( - drep_credential=DRepCredential.from_primitive(values[1]), - coin=values[2], - ) - else: - raise DeserializeException(f"Invalid UnregDRepCertificate type {values[0]}") + coin: int + """The amount of coins associated with this unregistration""" @dataclass(repr=False) -class UpdateDRepCertificate(ArrayCBORSerializable): - """Represents a certificate for updating a Delegate Representative (DRep)'s metadata. - - This certificate is used to modify the metadata associated with a DRep. - """ +class UpdateDRepCertificate(CodedSerializable): + """Certificate for updating delegate representative (DRep) metadata.""" _CODE: int = field(init=False, default=18) - drep_credential: DRepCredential - anchor: Optional[Anchor] + """The credential of the DRep being updated""" - def __post_init__(self): - self._CODE = 18 - - @classmethod - @limit_primitive_type(list) - def from_primitive( - cls: Type[UpdateDRepCertificate], values: Union[list, tuple] - ) -> UpdateDRepCertificate: - if values[0] == 18: - return cls( - drep_credential=DRepCredential.from_primitive(values[1]), - anchor=( - Anchor.from_primitive(values[2]) if values[2] is not None else None - ), - ) - else: - raise DeserializeException( - f"Invalid UpdateDRepCertificate type {values[0]}" - ) + anchor: Optional[Anchor] + """Optional anchor containing the updated metadata""" Certificate = Union[ diff --git a/pycardano/governance.py b/pycardano/governance.py new file mode 100644 index 00000000..ab51646f --- /dev/null +++ b/pycardano/governance.py @@ -0,0 +1,554 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum, unique +from fractions import Fraction +from typing import Dict, Optional, Tuple, Type, Union + +from pycardano.certificate import Anchor, StakeCredential +from pycardano.exception import DeserializeException +from pycardano.hash import PolicyHash, ScriptHash, TransactionId, VerificationKeyHash +from pycardano.plutus import ExecutionUnits +from pycardano.serialization import ( + ArrayCBORSerializable, + CodedSerializable, + DictCBORSerializable, + MapCBORSerializable, + OrderedSet, + Primitive, + limit_primitive_type, +) + +__all__ = [ + "CommitteeColdCredential", + "CommitteeColdCredentialEpochMap", + "ParameterChangeAction", + "HardForkInitiationAction", + "TreasuryWithdrawalsAction", + "NoConfidence", + "UpdateCommittee", + "NewConstitution", + "InfoAction", + "GovActionId", + "VotingProcedure", + "VotingProcedures", + "VoterType", + "Voter", + "GovAction", + "Vote", + "Anchor", + "GovActionIdToVotingProcedure", + "ProposalProcedure", + "ProtocolParamUpdate", + "ExUnitPrices", + "DRepVotingThresholds", + "TreasuryWithdrawal", + "PoolVotingThresholds", +] + + +class CommitteeColdCredential(StakeCredential): + """Represents a cold credential for a committee member.""" + + pass + + +@unique +class Vote(Enum): + """Represents possible voting choices in the governance system.""" + + NO = 0 + YES = 1 + ABSTAIN = 2 + + +@dataclass(repr=False) +class GovActionId(ArrayCBORSerializable): + """Represents a unique identifier for a governance action. + + This identifier consists of a transaction ID and an index within that transaction. + """ + + transaction_id: TransactionId + """The transaction ID where this governance action was submitted""" + + gov_action_index: int + """The index of this governance action within the transaction (0-65535)""" + + def __post_init__(self): + if not 0 <= self.gov_action_index <= 65535: # uint .size 2 + raise ValueError("gov_action_index must be between 0 and 65535") + + @classmethod + @limit_primitive_type(list, tuple) + def from_primitive( + cls: Type[GovActionId], values: Union[list, tuple] + ) -> GovActionId: + return cls(transaction_id=TransactionId(values[0]), gov_action_index=values[1]) + + def __hash__(self): + return hash((self.transaction_id, self.gov_action_index)) + + +@dataclass(repr=False) +class ExUnitPrices(ArrayCBORSerializable): + """Represents execution unit prices for memory and CPU steps.""" + + mem_price: Fraction + """Memory price as a nonnegative interval (numerator, denominator)""" + + step_price: Fraction + """Step price as a nonnegative interval (numerator, denominator)""" + + +@dataclass(repr=False) +class DRepVotingThresholds(ArrayCBORSerializable): + """Represents DRep voting thresholds as an array of unit intervals.""" + + motion_no_confidence: Fraction + """Threshold for no confidence motions""" + + committee_normal: Fraction + """Threshold for normal committee updates""" + + committee_no_confidence: Fraction + """Threshold for committee updates during no confidence""" + + update_constitution: Fraction + """Threshold for constitution updates""" + + hard_fork_initiation: Fraction + """Threshold for hard fork initiation""" + + pp_network_group: Fraction + """Threshold for network group protocol parameter updates""" + + pp_economic_group: Fraction + """Threshold for economic group protocol parameter updates""" + + pp_technical_group: Fraction + """Threshold for technical group protocol parameter updates""" + + pp_governance_group: Fraction + """Threshold for governance group protocol parameter updates""" + + treasury_withdrawal: Fraction + """Threshold for treasury withdrawals""" + + +@dataclass(repr=False) +class PoolVotingThresholds(ArrayCBORSerializable): + """Represents pool voting thresholds as an array of unit intervals.""" + + motion_no_confidence: Fraction + """Threshold for no confidence motions""" + + committee_normal: Fraction + """Threshold for normal committee updates""" + + committee_no_confidence: Fraction + """Threshold for committee updates during no confidence""" + + hard_fork_initiation: Fraction + """Threshold for hard fork initiation""" + + ppec_voting_threshold: Fraction + """Threshold for protocol parameter and economic group voting""" + + +@dataclass(repr=False) +class ProtocolParamUpdate(MapCBORSerializable): + """Represents a protocol parameter update.""" + + min_fee_a: Optional[int] = field( + default=None, metadata={"key": 0, "optional": True} + ) + """The 'a' parameter to calculate the minimum fee""" + + min_fee_b: Optional[int] = field( + default=None, metadata={"key": 1, "optional": True} + ) + """The 'b' parameter to calculate the minimum fee""" + + max_block_body_size: Optional[int] = field( + default=None, metadata={"key": 2, "optional": True} + ) + """Maximum block body size""" + + max_transaction_size: Optional[int] = field( + default=None, metadata={"key": 3, "optional": True} + ) + """Maximum transaction size""" + + max_block_header_size: Optional[int] = field( + default=None, metadata={"key": 4, "optional": True} + ) + """Maximum block header size""" + + key_deposit: Optional[int] = field( + default=None, metadata={"key": 5, "optional": True} + ) + """The deposit required for registering a stake key""" + + pool_deposit: Optional[int] = field( + default=None, metadata={"key": 6, "optional": True} + ) + """The deposit required for registering a stake pool""" + + maximum_epoch: Optional[int] = field( + default=None, metadata={"key": 7, "optional": True} + ) + """Maximum epoch interval (uint32)""" + + n_opt: Optional[int] = field(default=None, metadata={"key": 8, "optional": True}) + """Desired number of stake pools""" + + pool_pledge_influence: Optional[Fraction] = field( + default=None, metadata={"key": 9, "optional": True} + ) + """Pool pledge influence as a nonnegative interval (numerator, denominator)""" + + expansion_rate: Optional[Fraction] = field( + default=None, metadata={"key": 10, "optional": True} + ) + """Monetary expansion rate as a unit interval (numerator, denominator)""" + + treasury_growth_rate: Optional[Fraction] = field( + default=None, metadata={"key": 11, "optional": True} + ) + """Treasury growth rate as a unit interval (numerator, denominator)""" + + min_pool_cost: Optional[int] = field( + default=None, metadata={"key": 16, "optional": True} + ) + """Minimum pool cost""" + + ada_per_utxo_byte: Optional[int] = field( + default=None, metadata={"key": 17, "optional": True} + ) + """Ada per UTxO byte""" + + cost_models: Optional[Dict] = field( + default=None, metadata={"key": 18, "optional": True} + ) + """Cost models for script languages""" + + execution_costs: Optional[ExUnitPrices] = field( + default=None, metadata={"key": 19, "optional": True} + ) + """Execution costs (prices) for ex units""" + + max_tx_ex_units: Optional[ExecutionUnits] = field( + default=None, metadata={"key": 20, "optional": True} + ) + """Maximum execution units per transaction""" + + max_block_ex_units: Optional[ExecutionUnits] = field( + default=None, metadata={"key": 21, "optional": True} + ) + """Maximum execution units per block""" + + max_value_size: Optional[int] = field( + default=None, metadata={"key": 22, "optional": True} + ) + """Maximum value size""" + + collateral_percentage: Optional[int] = field( + default=None, metadata={"key": 23, "optional": True} + ) + """Collateral percentage""" + + max_collateral_inputs: Optional[int] = field( + default=None, metadata={"key": 24, "optional": True} + ) + """Maximum number of collateral inputs""" + + pool_voting_thresholds: Optional[PoolVotingThresholds] = field( + default=None, metadata={"key": 25, "optional": True} + ) + """Pool voting thresholds""" + + drep_voting_thresholds: Optional[DRepVotingThresholds] = field( + default=None, metadata={"key": 26, "optional": True} + ) + """DRep voting thresholds""" + + min_committee_size: Optional[int] = field( + default=None, metadata={"key": 27, "optional": True} + ) + """Minimum committee size""" + + committee_term_limit: Optional[int] = field( + default=None, metadata={"key": 28, "optional": True} + ) + """Committee term limit in epochs (uint32)""" + + governance_action_validity_period: Optional[int] = field( + default=None, metadata={"key": 29, "optional": True} + ) + """Governance action validity period in epochs (uint32)""" + + governance_action_deposit: Optional[int] = field( + default=None, metadata={"key": 30, "optional": True} + ) + """Deposit required for governance actions""" + + drep_deposit: Optional[int] = field( + default=None, metadata={"key": 31, "optional": True} + ) + """Deposit required for DRep registration""" + + drep_inactivity_period: Optional[int] = field( + default=None, metadata={"key": 32, "optional": True} + ) + """DRep inactivity period in epochs (uint32)""" + + min_fee_ref_script_cost: Optional[Fraction] = field( + default=None, metadata={"key": 33, "optional": True} + ) + """Minimum fee for reference scripts as a nonnegative interval (numerator, denominator)""" + + +@dataclass(repr=False) +class ParameterChangeAction(CodedSerializable): + """Represents a governance action to change protocol parameters.""" + + _CODE: int = field(init=False, default=0) + gov_action_id: Optional[GovActionId] + """Optional reference to a previous governance action""" + + protocol_param_update: ProtocolParamUpdate + """Dictionary containing the protocol parameters to be updated""" + + policy_hash: Optional[PolicyHash] + """Optional policy hash associated with this parameter change""" + + +@dataclass(repr=False) +class HardForkInitiationAction(CodedSerializable): + """Represents a governance action to initiate a hard fork.""" + + _CODE: int = field(init=False, default=1) + gov_action_id: Optional[GovActionId] + """Optional reference to a previous governance action""" + + protocol_version: Fraction + """The target protocol version as (major, minor) version numbers""" + + def __post_init__(self): + major, minor = self.protocol_version + if not 1 <= major <= 10: + raise ValueError("Major protocol version must be between 1 and 10") + + +class TreasuryWithdrawal(DictCBORSerializable): + """Represents a treasury withdrawal amount and destination.""" + + KEY_TYPE = bytes + + VALUE_TYPE = int + + +@dataclass(repr=False) +class TreasuryWithdrawalsAction(CodedSerializable): + """Represents a governance action to withdraw funds from the treasury.""" + + _CODE: int = field(init=False, default=2) + withdrawals: TreasuryWithdrawal + """The withdrawal amounts and their destinations""" + + policy_hash: Optional[PolicyHash] + """Optional policy hash associated with these withdrawals""" + + +@dataclass(repr=False) +class NoConfidence(CodedSerializable): + """Represents a governance action expressing no confidence.""" + + _CODE: int = field(init=False, default=3) + gov_action_id: Optional[GovActionId] + """Optional reference to a previous governance action""" + + +class CommitteeColdCredentialEpochMap(DictCBORSerializable): + """Represents a mapping of committee members to their expiration epochs.""" + + KEY_TYPE = CommitteeColdCredential + VALUE_TYPE = int + + +@dataclass(repr=False) +class UpdateCommittee(CodedSerializable): + """Represents a governance action to update the constitutional committee.""" + + _CODE: int = field(init=False, default=4) + gov_action_id: Optional[GovActionId] + """Optional reference to a previous governance action""" + + committee_cold_credentials: OrderedSet[CommitteeColdCredential] + """Set of cold credentials for committee members""" + + committee_expiration: CommitteeColdCredentialEpochMap + """Mapping of committee members to their expiration epochs""" + + quorum: Fraction # unit_interval + """The quorum threshold as a unit interval (numerator, denominator)""" + + +@dataclass(repr=False) +class NewConstitution(CodedSerializable): + """Represents a governance action to establish a new constitution.""" + + _CODE: int = field(init=False, default=5) + gov_action_id: Optional[GovActionId] + """Optional reference to a previous governance action""" + + constitution: Tuple[Anchor, Optional[ScriptHash]] + """The constitution data as (anchor, optional script hash)""" + + +@dataclass(repr=False) +class InfoAction(CodedSerializable): + """Represents an informational governance action with no direct effect.""" + + _CODE: int = field(init=False, default=6) + + +@dataclass(repr=False) +class VotingProcedure(ArrayCBORSerializable): + """Represents a voting procedure for governance actions. + + This defines how voting is conducted for a specific governance action. + """ + + vote: Vote + """The vote cast (YES, NO, or ABSTAIN)""" + + anchor: Optional[Anchor] + """Optional metadata associated with this vote""" + + @classmethod + @limit_primitive_type(list) + def from_primitive( + cls: Type[VotingProcedure], values: Union[list, tuple] + ) -> VotingProcedure: + return cls( + vote=Vote(values[0]), + anchor=Anchor.from_primitive(values[1]) if values[1] is not None else None, + ) + + def to_shallow_primitive(self) -> Primitive: + return [self.vote.value, self.anchor] + + +@unique +class VoterType(Enum): + """Represents the possible types of voters in the governance system.""" + + COMMITTEE_HOT = "committee_hot" + DREP = "drep" + STAKING_POOL = "staking_pool" + + +@dataclass(repr=False) +class Voter(ArrayCBORSerializable): + """Represents a voter in the governance system. + + Voters can be committee members, DReps, or stake pool operators. + """ + + _CODE: Optional[int] = field(init=False, default=None) + + credential: Union[VerificationKeyHash, ScriptHash] + """The credential identifying the voter""" + + voter_type: VoterType + """The type of voter (COMMITTEE_HOT, DREP, or STAKING_POOL)""" + + def __post_init__(self): + if self.voter_type == VoterType.COMMITTEE_HOT: + if isinstance(self.credential, VerificationKeyHash): + self._CODE = 0 + else: + self._CODE = 1 + elif self.voter_type == VoterType.DREP: + if isinstance(self.credential, VerificationKeyHash): + self._CODE = 2 + else: + self._CODE = 3 + elif self.voter_type == VoterType.STAKING_POOL: + if not isinstance(self.credential, VerificationKeyHash): + raise ValueError("Staking pool voter must use key hash credential") + self._CODE = 4 + else: + raise ValueError("Invalid voter_type") + + def to_shallow_primitive(self) -> Primitive: + return self._CODE, self.credential + + @classmethod + @limit_primitive_type(list, tuple) + def from_primitive(cls: Type[Voter], values: Union[list, tuple]) -> Voter: + code = values[0] + credential: Union[VerificationKeyHash, ScriptHash] + if code in (0, 2, 4): + credential = VerificationKeyHash(values[1]) + elif code in (1, 3): + credential = ScriptHash(values[1]) + else: + raise DeserializeException(f"Invalid Voter type {code}") + + voter_type = { + 0: VoterType.COMMITTEE_HOT, + 1: VoterType.COMMITTEE_HOT, + 2: VoterType.DREP, + 3: VoterType.DREP, + 4: VoterType.STAKING_POOL, + }[code] + + return cls(credential=credential, voter_type=voter_type) + + def __hash__(self): + return hash((self._CODE, self.credential)) + + +class GovActionIdToVotingProcedure(DictCBORSerializable): + """Represents a mapping of governance action IDs to their voting procedures.""" + + KEY_TYPE = GovActionId + VALUE_TYPE = VotingProcedure + + +class VotingProcedures(DictCBORSerializable): + """Represents a mapping of voters to their voting procedures.""" + + KEY_TYPE = Voter + VALUE_TYPE = GovActionIdToVotingProcedure + + +GovAction = Union[ + ParameterChangeAction, + HardForkInitiationAction, + TreasuryWithdrawalsAction, + NoConfidence, + UpdateCommittee, + NewConstitution, + InfoAction, +] + + +@dataclass(repr=False) +class ProposalProcedure(ArrayCBORSerializable): + """Represents a proposal procedure for governance actions.""" + + deposit: int + """The deposit required to submit a proposal""" + + reward_account: bytes + """The reward account for the proposal""" + + gov_action: GovAction + """The governance actions to be proposed""" + + anchor: Anchor + """The metadata anchor for the proposal""" diff --git a/pycardano/hash.py b/pycardano/hash.py index 000beabc..793c982e 100644 --- a/pycardano/hash.py +++ b/pycardano/hash.py @@ -18,6 +18,8 @@ "ConstrainedBytes", "VerificationKeyHash", "ScriptHash", + "PolicyHash", + "PolicyId", "ScriptDataHash", "TransactionId", "DatumHash", @@ -109,6 +111,14 @@ class ScriptHash(ConstrainedBytes): MAX_SIZE = MIN_SIZE = SCRIPT_HASH_SIZE +class PolicyHash(ScriptHash): + pass + + +class PolicyId(ScriptHash): + pass + + class ScriptDataHash(ConstrainedBytes): """Hash of script data. See https://github.com/input-output-hk/cardano-ledger/blob/525844be05adae151e82069dcd0000f3301ca0d0/eras/alonzo/ diff --git a/pycardano/plutus.py b/pycardano/plutus.py index 4386b99f..da089c26 100644 --- a/pycardano/plutus.py +++ b/pycardano/plutus.py @@ -948,6 +948,8 @@ class RedeemerTag(CBORSerializable, Enum): MINT = 1 CERTIFICATE = 2 WITHDRAWAL = 3 + VOTING = 4 + PROPOSING = 5 def to_primitive(self) -> int: return self.value diff --git a/pycardano/pool_params.py b/pycardano/pool_params.py index 3ecaede1..39137d55 100644 --- a/pycardano/pool_params.py +++ b/pycardano/pool_params.py @@ -234,26 +234,13 @@ class PoolMetadata(ArrayCBORSerializable): pool_metadata_hash: PoolMetadataHash -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) class PoolParams(ArrayCBORSerializable): operator: PoolKeyHash vrf_keyhash: VrfKeyHash pledge: int cost: int - margin: Fraction = field(metadata={"object_hook": fraction_parser}) + margin: Fraction reward_account: RewardAccountHash pool_owners: List[VerificationKeyHash] relays: Optional[List[Relay]] = None diff --git a/pycardano/serialization.py b/pycardano/serialization.py index efbefe93..c25139bc 100644 --- a/pycardano/serialization.py +++ b/pycardano/serialization.py @@ -6,9 +6,10 @@ import typing from collections import OrderedDict, UserList, defaultdict from copy import deepcopy -from dataclasses import Field, dataclass, fields +from dataclasses import Field, dataclass, field, fields from datetime import datetime from decimal import Decimal +from fractions import Fraction from functools import wraps from inspect import getfullargspec, isclass from typing import ( @@ -39,8 +40,15 @@ logger.warning("Failed to remove semantic decoder for CBOR tag 258", e) pass -from cbor2 import CBOREncoder, CBORSimpleValue, CBORTag, dumps, loads, undefined -from frozendict import frozendict +from cbor2 import ( + CBOREncoder, + CBORSimpleValue, + CBORTag, + FrozenDict, + dumps, + loads, + undefined, +) from frozenlist import FrozenList from pprintpp import pformat @@ -61,6 +69,7 @@ "limit_primitive_type", "OrderedSet", "NonEmptyOrderedSet", + "CodedSerializable", ] T = TypeVar("T") @@ -122,8 +131,9 @@ class RawCBOR: CBORSimpleValue, CBORTag, set, + Fraction, frozenset, - frozendict, + FrozenDict, FrozenList, IndefiniteFrozenList, ByteString, @@ -151,7 +161,8 @@ class RawCBOR: CBORTag, set, frozenset, - frozendict, + FrozenDict, + Fraction, FrozenList, IndefiniteFrozenList, ) @@ -201,7 +212,7 @@ def default_encoder( RawCBOR, FrozenList, IndefiniteFrozenList, - frozendict, + FrozenDict, ), ), ( f"Type of input value is not CBORSerializable, " f"got {type(value)} instead." @@ -227,7 +238,7 @@ def default_encoder( encoder.write(value.cbor) elif isinstance(value, FrozenList): encoder.encode(list(value)) - elif isinstance(value, frozendict): + elif isinstance(value, FrozenDict): encoder.encode(dict(value)) else: encoder.encode(value.to_validated_primitive()) @@ -292,7 +303,7 @@ def _dfs(value, freeze=False): for k, v in value.items(): _dict[_dfs(k, freeze=True)] = _dfs(v, freeze) if freeze: - return frozendict(_dict) + return FrozenDict(_dict) return _dict elif isinstance(value, set): _set = set(_dfs(v, freeze=True) for v in value) @@ -344,7 +355,7 @@ def _check_recursive(value, type_hint): return _check_recursive(value, type_hint.__args__[0]) elif origin is Union: return any(_check_recursive(value, arg) for arg in type_hint.__args__) - elif origin is Dict or isinstance(value, (dict, frozendict)): + elif origin is Dict or isinstance(value, (dict, FrozenDict)): key_type, value_type = type_hint.__args__ return all( _check_recursive(k, key_type) and _check_recursive(v, value_type) @@ -810,8 +821,8 @@ def to_shallow_primitive(self) -> Primitive: return primitives @classmethod - @limit_primitive_type(dict) - def from_primitive(cls: Type[MapBase], values: dict) -> MapBase: + @limit_primitive_type(dict, FrozenDict) + def from_primitive(cls: Type[MapBase], values: Union[dict, FrozenDict]) -> MapBase: """Restore a primitive value to its original class type. Args: @@ -1034,10 +1045,14 @@ def from_primitive( value.value = [type_arg.from_primitive(v) for v in value.value] return cls(value.value, use_tag=True) + use_tag = isinstance(value, set) + if isinstance(value, (list, tuple, set)): if isclass(type_arg) and issubclass(type_arg, CBORSerializable): value = [type_arg.from_primitive(v) for v in value] - return cls(list(value), use_tag=False) + + # If the value is a set, we know it is coming from a CBORTag (#6.258) + return cls(list(value), use_tag=use_tag) raise ValueError(f"Cannot deserialize {value} to {cls}") @@ -1060,3 +1075,48 @@ def from_primitive( if not result: raise ValueError("NonEmptyOrderedSet cannot be empty") return result + + +@dataclass(repr=False) +class CodedSerializable(ArrayCBORSerializable): + """A base class for CBORSerializable types that have a specific code. + + This class provides a mechanism to validate the type of the object based on its first element. + + Examples: + >>> from dataclasses import dataclass, field + >>> @dataclass + ... class TestCoded(CodedSerializable): + ... _CODE = 1 + ... value: str + >>> + >>> # Create and serialize an instance + >>> test = TestCoded("hello") + >>> primitives = test.to_primitive() + >>> primitives + [1, 'hello'] + >>> + >>> # Deserialize valid data + >>> restored = TestCoded.from_primitive(primitives) + >>> restored.value + 'hello' + >>> + >>> # Attempting to deserialize with wrong code raises exception + >>> invalid_data = [2, "hello"] + >>> TestCoded.from_primitive(invalid_data) # doctest: +IGNORE_EXCEPTION_DETAIL + Traceback (most recent call last): + ... + DeserializeException: Invalid TestCoded type 2 + """ + + _CODE: int = field(init=False) + + @classmethod + @limit_primitive_type(list, tuple) + def from_primitive( + cls: Type[CodedSerializable], values: Union[list, tuple] + ) -> CodedSerializable: + if values[0] != cls._CODE: + raise DeserializeException(f"Invalid {cls.__name__} type {values[0]}") + # Cast using Type[CodedSerializable] instead of cls directly + return cast(Type[CodedSerializable], super()).from_primitive(values[1:]) diff --git a/pycardano/transaction.py b/pycardano/transaction.py index e73820b1..f4a30eff 100644 --- a/pycardano/transaction.py +++ b/pycardano/transaction.py @@ -15,6 +15,7 @@ from pycardano.address import Address from pycardano.certificate import Certificate from pycardano.exception import InvalidDataException +from pycardano.governance import ProposalProcedure, VotingProcedures from pycardano.hash import ( TRANSACTION_HASH_SIZE, AuxiliaryDataHash, @@ -612,6 +613,22 @@ class TransactionBody(MapCBORSerializable): }, ) + voting_procedures: Optional[VotingProcedures] = field( + default=None, metadata={"key": 19, "optional": True} + ) + + proposal_procedures: Optional[NonEmptyOrderedSet[ProposalProcedure]] = field( + default=None, metadata={"key": 20, "optional": True} + ) + + current_treasury_value: Optional[int] = field( + default=None, metadata={"key": 21, "optional": True} + ) + + donation: Optional[int] = field( + default=None, metadata={"key": 22, "optional": True} + ) + def validate(self): if ( self.mint diff --git a/pycardano/txbuilder.py b/pycardano/txbuilder.py index 960ae819..d3befc9a 100644 --- a/pycardano/txbuilder.py +++ b/pycardano/txbuilder.py @@ -36,6 +36,17 @@ TransactionBuilderException, UTxOSelectionException, ) +from pycardano.governance import ( + Anchor, + GovAction, + GovActionId, + GovActionIdToVotingProcedure, + ProposalProcedure, + Vote, + Voter, + VotingProcedure, + VotingProcedures, +) from pycardano.hash import DatumHash, ScriptDataHash, ScriptHash, VerificationKeyHash from pycardano.key import ExtendedSigningKey, SigningKey, VerificationKey from pycardano.logging import log_state, logger @@ -135,6 +146,16 @@ class TransactionBuilder: use_redeemer_map: Optional[bool] = field(default=True) """Whether to serialize redeemers as a map or a list. Default is True.""" + voting_procedures: Optional[VotingProcedures] = field(init=False, default=None) + + proposal_procedures: Optional[NonEmptyOrderedSet[ProposalProcedure]] = field( + init=False, default=None + ) + + current_treasury_value: Optional[int] = field(init=False, default=None) + + donation: Optional[int] = field(init=False, default=None) + _inputs: List[UTxO] = field(init=False, default_factory=lambda: []) _potential_inputs: List[UTxO] = field(init=False, default_factory=lambda: []) @@ -617,6 +638,7 @@ def _calc_change( provided.coin += v provided.coin -= self._get_total_key_deposit() + provided.coin -= self._get_total_proposal_deposit() if not requested < provided: raise InvalidTransactionException( @@ -878,12 +900,23 @@ def _check_and_add_vkey(stake_credential: StakeCredential): ), ): _check_and_add_vkey(cert.stake_credential) + elif isinstance(cert, RegDRepCert): + _check_and_add_vkey(cert.drep_credential) elif isinstance(cert, PoolRegistration): results.add(cert.pool_params.operator) elif isinstance(cert, PoolRetirement): results.add(cert.pool_keyhash) return results + def _vote_vkey_hashes(self) -> Set[VerificationKeyHash]: + results = set() + + if self.voting_procedures: + for voter in self.voting_procedures: + if isinstance(voter.credential, VerificationKeyHash): + results.add(voter.credential) + return results + def _get_total_key_deposit(self): stake_registration_certs = set() stake_registration_certs_with_explicit_deposit = set() @@ -920,6 +953,13 @@ def _get_total_key_deposit(self): ) return stake_registration_deposit + stake_pool_registration_deposit + def _get_total_proposal_deposit(self): + proposal_deposit = 0 + if self.proposal_procedures: + for proposal in self.proposal_procedures: + proposal_deposit += proposal.deposit + return proposal_deposit + def _withdrawal_vkey_hashes(self) -> Set[VerificationKeyHash]: results = set() @@ -1022,6 +1062,17 @@ def _build_tx_body(self) -> TransactionBody: if self.reference_inputs else None ), + # Add new governance fields + voting_procedures=( + self.voting_procedures if self.voting_procedures else None + ), + proposal_procedures=( + self.proposal_procedures if self.proposal_procedures else None + ), + current_treasury_value=( + self.current_treasury_value if self.current_treasury_value else None + ), + donation=self.donation if self.donation else None, ) return tx_body @@ -1031,6 +1082,7 @@ def _build_required_vkeys(self) -> Set[VerificationKeyHash]: vkey_hashes.update(self._native_scripts_vkey_hashes()) vkey_hashes.update(self._certificate_vkey_hashes()) vkey_hashes.update(self._withdrawal_vkey_hashes()) + vkey_hashes.update(self._vote_vkey_hashes()) return vkey_hashes def _witness_count(self) -> int: @@ -1261,6 +1313,7 @@ def build( break selected_amount.coin -= self._get_total_key_deposit() + selected_amount.coin -= self._get_total_proposal_deposit() requested_amount = Value() for o in self.outputs: @@ -1660,3 +1713,79 @@ def build_and_sign( witness_set.vkey_witnesses = None return Transaction(tx_body, witness_set, auxiliary_data=self.auxiliary_data) + + # Add helper methods for governance operations + def add_vote( + self, + voter: Voter, + gov_action_id: GovActionId, + vote: Vote, + anchor: Optional[Anchor] = None, + ) -> TransactionBuilder: + """Add a vote to the transaction. + + Args: + voter: The voter casting the vote + gov_action_id: The ID of the governance action being voted on + vote: The vote being cast (YES/NO/ABSTAIN) + anchor: Optional metadata about the vote + + Returns: + self: The transaction builder instance + """ + if self.voting_procedures is None: + self.voting_procedures = VotingProcedures() + + # Initialize the inner map if this is the first vote for this voter + if voter not in self.voting_procedures: + self.voting_procedures[voter] = GovActionIdToVotingProcedure() + + # Add the voting procedure for this specific governance action + self.voting_procedures[voter][gov_action_id] = VotingProcedure(vote, anchor) + + return self + + def add_proposal( + self, + deposit: int, + reward_account: bytes, + gov_action: GovAction, + anchor: Anchor, + ) -> TransactionBuilder: + """Add a governance proposal to the transaction. + + Args: + deposit: The deposit amount required for the proposal + reward_account: The reward account for the proposal + gov_action: The governance action being proposed + anchor: Metadata about the proposal + + Returns: + self: The transaction builder instance + """ + if self.proposal_procedures is None: + self.proposal_procedures = NonEmptyOrderedSet() + + self.proposal_procedures.append( + ProposalProcedure( + deposit=deposit, + reward_account=reward_account, + gov_action=gov_action, + anchor=anchor, + ) + ) + return self + + def add_treasury_donation(self, amount: int) -> TransactionBuilder: + """Add a donation to the treasury. + + Args: + amount: The amount to donate (must be positive) + + Returns: + self: The transaction builder instance + """ + if amount <= 0: + raise ValueError("Treasury donation amount must be positive") + self.donation = amount + return self diff --git a/pyproject.toml b/pyproject.toml index d448f252..b2bb6d3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,9 @@ Flask = "^2.0.3" pytest-xdist = "^3.5.0" mypy = "1.11.2" +[tool.poetry.group.dev.dependencies] +standard-imghdr = "^3.13.0" + [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" diff --git a/test/pycardano/test_governance.py b/test/pycardano/test_governance.py new file mode 100644 index 00000000..0e883995 --- /dev/null +++ b/test/pycardano/test_governance.py @@ -0,0 +1,531 @@ +from fractions import Fraction + +import pytest + +from pycardano.address import Address +from pycardano.certificate import Anchor, StakeCredential +from pycardano.exception import DeserializeException +from pycardano.governance import ( + CommitteeColdCredential, + CommitteeColdCredentialEpochMap, + ExUnitPrices, + GovActionId, + GovActionIdToVotingProcedure, + HardForkInitiationAction, + InfoAction, + NewConstitution, + NoConfidence, + ParameterChangeAction, + ProposalProcedure, + TreasuryWithdrawal, + TreasuryWithdrawalsAction, + UpdateCommittee, + Vote, + Voter, + VoterType, + VotingProcedure, + VotingProcedures, +) +from pycardano.hash import ( + ANCHOR_DATA_HASH_SIZE, + SCRIPT_HASH_SIZE, + TRANSACTION_HASH_SIZE, + VERIFICATION_KEY_HASH_SIZE, + AnchorDataHash, + PolicyHash, + ScriptHash, + TransactionId, + VerificationKeyHash, +) + + +class TestGovActionId: + def test_gov_action_id_creation(self): + tx_id = TransactionId(bytes.fromhex("00" * TRANSACTION_HASH_SIZE)) + gov_action_id = GovActionId(transaction_id=tx_id, gov_action_index=123) + + assert gov_action_id.transaction_id == tx_id + assert gov_action_id.gov_action_index == 123 + + def test_gov_action_id_invalid_index(self): + tx_id = TransactionId(bytes.fromhex("00" * TRANSACTION_HASH_SIZE)) + + with pytest.raises( + ValueError, match="gov_action_index must be between 0 and 65535" + ): + GovActionId(transaction_id=tx_id, gov_action_index=70000) + + with pytest.raises( + ValueError, match="gov_action_index must be between 0 and 65535" + ): + GovActionId(transaction_id=tx_id, gov_action_index=-1) + + def test_gov_action_id_serialization(self): + tx_id = TransactionId(bytes.fromhex("00" * TRANSACTION_HASH_SIZE)) + gov_action_id = GovActionId(transaction_id=tx_id, gov_action_index=123) + + primitive = gov_action_id.to_primitive() + deserialized = GovActionId.from_primitive(primitive) + + assert deserialized == gov_action_id + + +class TestVote: + def test_vote_values(self): + assert Vote.NO.value == 0 + assert Vote.YES.value == 1 + assert Vote.ABSTAIN.value == 2 + + +class TestVotingProcedure: + def test_voting_procedure_creation(self): + anchor = Anchor( + url="https://example.com", + data_hash=AnchorDataHash(bytes.fromhex("00" * ANCHOR_DATA_HASH_SIZE)), + ) + procedure = VotingProcedure(vote=Vote.YES, anchor=anchor) + + assert procedure.vote == Vote.YES + assert procedure.anchor == anchor + + def test_voting_procedure_no_anchor(self): + procedure = VotingProcedure(vote=Vote.NO, anchor=None) + + assert procedure.vote == Vote.NO + assert procedure.anchor is None + + def test_voting_procedure_serialization(self): + anchor = Anchor( + url="https://example.com", + data_hash=AnchorDataHash(bytes.fromhex("00" * ANCHOR_DATA_HASH_SIZE)), + ) + procedure = VotingProcedure(vote=Vote.YES, anchor=anchor) + + primitive = procedure.to_primitive() + deserialized = VotingProcedure.from_primitive(primitive) + + assert deserialized == procedure + + +class TestVoterType: + def test_voter_type_values(self): + assert VoterType.COMMITTEE_HOT.value == "committee_hot" + assert VoterType.DREP.value == "drep" + assert VoterType.STAKING_POOL.value == "staking_pool" + + +class TestVoter: + def test_committee_hot_voter_creation(self): + vkey_hash = VerificationKeyHash( + bytes.fromhex("00" * VERIFICATION_KEY_HASH_SIZE) + ) + voter = Voter(credential=vkey_hash, voter_type=VoterType.COMMITTEE_HOT) + + assert voter._CODE == 0 + assert voter.credential == vkey_hash + assert voter.voter_type == VoterType.COMMITTEE_HOT + + def test_committee_hot_script_voter_creation(self): + script_hash = ScriptHash(bytes.fromhex("11" * SCRIPT_HASH_SIZE)) + voter = Voter(credential=script_hash, voter_type=VoterType.COMMITTEE_HOT) + + assert voter._CODE == 1 + assert voter.credential == script_hash + assert voter.voter_type == VoterType.COMMITTEE_HOT + + def test_drep_key_voter_creation(self): + vkey_hash = VerificationKeyHash( + bytes.fromhex("22" * VERIFICATION_KEY_HASH_SIZE) + ) + voter = Voter(credential=vkey_hash, voter_type=VoterType.DREP) + + assert voter._CODE == 2 + assert voter.credential == vkey_hash + assert voter.voter_type == VoterType.DREP + + def test_drep_script_voter_creation(self): + script_hash = ScriptHash(bytes.fromhex("33" * SCRIPT_HASH_SIZE)) + voter = Voter(credential=script_hash, voter_type=VoterType.DREP) + + assert voter._CODE == 3 + assert voter.credential == script_hash + assert voter.voter_type == VoterType.DREP + + def test_staking_pool_voter_creation(self): + vkey_hash = VerificationKeyHash( + bytes.fromhex("44" * VERIFICATION_KEY_HASH_SIZE) + ) + voter = Voter(credential=vkey_hash, voter_type=VoterType.STAKING_POOL) + + assert voter._CODE == 4 + assert voter.credential == vkey_hash + assert voter.voter_type == VoterType.STAKING_POOL + + def test_invalid_voter_type(self): + vkey_hash = VerificationKeyHash( + bytes.fromhex("00" * VERIFICATION_KEY_HASH_SIZE) + ) + with pytest.raises(ValueError, match="Invalid voter_type"): + Voter(credential=vkey_hash, voter_type="invalid_type") + + def test_invalid_staking_pool_credential(self): + script_hash = ScriptHash(bytes.fromhex("11" * SCRIPT_HASH_SIZE)) + with pytest.raises( + ValueError, match="Staking pool voter must use key hash credential" + ): + Voter(credential=script_hash, voter_type=VoterType.STAKING_POOL) + + def test_voter_serialization_committee_hot_key(self): + vkey_hash = VerificationKeyHash( + bytes.fromhex("00" * VERIFICATION_KEY_HASH_SIZE) + ) + voter = Voter(credential=vkey_hash, voter_type=VoterType.COMMITTEE_HOT) + + primitive = voter.to_primitive() + deserialized = Voter.from_primitive(primitive) + + assert deserialized._CODE == voter._CODE + assert deserialized.credential == voter.credential + assert deserialized.voter_type == voter.voter_type + + def test_voter_serialization_committee_hot_script(self): + script_hash = ScriptHash(bytes.fromhex("11" * SCRIPT_HASH_SIZE)) + voter = Voter(credential=script_hash, voter_type=VoterType.COMMITTEE_HOT) + + primitive = voter.to_primitive() + deserialized = Voter.from_primitive(primitive) + + assert deserialized._CODE == voter._CODE + assert deserialized.credential == voter.credential + assert deserialized.voter_type == voter.voter_type + + def test_voter_serialization_drep_key(self): + vkey_hash = VerificationKeyHash( + bytes.fromhex("22" * VERIFICATION_KEY_HASH_SIZE) + ) + voter = Voter(credential=vkey_hash, voter_type=VoterType.DREP) + + primitive = voter.to_primitive() + deserialized = Voter.from_primitive(primitive) + + assert deserialized._CODE == voter._CODE + assert deserialized.credential == voter.credential + assert deserialized.voter_type == voter.voter_type + + def test_voter_serialization_drep_script(self): + script_hash = ScriptHash(bytes.fromhex("33" * SCRIPT_HASH_SIZE)) + voter = Voter(credential=script_hash, voter_type=VoterType.DREP) + + primitive = voter.to_primitive() + deserialized = Voter.from_primitive(primitive) + + assert deserialized._CODE == voter._CODE + assert deserialized.credential == voter.credential + assert deserialized.voter_type == voter.voter_type + + def test_voter_serialization_staking_pool(self): + vkey_hash = VerificationKeyHash( + bytes.fromhex("44" * VERIFICATION_KEY_HASH_SIZE) + ) + voter = Voter(credential=vkey_hash, voter_type=VoterType.STAKING_POOL) + + primitive = voter.to_primitive() + deserialized = Voter.from_primitive(primitive) + + assert deserialized._CODE == voter._CODE + assert deserialized.credential == voter.credential + assert deserialized.voter_type == voter.voter_type + + def test_voter_invalid_deserialization_code(self): + invalid_primitive = [5, bytes.fromhex("00" * VERIFICATION_KEY_HASH_SIZE)] + with pytest.raises(DeserializeException, match="Invalid Voter type 5"): + Voter.from_primitive(invalid_primitive) + + def test_voter_invalid_primitive_format(self): + with pytest.raises( + Exception + ): # Specific exception type depends on implementation + Voter.from_primitive([]) # Empty list + + with pytest.raises(Exception): + Voter.from_primitive([0]) # Missing credential + + with pytest.raises(Exception): + Voter.from_primitive([0, "invalid"]) # Invalid credential format + + +class TestParameterChangeAction: + def test_parameter_change_action_creation(self): + tx_id = TransactionId(bytes.fromhex("00" * TRANSACTION_HASH_SIZE)) + gov_action_id = GovActionId(transaction_id=tx_id, gov_action_index=1) + protocol_params = {"key": "value"} + policy_hash = PolicyHash(bytes.fromhex("33" * SCRIPT_HASH_SIZE)) + + action = ParameterChangeAction( + gov_action_id=gov_action_id, + protocol_param_update=protocol_params, + policy_hash=policy_hash, + ) + + assert action._CODE == 0 + assert action.gov_action_id == gov_action_id + assert action.protocol_param_update == protocol_params + assert action.policy_hash == policy_hash + + +class TestHardForkInitiationAction: + def test_hard_fork_action_creation(self): + tx_id = TransactionId(bytes.fromhex("00" * TRANSACTION_HASH_SIZE)) + gov_action_id = GovActionId(transaction_id=tx_id, gov_action_index=1) + + action = HardForkInitiationAction( + gov_action_id=gov_action_id, protocol_version=(8, 0) + ) + + assert action._CODE == 1 + assert action.gov_action_id == gov_action_id + assert action.protocol_version == (8, 0) + + def test_invalid_protocol_version(self): + tx_id = TransactionId(bytes.fromhex("00" * TRANSACTION_HASH_SIZE)) + gov_action_id = GovActionId(transaction_id=tx_id, gov_action_index=1) + + with pytest.raises( + ValueError, match="Major protocol version must be between 1 and 10" + ): + HardForkInitiationAction( + gov_action_id=gov_action_id, protocol_version=(11, 0) + ) + + +class TestUpdateCommittee: + def test_update_committee_creation(self): + tx_id = TransactionId(bytes.fromhex("00" * TRANSACTION_HASH_SIZE)) + gov_action_id = GovActionId(transaction_id=tx_id, gov_action_index=1) + + credential1 = StakeCredential( + VerificationKeyHash(bytes.fromhex("44" * VERIFICATION_KEY_HASH_SIZE)) + ) + credential2 = StakeCredential( + VerificationKeyHash(bytes.fromhex("55" * VERIFICATION_KEY_HASH_SIZE)) + ) + + committee_credentials = {credential1, credential2} + committee_expiration = {credential1: 100, credential2: 200} + + action = UpdateCommittee( + gov_action_id=gov_action_id, + committee_cold_credentials=committee_credentials, + committee_expiration=committee_expiration, + quorum=(2, 3), + ) + + assert action._CODE == 4 + assert action.gov_action_id == gov_action_id + assert action.committee_cold_credentials == committee_credentials + assert action.committee_expiration == committee_expiration + assert action.quorum == (2, 3) + + +class TestNewConstitution: + def test_new_constitution_creation(self): + tx_id = TransactionId(bytes.fromhex("00" * TRANSACTION_HASH_SIZE)) + gov_action_id = GovActionId(transaction_id=tx_id, gov_action_index=1) + + anchor = Anchor( + url="https://example.com/constitution", + data_hash=AnchorDataHash(bytes.fromhex("66" * ANCHOR_DATA_HASH_SIZE)), + ) + script_hash = ScriptHash(bytes.fromhex("77" * SCRIPT_HASH_SIZE)) + + action = NewConstitution( + gov_action_id=gov_action_id, constitution=(anchor, script_hash) + ) + + assert action._CODE == 5 + assert action.gov_action_id == gov_action_id + assert action.constitution == (anchor, script_hash) + + +class TestInfoAction: + def test_info_action_creation(self): + action = InfoAction() + assert action._CODE == 6 + + +class TestExUnitPrices: + def test_ex_unit_prices_creation(self): + prices = ExUnitPrices(mem_price=Fraction(1, 2), step_price=Fraction(3, 4)) + assert prices.mem_price == Fraction(1, 2) + assert prices.step_price == Fraction(3, 4) + + def test_ex_unit_prices_serialization(self): + prices = ExUnitPrices(mem_price=Fraction(1, 2), step_price=Fraction(3, 4)) + primitive = prices.to_primitive() + # The primitive should be a list of two tuples + assert isinstance(primitive, list) + assert len(primitive) == 2 + assert primitive[0] == Fraction(1, 2) # Tuples are serialized as lists + assert primitive[1] == Fraction(3, 4) + + deserialized = ExUnitPrices.from_cbor(prices.to_cbor()) + assert deserialized.mem_price == prices.mem_price + assert deserialized.step_price == prices.step_price + + +class TestTreasuryWithdrawal: + def test_treasury_withdrawal_creation(self): + withdrawals = TreasuryWithdrawal() + withdrawals[b"addr1"] = 1000 + withdrawals[b"addr2"] = 2000 + + assert withdrawals[b"addr1"] == 1000 + assert withdrawals[b"addr2"] == 2000 + + def test_treasury_withdrawal_serialization(self): + withdrawals = TreasuryWithdrawal() + withdrawals[b"addr1"] = 1000 + withdrawals[b"addr2"] = 2000 + + primitive = withdrawals.to_primitive() + deserialized = TreasuryWithdrawal.from_primitive(primitive) + assert deserialized == withdrawals + + +class TestTreasuryWithdrawalsAction: + def test_treasury_withdrawals_action_creation(self): + withdrawals = TreasuryWithdrawal() + withdrawals[b"addr1"] = 1000 + policy_hash = PolicyHash(bytes.fromhex("00" * SCRIPT_HASH_SIZE)) + + action = TreasuryWithdrawalsAction( + withdrawals=withdrawals, policy_hash=policy_hash + ) + + assert action._CODE == 2 + assert action.withdrawals == withdrawals + assert action.policy_hash == policy_hash + + def test_treasury_withdrawals_action_no_policy(self): + withdrawals = TreasuryWithdrawal() + withdrawals[b"addr1"] = 1000 + + action = TreasuryWithdrawalsAction(withdrawals=withdrawals, policy_hash=None) + + assert action._CODE == 2 + assert action.withdrawals == withdrawals + assert action.policy_hash is None + + +class TestVotingProcedures: + def test_voting_procedures_creation(self): + # Create a voter + vkey_hash = VerificationKeyHash( + bytes.fromhex("00" * VERIFICATION_KEY_HASH_SIZE) + ) + voter = Voter(credential=vkey_hash, voter_type=VoterType.COMMITTEE_HOT) + + # Create a GovActionId + tx_id = TransactionId(bytes.fromhex("00" * TRANSACTION_HASH_SIZE)) + gov_action_id = GovActionId(transaction_id=tx_id, gov_action_index=1) + + # Create a VotingProcedure + anchor = Anchor( + url="https://example.com", + data_hash=AnchorDataHash(bytes.fromhex("00" * ANCHOR_DATA_HASH_SIZE)), + ) + procedure = VotingProcedure(vote=Vote.YES, anchor=anchor) + + # Create GovActionIdToVotingProcedure + gov_to_voting = GovActionIdToVotingProcedure() + gov_to_voting[gov_action_id] = procedure + + # Create VotingProcedures + procedures = VotingProcedures() + procedures[voter] = gov_to_voting + + assert procedures[voter][gov_action_id] == procedure + + def test_voting_procedures_serialization(self): + # Create test data + vkey_hash = VerificationKeyHash( + bytes.fromhex("00" * VERIFICATION_KEY_HASH_SIZE) + ) + voter = Voter(credential=vkey_hash, voter_type=VoterType.COMMITTEE_HOT) + + tx_id = TransactionId(bytes.fromhex("00" * TRANSACTION_HASH_SIZE)) + gov_action_id = GovActionId(transaction_id=tx_id, gov_action_index=1) + + procedure = VotingProcedure(vote=Vote.YES, anchor=None) + + voting_procedures = VotingProcedures() + voting_procedures[voter] = GovActionIdToVotingProcedure( + {gov_action_id: procedure} + ) + + # Test deserialization + deserialized = VotingProcedures.from_cbor(voting_procedures.to_cbor()) + + # Verify the structure was preserved + assert isinstance(deserialized, VotingProcedures) + assert len(deserialized) == 1 + + # Get the first (and only) key-value pair + deserialized_voter = next(iter(deserialized.keys())) + deserialized_gov_to_voting = deserialized[deserialized_voter] + + assert isinstance(deserialized_gov_to_voting, GovActionIdToVotingProcedure) + assert len(deserialized_gov_to_voting) == 1 + + # Get the first (and only) key-value pair from the nested map + deserialized_gov_action_id = next(iter(deserialized_gov_to_voting.keys())) + deserialized_procedure = deserialized_gov_to_voting[deserialized_gov_action_id] + + # Verify the contents + assert deserialized_voter == voter + assert deserialized_gov_action_id == gov_action_id + assert deserialized_procedure == procedure + + +class TestProposalProcedure: + def test_proposal_procedure_creation(self): + # Create an InfoAction as it's the simplest GovAction + gov_action = InfoAction() + + anchor = Anchor( + url="https://example.com", + data_hash=AnchorDataHash(bytes.fromhex("00" * ANCHOR_DATA_HASH_SIZE)), + ) + + procedure = ProposalProcedure( + deposit=1000000, + reward_account=b"reward_account", + gov_action=gov_action, + anchor=anchor, + ) + + assert procedure.deposit == 1000000 + assert procedure.reward_account == b"reward_account" + assert procedure.gov_action == gov_action + assert procedure.anchor == anchor + + def test_proposal_procedure_serialization(self): + gov_action = InfoAction() + anchor = Anchor( + url="https://example.com", + data_hash=AnchorDataHash(bytes.fromhex("00" * ANCHOR_DATA_HASH_SIZE)), + ) + + procedure = ProposalProcedure( + deposit=1000000, + reward_account=b"reward_account", + gov_action=gov_action, + anchor=anchor, + ) + + primitive = procedure.to_primitive() + deserialized = ProposalProcedure.from_primitive(primitive) + + assert deserialized.deposit == procedure.deposit + assert deserialized.reward_account == procedure.reward_account + assert deserialized.anchor == procedure.anchor diff --git a/test/pycardano/test_pool_params.py b/test/pycardano/test_pool_params.py index 0f038ccf..899db42d 100644 --- a/test/pycardano/test_pool_params.py +++ b/test/pycardano/test_pool_params.py @@ -23,7 +23,6 @@ PoolParams, SingleHostAddr, SingleHostName, - fraction_parser, is_bech32_cardano_pool_id, ) @@ -191,28 +190,6 @@ def test_pool_metadata(url, pool_metadata_hash): 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 = fraction_parser(input_value) - - # Assert - assert isinstance(result, Fraction) - - @pytest.mark.parametrize( "operator, vrf_keyhash, pledge, cost, margin, reward_account, pool_owners, relays, pool_metadata", [ @@ -222,7 +199,7 @@ def test_fraction_serializer(input_value): b"1" * VRF_KEY_HASH_SIZE, 10_000_000, 340_000_000, - "1/10", + Fraction(1, 10), b"1" * REWARD_ACCOUNT_HASH_SIZE, [b"1" * VERIFICATION_KEY_HASH_SIZE], [ @@ -267,7 +244,7 @@ def test_pool_params( vrf_keyhash, pledge, cost, - fraction_parser(margin), + margin, reward_account, pool_owners, relays, diff --git a/test/pycardano/test_serialization.py b/test/pycardano/test_serialization.py index 55c13282..9deb2233 100644 --- a/test/pycardano/test_serialization.py +++ b/test/pycardano/test_serialization.py @@ -35,6 +35,7 @@ ArrayCBORSerializable, ByteString, CBORSerializable, + CodedSerializable, DictCBORSerializable, IndefiniteList, MapCBORSerializable, @@ -730,3 +731,127 @@ def test_transaction_witness_set_with_ordered_sets(): primitive = witness_set.to_primitive() restored = TransactionWitnessSet.from_primitive(primitive) assert restored.vkey_witnesses is None + + +# Test fixtures +@dataclass(repr=False) +class TestCodedClass(CodedSerializable): + """A test class that uses CodedSerializable.""" + + _CODE: int = field(init=False, default=1) + value: str + numbers: List[int] + + +@dataclass(repr=False) +class AnotherCodedClass(CodedSerializable): + """Another test class with a different code.""" + + _CODE: int = field(init=False, default=2) + name: str + + +def test_coded_serializable_basic(): + """Test basic serialization and deserialization.""" + # Create an instance + obj = TestCodedClass(value="test", numbers=[1, 2, 3]) + + # Test serialization + primitive = obj.to_primitive() + assert primitive == [1, "test", [1, 2, 3]] + + # Test deserialization + restored = TestCodedClass.from_primitive(primitive) + assert isinstance(restored, TestCodedClass) + assert restored.value == "test" + assert restored.numbers == [1, 2, 3] + + +def test_coded_serializable_wrong_code(): + """Test that wrong codes raise appropriate exceptions.""" + # Try to deserialize with wrong code + with pytest.raises(DeserializeException) as exc_info: + TestCodedClass.from_primitive([2, "test", [1, 2, 3]]) + assert "Invalid TestCodedClass type" in str(exc_info.value) + + +def test_multiple_coded_classes(): + """Test that different coded classes work independently.""" + obj1 = TestCodedClass(value="test", numbers=[1, 2, 3]) + obj2 = AnotherCodedClass(name="example") + + # Serialize both + prim1 = obj1.to_primitive() + prim2 = obj2.to_primitive() + + # Check they have different codes + assert prim1[0] != prim2[0] + + # Restore both + restored1 = TestCodedClass.from_primitive(prim1) + restored2 = AnotherCodedClass.from_primitive(prim2) + + # Verify restorations + assert isinstance(restored1, TestCodedClass) + assert isinstance(restored2, AnotherCodedClass) + assert restored1.value == "test" + assert restored2.name == "example" + + +def test_coded_serializable_cbor(): + """Test CBOR serialization and deserialization.""" + original = TestCodedClass(value="test", numbers=[1, 2, 3]) + + # Convert to CBOR and back + cbor_bytes = original.to_cbor() + restored = TestCodedClass.from_cbor(cbor_bytes) + + # Verify restoration + assert isinstance(restored, TestCodedClass) + assert restored.value == original.value + assert restored.numbers == original.numbers + + +def test_invalid_primitive_type(): + """Test that invalid primitive types raise appropriate exceptions.""" + # Try to deserialize from invalid types + invalid_values = [ + {"wrong": "type"}, # dict instead of list + "not_a_list", # string instead of list + 42, # number instead of list + ] + + for invalid_value in invalid_values: + with pytest.raises(DeserializeException): + TestCodedClass.from_primitive(invalid_value) + + +def test_invliad_coded_serializable(): + with pytest.raises(DeserializeException): + TestCodedClass.from_primitive([2, "test", [1, 2, 3]]) + + +def test_coded_serializable_inheritance(): + """Test that inheritance works properly with CodedSerializable.""" + + @dataclass(repr=False) + class ChildCodedClass(TestCodedClass): + """A child class that inherits from a CodedSerializable.""" + + _CODE: int = field(init=False, default=3) + extra: str + + # Create and serialize child instance + child = ChildCodedClass(value="test", numbers=[1, 2], extra="extra") + primitive = child.to_primitive() + + # Verify code and structure + assert primitive[0] == 3 + assert len(primitive) == 4 + + # Restore and verify + restored = ChildCodedClass.from_primitive(primitive) + assert isinstance(restored, ChildCodedClass) + assert restored.value == "test" + assert restored.numbers == [1, 2] + assert restored.extra == "extra" diff --git a/test/pycardano/test_transaction.py b/test/pycardano/test_transaction.py index 8428c456..a7f5022d 100644 --- a/test/pycardano/test_transaction.py +++ b/test/pycardano/test_transaction.py @@ -1,9 +1,11 @@ from dataclasses import dataclass +from fractions import Fraction from test.pycardano.util import check_two_way_cbor import pytest from typeguard import TypeCheckError +from pycardano import ParameterChangeAction from pycardano.address import Address from pycardano.exception import InvalidDataException, InvalidOperationException from pycardano.hash import SCRIPT_HASH_SIZE, ScriptHash, TransactionId @@ -617,3 +619,17 @@ def test_add_empty_pop(): ) assert len(nft_output.multi_asset) == 1 assert len(nft_output.multi_asset[ScriptHash(policy)]) == 1 + + +def test_decode_param_update_proposal_tx(): + # The proposal of decreasing treasury tax from 20% to 10% on mainnet + # https://cardanoscan.io/transaction/941502b0aa104c850d197923259444d2b57cab7af18b63143775465aaacc84f5 + tx_cbor_hex = """84a700d90102868258202f980a7d47a6195c975c266335211afd3b9cabb5db5165e6e6d9cb18418415ab008258202f980a7d47a6195c975c266335211afd3b9cabb5db5165e6e6d9cb18418415ab018258202f980a7d47a6195c975c266335211afd3b9cabb5db5165e6e6d9cb18418415ab028258202f980a7d47a6195c975c266335211afd3b9cabb5db5165e6e6d9cb18418415ab0382582040aba0069d0dce7f801a9d16c26d469ec8ce16e1eb68379ae2774e5d28f33d5b008258206ba686304126196267200c6502df4b42af898ad2fb1621561fdb0a457fd8b68b000dd90102818258202f980a7d47a6195c975c266335211afd3b9cabb5db5165e6e6d9cb18418415ab040181825839013c55ef61a7fac4c7f94dc65052586f31dd659acddffc69f13d2c4364646c9e5f7484e8aeceba94566b73b8b50394eb6bfb54f67ac5885d591ab25dc1bf021a0004ee04031a08d0f5dc0b58204a080e29d89a598d6a3c000c9f15f4ab74a10ffdaa320f256fc7f69b75ff8a5914d9010281841b000000174876e800581de1646c9e5f7484e8aeceba94566b73b8b50394eb6bfb54f67ac5885d598400825820b2a591ac219ce6dcca5847e0248015209c7cb0436aa6bd6863d0c1f152a60bc500a10bd81e82010a581cfa24fb305126805cf2164c161d852a0e7330cf988f1fe558cf7d4a64827835697066733a2f2f516d634b51676763706f757568414176555947447a6f4b674d77625a536b57716945654536633637534a336b457158209b2438f0032a0c24ed62d12d6bdb79b47e2bd0c4d2dd4f4936c055ead7109cafa300d90102818258205d58313597871a1823742d172d738fcd1fee4800ba41859db790f981d4dae74e584089b07924734e5b9d813b43638c3e2e6f4ac1e473e454d2d5b404b7bee939d8b5046b6a5c4ba0b51096d5538feb933e802a5944442b046ef11b2381ffce70f70e07d90102815908545908510101003232323232323232323232323232323232323232323232323232323232323232323232323232323232259323255333573466e1d20000011180098111bab357426ae88d55cf00104554ccd5cd19b87480100044600422c6aae74004dd51aba1357446ae88d55cf1baa3255333573466e1d200a35573a002226ae84d5d11aab9e00111637546ae84d5d11aba235573c6ea800642b26006003149a2c8a4c301f801c0052000c00e0070018016006901e4070c00e003000c00d20d00fc000c0003003800a4005801c00e003002c00d20c09a0c80e1801c006001801a4101b5881380018000600700148013003801c006005801a410100078001801c006001801a4101001f8001800060070014801b0038018096007001800600690404002600060001801c0052008c00e006025801c006001801a41209d8001800060070014802b003801c006005801a410112f501c3003800c00300348202b7881300030000c00e00290066007003800c00b003482032ad7b806038403060070014803b00380180960003003800a4021801c00e003002c00d20f40380e1801c006001801a41403f800100a0c00e0029009600f0030078040c00e002900a600f003800c00b003301a483403e01a600700180060066034904801e00060001801c0052016c01e00600f801c006001801980c2402900e30000c00e002901060070030128060c00e00290116007003800c00b003483c0ba03860070018006006906432e00040283003800a40498003003800a404d802c00e00f003800c00b003301a480cb0003003800c003003301a4802b00030001801c01e0070018016006603490605c0160006007001800600660349048276000600030000c00e0029014600b003801c00c04b003800c00300348203a2489b00030001801c00e006025801c006001801a4101b11dc2df80018000c0003003800a4055802c00e007003012c00e003000c00d2080b8b872c000c0006007003801809600700180060069040607e4155016000600030000c00e00290166007003012c00e003000c00d2080c001c000c0003003800a405d801c00e003002c00d20c80180e1801c006001801a412007800100a0c00e00290186007003013c0006007001480cb005801801e006003801800e00600500403003800a4069802c00c00f003001c00c007003803c00e003002c00c05300333023480692028c0004014c00c00b003003c00c00f003003c00e00f003800c00b00301480590052008003003800a406d801c00e003002c00d2000c00d2006c00060070018006006900a600060001801c0052038c00e007001801600690006006901260003003800c003003483281300020141801c005203ac00e006027801c006001801a403d800180006007001480f3003801804e00700180060069040404af3c4e302600060001801c005203ec00e006013801c006001801a4101416f0fd20b80018000600700148103003801c006005801a403501c3003800c0030034812b00030000c00e0029021600f003800c00a01ac00e003000c00ccc08d20d00f4800b00030000c0000000000803c00c016008401e006009801c006001801807e0060298000c000401e006007801c0060018018074020c000400e00f003800c00b003010c000802180020070018006006019801805e0003000400600580180760060138000800c00b00330134805200c400e00300080330004006005801a4001801a410112f58000801c00600901260008019806a40118002007001800600690404a75ee01e00060008018046000801801e000300c4832004c025201430094800a0030028052003002c00d2002c000300648010c0092002300748028c0312000300b48018c0292012300948008c0212066801a40018000c0192008300a2233335573e00250002801994004d55ce800cd55cf0008d5d08014c00cd5d10011263009222532900389800a4d2219002912c80344c01526910c80148964cc04cdd68010034564cc03801400626601800e0071801226601800e01518010096400a3000910c008600444002600244004a664600200244246466004460044460040064600444600200646a660080080066a00600224446600644b20051800484ccc02600244666ae68cdc3801000c00200500a91199ab9a33710004003000801488ccd5cd19b89002001800400a44666ae68cdc4801000c00a00122333573466e20008006005000912a999ab9a3371200400222002220052255333573466e2400800444008440040026eb400a42660080026eb000a4264666015001229002914801c8954ccd5cd19b8700400211333573466e1c00c006001002118011229002914801c88cc044cdc100200099b82002003245200522900391199ab9a3371066e08010004cdc1001001c002004403245200522900391199ab9a3371266e08010004cdc1001001c00a00048a400a45200722333573466e20cdc100200099b820020038014000912c99807001000c40062004912c99807001000c400a2002001199919ab9a357466ae880048cc028dd69aba1003375a6ae84008d5d1000934000dd60010a40064666ae68d5d1800c0020052225933006003357420031330050023574400318010600a444aa666ae68cdc3a400000222c22aa666ae68cdc4000a4000226600666e05200000233702900000088994004cdc2001800ccdc20010008cc010008004c01088954ccd5cd19b87480000044400844cc00c004cdc300100091119803112c800c60012219002911919806912c800c4c02401a442b26600a004019130040018c008002590028c804c8888888800d1900991111111002a244b267201722222222008001000c600518000001112a999ab9a3370e004002230001155333573466e240080044600823002229002914801c88ccd5cd19b893370400800266e0800800e00100208c8c0040048c0088cc00800800505a182050082a0821a0007c6d41a06a71df2f5f6""" + tx = Transaction.from_cbor(tx_cbor_hex) + assert len(tx.transaction_body.proposal_procedures) == 1 + assert isinstance( + tx.transaction_body.proposal_procedures[0].gov_action, ParameterChangeAction + ) + assert tx.transaction_body.proposal_procedures[ + 0 + ].gov_action.protocol_param_update.treasury_growth_rate == Fraction(1, 10)