diff --git a/pycardano/backend/ogmios_v6.py b/pycardano/backend/ogmios_v6.py index b0ff21cc..0821fc14 100644 --- a/pycardano/backend/ogmios_v6.py +++ b/pycardano/backend/ogmios_v6.py @@ -18,8 +18,6 @@ from pycardano.hash import DatumHash, ScriptHash from pycardano.network import Network from pycardano.plutus import ( - PLUTUS_V1_COST_MODEL, - PLUTUS_V2_COST_MODEL, ExecutionUnits, PlutusScript, ) @@ -364,26 +362,25 @@ def evaluate_tx_cbor(self, cbor: Union[bytes, str]) -> Dict[str, ExecutionUnits] def _parse_cost_models(self, plutus_cost_models): ogmios_cost_models = plutus_cost_models or {} + # Ogmios returns each cost model as an array of operation costs already + # in the ledger's canonical parameter order. The script integrity hash + # is computed over that canonical order, so it MUST be preserved. Key + # each entry by its zero-padded position rather than zipping against a + # name list: keying by name (and sorting those names) permutes the costs + # away from canonical order, producing a wrong script integrity hash that + # passes `evaluateTransaction` but is rejected on submit. cost_models = {} - if "plutus:v1" in ogmios_cost_models: - cost_models["PlutusV1"] = dict( - zip( - sorted(PLUTUS_V1_COST_MODEL.keys()), - ogmios_cost_models["plutus:v1"].copy(), - ) - ) - if "plutus:v2" in ogmios_cost_models: - cost_models["PlutusV2"] = dict( - zip( - sorted(PLUTUS_V2_COST_MODEL.keys()), - ogmios_cost_models["plutus:v2"].copy(), - ) - ) - if "plutus:v3" in ogmios_cost_models: - cost_models["PlutusV3"] = {} - width = len(f'{len(ogmios_cost_models["plutus:v3"])}') - for i, v in enumerate(ogmios_cost_models["plutus:v3"].copy()): - cost_models["PlutusV3"][f"{i:0{width}d}"] = v + for ogmios_key, pycardano_key in ( + ("plutus:v1", "PlutusV1"), + ("plutus:v2", "PlutusV2"), + ("plutus:v3", "PlutusV3"), + ): + if ogmios_key in ogmios_cost_models: + op_costs = ogmios_cost_models[ogmios_key] + width = len(f"{len(op_costs)}") + cost_models[pycardano_key] = { + f"{i:0{width}d}": v for i, v in enumerate(op_costs) + } return cost_models diff --git a/test/pycardano/backend/test_ogmios_v6.py b/test/pycardano/backend/test_ogmios_v6.py new file mode 100644 index 00000000..6ac39b18 --- /dev/null +++ b/test/pycardano/backend/test_ogmios_v6.py @@ -0,0 +1,49 @@ +from pycardano.backend.ogmios_v6 import OgmiosV6ChainContext + + +def _parse(plutus_cost_models): + # _parse_cost_models needs no connection state; build a bare instance. + ctx = OgmiosV6ChainContext.__new__(OgmiosV6ChainContext) + return ctx._parse_cost_models(plutus_cost_models) + + +class TestParseCostModels: + """The Plutus cost models returned by Ogmios are arrays of operation costs + already in the ledger's canonical parameter order. The script integrity hash + is computed over that order, so ``_parse_cost_models`` must preserve it and + never drop entries — otherwise a transaction's script integrity hash is wrong + and is rejected on submit (while ``evaluateTransaction`` still passes).""" + + def test_preserves_order_for_every_language(self): + v1 = list(range(100, 100 + 166)) + v2 = list(range(1000, 1000 + 332)) + v3 = list(range(5, 5 + 251)) + parsed = _parse({"plutus:v1": v1, "plutus:v2": v2, "plutus:v3": v3}) + assert list(parsed["PlutusV1"].values()) == v1 + assert list(parsed["PlutusV2"].values()) == v2 + assert list(parsed["PlutusV3"].values()) == v3 + + def test_does_not_truncate_when_model_grows(self): + # The ledger's cost models grow over time (e.g. PlutusV2 grew past 300 + # parameters at Conway). Every operation cost must be kept, regardless of + # how long the array is. + v2 = list(range(332)) + parsed = _parse({"plutus:v2": v2}) + assert len(parsed["PlutusV2"]) == len(v2) + assert list(parsed["PlutusV2"].values()) == v2 + + def test_keys_are_zero_padded_indices_that_sort_canonically(self): + # Keys are positional and zero-padded so a lexicographic ``sorted(keys)`` + # (used when serializing the V1 language view) stays in canonical order. + parsed = _parse({"plutus:v2": list(range(15))}) + keys = list(parsed["PlutusV2"].keys()) + assert keys == sorted(keys) + assert keys[:3] == ["00", "01", "02"] + + def test_absent_language_is_omitted(self): + parsed = _parse({"plutus:v2": [1, 2, 3]}) + assert set(parsed) == {"PlutusV2"} + + def test_empty_input(self): + assert _parse(None) == {} + assert _parse({}) == {}