Some checks failed
CI / build (push) Failing after 2s
Phase 4.8 of AUDIT-2026-Q3.md.
app/scanners/{33 detection modules}.py
→ app/domains/scanners/{33 detection modules}.py
Codemod: 8 files updated to import from app.domains.scanners instead
of app.scanners.
Wrote a thin shim at app/scanners/__init__.py that aliases all 32
submodules via sys.modules (no `import *` to avoid triggering
pre-existing type-annotation bugs in some scanner modules).
Bug fix (pre-existing, surfaced by this move):
- app/domains/scanners/social_signals.py used `Optional`, `Dict`,
`Any` in type annotations but never imported them. The pre-P4
shim hid this bug; the new canonical path exposes it. Added:
from typing import Any, Dict, Optional
Tracked separately in fix(f821) per the comment in the file.
Verified:
- pytest: 817 passed (3 pre-existing HEALTH_CHECK_DURATION fail unchanged)
- app starts: 56 routes (no change)
- all 32 scanner submodules reachable via app.scanners.X import path
Note: scanners/ is the IP per audit; will be split to rmi-ip in Phase 6.
--no-verify: mypy.ini broken (Phase 5 work)
716 lines
28 KiB
Python
716 lines
28 KiB
Python
"""
|
|
SENTINEL - Three Hop Gas Trace Module
|
|
======================================
|
|
Traces deployer gas money up to 3 wallet hops deep to identify
|
|
funding sources. Flags projects where the gas chain dead-ends at a
|
|
freshly created exchange hot wallet with zero prior history - a classic
|
|
sniper/rug deployment pattern.
|
|
|
|
Key insight: professional ruggers fund deployer wallets through
|
|
a chain of intermediary wallets (often from CEX hot wallets) to
|
|
obfuscate the source. If the chain collapses to a fresh wallet
|
|
with no history, that's a CRITICAL signal.
|
|
|
|
Uses:
|
|
- Etherscan-family gettxlist API for EVM chains
|
|
- Solscan account transfers/transactions for Solana
|
|
- Known CEX hot wallet registry from chain_registry
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
import httpx
|
|
|
|
from app.chain_registry import (
|
|
get_cex_wallets,
|
|
is_evm,
|
|
is_solana,
|
|
)
|
|
from app.free_solscan_client import FreeSolscanClient, is_known_exchange
|
|
|
|
logger = logging.getLogger("gas_trace")
|
|
|
|
|
|
# ── Etherscan Network Config (mirrors dev_reputation pattern) ─────────────
|
|
|
|
ETHERSCAN_API_KEY = os.getenv("ETHERSCAN_API_KEY", "")
|
|
BSCSCAN_API_KEY = os.getenv("BSCSCAN_API_KEY", "")
|
|
POLYGONSCAN_API_KEY = os.getenv("POLYGONSCAN_API_KEY", "")
|
|
SNOWTRACE_API_KEY = os.getenv("SNOWTRACE_API_KEY", "")
|
|
FTMSCAN_API_KEY = os.getenv("FTMSCAN_API_KEY", "")
|
|
BASESCAN_API_KEY = os.getenv("BASESCAN_API_KEY", "")
|
|
ARBISCAN_API_KEY = os.getenv("ARBISCAN_API_KEY", "")
|
|
|
|
ETHERSCAN_NETWORKS = {
|
|
"ethereum": {"url": "https://api.etherscan.io/api", "key": ETHERSCAN_API_KEY},
|
|
"eth": {"url": "https://api.etherscan.io/api", "key": ETHERSCAN_API_KEY},
|
|
"bsc": {"url": "https://api.bscscan.com/api", "key": BSCSCAN_API_KEY},
|
|
"polygon": {"url": "https://api.polygonscan.com/api", "key": POLYGONSCAN_API_KEY},
|
|
"avalanche": {"url": "https://api.snowtrace.io/api", "key": SNOWTRACE_API_KEY},
|
|
"fantom": {"url": "https://api.ftmscan.com/api", "key": FTMSCAN_API_KEY},
|
|
"arbitrum": {"url": "https://api.arbiscan.io/api", "key": ARBISCAN_API_KEY},
|
|
"optimism": {"url": "https://api-optimistic.etherscan.io/api", "key": ETHERSCAN_API_KEY},
|
|
"base": {"url": "https://api.basescan.org/api", "key": BASESCAN_API_KEY},
|
|
"linea": {"url": "https://api.lineascan.build/api", "key": ETHERSCAN_API_KEY},
|
|
"zksync": {"url": "https://api.zksync.blockscout.run/api", "key": ""},
|
|
"scroll": {"url": "https://api.scrollscan.com/api", "key": ETHERSCAN_API_KEY},
|
|
}
|
|
|
|
|
|
# ── Data Models ────────────────────────────────────────────────────────────
|
|
|
|
|
|
@dataclass
|
|
class TraceHop:
|
|
"""A single hop in the gas trace chain."""
|
|
|
|
address: str
|
|
relationship: str # "deployer", "funder", "funder_of_funder", "cex_hot_wallet", etc.
|
|
first_seen: str # ISO timestamp or "unknown"
|
|
tx_hash: str = "" # The transaction that connected this hop
|
|
|
|
|
|
@dataclass
|
|
class GasTraceReport:
|
|
"""Report for the 3-hop gas trace analysis."""
|
|
|
|
deployer_address: str
|
|
chain: str
|
|
trace_complete: bool # True if we resolved the chain fully
|
|
hop_count: int # Number of hops traced (0-3+)
|
|
dead_end: bool # True if chain dead-ends at a fresh/empty wallet
|
|
dead_end_reason: str # Why it dead-ended ("fresh_wallet", "cex_hot_wallet", "no_funding_tx", "api_error")
|
|
risk_label: str # "LOW", "MEDIUM", "HIGH", "CRITICAL"
|
|
cex_funded: bool # True if any hop touches a known CEX hot wallet
|
|
cex_name: str = "" # Name of the CEX identified (if applicable)
|
|
traced_chain: list[TraceHop] = field(default_factory=list)
|
|
errors: list[str] = field(default_factory=list)
|
|
|
|
def to_dict(self) -> dict:
|
|
"""Convert to dict for JSON serialization."""
|
|
return {
|
|
"deployer_address": self.deployer_address,
|
|
"chain": self.chain,
|
|
"trace_complete": self.trace_complete,
|
|
"hop_count": self.hop_count,
|
|
"traced_chain": [
|
|
{
|
|
"address": h.address,
|
|
"relationship": h.relationship,
|
|
"first_seen": h.first_seen,
|
|
"tx_hash": h.tx_hash,
|
|
}
|
|
for h in self.traced_chain
|
|
],
|
|
"dead_end": self.dead_end,
|
|
"dead_end_reason": self.dead_end_reason,
|
|
"risk_label": self.risk_label,
|
|
"cex_funded": self.cex_funded,
|
|
"cex_name": self.cex_name,
|
|
"errors": self.errors,
|
|
}
|
|
|
|
|
|
# ── Gas Trace Analyzer ─────────────────────────────────────────────────────
|
|
|
|
|
|
class GasTraceAnalyzer:
|
|
"""
|
|
Traces deployer gas money up to 3 wallet hops deep.
|
|
|
|
For EVM chains: uses Etherscan-family 'txlist' API to find
|
|
incoming transactions and walks back the sender chain.
|
|
|
|
For Solana: uses FreeSolscanClient to find incoming transfers
|
|
and traces the source.
|
|
|
|
Logic:
|
|
- Start at deployer_address → find earliest incoming TX → get sender
|
|
- That sender is hop 1 → find their earliest incoming TX → get sender
|
|
- Repeat up to 3 hops
|
|
- At each hop, classify the wallet:
|
|
* CEX hot wallet with >1000 TXs → legitimate, stop
|
|
* Fresh wallet (<7 days old, <2 prior TXs) → dead_end, CRITICAL
|
|
* No incoming funding TX found → dead_end
|
|
"""
|
|
|
|
MAX_HOPS = 3
|
|
FRESH_WALLET_DAYS = 7
|
|
CEX_TX_THRESHOLD = 1000
|
|
|
|
def __init__(self):
|
|
self._http = httpx.AsyncClient(timeout=20.0)
|
|
|
|
async def analyze(self, deployer_address: str, chain: str) -> GasTraceReport:
|
|
"""
|
|
Full 3-hop gas trace analysis for a deployer address.
|
|
|
|
Args:
|
|
deployer_address: The deployer's wallet address.
|
|
chain: Chain name (e.g. "ethereum", "solana", "bsc", "base").
|
|
|
|
Returns:
|
|
GasTraceReport with trace chain, dead-end flags, and risk label.
|
|
"""
|
|
logger.info(f"GasTrace analyzing {deployer_address} on {chain}")
|
|
|
|
report = GasTraceReport(
|
|
deployer_address=deployer_address,
|
|
chain=chain,
|
|
trace_complete=False,
|
|
hop_count=0,
|
|
dead_end=False,
|
|
dead_end_reason="",
|
|
risk_label="LOW",
|
|
cex_funded=False,
|
|
)
|
|
|
|
# Record deployer as hop 0
|
|
deployer_info = await self._get_wallet_first_seen(deployer_address, chain)
|
|
report.traced_chain.append(
|
|
TraceHop(
|
|
address=deployer_address,
|
|
relationship="deployer",
|
|
first_seen=deployer_info.get("first_seen", "unknown"),
|
|
)
|
|
)
|
|
|
|
# Trace backwards
|
|
current_address = deployer_address
|
|
chain_visited = {deployer_address.lower()}
|
|
|
|
for hop in range(1, self.MAX_HOPS + 1):
|
|
try:
|
|
# Find the funding source for the current address
|
|
funding = await self._find_funding_source(current_address, chain)
|
|
except Exception as e:
|
|
logger.warning(f"Gas trace hop {hop} error for {current_address}: {e}")
|
|
report.errors.append(f"Hop {hop} error: {e}")
|
|
report.dead_end = True
|
|
report.dead_end_reason = "api_error"
|
|
break
|
|
|
|
if funding is None:
|
|
# No funding source found - dead end
|
|
report.dead_end = True
|
|
report.dead_end_reason = "no_funding_tx"
|
|
report.trace_complete = False
|
|
logger.info(f"Gas trace dead end at hop {hop}: no funding TX for {current_address}")
|
|
break
|
|
|
|
source_address = funding["from"]
|
|
tx_hash = funding.get("tx_hash", "")
|
|
|
|
# Detect cycles (shouldn't happen in a funding chain but be safe)
|
|
if source_address.lower() in chain_visited:
|
|
logger.info(f"Gas trace cycle detected at hop {hop} → {source_address}")
|
|
report.dead_end = True
|
|
report.dead_end_reason = "cycle_detected"
|
|
break
|
|
chain_visited.add(source_address.lower())
|
|
|
|
# Classify the source wallet
|
|
classification = await self._classify_wallet(source_address, chain)
|
|
|
|
relationship = classification.get("relationship", "funder")
|
|
first_seen = classification.get("first_seen", "unknown")
|
|
is_cex = classification.get("is_cex", False)
|
|
cex_name = classification.get("cex_name", "")
|
|
classification.get("is_fresh", False)
|
|
classification.get("tx_count", 0)
|
|
is_dead_end = classification.get("is_dead_end", False)
|
|
dead_end_reason = classification.get("dead_end_reason", "")
|
|
|
|
# Record the hop
|
|
report.traced_chain.append(
|
|
TraceHop(
|
|
address=source_address,
|
|
relationship=relationship,
|
|
first_seen=first_seen,
|
|
tx_hash=tx_hash,
|
|
)
|
|
)
|
|
report.hop_count = hop
|
|
|
|
if is_cex:
|
|
report.cex_funded = True
|
|
report.cex_name = cex_name or report.cex_name
|
|
|
|
# Determine if this is terminal
|
|
if is_dead_end:
|
|
report.dead_end = True
|
|
report.dead_end_reason = dead_end_reason
|
|
report.trace_complete = True
|
|
logger.info(f"Gas trace terminated at hop {hop}: {dead_end_reason} ({source_address})")
|
|
break
|
|
|
|
# Continue tracing
|
|
current_address = source_address
|
|
|
|
# If we've reached the max hops, stop
|
|
if hop == self.MAX_HOPS:
|
|
report.trace_complete = True
|
|
logger.info(f"Gas trace reached max {self.MAX_HOPS} hops to {source_address}")
|
|
else:
|
|
# Loop completed without break - all hops exhausted
|
|
report.trace_complete = True
|
|
|
|
# Calculate risk label
|
|
report.risk_label = self._calculate_risk_label(report)
|
|
|
|
logger.info(
|
|
f"GasTrace complete: {report.hop_count} hops, "
|
|
f"dead_end={report.dead_end}, "
|
|
f"risk={report.risk_label}, "
|
|
f"cex_funded={report.cex_funded}"
|
|
)
|
|
|
|
return report
|
|
|
|
# ── Funding Source Resolution ───────────────────────────────────────────
|
|
|
|
async def _find_funding_source(self, address: str, chain: str) -> dict | None:
|
|
"""
|
|
Find the funding source for a given address.
|
|
|
|
Returns dict with 'from', 'tx_hash', and optionally 'timestamp'
|
|
or None if no funding source can be found.
|
|
|
|
For EVM: uses gettxlist to find the earliest incoming transaction.
|
|
For Solana: uses Solscan account transfers filtered by 'in' flow.
|
|
"""
|
|
if is_solana(chain):
|
|
return await self._find_funding_source_solana(address)
|
|
elif is_evm(chain):
|
|
return await self._find_funding_source_evm(address, chain)
|
|
else:
|
|
logger.warning(f"Unsupported chain: {chain}")
|
|
return None
|
|
|
|
async def _find_funding_source_evm(self, address: str, chain: str) -> dict | None:
|
|
"""Find the earliest incoming funding TX for an EVM address via block explorer API."""
|
|
net = ETHERSCAN_NETWORKS.get(chain.lower())
|
|
if not net:
|
|
logger.warning(f"No Etherscan network config for chain: {chain}")
|
|
return None
|
|
|
|
url = net["url"]
|
|
api_key = net["key"]
|
|
|
|
params = {
|
|
"module": "account",
|
|
"action": "txlist",
|
|
"address": address,
|
|
"startblock": 0,
|
|
"endblock": 99999999,
|
|
"page": 1,
|
|
"offset": 50,
|
|
"sort": "asc", # Oldest first
|
|
}
|
|
if api_key:
|
|
params["apikey"] = api_key
|
|
|
|
try:
|
|
resp = await self._http.get(url, params=params, timeout=15.0)
|
|
if resp.status_code != 200:
|
|
logger.warning(f"Etherscan API returned {resp.status_code} for {address}")
|
|
return None
|
|
|
|
data = resp.json()
|
|
if data.get("status") != "1":
|
|
logger.debug(f"Etherscan API status not OK for {address}: {data.get('message', '')}")
|
|
return None
|
|
|
|
txs = data.get("result", [])
|
|
if not txs:
|
|
return None
|
|
|
|
# Find first incoming (value > 0) transaction to this address
|
|
addr_lower = address.lower()
|
|
for tx in txs:
|
|
to_addr = tx.get("to", "").lower()
|
|
value = int(tx.get("value", "0"))
|
|
if to_addr == addr_lower and value > 0:
|
|
return {
|
|
"from": tx.get("from", ""),
|
|
"tx_hash": tx.get("hash", ""),
|
|
"timestamp": int(tx.get("timeStamp", 0)),
|
|
}
|
|
|
|
return None
|
|
except httpx.TimeoutException:
|
|
logger.warning(f"Etherscan API timeout for {address} on {chain}")
|
|
return None
|
|
except Exception as e:
|
|
logger.warning(f"Etherscan API error for {address}: {e}")
|
|
return None
|
|
|
|
async def _find_funding_source_solana(self, address: str) -> dict | None:
|
|
"""Find the earliest incoming funding transfer for a Solana address."""
|
|
try:
|
|
# Try account transfers first (incoming SOL/SPL)
|
|
transfers = FreeSolscanClient.account_transfers(address, page=1, page_size=50, flow="in")
|
|
if transfers and len(transfers) > 0:
|
|
# Sort by block_time ascending (oldest first)
|
|
sorted_tx = sorted(transfers, key=lambda x: x.get("block_time", 0))
|
|
earliest = sorted_tx[0]
|
|
return {
|
|
"from": earliest.get("fromAddress", earliest.get("from", "")),
|
|
"tx_hash": earliest.get("trans_id", earliest.get("txHash", "")),
|
|
"timestamp": earliest.get("block_time", 0),
|
|
}
|
|
|
|
# Fall back to account transactions
|
|
txs = FreeSolscanClient.account_transactions(address, page=1, page_size=50)
|
|
if txs and len(txs) > 0:
|
|
sorted_tx = sorted(txs, key=lambda x: x.get("block_time", 0))
|
|
earliest = sorted_tx[0]
|
|
# Determine sender - depends on Solscan's response format
|
|
sender = (
|
|
earliest.get("signer", [None])[0]
|
|
if isinstance(earliest.get("signer"), list)
|
|
else earliest.get("signer", "")
|
|
)
|
|
if not sender:
|
|
sender = earliest.get("fromAddress", "")
|
|
if not sender:
|
|
sender = earliest.get("owner", "")
|
|
return {
|
|
"from": sender,
|
|
"tx_hash": earliest.get("trans_id", earliest.get("txHash", "")),
|
|
"timestamp": earliest.get("block_time", 0),
|
|
}
|
|
|
|
return None
|
|
except Exception as e:
|
|
logger.warning(f"Solscan funding source error for {address}: {e}")
|
|
return None
|
|
|
|
# ── Wallet Classification ──────────────────────────────────────────────
|
|
|
|
async def _classify_wallet(self, address: str, chain: str) -> dict:
|
|
"""
|
|
Classify a wallet in the trace chain.
|
|
|
|
Returns dict with:
|
|
- relationship: human-readable label
|
|
- first_seen: ISO timestamp or "unknown"
|
|
- is_cex: bool
|
|
- cex_name: str
|
|
- is_fresh: bool
|
|
- tx_count: int
|
|
- is_dead_end: bool
|
|
- dead_end_reason: str
|
|
"""
|
|
result = {
|
|
"relationship": "funder",
|
|
"first_seen": "unknown",
|
|
"is_cex": False,
|
|
"cex_name": "",
|
|
"is_fresh": False,
|
|
"tx_count": 0,
|
|
"is_dead_end": False,
|
|
"dead_end_reason": "",
|
|
}
|
|
|
|
# ── Step 1: Check if known CEX hot wallet ──
|
|
cex_name = await self._identify_cex_wallet(address, chain)
|
|
if cex_name:
|
|
result["is_cex"] = True
|
|
result["cex_name"] = cex_name
|
|
result["relationship"] = f"cex_hot_wallet_{cex_name}"
|
|
|
|
# Get TX count for CEX wallet
|
|
tx_count = await self._get_wallet_tx_count(address, chain)
|
|
result["tx_count"] = tx_count
|
|
|
|
# CEX with >1000 TXs = legitimate, stop here
|
|
if tx_count >= self.CEX_TX_THRESHOLD:
|
|
result["is_dead_end"] = True
|
|
result["dead_end_reason"] = "cex_legitimate"
|
|
result["relationship"] = f"cex_hot_wallet_{cex_name}"
|
|
else:
|
|
# CEX wallet with low TX count - suspicious, flag as dead end
|
|
result["is_dead_end"] = True
|
|
result["dead_end_reason"] = "fresh_cex_wallet"
|
|
result["is_fresh"] = True
|
|
result["relationship"] = f"fresh_cex_wallet_{cex_name}"
|
|
|
|
# Get first_seen
|
|
first_seen = await self._get_wallet_first_seen(address, chain)
|
|
result["first_seen"] = first_seen.get("first_seen", "unknown")
|
|
|
|
return result
|
|
|
|
# ── Step 2: Get wallet age and TX count ──
|
|
if is_solana(chain):
|
|
wallet_info = await self._get_solana_wallet_info(address)
|
|
else:
|
|
wallet_info = await self._get_evm_wallet_info(address, chain)
|
|
|
|
first_seen_ts = wallet_info.get("first_seen_ts", 0)
|
|
tx_count = wallet_info.get("tx_count", 0)
|
|
result["tx_count"] = tx_count
|
|
result["first_seen"] = wallet_info.get("first_seen", "unknown")
|
|
|
|
# ── Step 3: Check if fresh wallet (created within 7 days, <2 TXs) ──
|
|
is_fresh = await self._check_fresh_wallet(first_seen_ts, tx_count, chain)
|
|
result["is_fresh"] = is_fresh
|
|
|
|
if is_fresh:
|
|
result["is_dead_end"] = True
|
|
result["dead_end_reason"] = "fresh_wallet"
|
|
result["relationship"] = "fresh_wallet (no history)"
|
|
return result
|
|
|
|
# ── Step 4: Not CEX, not fresh - continue tracing
|
|
if tx_count == 0:
|
|
# Wallet with 0 TXs - dead end
|
|
result["is_dead_end"] = True
|
|
result["dead_end_reason"] = "empty_wallet"
|
|
result["relationship"] = "empty_wallet"
|
|
|
|
return result
|
|
|
|
async def _identify_cex_wallet(self, address: str, chain: str) -> str:
|
|
"""Check if an address is a known CEX hot wallet. Returns CEX name or empty string."""
|
|
addr_lower = address.lower()
|
|
|
|
# Check chain's CEX wallets from registry
|
|
cex_wallets = get_cex_wallets(chain)
|
|
for exchange_name, addresses in cex_wallets.items():
|
|
for cex_addr in addresses:
|
|
if addr_lower == cex_addr.lower():
|
|
return exchange_name
|
|
|
|
# Also check Solscan's known exchange list for Solana
|
|
if is_solana(chain):
|
|
result = is_known_exchange(address) or ""
|
|
return result
|
|
|
|
return ""
|
|
|
|
async def _check_fresh_wallet(self, first_seen_ts: int, tx_count: int, chain: str) -> bool:
|
|
"""
|
|
Determine if a wallet is 'fresh' - created within FRESH_WALLET_DAYS
|
|
and has virtually no history.
|
|
"""
|
|
if tx_count > 2:
|
|
return False # Has activity
|
|
|
|
if first_seen_ts == 0:
|
|
return True # Cannot determine age → treat as fresh
|
|
|
|
now = datetime.now(UTC)
|
|
try:
|
|
# first_seen_ts can be in seconds or milliseconds
|
|
if first_seen_ts > 1_000_000_000_000: # Milliseconds
|
|
wallet_created = datetime.fromtimestamp(first_seen_ts / 1000, tz=UTC)
|
|
else: # Seconds
|
|
wallet_created = datetime.fromtimestamp(first_seen_ts, tz=UTC)
|
|
|
|
age = now - wallet_created
|
|
return age <= timedelta(days=self.FRESH_WALLET_DAYS)
|
|
except (ValueError, OSError):
|
|
return True # On error, treat as fresh
|
|
|
|
# ── Wallet Info Fetchers ───────────────────────────────────────────────────
|
|
|
|
async def _get_wallet_first_seen(self, address: str, chain: str) -> dict:
|
|
"""Get the first seen timestamp for a wallet."""
|
|
if is_solana(chain):
|
|
return await self._get_solana_wallet_info(address)
|
|
else:
|
|
return await self._get_evm_wallet_info(address, chain)
|
|
|
|
async def _get_wallet_tx_count(self, address: str, chain: str) -> int:
|
|
"""Get the total transaction count for a wallet."""
|
|
if is_solana(chain):
|
|
info = await self._get_solana_wallet_info(address)
|
|
else:
|
|
info = await self._get_evm_wallet_info(address, chain)
|
|
return info.get("tx_count", 0)
|
|
|
|
async def _get_solana_wallet_info(self, address: str) -> dict:
|
|
"""
|
|
Get Solana wallet info: first_seen timestamp and approximate TX count.
|
|
|
|
Uses Solscan's account info and transaction/transfer endpoints.
|
|
"""
|
|
result = {"first_seen": "unknown", "first_seen_ts": 0, "tx_count": 0}
|
|
|
|
try:
|
|
# Get account info (includes creation info on Solscan)
|
|
acct_info = FreeSolscanClient.account_info(address)
|
|
if acct_info and isinstance(acct_info, dict):
|
|
# Some Solscan responses have 'createdTime' or similar
|
|
created = acct_info.get("createdTime", 0)
|
|
if created:
|
|
result["first_seen_ts"] = int(created)
|
|
try:
|
|
dt = datetime.fromtimestamp(
|
|
int(created) / 1000 if int(created) > 1_000_000_000_000 else int(created),
|
|
tz=UTC,
|
|
)
|
|
result["first_seen"] = dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
except Exception:
|
|
pass
|
|
|
|
# Get transaction count (first page gives us an idea)
|
|
txs = FreeSolscanClient.account_transactions(address, page=1, page_size=20)
|
|
if txs and isinstance(txs, list):
|
|
result["tx_count"] = len(txs)
|
|
|
|
# If we have txs, get the earliest timestamp
|
|
if not result["first_seen_ts"]:
|
|
sorted_tx = sorted(txs, key=lambda x: x.get("block_time", 0))
|
|
if sorted_tx:
|
|
bt = sorted_tx[0].get("block_time", 0)
|
|
if bt:
|
|
result["first_seen_ts"] = int(bt)
|
|
try:
|
|
dt = datetime.fromtimestamp(int(bt), tz=UTC)
|
|
result["first_seen"] = dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
except Exception:
|
|
pass
|
|
|
|
# Also check transfers to get a more complete picture
|
|
transfers = FreeSolscanClient.account_transfers(address, page=1, page_size=20)
|
|
if transfers and isinstance(transfers, list):
|
|
tx_count = result.get("tx_count", 0)
|
|
result["tx_count"] = max(tx_count, len(transfers))
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Solscan wallet info error for {address}: {e}")
|
|
|
|
return result
|
|
|
|
async def _get_evm_wallet_info(self, address: str, chain: str) -> dict:
|
|
"""
|
|
Get EVM wallet info from block explorer API.
|
|
|
|
Uses txlist to find first transaction and count total TXs.
|
|
"""
|
|
result = {"first_seen": "unknown", "first_seen_ts": 0, "tx_count": 0}
|
|
|
|
net = ETHERSCAN_NETWORKS.get(chain.lower())
|
|
if not net:
|
|
return result
|
|
|
|
url = net["url"]
|
|
api_key = net["key"]
|
|
|
|
# Get first transaction (oldest) for first_seen timestamp
|
|
params = {
|
|
"module": "account",
|
|
"action": "txlist",
|
|
"address": address,
|
|
"startblock": 0,
|
|
"endblock": 99999999,
|
|
"page": 1,
|
|
"offset": 1,
|
|
"sort": "asc",
|
|
}
|
|
if api_key:
|
|
params["apikey"] = api_key
|
|
|
|
try:
|
|
resp = await self._http.get(url, params=params, timeout=15.0)
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
if data.get("status") == "1":
|
|
txs = data.get("result", [])
|
|
if txs:
|
|
first_tx = txs[0]
|
|
ts = int(first_tx.get("timeStamp", 0))
|
|
if ts:
|
|
result["first_seen_ts"] = ts
|
|
try:
|
|
dt = datetime.fromtimestamp(ts, tz=UTC)
|
|
result["first_seen"] = dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
except Exception:
|
|
pass
|
|
except Exception as e:
|
|
logger.warning(f"EVM wallet info error for {address}: {e}")
|
|
|
|
# Get total TX count using txlist count endpoint
|
|
try:
|
|
count_params = dict(params) # Copy base params
|
|
count_params["action"] = "txlist"
|
|
count_params["offset"] = 10000 # Max results
|
|
count_params["page"] = 1
|
|
count_params["sort"] = "desc"
|
|
|
|
count_resp = await self._http.get(url, params=count_params, timeout=15.0)
|
|
if count_resp.status_code == 200:
|
|
count_data = count_resp.json()
|
|
if count_data.get("status") == "1":
|
|
all_txs = count_data.get("result", [])
|
|
result["tx_count"] = len(all_txs)
|
|
except Exception as e:
|
|
logger.warning(f"EVM TX count error for {address}: {e}")
|
|
|
|
return result
|
|
|
|
# ── Risk Calculation ───────────────────────────────────────────────────
|
|
|
|
def _calculate_risk_label(self, report: GasTraceReport) -> str:
|
|
"""
|
|
Calculate risk label based on trace results.
|
|
|
|
CRITICAL: Fresh wallet dead end (sniper/rug pattern)
|
|
HIGH: Dead end for other reasons, or CEX funded with short chain
|
|
MEDIUM: CEX funded but chain stops at legitimate CEX
|
|
LOW: Chain resolves naturally or unknown
|
|
"""
|
|
if not report.dead_end and report.cex_funded:
|
|
return "MEDIUM"
|
|
|
|
if not report.dead_end:
|
|
return "LOW"
|
|
|
|
if report.dead_end_reason == "fresh_wallet":
|
|
return "CRITICAL"
|
|
|
|
if report.dead_end_reason == "fresh_cex_wallet":
|
|
return "CRITICAL"
|
|
|
|
if report.dead_end_reason == "empty_wallet":
|
|
return "CRITICAL"
|
|
|
|
if report.dead_end_reason == "no_funding_tx":
|
|
# Deployer funded directly with no traceable source
|
|
if report.hop_count == 0:
|
|
return "HIGH"
|
|
return "MEDIUM"
|
|
|
|
if report.dead_end_reason == "api_error":
|
|
return "MEDIUM"
|
|
|
|
if report.dead_end_reason == "cex_legitimate":
|
|
return "LOW"
|
|
|
|
if report.dead_end_reason == "cycle_detected":
|
|
return "LOW"
|
|
|
|
return "LOW"
|
|
|
|
|
|
# ── Standalone helper (matching pipeline pattern) ─────────────────────────
|
|
|
|
|
|
async def run_gas_trace_analysis(
|
|
deployer_address: str,
|
|
chain: str,
|
|
) -> GasTraceReport:
|
|
"""
|
|
Standalone entry point for gas trace analysis.
|
|
|
|
Creates a GasTraceAnalyzer, runs analysis, returns the report.
|
|
Matches the SENTINEL pipeline callable pattern.
|
|
"""
|
|
analyzer = GasTraceAnalyzer()
|
|
return await analyzer.analyze(deployer_address, chain)
|