first commit
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
"""Etherscan API service for Ethereum on-chain data (wallet balances, token transfers, contracts)."""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from src.utils.http import get_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ETHERSCAN_API_V2 = "https://api.etherscan.io/v2/api"
|
||||
|
||||
# Default chain: Polygon (137) where Polymarket operates.
|
||||
# Ethereum mainnet = 1, can be overridden per-call.
|
||||
DEFAULT_CHAIN_ID = 137
|
||||
|
||||
# Well-known ERC-20 token contracts on Polygon
|
||||
TOKEN_CONTRACTS = {
|
||||
"USDC": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", # USDC native on Polygon
|
||||
"USDC.e": "0x2791bca1f2de4661ed88a30c99a7a9449aa84174", # USDC.e (bridged) on Polygon
|
||||
"USDT": "0xc2132d05d31c914a87c6611c10748aeb04b58e8f",
|
||||
"WETH": "0x7ceb23fd6bc0add59e62ac25578270cff1b9f619",
|
||||
"DAI": "0x8f3cf7ad23cd3cadbd9735aff958023239c6a063",
|
||||
}
|
||||
|
||||
# Decimals per token (used for converting raw amounts)
|
||||
TOKEN_DECIMALS = {
|
||||
"USDC": 6,
|
||||
"USDC.e": 6,
|
||||
"USDT": 6,
|
||||
"WETH": 18,
|
||||
"DAI": 18,
|
||||
}
|
||||
|
||||
|
||||
def _format_amount(raw_value: str, decimals: int) -> float:
|
||||
"""Convert a raw token amount string to a human-readable float."""
|
||||
try:
|
||||
return int(raw_value) / (10 ** decimals)
|
||||
except (ValueError, TypeError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _short_address(address: str) -> str:
|
||||
"""Shorten an Ethereum address for display."""
|
||||
if len(address) >= 10:
|
||||
return f"{address[:6]}...{address[-4:]}"
|
||||
return address
|
||||
|
||||
|
||||
def _ts_to_str(timestamp: str) -> str:
|
||||
"""Convert a unix timestamp string to a readable UTC datetime."""
|
||||
try:
|
||||
dt = datetime.fromtimestamp(int(timestamp), tz=timezone.utc)
|
||||
return dt.strftime("%Y-%m-%d %H:%M UTC")
|
||||
except (ValueError, TypeError):
|
||||
return timestamp
|
||||
|
||||
|
||||
class EtherscanService:
|
||||
"""
|
||||
Etherscan API client for Ethereum on-chain data.
|
||||
|
||||
Note: Free tier is limited to 5 calls/sec. Add delays between rapid
|
||||
successive calls if needed.
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str = ""):
|
||||
self.api_key = api_key
|
||||
self._client = get_client(timeout=20.0)
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return bool(self.api_key and self.api_key.strip())
|
||||
|
||||
def _get(self, params: dict, chain_id: int = DEFAULT_CHAIN_ID) -> dict:
|
||||
"""Make authenticated GET request to Etherscan V2 API."""
|
||||
params["apikey"] = self.api_key
|
||||
params["chainid"] = chain_id
|
||||
resp = self._client.get(ETHERSCAN_API_V2, params=params)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. Token transfers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_wallet_token_transfers(self, address: str, token: str = "USDC") -> str:
|
||||
"""
|
||||
Get recent ERC-20 token transfers for a wallet.
|
||||
|
||||
Args:
|
||||
address: Ethereum wallet address.
|
||||
token: Token symbol to filter on (USDC, USDT, etc.).
|
||||
Pass "ALL" to show all ERC-20 transfers.
|
||||
|
||||
Returns:
|
||||
Formatted transfer report string for LLM consumption.
|
||||
"""
|
||||
addr = address.strip().lower()
|
||||
token_upper = token.strip().upper()
|
||||
|
||||
try:
|
||||
params = {
|
||||
"module": "account",
|
||||
"action": "tokentx",
|
||||
"address": addr,
|
||||
"sort": "desc",
|
||||
"page": "1",
|
||||
"offset": "20",
|
||||
}
|
||||
|
||||
data = self._get(params)
|
||||
|
||||
if data.get("status") != "1" or not data.get("result"):
|
||||
message = data.get("message", "No transfers found")
|
||||
return f"No ERC-20 token transfers found for {_short_address(addr)}: {message}"
|
||||
|
||||
transfers = data["result"]
|
||||
|
||||
# Filter by token if not "ALL"
|
||||
if token_upper != "ALL":
|
||||
# For USDC, match both native and bridged (USDC.e) contracts
|
||||
if token_upper == "USDC":
|
||||
allowed = {
|
||||
TOKEN_CONTRACTS.get("USDC", "").lower(),
|
||||
TOKEN_CONTRACTS.get("USDC.e", "").lower(),
|
||||
}
|
||||
allowed.discard("")
|
||||
transfers = [
|
||||
tx for tx in transfers
|
||||
if tx.get("contractAddress", "").lower() in allowed
|
||||
]
|
||||
else:
|
||||
contract = TOKEN_CONTRACTS.get(token_upper, "").lower()
|
||||
if contract:
|
||||
transfers = [
|
||||
tx for tx in transfers
|
||||
if tx.get("contractAddress", "").lower() == contract
|
||||
]
|
||||
else:
|
||||
# Try matching by symbol in the response
|
||||
transfers = [
|
||||
tx for tx in transfers
|
||||
if tx.get("tokenSymbol", "").upper() == token_upper
|
||||
]
|
||||
|
||||
if not transfers:
|
||||
return f"No {token_upper} transfers found for {_short_address(addr)} in the last 20 token transactions."
|
||||
|
||||
lines = [f"--- Token Transfers for {_short_address(addr)} ({token_upper}) ---"]
|
||||
|
||||
for tx in transfers:
|
||||
tx_from = tx.get("from", "").lower()
|
||||
tx_to = tx.get("to", "").lower()
|
||||
symbol = tx.get("tokenSymbol", "???")
|
||||
decimals = int(tx.get("tokenDecimal", TOKEN_DECIMALS.get(symbol.upper(), 18)))
|
||||
raw_value = tx.get("value", "0")
|
||||
amount = _format_amount(raw_value, decimals)
|
||||
ts = _ts_to_str(tx.get("timeStamp", ""))
|
||||
tx_hash = tx.get("hash", "")
|
||||
|
||||
# Determine direction
|
||||
if tx_from == addr:
|
||||
direction = "OUT"
|
||||
counterparty = _short_address(tx_to)
|
||||
elif tx_to == addr:
|
||||
direction = "IN"
|
||||
counterparty = _short_address(tx_from)
|
||||
else:
|
||||
direction = "???"
|
||||
counterparty = f"{_short_address(tx_from)} -> {_short_address(tx_to)}"
|
||||
|
||||
# Flag large transfers
|
||||
large_flag = ""
|
||||
if symbol.upper() in ("USDC", "USDT", "DAI") and amount > 10_000:
|
||||
large_flag = " [LARGE]"
|
||||
elif symbol.upper() == "WETH" and amount > 5:
|
||||
large_flag = " [LARGE]"
|
||||
|
||||
lines.append(
|
||||
f" {direction} {amount:,.2f} {symbol}{large_flag} | "
|
||||
f"{'to' if direction == 'OUT' else 'from'}: {counterparty} | {ts}"
|
||||
)
|
||||
|
||||
lines.append("---")
|
||||
return "\n".join(lines)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
msg = f"Etherscan API error fetching token transfers for '{address}': {e}"
|
||||
logger.error(msg)
|
||||
return msg
|
||||
except Exception as e:
|
||||
msg = f"Etherscan token transfer query failed for '{address}': {e}"
|
||||
logger.error(msg)
|
||||
return msg
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. Contract info
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_contract_info(self, address: str) -> str:
|
||||
"""
|
||||
Check if an address is a smart contract and retrieve basic contract metadata.
|
||||
|
||||
Args:
|
||||
address: Ethereum address to inspect.
|
||||
|
||||
Returns:
|
||||
Formatted contract info string for LLM consumption.
|
||||
"""
|
||||
addr = address.strip()
|
||||
|
||||
try:
|
||||
# First check if ABI is available (verified contract)
|
||||
abi_data = self._get({
|
||||
"module": "contract",
|
||||
"action": "getabi",
|
||||
"address": addr,
|
||||
})
|
||||
|
||||
is_verified = abi_data.get("status") == "1"
|
||||
|
||||
# Get source code info (includes contract name, compiler, etc.)
|
||||
source_data = self._get({
|
||||
"module": "contract",
|
||||
"action": "getsourcecode",
|
||||
"address": addr,
|
||||
})
|
||||
|
||||
results = source_data.get("result", [])
|
||||
|
||||
lines = [f"--- Contract Info for {_short_address(addr)} ---"]
|
||||
|
||||
if not results or (isinstance(results, list) and len(results) == 0):
|
||||
lines.append("No contract data returned. Address may be an EOA (externally owned account).")
|
||||
lines.append("---")
|
||||
return "\n".join(lines)
|
||||
|
||||
info = results[0] if isinstance(results, list) else results
|
||||
|
||||
contract_name = info.get("ContractName", "")
|
||||
compiler = info.get("CompilerVersion", "")
|
||||
optimization = info.get("OptimizationUsed", "")
|
||||
proxy = info.get("Proxy", "0")
|
||||
implementation = info.get("Implementation", "")
|
||||
|
||||
if not contract_name:
|
||||
lines.append("This address does not appear to be a verified contract.")
|
||||
lines.append("It may be an EOA (regular wallet) or an unverified contract.")
|
||||
else:
|
||||
lines.append(f"Contract Name: {contract_name}")
|
||||
lines.append(f"Verified: {'Yes' if is_verified else 'No'}")
|
||||
if compiler:
|
||||
lines.append(f"Compiler: {compiler}")
|
||||
if optimization:
|
||||
lines.append(f"Optimization: {'Yes' if optimization == '1' else 'No'}")
|
||||
if proxy == "1":
|
||||
lines.append(f"Proxy Contract: Yes")
|
||||
if implementation:
|
||||
lines.append(f"Implementation: {implementation}")
|
||||
|
||||
lines.append("---")
|
||||
return "\n".join(lines)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
msg = f"Etherscan API error fetching contract info for '{address}': {e}"
|
||||
logger.error(msg)
|
||||
return msg
|
||||
except Exception as e:
|
||||
msg = f"Etherscan contract query failed for '{address}': {e}"
|
||||
logger.error(msg)
|
||||
return msg
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. ETH balance
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_wallet_eth_balance(self, address: str) -> str:
|
||||
"""
|
||||
Get ETH balance for a wallet address.
|
||||
|
||||
Args:
|
||||
address: Ethereum wallet address.
|
||||
|
||||
Returns:
|
||||
Formatted ETH balance string for LLM consumption.
|
||||
"""
|
||||
addr = address.strip()
|
||||
|
||||
try:
|
||||
data = self._get({
|
||||
"module": "account",
|
||||
"action": "balance",
|
||||
"address": addr,
|
||||
"tag": "latest",
|
||||
})
|
||||
|
||||
if data.get("status") != "1":
|
||||
message = data.get("message", "Unknown error")
|
||||
return f"Could not fetch ETH balance for {_short_address(addr)}: {message}"
|
||||
|
||||
raw_balance = data.get("result", "0")
|
||||
eth_balance = _format_amount(raw_balance, 18)
|
||||
|
||||
lines = [
|
||||
f"--- ETH Balance for {_short_address(addr)} ---",
|
||||
f"Balance: {eth_balance:,.6f} ETH",
|
||||
"---",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
msg = f"Etherscan API error fetching ETH balance for '{address}': {e}"
|
||||
logger.error(msg)
|
||||
return msg
|
||||
except Exception as e:
|
||||
msg = f"Etherscan balance query failed for '{address}': {e}"
|
||||
logger.error(msg)
|
||||
return msg
|
||||
Reference in New Issue
Block a user