diff --git a/async_substrate_interface/async_substrate.py b/async_substrate_interface/async_substrate.py index bdaafe74..c8ffc8aa 100644 --- a/async_substrate_interface/async_substrate.py +++ b/async_substrate_interface/async_substrate.py @@ -78,6 +78,16 @@ convert_account_ids, decode_query_map_async, ) +from async_substrate_interface.utils.receipt import ( + build_system_error_message, + extract_failure_details, + extract_fallback_deposit_fee_amount, + extract_success_weight, + extract_total_fee_amount, + is_extrinsic_failure_event, + is_extrinsic_success_event, + normalize_module_error, +) from async_substrate_interface.utils.storage import StorageKey from async_substrate_interface.type_registry import _TYPE_REGISTRY @@ -261,142 +271,69 @@ async def process_events(self): if await self.triggered_events: self.__total_fee_amount = 0 - # Process fees - has_transaction_fee_paid_event = False - - for event in await self.triggered_events: - if ( - event["event"]["module_id"] == "TransactionPayment" - and event["event"]["event_id"] == "TransactionFeePaid" - ): - self.__total_fee_amount = event["event"]["attributes"]["actual_fee"] - has_transaction_fee_paid_event = True + events = await self.triggered_events + self.__total_fee_amount, has_transaction_fee_paid_event = ( + extract_total_fee_amount(events) + ) # Process other events possible_success = False - for event in await self.triggered_events: - # TODO make this more readable - # Check events - if ( - event["event"]["module_id"] == "System" - and event["event"]["event_id"] == "ExtrinsicSuccess" - ): + for event in events: + if is_extrinsic_success_event(event): possible_success = True - - if "dispatch_info" in event["event"]["attributes"]: - self.__weight = event["event"]["attributes"]["dispatch_info"][ - "weight" - ] - else: - # Backwards compatibility - self.__weight = event["event"]["attributes"]["weight"] - - elif ( - event["event"]["module_id"] == "System" - and event["event"]["event_id"] == "ExtrinsicFailed" - ) or ( - event["event"]["module_id"] == "MevShield" - and event["event"]["event_id"] - in ("DecryptedRejected", "DecryptionFailed") - ): + self.__weight = extract_success_weight(event) + elif is_extrinsic_failure_event(event): possible_success = False self.__is_success = False + failure_details = extract_failure_details(event) + if failure_details["has_weight"]: + self.__weight = failure_details["weight"] + if failure_details["error_message"] is not None: + self.__error_message = failure_details["error_message"] + continue - if event["event"]["module_id"] == "System": - dispatch_info = event["event"]["attributes"]["dispatch_info"] - dispatch_error = event["event"]["attributes"]["dispatch_error"] - self.__weight = dispatch_info["weight"] - else: - # MEV shield extrinsics - if event["event"]["event_id"] == "DecryptedRejected": - dispatch_info = event["event"]["attributes"]["reason"][ - "post_info" - ] - dispatch_error = event["event"]["attributes"]["reason"][ - "error" - ] - self.__weight = event["event"]["attributes"]["reason"][ - "post_info" - ]["actual_weight"] - else: - self.__error_message = { - "type": "MevShield", - "name": "DecryptionFailed", - "docs": event["event"]["attributes"]["reason"], - } - continue - - if "Module" in dispatch_error: - if isinstance(dispatch_error["Module"], tuple): - module_index = dispatch_error["Module"][0] - error_index = dispatch_error["Module"][1] - else: - module_index = dispatch_error["Module"]["index"] - error_index = dispatch_error["Module"]["error"] - - if isinstance(error_index, str): - # Actual error index is first u8 in new [u8; 4] format - error_index = int(error_index[2:4], 16) + dispatch_error = failure_details["dispatch_error"] + if dispatch_error is None: + continue - if self.block_hash: - runtime = await self.substrate.init_runtime( - block_hash=self.block_hash - ) - else: - runtime = await self.substrate.init_runtime( - block_id=self.block_number + module_error = normalize_module_error(dispatch_error) + if module_error is not None: + self.__error_message = ( + await self._resolve_module_error_message( + module_index=module_error["module_index"], + error_index=module_error["error_index"], ) - module_error = runtime.metadata.get_module_error( - module_index=module_index, error_index=error_index ) - self.__error_message = { - "type": "Module", - "name": module_error.name, - "docs": module_error.docs, - } - elif "BadOrigin" in dispatch_error: - self.__error_message = { - "type": "System", - "name": "BadOrigin", - "docs": "Bad origin", - } - elif "CannotLookup" in dispatch_error: - self.__error_message = { - "type": "System", - "name": "CannotLookup", - "docs": "Cannot lookup", - } - elif "Other" in dispatch_error: - self.__error_message = { - "type": "System", - "name": "Other", - "docs": "Unspecified error occurred", - } - elif "Token" in dispatch_error: - self.__error_message = { - "type": "System", - "name": "Token", - "docs": dispatch_error["Token"], - } - + else: + self.__error_message = build_system_error_message( + dispatch_error + ) elif not has_transaction_fee_paid_event: - if ( - event["event"]["module_id"] == "Treasury" - and event["event"]["event_id"] == "Deposit" - ): - self.__total_fee_amount += event["event"]["attributes"]["value"] - elif ( - event["event"]["module_id"] == "Balances" - and event["event"]["event_id"] == "Deposit" - ): - self.__total_fee_amount += event["event"]["attributes"][ - "amount" - ] + self.__total_fee_amount += extract_fallback_deposit_fee_amount( + event + ) if possible_success is True and self.__error_message is None: # we delay the positive setting of the __is_success flag until we have finished iteration of the # events and have ensured nothing has set an error message self.__is_success = True + async def _resolve_module_error_message( + self, module_index: int, error_index: int + ) -> dict: + if self.block_hash: + runtime = await self.substrate.init_runtime(block_hash=self.block_hash) + else: + runtime = await self.substrate.init_runtime(block_id=self.block_number) + + module_error = runtime.metadata.get_module_error( + module_index=module_index, error_index=error_index + ) + return { + "type": "Module", + "name": module_error.name, + "docs": module_error.docs, + } + @property async def is_success(self) -> bool: """ diff --git a/async_substrate_interface/sync_substrate.py b/async_substrate_interface/sync_substrate.py index 7ee2507c..4713e1cd 100644 --- a/async_substrate_interface/sync_substrate.py +++ b/async_substrate_interface/sync_substrate.py @@ -50,6 +50,16 @@ legacy_scale_decode, convert_account_ids, ) +from async_substrate_interface.utils.receipt import ( + build_system_error_message, + extract_failure_details, + extract_fallback_deposit_fee_amount, + extract_success_weight, + extract_total_fee_amount, + is_extrinsic_failure_event, + is_extrinsic_success_event, + normalize_module_error, +) from async_substrate_interface.utils.storage import StorageKey from async_substrate_interface.type_registry import _TYPE_REGISTRY @@ -223,134 +233,59 @@ def process_events(self): if self.triggered_events: self.__total_fee_amount = 0 - # Process fees - has_transaction_fee_paid_event = False - - for event in self.triggered_events: - if ( - event["event"]["module_id"] == "TransactionPayment" - and event["event"]["event_id"] == "TransactionFeePaid" - ): - self.__total_fee_amount = event["event"]["attributes"]["actual_fee"] - has_transaction_fee_paid_event = True + self.__total_fee_amount, has_transaction_fee_paid_event = ( + extract_total_fee_amount(self.triggered_events) + ) # Process other events possible_success = False for event in self.triggered_events: - # TODO make this more readable - # Check events - if ( - event["event"]["module_id"] == "System" - and event["event"]["event_id"] == "ExtrinsicSuccess" - ): + if is_extrinsic_success_event(event): possible_success = True - - if "dispatch_info" in event["event"]["attributes"]: - self.__weight = event["event"]["attributes"]["dispatch_info"][ - "weight" - ] - else: - # Backwards compatibility - self.__weight = event["event"]["attributes"]["weight"] - - elif ( - event["event"]["module_id"] == "System" - and event["event"]["event_id"] == "ExtrinsicFailed" - ) or ( - event["event"]["module_id"] == "MevShield" - and event["event"]["event_id"] - in ("DecryptedRejected", "DecryptionFailed") - ): + self.__weight = extract_success_weight(event) + elif is_extrinsic_failure_event(event): possible_success = False self.__is_success = False - - if event["event"]["module_id"] == "System": - dispatch_info = event["event"]["attributes"]["dispatch_info"] - dispatch_error = event["event"]["attributes"]["dispatch_error"] - self.__weight = dispatch_info["weight"] + failure_details = extract_failure_details(event) + if failure_details["has_weight"]: + self.__weight = failure_details["weight"] + if failure_details["error_message"] is not None: + self.__error_message = failure_details["error_message"] + continue + + dispatch_error = failure_details["dispatch_error"] + if dispatch_error is None: + continue + + module_error = normalize_module_error(dispatch_error) + if module_error is not None: + self.__error_message = self._resolve_module_error_message( + module_index=module_error["module_index"], + error_index=module_error["error_index"], + ) else: - # MEV shield extrinsics - if event["event"]["event_id"] == "DecryptedRejected": - dispatch_info = event["event"]["attributes"]["reason"][ - "post_info" - ] - dispatch_error = event["event"]["attributes"]["reason"][ - "error" - ] - self.__weight = event["event"]["attributes"]["reason"][ - "post_info" - ]["actual_weight"] - else: - self.__error_message = { - "type": "MevShield", - "name": "DecryptionFailed", - "docs": event["event"]["attributes"]["reason"], - } - continue - - if "Module" in dispatch_error: - if isinstance(dispatch_error["Module"], tuple): - module_index = dispatch_error["Module"][0] - error_index = dispatch_error["Module"][1] - else: - module_index = dispatch_error["Module"]["index"] - error_index = dispatch_error["Module"]["error"] - - if isinstance(error_index, str): - # Actual error index is first u8 in new [u8; 4] format - error_index = int(error_index[2:4], 16) - - module_error = self.substrate.metadata.get_module_error( - module_index=module_index, error_index=error_index + self.__error_message = build_system_error_message( + dispatch_error ) - self.__error_message = { - "type": "Module", - "name": module_error.name, - "docs": module_error.docs, - } - elif "BadOrigin" in dispatch_error: - self.__error_message = { - "type": "System", - "name": "BadOrigin", - "docs": "Bad origin", - } - elif "CannotLookup" in dispatch_error: - self.__error_message = { - "type": "System", - "name": "CannotLookup", - "docs": "Cannot lookup", - } - elif "Other" in dispatch_error: - self.__error_message = { - "type": "System", - "name": "Other", - "docs": "Unspecified error occurred", - } - elif "Token" in dispatch_error: - self.__error_message = { - "type": "System", - "name": "Token", - "docs": dispatch_error["Token"], - } - elif not has_transaction_fee_paid_event: - if ( - event["event"]["module_id"] == "Treasury" - and event["event"]["event_id"] == "Deposit" - ): - self.__total_fee_amount += event["event"]["attributes"]["value"] - elif ( - event["event"]["module_id"] == "Balances" - and event["event"]["event_id"] == "Deposit" - ): - self.__total_fee_amount += event["event"]["attributes"][ - "amount" - ] + self.__total_fee_amount += extract_fallback_deposit_fee_amount( + event + ) if possible_success is True and self.__error_message is None: # we delay the positive setting of the __is_success flag until we have finished iteration of the # events and have ensured nothing has set an error message self.__is_success = True + def _resolve_module_error_message(self, module_index: int, error_index: int) -> dict: + module_error = self.substrate.metadata.get_module_error( + module_index=module_index, error_index=error_index + ) + return { + "type": "Module", + "name": module_error.name, + "docs": module_error.docs, + } + @property def is_success(self) -> bool: """ diff --git a/async_substrate_interface/utils/receipt.py b/async_substrate_interface/utils/receipt.py new file mode 100644 index 00000000..318d9cac --- /dev/null +++ b/async_substrate_interface/utils/receipt.py @@ -0,0 +1,133 @@ +from typing import Optional, Union + + +def _get_event_parts(event: dict) -> tuple[str, str, dict]: + event_data = event["event"] + return event_data["module_id"], event_data["event_id"], event_data["attributes"] + + +def extract_total_fee_amount(events: list[dict]) -> tuple[int, bool]: + total_fee_amount = 0 + has_transaction_fee_paid_event = False + + for event in events: + module_id, event_id, attributes = _get_event_parts(event) + if module_id == "TransactionPayment" and event_id == "TransactionFeePaid": + total_fee_amount = attributes["actual_fee"] + has_transaction_fee_paid_event = True + + return total_fee_amount, has_transaction_fee_paid_event + + +def extract_fallback_deposit_fee_amount(event: dict) -> int: + module_id, event_id, attributes = _get_event_parts(event) + if module_id == "Treasury" and event_id == "Deposit": + return attributes["value"] + + if module_id == "Balances" and event_id == "Deposit": + return attributes["amount"] + + return 0 + + +def is_extrinsic_success_event(event: dict) -> bool: + module_id, event_id, _ = _get_event_parts(event) + return module_id == "System" and event_id == "ExtrinsicSuccess" + + +def is_extrinsic_failure_event(event: dict) -> bool: + module_id, event_id, _ = _get_event_parts(event) + return (module_id == "System" and event_id == "ExtrinsicFailed") or ( + module_id == "MevShield" + and event_id in ("DecryptedRejected", "DecryptionFailed") + ) + + +def extract_success_weight(event: dict) -> Union[int, dict]: + _, _, attributes = _get_event_parts(event) + if "dispatch_info" in attributes: + return attributes["dispatch_info"]["weight"] + + # Backwards compatibility + return attributes["weight"] + + +def extract_failure_details(event: dict) -> dict: + module_id, event_id, attributes = _get_event_parts(event) + has_weight = False + weight = None + dispatch_error = None + error_message = None + + if module_id == "System": + dispatch_info = attributes["dispatch_info"] + has_weight = True + weight = dispatch_info["weight"] + dispatch_error = attributes["dispatch_error"] + elif event_id == "DecryptedRejected": + reason = attributes["reason"] + has_weight = True + weight = reason["post_info"]["actual_weight"] + dispatch_error = reason["error"] + else: + error_message = { + "type": "MevShield", + "name": "DecryptionFailed", + "docs": attributes["reason"], + } + + return { + "has_weight": has_weight, + "weight": weight, + "dispatch_error": dispatch_error, + "error_message": error_message, + } + + +def normalize_module_error(dispatch_error: dict) -> Optional[dict]: + if "Module" not in dispatch_error: + return None + + module_dispatch_error = dispatch_error["Module"] + if isinstance(module_dispatch_error, tuple): + module_index = module_dispatch_error[0] + error_index = module_dispatch_error[1] + else: + module_index = module_dispatch_error["index"] + error_index = module_dispatch_error["error"] + + if isinstance(error_index, str): + # Actual error index is first u8 in new [u8; 4] format + error_index = int(error_index[2:4], 16) + + return { + "module_index": module_index, + "error_index": error_index, + } + + +def build_system_error_message(dispatch_error: dict) -> Optional[dict]: + name = None + docs = None + + if "BadOrigin" in dispatch_error: + name = "BadOrigin" + docs = "Bad origin" + elif "CannotLookup" in dispatch_error: + name = "CannotLookup" + docs = "Cannot lookup" + elif "Other" in dispatch_error: + name = "Other" + docs = "Unspecified error occurred" + elif "Token" in dispatch_error: + name = "Token" + docs = dispatch_error["Token"] + + if name is None: + return None + + return { + "type": "System", + "name": name, + "docs": docs, + } diff --git a/tests/unit_tests/asyncio_/test_substrate_interface.py b/tests/unit_tests/asyncio_/test_substrate_interface.py index a0ac1235..bd5ade46 100644 --- a/tests/unit_tests/asyncio_/test_substrate_interface.py +++ b/tests/unit_tests/asyncio_/test_substrate_interface.py @@ -7,6 +7,7 @@ from websockets.protocol import State from async_substrate_interface.async_substrate import ( + AsyncExtrinsicReceipt, AsyncQueryMapResult, AsyncSubstrateInterface, get_async_substrate_interface, @@ -342,3 +343,307 @@ async def test_get_account_next_index_bypass_mode_raises_on_rpc_error(): "5F3sa2TJAWMqDhXG6jhV4N8ko9NoFz5Y2s8vS8uM9f7v7mA", use_cache=False, ) + + +class TestAsyncExtrinsicReceiptProcessEvents: + def _make_event(self, module_id, event_id, attributes, extrinsic_idx=0): + return { + "extrinsic_idx": extrinsic_idx, + "event": { + "module_id": module_id, + "event_id": event_id, + "attributes": attributes, + }, + } + + def _make_module_error(self, name="ModuleError", docs=None): + module_error = MagicMock() + module_error.name = name + module_error.docs = docs if docs is not None else ["module error docs"] + return module_error + + def _make_receipt(self, events): + substrate = MagicMock() + runtime = MagicMock() + runtime.metadata = MagicMock() + substrate.get_events = AsyncMock(return_value=events) + substrate.init_runtime = AsyncMock(return_value=runtime) + receipt = AsyncExtrinsicReceipt( + substrate=substrate, + extrinsic_hash="0xdeadbeef", + block_hash="0xabc", + extrinsic_idx=0, + ) + return receipt, substrate, runtime + + @pytest.mark.asyncio + async def test_extracts_dispatch_info_weight(self): + events = [ + self._make_event( + "System", + "ExtrinsicSuccess", + {"dispatch_info": {"weight": {"ref_time": 1, "proof_size": 2}}}, + ) + ] + receipt, _, _ = self._make_receipt(events) + + assert await receipt.is_success is True + assert await receipt.error_message is None + assert await receipt.weight == {"ref_time": 1, "proof_size": 2} + + @pytest.mark.asyncio + async def test_extracts_legacy_weight(self): + events = [self._make_event("System", "ExtrinsicSuccess", {"weight": 7})] + receipt, _, _ = self._make_receipt(events) + + assert await receipt.is_success is True + assert await receipt.error_message is None + assert await receipt.weight == 7 + + @pytest.mark.asyncio + async def test_prefers_transaction_fee_paid_over_deposit_fallback(self): + events = [ + self._make_event( + "TransactionPayment", + "TransactionFeePaid", + {"actual_fee": 10}, + ), + self._make_event("Treasury", "Deposit", {"value": 99}), + self._make_event("Balances", "Deposit", {"amount": 88}), + ] + receipt, _, _ = self._make_receipt(events) + + assert await receipt.total_fee_amount == 10 + + @pytest.mark.asyncio + async def test_accumulates_fallback_fee_from_deposits(self): + events = [ + self._make_event("Treasury", "Deposit", {"value": 3}), + self._make_event("Balances", "Deposit", {"amount": 2}), + ] + receipt, _, _ = self._make_receipt(events) + + assert await receipt.total_fee_amount == 5 + + @pytest.mark.asyncio + async def test_decodes_legacy_module_error_tuple(self): + events = [ + self._make_event( + "System", + "ExtrinsicFailed", + { + "dispatch_info": {"weight": 9}, + "dispatch_error": {"Module": (3, 4)}, + }, + ) + ] + receipt, substrate, runtime = self._make_receipt(events) + runtime.metadata.get_module_error.return_value = self._make_module_error( + name="InsufficientBalance", + docs=["balance too low"], + ) + + assert await receipt.is_success is False + assert await receipt.error_message == { + "type": "Module", + "name": "InsufficientBalance", + "docs": ["balance too low"], + } + assert await receipt.weight == 9 + substrate.init_runtime.assert_awaited_once_with(block_hash="0xabc") + runtime.metadata.get_module_error.assert_called_once_with( + module_index=3, error_index=4 + ) + + @pytest.mark.asyncio + async def test_decodes_module_error_from_hex_error_bytes(self): + events = [ + self._make_event( + "System", + "ExtrinsicFailed", + { + "dispatch_info": {"weight": 9}, + "dispatch_error": {"Module": {"index": 5, "error": "0x0a000000"}}, + }, + ) + ] + receipt, substrate, runtime = self._make_receipt(events) + runtime.metadata.get_module_error.return_value = self._make_module_error( + name="DecodedHexError", + docs=["decoded from first byte"], + ) + + assert await receipt.is_success is False + assert await receipt.error_message == { + "type": "Module", + "name": "DecodedHexError", + "docs": ["decoded from first byte"], + } + assert await receipt.weight == 9 + substrate.init_runtime.assert_awaited_once_with(block_hash="0xabc") + runtime.metadata.get_module_error.assert_called_once_with( + module_index=5, error_index=10 + ) + + @pytest.mark.asyncio + async def test_maps_bad_origin_error(self): + events = [ + self._make_event( + "System", + "ExtrinsicFailed", + { + "dispatch_info": {"weight": 9}, + "dispatch_error": {"BadOrigin": None}, + }, + ) + ] + receipt, substrate, _ = self._make_receipt(events) + + assert await receipt.is_success is False + assert await receipt.error_message == { + "type": "System", + "name": "BadOrigin", + "docs": "Bad origin", + } + assert await receipt.weight == 9 + substrate.init_runtime.assert_not_awaited() + + @pytest.mark.asyncio + async def test_maps_cannot_lookup_error(self): + events = [ + self._make_event( + "System", + "ExtrinsicFailed", + { + "dispatch_info": {"weight": 9}, + "dispatch_error": {"CannotLookup": None}, + }, + ) + ] + receipt, substrate, _ = self._make_receipt(events) + + assert await receipt.is_success is False + assert await receipt.error_message == { + "type": "System", + "name": "CannotLookup", + "docs": "Cannot lookup", + } + assert await receipt.weight == 9 + substrate.init_runtime.assert_not_awaited() + + @pytest.mark.asyncio + async def test_maps_token_error(self): + events = [ + self._make_event( + "System", + "ExtrinsicFailed", + { + "dispatch_info": {"weight": 9}, + "dispatch_error": {"Token": "FundsUnavailable"}, + }, + ) + ] + receipt, substrate, _ = self._make_receipt(events) + + assert await receipt.is_success is False + assert await receipt.error_message == { + "type": "System", + "name": "Token", + "docs": "FundsUnavailable", + } + assert await receipt.weight == 9 + substrate.init_runtime.assert_not_awaited() + + @pytest.mark.asyncio + async def test_preserves_unknown_dispatch_error_as_none(self): + events = [ + self._make_event( + "System", + "ExtrinsicFailed", + { + "dispatch_info": {"weight": 9}, + "dispatch_error": {"Arithmetic": "Overflow"}, + }, + ) + ] + receipt, substrate, _ = self._make_receipt(events) + + assert await receipt.is_success is False + assert await receipt.error_message is None + assert await receipt.weight == 9 + substrate.init_runtime.assert_not_awaited() + + @pytest.mark.asyncio + async def test_failure_takes_precedence_over_success(self): + events = [ + self._make_event( + "System", + "ExtrinsicSuccess", + {"dispatch_info": {"weight": 1}}, + ), + self._make_event( + "System", + "ExtrinsicFailed", + { + "dispatch_info": {"weight": 9}, + "dispatch_error": {"Other": None}, + }, + ), + ] + receipt, substrate, _ = self._make_receipt(events) + + assert await receipt.is_success is False + assert await receipt.error_message == { + "type": "System", + "name": "Other", + "docs": "Unspecified error occurred", + } + assert await receipt.weight == 9 + substrate.init_runtime.assert_not_awaited() + + @pytest.mark.asyncio + async def test_maps_mevshield_decrypted_rejected_error(self): + events = [ + self._make_event( + "MevShield", + "DecryptedRejected", + { + "reason": { + "post_info": {"actual_weight": 123}, + "error": {"Other": None}, + } + }, + ) + ] + receipt, substrate, _ = self._make_receipt(events) + + assert await receipt.is_success is False + assert await receipt.error_message == { + "type": "System", + "name": "Other", + "docs": "Unspecified error occurred", + } + assert await receipt.weight == 123 + assert await receipt.total_fee_amount == 0 + substrate.init_runtime.assert_not_awaited() + + @pytest.mark.asyncio + async def test_maps_mevshield_decryption_failed_error(self): + events = [ + self._make_event( + "MevShield", + "DecryptionFailed", + {"reason": "ciphertext could not be decrypted"}, + ) + ] + receipt, substrate, _ = self._make_receipt(events) + + assert await receipt.is_success is False + assert await receipt.error_message == { + "type": "MevShield", + "name": "DecryptionFailed", + "docs": "ciphertext could not be decrypted", + } + assert await receipt.total_fee_amount == 0 + assert await receipt.weight is None + substrate.init_runtime.assert_not_awaited() diff --git a/tests/unit_tests/sync/test_substrate_interface.py b/tests/unit_tests/sync/test_substrate_interface.py index 94a43a00..312fdce9 100644 --- a/tests/unit_tests/sync/test_substrate_interface.py +++ b/tests/unit_tests/sync/test_substrate_interface.py @@ -1,7 +1,11 @@ import tracemalloc from unittest.mock import MagicMock -from async_substrate_interface.sync_substrate import SubstrateInterface, QueryMapResult +from async_substrate_interface.sync_substrate import ( + SubstrateInterface, + QueryMapResult, + ExtrinsicReceipt, +) from async_substrate_interface.types import ScaleObj from tests.helpers.settings import ARCHIVE_ENTRYPOINT, LATENT_LITE_ENTRYPOINT @@ -230,3 +234,290 @@ def test_cache_miss_fetches_and_stores(self): substrate.runtime_cache.add_item.assert_called_once_with( block_hash="0xABC", block=100 ) + + +class TestExtrinsicReceiptProcessEvents: + def _make_event(self, module_id, event_id, attributes, extrinsic_idx=0): + return { + "extrinsic_idx": extrinsic_idx, + "event": { + "module_id": module_id, + "event_id": event_id, + "attributes": attributes, + } + } + + def _make_module_error(self, name="ModuleError", docs=None): + module_error = MagicMock() + module_error.name = name + module_error.docs = docs if docs is not None else ["module error docs"] + return module_error + + def _make_receipt(self, events): + substrate = MagicMock() + substrate.metadata = MagicMock() + substrate.get_events = MagicMock(return_value=events) + receipt = ExtrinsicReceipt( + substrate=substrate, + extrinsic_hash="0xdeadbeef", + block_hash="0xabc", + extrinsic_idx=0, + ) + return receipt, substrate + + def test_extracts_dispatch_info_weight(self): + events = [ + self._make_event( + "System", + "ExtrinsicSuccess", + {"dispatch_info": {"weight": {"ref_time": 1, "proof_size": 2}}}, + ) + ] + receipt, _ = self._make_receipt(events) + + assert receipt.is_success is True + assert receipt.error_message is None + assert receipt.weight == {"ref_time": 1, "proof_size": 2} + + def test_extracts_legacy_weight(self): + events = [self._make_event("System", "ExtrinsicSuccess", {"weight": 7})] + receipt, _ = self._make_receipt(events) + + assert receipt.is_success is True + assert receipt.error_message is None + assert receipt.weight == 7 + + def test_prefers_transaction_fee_paid_over_deposit_fallback(self): + events = [ + self._make_event( + "TransactionPayment", + "TransactionFeePaid", + {"actual_fee": 10}, + ), + self._make_event("Treasury", "Deposit", {"value": 99}), + self._make_event("Balances", "Deposit", {"amount": 88}), + ] + receipt, _ = self._make_receipt(events) + + assert receipt.total_fee_amount == 10 + + def test_accumulates_fallback_fee_from_deposits(self): + events = [ + self._make_event("Treasury", "Deposit", {"value": 3}), + self._make_event("Balances", "Deposit", {"amount": 2}), + ] + receipt, _ = self._make_receipt(events) + + assert receipt.total_fee_amount == 5 + + def test_decodes_legacy_module_error_tuple(self): + events = [ + self._make_event( + "System", + "ExtrinsicFailed", + { + "dispatch_info": {"weight": 9}, + "dispatch_error": {"Module": (3, 4)}, + }, + ) + ] + receipt, substrate = self._make_receipt(events) + substrate.metadata.get_module_error.return_value = self._make_module_error( + name="InsufficientBalance", + docs=["balance too low"], + ) + + assert receipt.is_success is False + assert receipt.error_message == { + "type": "Module", + "name": "InsufficientBalance", + "docs": ["balance too low"], + } + assert receipt.weight == 9 + substrate.metadata.get_module_error.assert_called_once_with( + module_index=3, error_index=4 + ) + + def test_decodes_module_error_from_hex_error_bytes(self): + events = [ + self._make_event( + "System", + "ExtrinsicFailed", + { + "dispatch_info": {"weight": 9}, + "dispatch_error": {"Module": {"index": 5, "error": "0x0a000000"}}, + }, + ) + ] + receipt, substrate = self._make_receipt(events) + substrate.metadata.get_module_error.return_value = self._make_module_error( + name="DecodedHexError", + docs=["decoded from first byte"], + ) + + assert receipt.is_success is False + assert receipt.error_message == { + "type": "Module", + "name": "DecodedHexError", + "docs": ["decoded from first byte"], + } + assert receipt.weight == 9 + substrate.metadata.get_module_error.assert_called_once_with( + module_index=5, error_index=10 + ) + + def test_maps_bad_origin_error(self): + events = [ + self._make_event( + "System", + "ExtrinsicFailed", + { + "dispatch_info": {"weight": 9}, + "dispatch_error": {"BadOrigin": None}, + }, + ) + ] + receipt, substrate = self._make_receipt(events) + + assert receipt.is_success is False + assert receipt.error_message == { + "type": "System", + "name": "BadOrigin", + "docs": "Bad origin", + } + assert receipt.weight == 9 + substrate.metadata.get_module_error.assert_not_called() + + def test_maps_cannot_lookup_error(self): + events = [ + self._make_event( + "System", + "ExtrinsicFailed", + { + "dispatch_info": {"weight": 9}, + "dispatch_error": {"CannotLookup": None}, + }, + ) + ] + receipt, substrate = self._make_receipt(events) + + assert receipt.is_success is False + assert receipt.error_message == { + "type": "System", + "name": "CannotLookup", + "docs": "Cannot lookup", + } + assert receipt.weight == 9 + substrate.metadata.get_module_error.assert_not_called() + + def test_maps_token_error(self): + events = [ + self._make_event( + "System", + "ExtrinsicFailed", + { + "dispatch_info": {"weight": 9}, + "dispatch_error": {"Token": "FundsUnavailable"}, + }, + ) + ] + receipt, substrate = self._make_receipt(events) + + assert receipt.is_success is False + assert receipt.error_message == { + "type": "System", + "name": "Token", + "docs": "FundsUnavailable", + } + assert receipt.weight == 9 + substrate.metadata.get_module_error.assert_not_called() + + def test_preserves_unknown_dispatch_error_as_none(self): + events = [ + self._make_event( + "System", + "ExtrinsicFailed", + { + "dispatch_info": {"weight": 9}, + "dispatch_error": {"Arithmetic": "Overflow"}, + }, + ) + ] + receipt, substrate = self._make_receipt(events) + + assert receipt.is_success is False + assert receipt.error_message is None + assert receipt.weight == 9 + substrate.metadata.get_module_error.assert_not_called() + + def test_failure_takes_precedence_over_success(self): + events = [ + self._make_event( + "System", + "ExtrinsicSuccess", + {"dispatch_info": {"weight": 1}}, + ), + self._make_event( + "System", + "ExtrinsicFailed", + { + "dispatch_info": {"weight": 9}, + "dispatch_error": {"Other": None}, + }, + ), + ] + receipt, substrate = self._make_receipt(events) + + assert receipt.is_success is False + assert receipt.error_message == { + "type": "System", + "name": "Other", + "docs": "Unspecified error occurred", + } + assert receipt.weight == 9 + substrate.metadata.get_module_error.assert_not_called() + + def test_maps_mevshield_decrypted_rejected_error(self): + events = [ + self._make_event( + "MevShield", + "DecryptedRejected", + { + "reason": { + "post_info": {"actual_weight": 123}, + "error": {"Other": None}, + } + }, + ) + ] + receipt, substrate = self._make_receipt(events) + + assert receipt.is_success is False + assert receipt.error_message == { + "type": "System", + "name": "Other", + "docs": "Unspecified error occurred", + } + assert receipt.weight == 123 + assert receipt.total_fee_amount == 0 + substrate.metadata.get_module_error.assert_not_called() + + def test_maps_mevshield_decryption_failed_error(self): + events = [ + self._make_event( + "MevShield", + "DecryptionFailed", + {"reason": "ciphertext could not be decrypted"}, + ) + ] + receipt, substrate = self._make_receipt(events) + + assert receipt.is_success is False + assert receipt.error_message == { + "type": "MevShield", + "name": "DecryptionFailed", + "docs": "ciphertext could not be decrypted", + } + assert receipt.total_fee_amount == 0 + assert receipt.weight is None + substrate.metadata.get_module_error.assert_not_called()