scripts/evm_client.py
scripts/evm_client.pyBrowse 2 files
15,780 tokens
55,885 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env python32"""3evm_client.py — EVM blockchain CLI tool for the Hermes Agent project.4Zero external dependencies. Uses stdlib only: urllib, json, argparse, time, os, sys, typing.5"""6 7import argparse8import json9import os10import sys11import time12import urllib.error13import urllib.request14from typing import Any, Dict, List, Optional, Tuple15 16# ---------------------------------------------------------------------------17# Chain registry18# ---------------------------------------------------------------------------19 20CHAINS: Dict[str, Dict[str, Any]] = {21 "ethereum": {22 "chain_id": 1,23 "rpc": "https://ethereum-rpc.publicnode.com",24 "native": "ETH",25 "coingecko": "ethereum",26 "explorer": "https://etherscan.io",27 "decimals": 18,28 },29 "bsc": {30 "chain_id": 56,31 "rpc": "https://bsc-dataseed1.binance.org",32 "native": "BNB",33 "coingecko": "binancecoin",34 "explorer": "https://bscscan.com",35 "decimals": 18,36 },37 "base": {38 "chain_id": 8453,39 "rpc": "https://mainnet.base.org",40 "native": "ETH",41 "coingecko": "ethereum",42 "explorer": "https://basescan.org",43 "decimals": 18,44 },45 "arbitrum": {46 "chain_id": 42161,47 "rpc": "https://arb1.arbitrum.io/rpc",48 "native": "ETH",49 "coingecko": "ethereum",50 "explorer": "https://arbiscan.io",51 "decimals": 18,52 },53 "polygon": {54 "chain_id": 137,55 "rpc": "https://polygon-rpc.com",56 "native": "MATIC",57 "coingecko": "matic-network",58 "explorer": "https://polygonscan.com",59 "decimals": 18,60 },61 "optimism": {62 "chain_id": 10,63 "rpc": "https://mainnet.optimism.io",64 "native": "ETH",65 "coingecko": "ethereum",66 "explorer": "https://optimistic.etherscan.io",67 "decimals": 18,68 },69 "avalanche": {70 "chain_id": 43114,71 "rpc": "https://api.avax.network/ext/bc/C/rpc",72 "native": "AVAX",73 "coingecko": "avalanche-2",74 "explorer": "https://snowtrace.io",75 "decimals": 18,76 },77 "zksync": {78 "chain_id": 324,79 "rpc": "https://mainnet.era.zksync.io",80 "native": "ETH",81 "coingecko": "ethereum",82 "explorer": "https://explorer.zksync.io",83 "decimals": 18,84 },85}86 87DEFAULT_CHAIN = "ethereum"88 89# ---------------------------------------------------------------------------90# Known ERC-20 token registry {chain -> {symbol -> address}}91# ---------------------------------------------------------------------------92 93KNOWN_TOKENS: Dict[str, Dict[str, str]] = {94 "ethereum": {95 "USDT": "0xdAC17F958D2ee523a2206206994597C13D831ec7",96 "USDC": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",97 "DAI": "0x6B175474E89094C44Da98b954EedeAC495271d0F",98 "WETH": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",99 "WBTC": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599",100 "LINK": "0x514910771AF9Ca656af840dff83E8264EcF986CA",101 "UNI": "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984",102 "AAVE": "0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9",103 "MKR": "0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2",104 "COMP": "0xc00e94Cb662C3520282E6f5717214004A7f26888",105 "SNX": "0xC011a73ee8576Fb46F5E1c5751cA3B9Fe0af2a6F",106 "CRV": "0xD533a949740bb3306d119CC777fa900bA034cd52",107 "LDO": "0x5A98FcBEA516Cf06857215779Fd812CA3beF1B32",108 "RPL": "0xD33526068D116cE69F19A9ee46F0bd304F21A51f",109 "MATIC": "0x7D1AfA7B718fb893dB30A3aBc0Cfc608AaCfeBB0",110 "SHIB": "0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE",111 "APE": "0x4d224452801ACEd8B2F0aebE155379bb5D594381",112 "GRT": "0xc944E90C64B2c07662A292be6244BDf05Cda44a7",113 "FXS": "0x3432B6A60D23Ca0dFCa7761B7ab56459D9C964D0",114 "FRAX": "0x853d955aCEf822Db058eb8505911ED77F175b99e",115 "BAL": "0xba100000625a3754423978a60c9317c58a424e3D",116 "SUSHI": "0x6B3595068778DD592e39A122f4f5a5cF09C90fE2",117 "YFI": "0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e",118 "1INCH": "0x111111111117dC0aa78b770fA6A738034120C302",119 "ENS": "0xC18360217D8F7Ab5e7c516566761Ea12Ce7F9D72",120 "IMX": "0xF57e7e7C23978C3cAEC3C3548E3D615c346e79fF",121 "SAND": "0x3845badAde8e6dFF049820680d1F14bD3903a5d0",122 "MANA": "0x0F5D2fB29fb7d3CFeE444a200298f468908cC942",123 "AXS": "0xBB0E17EF65F82Ab018d8EDd776e8DD940327B28b",124 "CHZ": "0x3506424F91fD33084466F402d5D97f05F8e3b4AF",125 "PEPE": "0x6982508145454Ce325dDbE47a25d4ec3d2311933",126 },127 "bsc": {128 "USDT": "0x55d398326f99059fF775485246999027B3197955",129 "USDC": "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d",130 "BUSD": "0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56",131 "WBNB": "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c",132 "CAKE": "0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82",133 "XVS": "0xcF6BB5389c92Bdda8a3747Ddb454cB7a64626C63",134 "ALPACA":"0x8F0528cE5eF7B51152A59745bEfDD91D97091d2F",135 "BAKE": "0xE02dF9e3e622DeBdD69fb838bB799E3F168902c5",136 "BURGER":"0xAe9269f27437f0fcBC232d39Ec814844a51d6b8f",137 "DOGE": "0xbA2aE424d960c26247Dd6c32edC70B295c744C43",138 },139 "base": {140 # Stables + wrapped141 "USDC": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",142 "DAI": "0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb",143 "WETH": "0x4200000000000000000000000000000000000006",144 # Liquid-staked ETH variants145 "cbETH": "0x2Ae3F1Ec7F1F5012CFEab0185bfc7aa3cF0DEc22",146 "wstETH": "0xc1CBa3fCea344f92D9239c08C0568f6F2F0ee452",147 "rETH": "0xB6fe221Fe9EeF5aBa221c348bA20A1Bf5e73624c",148 "cbBTC": "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf",149 # Base-native DeFi + meme tokens (carried over from the standalone base/ skill)150 "AERO": "0x940181a94A35A4569E4529A3CDfB74e38FD98631",151 "DEGEN": "0x4ed4E862860beD51a9570b96d89aF5E1B0Efefed",152 "TOSHI": "0xAC1Bd2486aAf3B5C0fc3Fd868558b082a531B2B4",153 "BRETT": "0x532f27101965dd16442E59d40670FaF5eBB142E4",154 "WELL": "0xA88594D404727625A9437C3f886C7643872296AE",155 },156 "arbitrum": {157 "USDC": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",158 "USDT": "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9",159 "WETH": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1",160 "ARB": "0x912CE59144191C1204E64559FE8253a0e49E6548",161 },162 "optimism": {163 "USDC": "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85",164 "USDT": "0x94b008aA00579c1307B0EF2c499aD98a8ce58e58",165 "WETH": "0x4200000000000000000000000000000000000006",166 "OP": "0x4200000000000000000000000000000000000042",167 },168 "polygon": {169 "USDC": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",170 "USDT": "0xc2132D05D31c914a87C6611C10748AEb04B58e8F",171 "WMATIC":"0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270",172 "WETH": "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619",173 "DAI": "0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063",174 },175 "avalanche": {176 "USDC": "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E",177 "USDT": "0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7",178 "WAVAX": "0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7",179 },180}181 182# Gas estimates (units) for common operations183GAS_ESTIMATES = {184 "transfer": 21_000,185 "erc20": 65_000,186 "approve": 46_000,187 "swap": 180_000,188 "nft_mint": 150_000,189 "nft_transfer": 85_000,190}191 192# CoinGecko symbol -> id map for common tokens193COINGECKO_IDS: Dict[str, str] = {194 "ETH": "ethereum",195 "BTC": "bitcoin",196 "BNB": "binancecoin",197 "MATIC": "matic-network",198 "AVAX": "avalanche-2",199 "USDT": "tether",200 "USDC": "usd-coin",201 "DAI": "dai",202 "WBTC": "wrapped-bitcoin",203 "WETH": "weth",204 "LINK": "chainlink",205 "UNI": "uniswap",206 "AAVE": "aave",207 "MKR": "maker",208 "COMP": "compound-governance-token",209 "SNX": "havven",210 "CRV": "curve-dao-token",211 "LDO": "lido-dao",212 "RPL": "rocket-pool",213 "SHIB": "shiba-inu",214 "APE": "apecoin",215 "GRT": "the-graph",216 "BAL": "balancer",217 "SUSHI": "sushi",218 "YFI": "yearn-finance",219 "1INCH": "1inch",220 "ENS": "ethereum-name-service",221 "IMX": "immutable-x",222 "SAND": "the-sandbox",223 "MANA": "decentraland",224 "AXS": "axie-infinity",225 "ARB": "arbitrum",226 "OP": "optimism",227 "CAKE": "pancakeswap-token",228 "PEPE": "pepe",229 "CHZ": "chiliz",230}231 232# ---------------------------------------------------------------------------233# Helper utilities234# ---------------------------------------------------------------------------235 236def hex_to_int(h: str) -> int:237 if not h or h == "0x":238 return 0239 return int(h, 16)240 241 242# ---------------------------------------------------------------------------243# Input validation244# ---------------------------------------------------------------------------245 246def is_valid_address(s: str) -> bool:247 """Return True if `s` looks like a 20-byte hex Ethereum address.248 249 Does NOT validate EIP-55 checksum — RPC endpoints accept any-case hex.250 Just guards against typos / wrong-length input before we burn an RPC call.251 """252 if not isinstance(s, str):253 return False254 if not s.startswith("0x") and not s.startswith("0X"):255 return False256 if len(s) != 42:257 return False258 try:259 int(s, 16)260 except ValueError:261 return False262 return True263 264 265def is_valid_txhash(s: str) -> bool:266 """Return True if `s` looks like a 32-byte hex transaction hash."""267 if not isinstance(s, str):268 return False269 if not s.startswith("0x") and not s.startswith("0X"):270 return False271 if len(s) != 66:272 return False273 try:274 int(s, 16)275 except ValueError:276 return False277 return True278 279 280def require_address(s: str, *, field: str = "address") -> str:281 """Return `s` lowercased if valid, else exit with an error message.282 283 Centralizing validation here means every subcommand fails fast on bad input284 instead of bubbling up an opaque RPC error 30 seconds later.285 """286 if not is_valid_address(s):287 sys.stderr.write(288 f"error: invalid {field} {s!r}: expected 0x-prefixed 40-hex-char address\n"289 )290 sys.exit(2)291 return s.lower()292 293 294def require_txhash(s: str, *, field: str = "tx hash") -> str:295 """Return `s` lowercased if valid, else exit with an error message."""296 if not is_valid_txhash(s):297 sys.stderr.write(298 f"error: invalid {field} {s!r}: expected 0x-prefixed 64-hex-char tx hash\n"299 )300 sys.exit(2)301 return s.lower()302 303 304def wei_to_native(wei: int, decimals: int = 18) -> float:305 return wei / (10 ** decimals)306 307 308def gwei_from_wei(wei: int) -> float:309 return wei / 1e9310 311def _short_addr(addr: str) -> str:312 if addr and len(addr) >= 10:313 return addr[:6] + "..." + addr[-4:]314 return addr or ""315 316def print_json(data: Any) -> None:317 print(json.dumps(data, indent=2, default=str))318 319# ---------------------------------------------------------------------------320# HTTP / JSON-RPC layer321# ---------------------------------------------------------------------------322 323def _http_post(url: str, payload: Any, retries: int = 5, timeout: int = 20) -> Any:324 body = json.dumps(payload).encode()325 headers = {326 "Content-Type": "application/json",327 "Accept": "application/json",328 "User-Agent": "Mozilla/5.0 (compatible; evm_client/1.0)",329 }330 req = urllib.request.Request(url, data=body, headers=headers, method="POST")331 delay = 1.0332 last_err: Exception = RuntimeError("No attempts made")333 for attempt in range(retries):334 try:335 with urllib.request.urlopen(req, timeout=timeout) as resp:336 return json.loads(resp.read().decode())337 except urllib.error.HTTPError as e:338 if e.code == 429:339 time.sleep(delay)340 delay = min(delay * 2, 30)341 last_err = e342 continue343 body_text = ""344 try:345 body_text = e.read().decode()346 except Exception:347 pass348 raise RuntimeError(f"HTTP {e.code}: {body_text}") from e349 except Exception as e:350 last_err = e351 if attempt < retries - 1:352 time.sleep(delay)353 delay = min(delay * 2, 30)354 raise RuntimeError(f"Request failed after {retries} retries: {last_err}") from last_err355 356def _http_get(url: str, retries: int = 5, timeout: int = 20) -> Any:357 headers = {"Accept": "application/json", "User-Agent": "evm_client/1.0"}358 req = urllib.request.Request(url, headers=headers, method="GET")359 delay = 1.0360 last_err: Exception = RuntimeError("No attempts made")361 for attempt in range(retries):362 try:363 with urllib.request.urlopen(req, timeout=timeout) as resp:364 return json.loads(resp.read().decode())365 except urllib.error.HTTPError as e:366 if e.code == 429:367 time.sleep(delay)368 delay = min(delay * 2, 30)369 last_err = e370 continue371 body_text = ""372 try:373 body_text = e.read().decode()374 except Exception:375 pass376 raise RuntimeError(f"HTTP {e.code}: {body_text}") from e377 except Exception as e:378 last_err = e379 if attempt < retries - 1:380 time.sleep(delay)381 delay = min(delay * 2, 30)382 raise RuntimeError(f"Request failed after {retries} retries: {last_err}") from last_err383 384# ---------------------------------------------------------------------------385# RPC helpers386# ---------------------------------------------------------------------------387 388def get_rpc_url(chain: str) -> str:389 env = os.environ.get("EVM_RPC_URL", "")390 if env:391 return env392 cfg = CHAINS.get(chain)393 if not cfg:394 raise ValueError(f"Unknown chain '{chain}'. Available: {', '.join(CHAINS)}")395 return cfg["rpc"]396 397def rpc_call(chain: str, method: str, params: List[Any], req_id: int = 1) -> Any:398 url = get_rpc_url(chain)399 payload = {"jsonrpc": "2.0", "id": req_id, "method": method, "params": params}400 resp = _http_post(url, payload)401 if "error" in resp:402 raise RuntimeError(f"RPC error: {resp['error']}")403 return resp.get("result")404 405def rpc_batch(chain: str, calls: List[Tuple[str, List[Any]]], batch_limit: int = 10) -> List[Any]:406 """Send a batch of JSON-RPC calls; returns list of results in same order.407 408 Auto-chunks at `batch_limit` (default 10) so we stay under per-RPC limits.409 Base's public RPC caps batches at 10 — exceeding that returns a single error410 dict instead of a results list, which would mask all our calls.411 """412 url = get_rpc_url(chain)413 414 # Build the full payload, preserving order via JSON-RPC `id`415 items = [416 {"jsonrpc": "2.0", "id": i, "method": m, "params": p}417 for i, (m, p) in enumerate(calls)418 ]419 420 out: List[Any] = [None] * len(items)421 for start in range(0, len(items), batch_limit):422 chunk = items[start:start + batch_limit]423 resp = _http_post(url, chunk)424 if not isinstance(resp, list):425 # Single error response (e.g. batch-too-large) — leave this chunk as None426 continue427 for r in resp:428 rid = r.get("id")429 if isinstance(rid, int) and 0 <= rid < len(out):430 if "error" in r:431 out[rid] = None432 else:433 out[rid] = r.get("result")434 return out435 436# ---------------------------------------------------------------------------437# ABI encoding helpers (minimal, for ERC-20 calls)438# ---------------------------------------------------------------------------439 440def _encode_address(addr: str) -> str:441 """Pad address to 32 bytes."""442 return addr.lower().replace("0x", "").zfill(64)443 444def _keccak256(data: bytes) -> bytes:445 """Pure Python Keccak-256 (Ethereum's hash, NOT SHA3-256)."""446 # Keccak-256 round constants447 RC = [448 0x0000000000000001, 0x0000000000008082, 0x800000000000808A, 0x8000000080008000,449 0x000000000000808B, 0x0000000080000001, 0x8000000080008081, 0x8000000000008009,450 0x000000000000008A, 0x0000000000000088, 0x0000000080008009, 0x000000008000000A,451 0x000000008000808B, 0x800000000000008B, 0x8000_0000_0000_8089, 0x8000000000008003,452 0x8000000000008002, 0x8000000000000080, 0x000000000000800A, 0x800000008000000A,453 0x8000000080008081, 0x8000000000008080, 0x0000000080000001, 0x8000000080008008,454 ]455 ROT = [456 [0, 36, 3, 41, 18], [1, 44, 10, 45, 2], [62, 6, 43, 15, 61],457 [28, 55, 25, 21, 56], [27, 20, 39, 8, 14],458 ]459 def rot64(x, n): return ((x << n) | (x >> (64 - n))) & 0xFFFFFFFFFFFFFFFF460 rate = 136 # 1088 bits for keccak-256461 # Padding462 msg = bytearray(data)463 msg.append(0x01)464 while len(msg) % rate != 0:465 msg.append(0x00)466 msg[-1] |= 0x80467 # Absorb468 state = [0] * 25469 for block_start in range(0, len(msg), rate):470 block = msg[block_start:block_start + rate]471 for i in range(rate // 8):472 state[i] ^= int.from_bytes(block[i*8:(i+1)*8], "little")473 # Keccak-f[1600]474 for rnd in range(24):475 # Theta476 C = [state[x] ^ state[x+5] ^ state[x+10] ^ state[x+15] ^ state[x+20] for x in range(5)]477 D = [C[(x-1) % 5] ^ rot64(C[(x+1) % 5], 1) for x in range(5)]478 state = [state[i] ^ D[i % 5] for i in range(25)]479 # Rho + Pi480 B = [0] * 25481 for x in range(5):482 for y in range(5):483 B[y*5 + ((2*x+3*y) % 5)] = rot64(state[x + 5*y], ROT[x][y])484 # Chi485 state = [B[i] ^ ((~B[(i//5)*5 + (i%5+1)%5]) & B[(i//5)*5 + (i%5+2)%5]) for i in range(25)]486 # Iota487 state[0] ^= RC[rnd]488 # Squeeze489 out = b"".join(state[i].to_bytes(8, "little") for i in range(4))490 return out491 492 493def _selector(sig: str) -> str:494 """Compute 4-byte function selector via keccak-256."""495 return "0x" + _keccak256(sig.encode()).hex()[:8]496 497# Precomputed selectors for ERC-20 functions498ERC20_SELECTORS: Dict[str, str] = {499 "name()": "0x06fdde03",500 "symbol()": "0x95d89b41",501 "decimals()": "0x313ce567",502 "totalSupply()": "0x18160ddd",503 "balanceOf(address)": "0x70a08231",504}505 506def eth_call_erc20(chain: str, contract: str, fn: str, arg_addr: Optional[str] = None) -> str:507 selector = ERC20_SELECTORS[fn]508 data = selector509 if arg_addr:510 data += _encode_address(arg_addr)511 params = [{"to": contract, "data": data}, "latest"]512 return rpc_call(chain, "eth_call", params) or "0x"513 514def decode_string(hex_data: str) -> str:515 """Decode ABI-encoded string from eth_call result."""516 try:517 raw = hex_data[2:] if hex_data.startswith("0x") else hex_data518 if len(raw) < 128:519 # Try decoding as raw bytes (some tokens return non-ABI strings)520 b = bytes.fromhex(raw)521 return b.rstrip(b"\x00").decode("utf-8", errors="replace").strip()522 # offset (skip 32 bytes), length, data523 length = int(raw[64:128], 16)524 chars = raw[128:128 + length * 2]525 return bytes.fromhex(chars).decode("utf-8", errors="replace").strip()526 except Exception:527 return ""528 529def decode_uint256(hex_data: str) -> int:530 try:531 raw = hex_data[2:] if hex_data.startswith("0x") else hex_data532 if not raw:533 return 0534 return int(raw, 16)535 except Exception:536 return 0537 538def decode_uint8(hex_data: str) -> int:539 return decode_uint256(hex_data)540 541# ---------------------------------------------------------------------------542# CoinGecko price fetching543# ---------------------------------------------------------------------------544 545COINGECKO_BASE = "https://api.coingecko.com/api/v3"546 547def cg_price_by_id(cg_id: str) -> Optional[float]:548 try:549 url = f"{COINGECKO_BASE}/simple/price?ids={cg_id}&vs_currencies=usd"550 data = _http_get(url)551 return data.get(cg_id, {}).get("usd")552 except Exception:553 return None554 555def cg_price_by_ids(cg_ids: List[str]) -> Dict[str, float]:556 """Fetch multiple prices in one request."""557 if not cg_ids:558 return {}559 try:560 joined = ",".join(cg_ids)561 url = f"{COINGECKO_BASE}/simple/price?ids={joined}&vs_currencies=usd"562 data = _http_get(url)563 return {k: v.get("usd", 0.0) for k, v in data.items() if "usd" in v}564 except Exception:565 return {}566 567def cg_price_by_contract(chain: str, contract: str) -> Optional[float]:568 cg_platform_map = {569 "ethereum": "ethereum",570 "bsc": "binance-smart-chain",571 "base": "base",572 "arbitrum": "arbitrum-one",573 "polygon": "polygon-pos",574 "optimism": "optimistic-ethereum",575 "avalanche":"avalanche",576 "zksync": "zksync",577 }578 platform = cg_platform_map.get(chain)579 if not platform:580 return None581 try:582 url = (583 f"{COINGECKO_BASE}/simple/token_price/{platform}"584 f"?contract_addresses={contract}&vs_currencies=usd"585 )586 data = _http_get(url)587 addr_lower = contract.lower()588 for k, v in data.items():589 if k.lower() == addr_lower:590 return v.get("usd")591 return None592 except Exception:593 return None594 595def get_native_price(chain: str) -> Optional[float]:596 cg_id = CHAINS[chain]["coingecko"]597 return cg_price_by_id(cg_id)598 599# ---------------------------------------------------------------------------600# Command implementations601# ---------------------------------------------------------------------------602 603def cmd_stats(args: argparse.Namespace) -> None:604 chain = args.chain605 cfg = CHAINS[chain]606 607 # Batch: blockNumber + gasPrice608 results = rpc_batch(chain, [609 ("eth_blockNumber", []),610 ("eth_gasPrice", []),611 ])612 block_num = hex_to_int(results[0] or "0x0")613 gas_price_wei = hex_to_int(results[1] or "0x0")614 615 # TPS estimate: compare latest block timestamp with parent616 tps: Optional[float] = None617 try:618 latest_block = rpc_call(chain, "eth_getBlockByNumber", ["latest", False])619 if latest_block:620 parent_hex = latest_block.get("parentHash")621 parent_block = rpc_call(chain, "eth_getBlockByHash", [parent_hex, False])622 if parent_block:623 t1 = hex_to_int(latest_block.get("timestamp", "0x0"))624 t0 = hex_to_int(parent_block.get("timestamp", "0x0"))625 tx_count = len(latest_block.get("transactions", []))626 if t1 > t0:627 tps = round(tx_count / (t1 - t0), 2)628 except Exception:629 pass630 631 native_price = get_native_price(chain)632 633 print_json({634 "chain": chain,635 "block_number": block_num,636 "gas_price_gwei": round(gwei_from_wei(gas_price_wei), 4),637 "gas_price_wei": gas_price_wei,638 "native_token": cfg["native"],639 "native_price_usd": native_price,640 "tps_estimate": tps,641 "explorer": cfg["explorer"],642 })643 644 645def cmd_wallet(args: argparse.Namespace) -> None:646 address = require_address(args.address)647 chain = args.chain648 limit = args.limit649 no_prices = args.no_prices650 cfg = CHAINS[chain]651 652 # Native balance653 balance_hex = rpc_call(chain, "eth_getBalance", [address, "latest"])654 native_wei = hex_to_int(balance_hex or "0x0")655 native_val = wei_to_native(native_wei, cfg["decimals"])656 657 native_usd_price: Optional[float] = None658 native_usd: Optional[float] = None659 if not no_prices:660 native_usd_price = get_native_price(chain)661 if native_usd_price is not None:662 native_usd = round(native_val * native_usd_price, 4)663 664 # ERC-20 tokens665 token_list = list((KNOWN_TOKENS.get(chain) or {}).items())[:limit]666 tokens_out = []667 portfolio_usd = native_usd or 0.0668 669 if token_list:670 # Batch balanceOf calls671 balance_calls = [672 ("eth_call", [{"to": addr, "data": ERC20_SELECTORS["balanceOf(address)"] + _encode_address(address)}, "latest"])673 for _, addr in token_list674 ]675 balances = rpc_batch(chain, balance_calls)676 677 for idx, (symbol, addr) in enumerate(token_list):678 raw_bal = decode_uint256(balances[idx] or "0x0")679 if raw_bal == 0:680 continue681 682 # Fetch decimals683 dec_hex = eth_call_erc20(chain, addr, "decimals()")684 decimals = decode_uint8(dec_hex) if dec_hex and dec_hex != "0x" else 18685 bal_human = wei_to_native(raw_bal, decimals)686 687 token_price: Optional[float] = None688 token_usd: Optional[float] = None689 if not no_prices:690 try:691 cg_id = COINGECKO_IDS.get(symbol)692 if cg_id:693 token_price = cg_price_by_id(cg_id)694 if token_price is None:695 token_price = cg_price_by_contract(chain, addr)696 if token_price is not None:697 token_usd = round(bal_human * token_price, 4)698 portfolio_usd += token_usd699 except Exception:700 pass701 702 tokens_out.append({703 "symbol": symbol,704 "contract": addr,705 "balance": round(bal_human, 8),706 "price_usd": token_price,707 "value_usd": token_usd,708 })709 710 print_json({711 "chain": chain,712 "address": address,713 "native_token": cfg["native"],714 "native_balance": round(native_val, 8),715 "native_price_usd": native_usd_price,716 "native_value_usd": native_usd,717 "erc20_tokens": tokens_out,718 "portfolio_total_usd": round(portfolio_usd, 4) if not no_prices else None,719 })720 721 722def cmd_tx(args: argparse.Namespace) -> None:723 tx_hash = require_txhash(args.hash)724 chain = args.chain725 cfg = CHAINS[chain]726 727 results = rpc_batch(chain, [728 ("eth_getTransactionByHash", [tx_hash]),729 ("eth_getTransactionReceipt", [tx_hash]),730 ])731 tx = results[0]732 receipt = results[1]733 734 if not tx:735 print_json({"error": f"Transaction {tx_hash} not found on {chain}"})736 return737 738 block_num = hex_to_int(tx.get("blockNumber") or "0x0")739 timestamp: Optional[int] = None740 try:741 blk = rpc_call(chain, "eth_getBlockByNumber", [hex(block_num), False])742 if blk:743 timestamp = hex_to_int(blk.get("timestamp", "0x0"))744 except Exception:745 pass746 747 value_wei = hex_to_int(tx.get("value", "0x0"))748 value_eth = wei_to_native(value_wei, cfg["decimals"])749 gas_price = hex_to_int(tx.get("gasPrice") or "0x0")750 gas_limit = hex_to_int(tx.get("gas", "0x0"))751 gas_used = hex_to_int((receipt or {}).get("gasUsed", "0x0")) if receipt else None752 status = None753 if receipt:754 status = "success" if hex_to_int(receipt.get("status", "0x0")) == 1 else "failed"755 756 input_data = tx.get("input", "0x")757 input_preview = input_data[:66] + ("..." if len(input_data) > 66 else "")758 759 native_price = get_native_price(chain)760 value_usd = round(value_eth * native_price, 4) if native_price else None761 762 fee_eth: Optional[float] = None763 fee_usd: Optional[float] = None764 if gas_used is not None:765 fee_eth = wei_to_native(gas_used * gas_price, cfg["decimals"])766 if native_price:767 fee_usd = round(fee_eth * native_price, 6)768 769 print_json({770 "chain": chain,771 "hash": tx_hash,772 "block": block_num,773 "timestamp": timestamp,774 "from": tx.get("from"),775 "to": tx.get("to"),776 "value": round(value_eth, 8),777 "value_usd": value_usd,778 "native_token": cfg["native"],779 "gas_limit": gas_limit,780 "gas_used": gas_used,781 "gas_price_gwei": round(gwei_from_wei(gas_price), 4),782 "fee_native": round(fee_eth, 8) if fee_eth is not None else None,783 "fee_usd": fee_usd,784 "status": status,785 "input_preview": input_preview,786 "nonce": hex_to_int(tx.get("nonce", "0x0")),787 "explorer_url": f"{cfg['explorer']}/tx/{tx_hash}",788 })789 790 791def cmd_token(args: argparse.Namespace) -> None:792 contract = require_address(args.contract, field="contract address")793 chain = args.chain794 795 # Batch all ERC-20 metadata calls796 calls = [797 ("eth_call", [{"to": contract, "data": ERC20_SELECTORS["name()"]}, "latest"]),798 ("eth_call", [{"to": contract, "data": ERC20_SELECTORS["symbol()"]}, "latest"]),799 ("eth_call", [{"to": contract, "data": ERC20_SELECTORS["decimals()"]}, "latest"]),800 ("eth_call", [{"to": contract, "data": ERC20_SELECTORS["totalSupply()"]}, "latest"]),801 ]802 results = rpc_batch(chain, calls)803 name = decode_string(results[0] or "0x")804 symbol = decode_string(results[1] or "0x")805 decimals = decode_uint8(results[2] or "0x0")806 supply_raw = decode_uint256(results[3] or "0x0")807 supply = wei_to_native(supply_raw, decimals)808 809 price: Optional[float] = None810 market_cap: Optional[float] = None811 cg_id = COINGECKO_IDS.get(symbol.upper())812 if cg_id:813 price = cg_price_by_id(cg_id)814 if price is None:815 price = cg_price_by_contract(chain, contract)816 if price is not None and supply > 0:817 market_cap = round(price * supply, 2)818 819 cfg = CHAINS[chain]820 print_json({821 "chain": chain,822 "contract": contract,823 "name": name,824 "symbol": symbol,825 "decimals": decimals,826 "total_supply": round(supply, 4),827 "price_usd": price,828 "market_cap_usd": market_cap,829 "explorer_url": f"{cfg['explorer']}/token/{contract}",830 })831 832 833def cmd_activity(args: argparse.Namespace) -> None:834 address = require_address(args.address)835 chain = args.chain836 limit = args.limit837 cfg = CHAINS[chain]838 839 # Get current block840 block_hex = rpc_call(chain, "eth_blockNumber", [])841 latest = hex_to_int(block_hex or "0x0")842 843 txs_out: List[Dict[str, Any]] = []844 scan_range = min(200, latest)845 blocks_checked = 0846 847 for bn in range(latest, max(0, latest - scan_range), -1):848 if len(txs_out) >= limit:849 break850 try:851 blk = rpc_call(chain, "eth_getBlockByNumber", [hex(bn), True])852 except Exception:853 continue854 if not blk:855 continue856 blocks_checked += 1857 timestamp = hex_to_int(blk.get("timestamp", "0x0"))858 for tx in blk.get("transactions", []):859 if len(txs_out) >= limit:860 break861 frm = (tx.get("from") or "").lower()862 to = (tx.get("to") or "").lower()863 addr_lower = address.lower()864 if frm == addr_lower or to == addr_lower:865 value_wei = hex_to_int(tx.get("value", "0x0"))866 value_eth = wei_to_native(value_wei, cfg["decimals"])867 gas_price = hex_to_int(tx.get("gasPrice") or "0x0")868 txs_out.append({869 "hash": tx.get("hash"),870 "block": bn,871 "timestamp": timestamp,872 "from": tx.get("from"),873 "to": tx.get("to"),874 "value": round(value_eth, 8),875 "native_token": cfg["native"],876 "gas_price_gwei": round(gwei_from_wei(gas_price), 4),877 "direction": "out" if frm == addr_lower else "in",878 })879 880 print_json({881 "chain": chain,882 "address": address,883 "blocks_scanned": blocks_checked,884 "tx_count": len(txs_out),885 "transactions": txs_out,886 })887 888 889def cmd_gas(args: argparse.Namespace) -> None:890 chain = args.chain891 cfg = CHAINS[chain]892 893 gas_price_hex = rpc_call(chain, "eth_gasPrice", [])894 gas_wei = hex_to_int(gas_price_hex or "0x0")895 gas_gwei = gwei_from_wei(gas_wei)896 897 native_price = get_native_price(chain)898 899 estimates: Dict[str, Any] = {}900 for op, gas_units in GAS_ESTIMATES.items():901 cost_wei = gas_wei * gas_units902 cost_native = wei_to_native(cost_wei, cfg["decimals"])903 cost_usd = round(cost_native * native_price, 6) if native_price else None904 estimates[op] = {905 "gas_units": gas_units,906 "cost_native": round(cost_native, 8),907 "cost_usd": cost_usd,908 }909 910 print_json({911 "chain": chain,912 "native_token": cfg["native"],913 "gas_price_gwei": round(gas_gwei, 4),914 "gas_price_wei": gas_wei,915 "native_price_usd": native_price,916 "estimates": estimates,917 })918 919 920def cmd_price(args: argparse.Namespace) -> None:921 token = args.token922 chain = args.chain923 924 price: Optional[float] = None925 source = "unknown"926 927 # Check if it's a contract address928 if token.startswith("0x") and len(token) >= 10:929 price = cg_price_by_contract(chain, token)930 source = "coingecko_contract"931 if price is None:932 print_json({"error": f"Could not find price for contract {token} on {chain}"})933 return934 else:935 symbol = token.upper()936 cg_id = COINGECKO_IDS.get(symbol)937 if cg_id:938 price = cg_price_by_id(cg_id)939 source = f"coingecko:{cg_id}"940 if price is None:941 # Try known tokens on given chain942 contract = (KNOWN_TOKENS.get(chain) or {}).get(symbol)943 if contract:944 price = cg_price_by_contract(chain, contract)945 source = f"coingecko_contract:{contract}"946 if price is None:947 print_json({"error": f"Could not find price for '{token}'. Try a contract address."})948 return949 950 print_json({951 "token": token,952 "chain": chain,953 "price_usd": price,954 "source": source,955 })956 957 958def _fetch_chain_stats(chain: str) -> Dict[str, Any]:959 """Fetch gas price + native price for a single chain (used in compare)."""960 try:961 gas_hex = rpc_call(chain, "eth_gasPrice", [])962 gas_wei = hex_to_int(gas_hex or "0x0")963 gas_gwei = round(gwei_from_wei(gas_wei), 4)964 except Exception:965 gas_gwei = None966 967 cg_id = CHAINS[chain]["coingecko"]968 native_price = cg_price_by_id(cg_id)969 970 transfer_usd: Optional[float] = None971 if gas_gwei is not None and native_price is not None:972 gas_wei_val = int(gas_gwei * 1e9)973 cost_wei = gas_wei_val * GAS_ESTIMATES["transfer"]974 cost_native = wei_to_native(cost_wei, CHAINS[chain]["decimals"])975 transfer_usd = round(cost_native * native_price, 6)976 977 return {978 "chain": chain,979 "native_token": CHAINS[chain]["native"],980 "gas_price_gwei": gas_gwei,981 "native_price_usd": native_price,982 "transfer_cost_usd": transfer_usd,983 }984 985 986def cmd_compare(_args: argparse.Namespace) -> None:987 """Compare gas prices and native token prices across all chains simultaneously."""988 import threading989 990 results: Dict[str, Any] = {}991 errors: Dict[str, str] = {}992 lock = threading.Lock()993 994 def fetch(chain: str) -> None:995 try:996 data = _fetch_chain_stats(chain)997 with lock:998 results[chain] = data999 except Exception as e:1000 with lock:1001 errors[chain] = str(e)1002 1003 threads = [threading.Thread(target=fetch, args=(c,), daemon=True) for c in CHAINS]1004 for t in threads:1005 t.start()1006 for t in threads:1007 t.join(timeout=30)1008 1009 sorted_by_gas = sorted(1010 results.values(),1011 key=lambda x: x.get("gas_price_gwei") or float("inf"),1012 )1013 1014 print_json({1015 "comparison": sorted_by_gas,1016 "errors": errors,1017 "cheapest_gas": sorted_by_gas[0]["chain"] if sorted_by_gas else None,1018 "most_expensive_gas": sorted_by_gas[-1]["chain"] if sorted_by_gas else None,1019 })1020 1021 1022def cmd_whale(args: argparse.Namespace) -> None:1023 chain = args.chain1024 blocks = args.blocks1025 min_usd = args.min_usd1026 cfg = CHAINS[chain]1027 1028 native_price = get_native_price(chain)1029 if native_price is None:1030 print_json({"error": "Could not fetch native token price for USD conversion."})1031 return1032 1033 block_hex = rpc_call(chain, "eth_blockNumber", [])1034 latest = hex_to_int(block_hex or "0x0")1035 1036 whales: List[Dict[str, Any]] = []1037 blocks_scanned = 01038 1039 for bn in range(latest, max(0, latest - blocks), -1):1040 try:1041 blk = rpc_call(chain, "eth_getBlockByNumber", [hex(bn), True])1042 except Exception:1043 continue1044 if not blk:1045 continue1046 blocks_scanned += 11047 timestamp = hex_to_int(blk.get("timestamp", "0x0"))1048 1049 for tx in blk.get("transactions", []):1050 value_wei = hex_to_int(tx.get("value", "0x0"))1051 if value_wei == 0:1052 continue1053 value_native = wei_to_native(value_wei, cfg["decimals"])1054 value_usd = value_native * native_price1055 if value_usd >= min_usd:1056 whales.append({1057 "hash": tx.get("hash"),1058 "block": bn,1059 "timestamp": timestamp,1060 "from": tx.get("from"),1061 "from_short": _short_addr(tx.get("from") or ""),1062 "to": tx.get("to"),1063 "to_short": _short_addr(tx.get("to") or ""),1064 "value_native": round(value_native, 6),1065 "native_token": cfg["native"],1066 "value_usd": round(value_usd, 2),1067 })1068 1069 whales.sort(key=lambda x: x["value_usd"], reverse=True)1070 1071 print_json({1072 "chain": chain,1073 "blocks_scanned": blocks_scanned,1074 "latest_block": latest,1075 "min_usd": min_usd,1076 "native_price_usd": native_price,1077 "whale_count": len(whales),1078 "transfers": whales,1079 })1080 1081 1082# ---------------------------------------------------------------------------1083# New commands: multichain, allowance, decode, ens, contract1084# ---------------------------------------------------------------------------1085 1086def cmd_multichain(args: argparse.Namespace) -> None:1087 """Scan same wallet across all 8 chains simultaneously."""1088 import threading1089 1090 address = require_address(args.address)1091 results: Dict[str, Any] = {}1092 lock = threading.Lock()1093 1094 def scan_chain(chain: str) -> None:1095 cfg = CHAINS[chain]1096 try:1097 bal_hex = rpc_call(chain, "eth_getBalance", [address, "latest"])1098 native_bal = int(bal_hex, 16) / 1e18 if bal_hex else 0.01099 native_price = get_native_price(chain)1100 native_usd = round(native_bal * native_price, 2) if native_price else None1101 entry: Dict[str, Any] = {1102 "native_symbol": cfg["native"],1103 "native_balance": round(native_bal, 8),1104 "native_price_usd": native_price,1105 "native_value_usd": native_usd,1106 "tokens": [],1107 "total_usd": native_usd or 0.0,1108 }1109 # Check known tokens for this chain.1110 # KNOWN_TOKENS[chain] maps {symbol: contract_address}, not {addr: (sym, name)}.1111 known = KNOWN_TOKENS.get(chain, {})1112 for symbol, contract in known.items():1113 raw = eth_call_erc20(chain, contract, "balanceOf(address)", address)1114 if not raw or raw == "0x":1115 continue1116 try:1117 bal_int = int(raw, 16)1118 except Exception:1119 continue1120 if bal_int == 0:1121 continue1122 dec_raw = eth_call_erc20(chain, contract, "decimals()")1123 decimals = decode_uint8(dec_raw) if dec_raw else 181124 human = bal_int / (10 ** decimals)1125 tok_price = cg_price_by_contract(chain, contract)1126 tok_usd = round(human * tok_price, 2) if tok_price else None1127 entry["tokens"].append({1128 "symbol": symbol,1129 "balance": round(human, 6),1130 "value_usd": tok_usd,1131 })1132 if tok_usd:1133 entry["total_usd"] = round(entry["total_usd"] + tok_usd, 2)1134 with lock:1135 results[chain] = entry1136 except Exception as exc:1137 with lock:1138 results[chain] = {"error": str(exc)}1139 1140 threads = [threading.Thread(target=scan_chain, args=(c,)) for c in CHAINS]1141 for t in threads:1142 t.start()1143 for t in threads:1144 t.join()1145 1146 grand_total = sum(1147 v.get("total_usd", 0) for v in results.values() if isinstance(v, dict)1148 )1149 print_json({1150 "address": address,1151 "chains": results,1152 "grand_total_usd": round(grand_total, 2),1153 })1154 1155 1156def cmd_allowance(args: argparse.Namespace) -> None:1157 """Check dangerous ERC-20 approvals for a wallet (known spenders)."""1158 address = require_address(args.address)1159 chain = args.chain1160 1161 # Well-known spender contracts (DEXes, bridges, etc.)1162 KNOWN_SPENDERS = {1163 "0x000000000022D473030F116dDEE9F6B43aC78BA3": "Permit2 (Uniswap)",1164 "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D": "Uniswap V2 Router",1165 "0xE592427A0AEce92De3Edee1F18E0157C05861564": "Uniswap V3 Router",1166 "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45": "Uniswap Universal Router",1167 "0x1111111254EEB25477B68fb85Ed929f73A960582": "1inch Router V5",1168 "0x6131B5fae19EA4f9D964eAc0408E4408b66337b5": "KyberSwap Router",1169 "0xDef1C0ded9bec7F1a1670819833240f027b25EfF": "0x Exchange Proxy",1170 "0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad": "Uniswap Universal Router 2",1171 }1172 1173 known = KNOWN_TOKENS.get(chain, {})1174 approvals = []1175 1176 # KNOWN_TOKENS[chain] is {symbol: contract_address}, not {addr: (sym, name)}.1177 for symbol, contract in known.items():1178 for spender_addr, spender_name in KNOWN_SPENDERS.items():1179 # allowance(owner, spender) = 0xdd62ed3e1180 owner_pad = address.lower().replace("0x", "").zfill(64)1181 spender_pad = spender_addr.lower().replace("0x", "").zfill(64)1182 data = "0xdd62ed3e" + owner_pad + spender_pad1183 raw = rpc_call(chain, "eth_call", [{"to": contract, "data": data}, "latest"])1184 if not raw or raw == "0x":1185 continue1186 try:1187 allowance_int = int(raw, 16)1188 except Exception:1189 continue1190 if allowance_int == 0:1191 continue1192 1193 dec_raw = eth_call_erc20(chain, contract, "decimals()")1194 decimals = decode_uint8(dec_raw) if dec_raw else 181195 max_uint = 2**256 - 11196 is_unlimited = allowance_int >= max_uint // 21197 1198 approvals.append({1199 "token": symbol,1200 "contract": contract,1201 "spender": spender_name,1202 "spender_address": spender_addr,1203 "allowance": "UNLIMITED" if is_unlimited else str(round(allowance_int / 10**decimals, 4)),1204 "risk": "HIGH" if is_unlimited else "LOW",1205 })1206 1207 print_json({1208 "chain": chain,1209 "address": address,1210 "approvals_found": len(approvals),1211 "approvals": approvals,1212 "note": "Only checks known DEX/bridge spenders. Use a full allowance checker for complete coverage.",1213 })1214 1215 1216def cmd_decode(args: argparse.Namespace) -> None:1217 """Decode transaction input data using 4byte.directory."""1218 chain = args.chain1219 tx_hash = require_txhash(args.hash)1220 1221 tx = rpc_call(chain, "eth_getTransactionByHash", [tx_hash])1222 if not tx:1223 print_json({"error": "Transaction not found"})1224 return1225 1226 input_data: str = tx.get("input", "0x")1227 if not input_data or input_data == "0x":1228 print_json({1229 "chain": chain,1230 "hash": tx_hash,1231 "decoded": None,1232 "note": "No input data (plain ETH transfer)",1233 })1234 return1235 1236 selector = input_data[:10] # 0x + 4 bytes = 10 chars1237 1238 # Query 4byte.directory1239 url = f"https://www.4byte.directory/api/v1/signatures/?hex_signature={selector}"1240 data = _http_get(url)1241 1242 signatures = []1243 if data and data.get("results"):1244 signatures = [r["text_signature"] for r in data["results"]]1245 1246 # Decode known transfer(address,uint256) manually as fallback1247 decoded_args: Optional[Dict] = None1248 if signatures and len(input_data) >= 74:1249 sig = signatures[0]1250 if sig == "transfer(address,uint256)" and len(input_data) == 138:1251 to_addr = "0x" + input_data[34:74]1252 amount_hex = input_data[74:]1253 try:1254 amount = int(amount_hex, 16)1255 decoded_args = {"to": to_addr, "amount_raw": amount}1256 except Exception:1257 pass1258 1259 print_json({1260 "chain": chain,1261 "hash": tx_hash,1262 "selector": selector,1263 "input_length_bytes": (len(input_data) - 2) // 2,1264 "from": tx.get("from"),1265 "to": tx.get("to"),1266 "signatures": signatures,1267 "primary_signature": signatures[0] if signatures else None,1268 "decoded_args": decoded_args,1269 "raw_input_preview": input_data[:74] + ("..." if len(input_data) > 74 else ""),1270 "source": "4byte.directory",1271 })1272 1273 1274def cmd_ens(args: argparse.Namespace) -> None:1275 """Resolve ENS name <-> address via ensideas.com public API (no key needed)."""1276 query = args.name_or_address1277 1278 # ensideas.com handles both forward (name->address) and reverse (address->name)1279 try:1280 data = _http_get(f"https://api.ensideas.com/ens/resolve/{query}")1281 except Exception as exc:1282 print_json({"error": str(exc), "note": "ENS API unavailable"})1283 return1284 1285 if not data:1286 print_json({"query": query, "address": None, "ens_name": None, "note": "Not found"})1287 return1288 1289 print_json({1290 "query": query,1291 "address": data.get("address"),1292 "ens_name": data.get("name"),1293 "avatar": data.get("avatar"),1294 "display": data.get("displayName"),1295 "twitter": data.get("twitter"),1296 "github": data.get("github"),1297 "source": "ensideas.com",1298 })1299 1300 1301def cmd_contract(args: argparse.Namespace) -> None:1302 """Inspect a smart contract: bytecode size, proxy detection, creation info."""1303 chain = args.chain1304 address = require_address(args.address)1305 1306 # Get bytecode1307 code_hex = rpc_call(chain, "eth_getCode", [address, "latest"])1308 if not code_hex or code_hex == "0x":1309 print_json({"chain": chain, "address": address, "is_contract": False, "note": "EOA (externally owned account)"})1310 return1311 1312 bytecode_bytes = (len(code_hex) - 2) // 21313 1314 # Proxy detection patterns1315 # EIP-1967: implementation slot 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc1316 impl_slot = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"1317 impl_raw = rpc_call(chain, "eth_getStorageAt", [address, impl_slot, "latest"])1318 implementation = None1319 is_proxy = False1320 if impl_raw and impl_raw != "0x" and int(impl_raw, 16) != 0:1321 is_proxy = True1322 implementation = "0x" + impl_raw[-40:]1323 1324 # EIP-1167 minimal proxy detection (starts with 0x363d3d37)1325 if code_hex[2:10] == "363d3d37" or code_hex[2:18] == "3d602d80600a3d39":1326 is_proxy = True1327 1328 # supportsInterface check: ERC-1651329 supports_erc165 = False1330 try:1331 erc165_data = "0x01ffc9a701ffc9a700000000000000000000000000000000000000000000000000000000"1332 erc165_raw = rpc_call(chain, "eth_call", [{"to": address, "data": erc165_data}, "latest"])1333 supports_erc165 = bool(erc165_raw and erc165_raw != "0x" and int(erc165_raw, 16) == 1)1334 except Exception:1335 pass1336 1337 # Try to detect ERC-20 (has totalSupply)1338 is_erc20 = False1339 try:1340 ts_raw = eth_call_erc20(chain, address, "totalSupply()")1341 is_erc20 = ts_raw is not None and ts_raw != "0x" and int(ts_raw, 16) > 01342 except Exception:1343 pass1344 1345 # Try to detect ERC-721 (supportsInterface 0x80ac58cd)1346 is_erc721 = False1347 try:1348 erc721_data = "0x01ffc9a780ac58cd00000000000000000000000000000000000000000000000000000000"1349 erc721_raw = rpc_call(chain, "eth_call", [{"to": address, "data": erc721_data}, "latest"])1350 is_erc721 = bool(erc721_raw and erc721_raw != "0x" and int(erc721_raw, 16) == 1)1351 except Exception:1352 pass1353 1354 detected_standards = []1355 if is_erc20:1356 detected_standards.append("ERC-20")1357 if is_erc721:1358 detected_standards.append("ERC-721")1359 if supports_erc165:1360 detected_standards.append("ERC-165")1361 1362 print_json({1363 "chain": chain,1364 "address": address,1365 "is_contract": True,1366 "bytecode_size_bytes": bytecode_bytes,1367 "is_proxy": is_proxy,1368 "implementation": implementation,1369 "detected_standards": detected_standards,1370 "explorer_url": f"{CHAINS[chain]['explorer']}/address/{address}",1371 "note": "Proxy detected via EIP-1967 storage slot. Standards via EIP-165 + heuristics." if is_proxy else None,1372 })1373 1374 1375# ---------------------------------------------------------------------------1376# Argument parsing & dispatch1377# ---------------------------------------------------------------------------1378 1379def build_parser() -> argparse.ArgumentParser:1380 chain_choices = list(CHAINS.keys())1381 1382 parser = argparse.ArgumentParser(1383 prog="evm_client",1384 description="EVM blockchain CLI — stdlib only, zero dependencies.",1385 )1386 sub = parser.add_subparsers(dest="command", metavar="COMMAND")1387 sub.required = True1388 1389 # -- stats --1390 p_stats = sub.add_parser("stats", help="Chain stats: block, gas price, native price, TPS")1391 p_stats.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices)1392 1393 # -- wallet --1394 p_wallet = sub.add_parser("wallet", help="Wallet balance + ERC-20 portfolio")1395 p_wallet.add_argument("address", help="Wallet address (0x...)")1396 p_wallet.add_argument("--limit", type=int, default=20, metavar="N",1397 help="Max number of known tokens to check (default: 20)")1398 p_wallet.add_argument("--no-prices", action="store_true",1399 help="Skip USD price lookups (faster)")1400 p_wallet.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices)1401 1402 # -- tx --1403 p_tx = sub.add_parser("tx", help="Transaction details")1404 p_tx.add_argument("hash", help="Transaction hash (0x...)")1405 p_tx.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices)1406 1407 # -- token --1408 p_token = sub.add_parser("token", help="ERC-20 token metadata + price")1409 p_token.add_argument("contract", help="Token contract address (0x...)")1410 p_token.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices)1411 1412 # -- activity --1413 p_act = sub.add_parser("activity", help="Recent transactions for an address")1414 p_act.add_argument("address", help="Wallet address (0x...)")1415 p_act.add_argument("--limit", type=int, default=10, metavar="N",1416 help="Max transactions to return (default: 10)")1417 p_act.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices)1418 1419 # -- gas --1420 p_gas = sub.add_parser("gas", help="Gas prices and cost estimates")1421 p_gas.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices)1422 1423 # -- price --1424 p_price = sub.add_parser("price", help="Token price by symbol or contract address")1425 p_price.add_argument("token", help="Symbol (e.g. ETH, USDC) or contract address")1426 p_price.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices)1427 1428 # -- compare --1429 sub.add_parser("compare", help="Gas + native prices across ALL chains simultaneously")1430 1431 # -- whale --1432 p_whale = sub.add_parser("whale", help="Scan for large value transfers in recent blocks")1433 p_whale.add_argument("--blocks", type=int, default=20, metavar="N",1434 help="Number of recent blocks to scan (default: 20)")1435 p_whale.add_argument("--min-usd", type=float, default=10_000.0, metavar="N",1436 help="Minimum USD value to report (default: 10000)")1437 p_whale.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices)1438 1439 # -- multichain --1440 p_multi = sub.add_parser("multichain", help="Scan same wallet across ALL chains simultaneously")1441 p_multi.add_argument("address", help="Wallet address (0x...)")1442 1443 # -- allowance --1444 p_allow = sub.add_parser("allowance", help="Check dangerous ERC-20 approvals (known DEX/bridge spenders)")1445 p_allow.add_argument("address", help="Wallet address (0x...)")1446 p_allow.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices)1447 1448 # -- decode --1449 p_decode = sub.add_parser("decode", help="Decode transaction input data via 4byte.directory")1450 p_decode.add_argument("hash", help="Transaction hash (0x...)")1451 p_decode.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices)1452 1453 # -- ens --1454 p_ens = sub.add_parser("ens", help="Resolve ENS name <-> address (Ethereum only)")1455 p_ens.add_argument("name_or_address", help="ENS name (vitalik.eth) or address (0x...)")1456 1457 # -- contract --1458 p_contract = sub.add_parser("contract", help="Inspect a smart contract: proxy, standards, bytecode size")1459 p_contract.add_argument("address", help="Contract address (0x...)")1460 p_contract.add_argument("--chain", default=DEFAULT_CHAIN, choices=chain_choices)1461 1462 return parser1463 1464 1465DISPATCH = {1466 "stats": cmd_stats,1467 "wallet": cmd_wallet,1468 "tx": cmd_tx,1469 "token": cmd_token,1470 "activity": cmd_activity,1471 "gas": cmd_gas,1472 "price": cmd_price,1473 "compare": cmd_compare,1474 "whale": cmd_whale,1475 "multichain": cmd_multichain,1476 "allowance": cmd_allowance,1477 "decode": cmd_decode,1478 "ens": cmd_ens,1479 "contract": cmd_contract,1480}1481 1482 1483def main() -> None:1484 parser = build_parser()1485 args = parser.parse_args()1486 1487 # Validate chain exists (argparse choices already handles this, but for ENV override)1488 if hasattr(args, "chain") and args.chain not in CHAINS:1489 print_json({"error": f"Unknown chain '{args.chain}'. Available: {list(CHAINS.keys())}"})1490 sys.exit(1)1491 1492 cmd_fn = DISPATCH.get(args.command)1493 if cmd_fn is None:1494 print_json({"error": f"Unknown command '{args.command}'"})1495 sys.exit(1)1496 1497 try:1498 cmd_fn(args)1499 except KeyboardInterrupt:1500 print_json({"error": "Interrupted by user"})1501 sys.exit(130)1502 except Exception as e:1503 print_json({"error": str(e)})1504 sys.exit(1)1505 1506 1507if __name__ == "__main__":1508 main()1509