diff --git a/cardano_node_tests/tests/test_scripts.py b/cardano_node_tests/tests/test_scripts.py index 796870a08..e35d41579 100644 --- a/cardano_node_tests/tests/test_scripts.py +++ b/cardano_node_tests/tests/test_scripts.py @@ -2659,6 +2659,12 @@ def test_spend_reference_script( signing_key_files=[reference_addr.skey_file], ) + # The reference script is pulled into the Tx by spending the UTxO that holds it, so its + # size must be accounted for in the fee. + reference_script_size = clusterlib_utils.get_reference_script_size( + script_file=multisig_script + ) + # The `tx_out_spend` clusterlib_utils.build_and_submit_tx( cluster_obj=cluster, @@ -2671,6 +2677,7 @@ def test_spend_reference_script( tx_files=tx_files, witness_override=3, byron_witness_count=1 if address_type == "byron" else 0, + reference_script_size=reference_script_size, ) # Check that the reference UTxO was spent diff --git a/cardano_node_tests/utils/clusterlib_utils.py b/cardano_node_tests/utils/clusterlib_utils.py index 297964efc..5f5c40e5d 100644 --- a/cardano_node_tests/utils/clusterlib_utils.py +++ b/cardano_node_tests/utils/clusterlib_utils.py @@ -1372,6 +1372,64 @@ def create_reference_utxo( return reference_utxo, tx_raw_output +def _encode_simple_script(*, script: dict) -> list: + """Encode a simple (native) script into the structure the ledger serializes. + + Args: + script: A dict with the JSON representation of a simple script. + + Returns: + list: A structure that is CBOR encoded the same way as the ledger encodes the script. + """ + script_type = script["type"] + + if script_type == "sig": + return [0, bytes.fromhex(script["keyHash"])] + if script_type == clusterlib.MultiSigTypeArgs.ALL: + return [1, [_encode_simple_script(script=s) for s in script["scripts"]]] + if script_type == clusterlib.MultiSigTypeArgs.ANY: + return [2, [_encode_simple_script(script=s) for s in script["scripts"]]] + if script_type == clusterlib.MultiSigTypeArgs.AT_LEAST: + return [ + 3, + script["required"], + [_encode_simple_script(script=s) for s in script["scripts"]], + ] + if script_type == clusterlib.MultiSlotTypeArgs.AFTER: + return [4, script["slot"]] + if script_type == clusterlib.MultiSlotTypeArgs.BEFORE: + return [5, script["slot"]] + + err = f"Unsupported simple script type: {script_type}" + raise ValueError(err) + + +def get_reference_script_size(*, script_file: cl_types.FileType) -> int: + """Get the size of a script as it is accounted for when used as a reference script. + + Since Conway, the ledger charges `minFeeRefScriptCostPerByte` for every byte of every + reference script a transaction pulls in, be it through a reference input or through a spent + UTxO that holds the script. The size is the size of the script as serialized by the ledger, + which is the bare script for Plutus scripts and the CBOR encoded structure for simple + scripts. + + Args: + script_file: A path to the script file. + + Returns: + int: A size of the reference script in bytes. + """ + with open(script_file, encoding="utf-8") as fp_in: + script: dict = json.load(fp_in) + + # A Plutus script file is a text envelope that carries the script in `cborHex` + cbor_hex = script.get("cborHex") + if cbor_hex: + return len(cbor2.loads(bytes.fromhex(cbor_hex))) + + return len(cbor2.dumps(_encode_simple_script(script=script))) + + def get_utxo_ix_offset(*, utxos: list[clusterlib.UTXOData], txouts: list[clusterlib.TxOut]) -> int: """Get offset of index of the first user-defined txout. diff --git a/framework_tests/test_clusterlib_utils.py b/framework_tests/test_clusterlib_utils.py new file mode 100644 index 000000000..33cbf3af5 --- /dev/null +++ b/framework_tests/test_clusterlib_utils.py @@ -0,0 +1,114 @@ +"""Unit tests for `cardano_node_tests.utils.clusterlib_utils`. + +The tests must not depend on project-specific binaries (`cardano-cli`, ...) being present. +""" + +import json +import pathlib as pl + +import cbor2 +import pytest + +from cardano_node_tests.utils import clusterlib_utils + +KEY_HASH1 = "9e1156acae8bd72bc1815d0be9fcb64e2d50e61f4204c45b901dad6b" +KEY_HASH2 = "7c2086ea4ebaa880c6e6c70604c0deb37ffbaa0567aec0bea8564055" + + +def write_script(*, script: dict, dest_dir: pl.Path) -> pl.Path: + """Write a script into a file and return its path.""" + script_file = dest_dir / "script.json" + with open(script_file, "w", encoding="utf-8") as fp_out: + json.dump(script, fp_out, indent=4) + return script_file + + +class TestGetReferenceScriptSize: + """Tests for `get_reference_script_size`. + + The expected sizes are the sizes of the scripts as serialized by the ledger. The `sig` and + `any` sizes were confirmed against `FeeTooSmallUTxO` ledger errors on the Preview testnet, + where `minFeeRefScriptCostPerByte` is 15: the fee was short by exactly 32 * 15 for the `sig` + script and by 167 * 15 for the `any` script below. + """ + + def test_sig(self, tmp_path: pl.Path): + """Get size of a `sig` script.""" + script_file = write_script(script={"keyHash": KEY_HASH1, "type": "sig"}, dest_dir=tmp_path) + assert clusterlib_utils.get_reference_script_size(script_file=script_file) == 32 + + def test_any_with_slot(self, tmp_path: pl.Path): + """Get size of an `any` script that nests `sig` scripts and a slot condition.""" + script_file = write_script( + script={ + "scripts": [ + {"keyHash": KEY_HASH1, "type": "sig"}, + {"keyHash": KEY_HASH2, "type": "sig"}, + {"keyHash": KEY_HASH1, "type": "sig"}, + {"keyHash": KEY_HASH2, "type": "sig"}, + {"keyHash": KEY_HASH1, "type": "sig"}, + {"slot": 100, "type": "after"}, + ], + "type": "any", + }, + dest_dir=tmp_path, + ) + assert clusterlib_utils.get_reference_script_size(script_file=script_file) == 167 + + def test_all(self, tmp_path: pl.Path): + """Get size of an `all` script.""" + script_file = write_script( + script={ + "scripts": [ + {"keyHash": KEY_HASH1, "type": "sig"}, + {"keyHash": KEY_HASH2, "type": "sig"}, + ], + "type": "all", + }, + dest_dir=tmp_path, + ) + # 2 bytes for the outer array and tag, 1 byte for the inner array, 2 * 32 bytes for the + # nested `sig` scripts + assert clusterlib_utils.get_reference_script_size(script_file=script_file) == 67 + + def test_at_least(self, tmp_path: pl.Path): + """Get size of an `atLeast` script.""" + script_file = write_script( + script={ + "required": 2, + "scripts": [ + {"keyHash": KEY_HASH1, "type": "sig"}, + {"keyHash": KEY_HASH2, "type": "sig"}, + ], + "type": "atLeast", + }, + dest_dir=tmp_path, + ) + # One more byte than the `all` script above, for the `required` value + assert clusterlib_utils.get_reference_script_size(script_file=script_file) == 68 + + def test_before(self, tmp_path: pl.Path): + """Get size of a `before` script.""" + script_file = write_script(script={"slot": 100, "type": "before"}, dest_dir=tmp_path) + assert clusterlib_utils.get_reference_script_size(script_file=script_file) == 4 + + def test_plutus(self, tmp_path: pl.Path): + """Get size of a Plutus script, which is the size of the bare script.""" + plutus_bytes = b"\x01\x02\x03\x04\x05" + script_file = write_script( + script={ + "type": "PlutusScriptV3", + "description": "", + "cborHex": cbor2.dumps(plutus_bytes).hex(), + }, + dest_dir=tmp_path, + ) + assert clusterlib_utils.get_reference_script_size(script_file=script_file) == len( + plutus_bytes + ) + + def test_unsupported_type(self, tmp_path: pl.Path): + """Fail on an unknown simple script type.""" + script_file = write_script(script={"type": "unknown"}, dest_dir=tmp_path) + with pytest.raises(ValueError, match="Unsupported simple script type: unknown"): + clusterlib_utils.get_reference_script_size(script_file=script_file)