Skip to content

Commit 416ec36

Browse files
committed
feat: add global error decoder and implement fix for Olas owners
1 parent 198e7d0 commit 416ec36

4 files changed

Lines changed: 183 additions & 1 deletion

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "iwa"
3-
version = "0.0.27"
3+
version = "0.0.28"
44
description = "A secure, modular, and plugin-based framework for crypto agents and ops"
55
readme = "README.md"
66
requires-python = ">=3.12,<4.0"

src/iwa/core/cli.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from iwa.core.services import PluginService
1212
from iwa.core.tables import list_accounts
1313
from iwa.core.wallet import Wallet
14+
from iwa.core.contracts.decoder import ErrorDecoder
1415
from iwa.tui.app import IwaApp
1516

1617
iwa_cli = typer.Typer(help="iwa command line interface")
@@ -206,6 +207,23 @@ def web_server(
206207
run_server(host=host, port=server_port)
207208

208209

210+
@iwa_cli.command("decode")
211+
def decode_hex(
212+
hex_data: str = typer.Argument(..., help="The hex-encoded error data (e.g., 0xa43d6ada...)"),
213+
):
214+
"""Decode a hex error identifier into a human-readable message."""
215+
decoder = ErrorDecoder()
216+
results = decoder.decode(hex_data)
217+
218+
if not results:
219+
typer.echo(f"Could not decode error data: {hex_data}")
220+
return
221+
222+
typer.echo(f"\nDecoding results for {hex_data[:10]}:")
223+
for name, msg, source in results:
224+
typer.echo(f" [{source}] {msg}")
225+
226+
209227
@wallet_cli.command("drain")
210228
def drain_wallet(
211229
from_address_or_tag: str = typer.Option(..., "--from", "-f", help="From address or tag"),

src/iwa/core/contracts/contract.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from web3.exceptions import ContractCustomError
1212

1313
from iwa.core.chain import ChainInterfaces
14+
from iwa.core.contracts.decoder import ErrorDecoder
1415
from iwa.core.rpc_monitor import RPCMonitor
1516
from iwa.core.utils import configure_logger
1617

@@ -172,6 +173,16 @@ def decode_error(self, error_data: str) -> Optional[Tuple[str, str]]:
172173
logger.debug(f"Failed to decode Panic(uint256): {e}")
173174
return ("Panic", "Failed to decode panic code")
174175

176+
# 4. Global Fallback Decoder
177+
try:
178+
global_results = ErrorDecoder().decode(error_data)
179+
if global_results:
180+
# Use the first match
181+
error_name, error_msg, _ = global_results[0]
182+
return (error_name, error_msg)
183+
except Exception as e:
184+
logger.debug(f"Global decoder failed: {e}")
185+
175186
return None
176187

177188
def _extract_error_data(self, exception: Exception) -> Optional[str]:

src/iwa/core/contracts/decoder.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
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

Comments
 (0)