|
| 1 | +"""Global error decoder for Ethereum contracts.""" |
| 2 | + |
| 3 | +import json |
| 4 | +from pathlib import Path |
| 5 | +from typing import Any, Dict, List, Optional, Tuple |
| 6 | + |
| 7 | +from eth_abi import decode |
| 8 | +from loguru import logger |
| 9 | +from web3 import Web3 |
| 10 | + |
| 11 | +# Standard error selectors (copied from contract.py for consistency) |
| 12 | +ERROR_SELECTOR = "0x08c379a0" # Error(string) |
| 13 | +PANIC_SELECTOR = "0x4e487b71" # Panic(uint256) |
| 14 | + |
| 15 | +PANIC_CODES = { |
| 16 | + 0x00: "Generic compiler inserted panic", |
| 17 | + 0x01: "Assert failed", |
| 18 | + 0x11: "Arithmetic overflow/underflow", |
| 19 | + 0x12: "Division by zero", |
| 20 | + 0x21: "Invalid enum value", |
| 21 | + 0x22: "Storage byte array incorrectly encoded", |
| 22 | + 0x31: "Pop on empty array", |
| 23 | + 0x32: "Array index out of bounds", |
| 24 | + 0x41: "Too much memory allocated", |
| 25 | + 0x51: "Invalid internal function call", |
| 26 | +} |
| 27 | + |
| 28 | + |
| 29 | +class ErrorDecoder: |
| 30 | + """Global registry of error selectors from all project ABIs.""" |
| 31 | + |
| 32 | + _instance = None |
| 33 | + _selectors: Dict[str, List[Dict[str, Any]]] = {} # selector -> list of possible decodings |
| 34 | + _initialized = False |
| 35 | + |
| 36 | + def __new__(cls): |
| 37 | + """Singleton pattern.""" |
| 38 | + if cls._instance is None: |
| 39 | + cls._instance = super(ErrorDecoder, cls).__new__(cls) |
| 40 | + return cls._instance |
| 41 | + |
| 42 | + def __init__(self): |
| 43 | + """Initialize and load all ABIs once.""" |
| 44 | + if self._initialized: |
| 45 | + return |
| 46 | + self.load_all_abis() |
| 47 | + self._initialized = True |
| 48 | + |
| 49 | + def load_all_abis(self): |
| 50 | + """Find and load all ABI files in the project.""" |
| 51 | + # Find the root of the source tree |
| 52 | + # Assuming we are in src/iwa/core/contracts/decoder.py |
| 53 | + current_file = Path(__file__).resolve() |
| 54 | + src_root = current_file.parents[3] # Go up to 'src' |
| 55 | + |
| 56 | + abi_files = list(src_root.glob("**/contracts/abis/*.json")) |
| 57 | + |
| 58 | + # Also check core ABIs if they are in a different place |
| 59 | + core_abi_path = src_root / "iwa" / "core" / "contracts" / "abis" |
| 60 | + if core_abi_path.exists() and core_abi_path not in [f.parent for f in abi_files]: |
| 61 | + abi_files.extend(list(core_abi_path.glob("*.json"))) |
| 62 | + |
| 63 | + logger.debug(f"Found {len(abi_files)} ABI files for error decoding.") |
| 64 | + |
| 65 | + for abi_path in abi_files: |
| 66 | + try: |
| 67 | + with open(abi_path, "r", encoding="utf-8") as f: |
| 68 | + content = json.load(f) |
| 69 | + abi = content.get("abi") if isinstance(content, dict) and "abi" in content else content |
| 70 | + if isinstance(abi, list): |
| 71 | + self._process_abi(abi, abi_path.name) |
| 72 | + except Exception as e: |
| 73 | + logger.warning(f"Failed to load ABI {abi_path}: {e}") |
| 74 | + |
| 75 | + def _process_abi(self, abi: List[Dict], source_name: str): |
| 76 | + """Extract error selectors from an ABI.""" |
| 77 | + for entry in abi: |
| 78 | + if entry.get("type") == "error": |
| 79 | + name = entry["name"] |
| 80 | + inputs = entry.get("inputs", []) |
| 81 | + types = [i["type"] for i in inputs] |
| 82 | + names = [i["name"] for i in inputs] |
| 83 | + |
| 84 | + # Signature: Name(type1,type2,...) |
| 85 | + types_str = ",".join(types) |
| 86 | + signature = f"{name}({types_str})" |
| 87 | + selector = "0x" + Web3.keccak(text=signature)[:4].hex() |
| 88 | + |
| 89 | + decoding = { |
| 90 | + "name": name, |
| 91 | + "types": types, |
| 92 | + "arg_names": names, |
| 93 | + "source": source_name, |
| 94 | + "signature": signature |
| 95 | + } |
| 96 | + |
| 97 | + if selector not in self._selectors: |
| 98 | + self._selectors[selector] = [] |
| 99 | + |
| 100 | + # Avoid duplicates |
| 101 | + if decoding not in self._selectors[selector]: |
| 102 | + self._selectors[selector].append(decoding) |
| 103 | + |
| 104 | + def decode(self, error_data: str) -> List[Tuple[str, str, str]]: |
| 105 | + """Decode hex error data. |
| 106 | +
|
| 107 | + Returns: |
| 108 | + List of (error_name, formatted_message, source_abi) |
| 109 | + """ |
| 110 | + if not error_data: |
| 111 | + return [] |
| 112 | + |
| 113 | + if not error_data.startswith("0x"): |
| 114 | + error_data = "0x" + error_data |
| 115 | + |
| 116 | + if len(error_data) < 10: |
| 117 | + return [] |
| 118 | + |
| 119 | + selector = error_data[:10].lower() |
| 120 | + encoded_args = error_data[10:] |
| 121 | + |
| 122 | + results = [] |
| 123 | + |
| 124 | + # 1. Check Standard Error(string) |
| 125 | + if selector == ERROR_SELECTOR: |
| 126 | + try: |
| 127 | + decoded = decode(["string"], bytes.fromhex(encoded_args)) |
| 128 | + results.append(("Error", f"Error: {decoded[0]}", "Built-in")) |
| 129 | + except Exception: |
| 130 | + pass |
| 131 | + |
| 132 | + # 2. Check Panic(uint256) |
| 133 | + if selector == PANIC_SELECTOR: |
| 134 | + try: |
| 135 | + decoded = decode(["uint256"], bytes.fromhex(encoded_args)) |
| 136 | + code = decoded[0] |
| 137 | + msg = PANIC_CODES.get(code, f"Unknown panic code {code}") |
| 138 | + results.append(("Panic", f"Panic: {msg}", "Built-in")) |
| 139 | + except Exception: |
| 140 | + pass |
| 141 | + |
| 142 | + # 3. Check Custom Errors |
| 143 | + if selector in self._selectors: |
| 144 | + for d in self._selectors[selector]: |
| 145 | + try: |
| 146 | + decoded = decode(d["types"], bytes.fromhex(encoded_args)) |
| 147 | + args_str = ", ".join(f"{n}={v}" for n, v in zip(d["arg_names"], decoded)) |
| 148 | + results.append((d["name"], f"{d['name']}({args_str})", d["source"])) |
| 149 | + except Exception: |
| 150 | + # Try next possible decoding for this selector |
| 151 | + continue |
| 152 | + |
| 153 | + return results |
0 commit comments