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)
572 lines
22 KiB
Python
572 lines
22 KiB
Python
"""
|
|
SENTINEL - Developer Reputation Engine
|
|
======================================
|
|
Tracks every token ever launched by a wallet, their outcomes, holding patterns,
|
|
and time-to-rug metrics. Detects serial ruggers through pattern matching
|
|
and cross-chain address portability.
|
|
|
|
Scoring:
|
|
rug_rate >= 80% with 3+ launches → score 0-20 (serial rugger)
|
|
rug_rate >= 50% → score 20-40 (high risk)
|
|
avg_lifespan < 7 days with 2+ launches → score 25-35
|
|
dev holding 0% at rug time → high confidence rug signal
|
|
Cross-chain same address → score 0-15 (same operator everywhere)
|
|
|
|
Data sources (direct API calls - no self.api_base):
|
|
DexScreener: Token search by deployer address (free, no key needed)
|
|
Etherscan-family: EVM deployer transaction history
|
|
Moralis: Cross-chain wallet token history
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from datetime import UTC, datetime
|
|
from enum import Enum
|
|
|
|
import httpx
|
|
|
|
from app.chain_registry import CHAINS
|
|
|
|
logger = logging.getLogger("dev_reputation")
|
|
|
|
# ── API Keys ────────────────────────────────────────────────
|
|
|
|
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", "")
|
|
MORALIS_API_KEY = os.getenv("MORALIS_API_KEY", "")
|
|
|
|
DEXSCREENER_URL = "https://api.dexscreener.com"
|
|
MORALIS_DATA_URL = "https://deep-index.moralis.io/api/v2.2"
|
|
|
|
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": ETHERSCAN_API_KEY},
|
|
"optimism": {"url": "https://api-optimistic.etherscan.io/api", "key": ETHERSCAN_API_KEY},
|
|
"base": {"url": "https://api.basescan.org/api", "key": ETHERSCAN_API_KEY},
|
|
}
|
|
|
|
MORALIS_CHAIN_MAP = {
|
|
"ethereum": "eth",
|
|
"eth": "eth",
|
|
"bsc": "bsc",
|
|
"polygon": "polygon",
|
|
"avalanche": "avalanche",
|
|
"fantom": "fantom",
|
|
"arbitrum": "arbitrum",
|
|
"optimism": "optimism",
|
|
"base": "base",
|
|
}
|
|
|
|
# DexScreener chain ID mapping (used in pair responses)
|
|
DEXSCREENER_CHAIN_MAP = {
|
|
"ethereum": "ethereum",
|
|
"eth": "ethereum",
|
|
"bsc": "bsc",
|
|
"polygon": "polygon",
|
|
"avalanche": "avalanche",
|
|
"fantom": "fantom",
|
|
"arbitrum": "arbitrum",
|
|
"optimism": "optimism",
|
|
"base": "base",
|
|
"solana": "solana",
|
|
}
|
|
|
|
|
|
class ReputationLevel(Enum):
|
|
TRUSTED = "trusted" # score >= 70
|
|
NEUTRAL = "neutral" # score 40-69
|
|
SUSPICIOUS = "suspicious" # score 20-39
|
|
DANGEROUS = "dangerous" # score < 20
|
|
|
|
|
|
@dataclass
|
|
class TokenLaunch:
|
|
address: str
|
|
chain: str
|
|
name: str = ""
|
|
symbol: str = ""
|
|
launch_timestamp: int = 0
|
|
max_market_cap: float = 0.0
|
|
lifespan_days: float = 0.0
|
|
outcome: str = "unknown" # "active", "rugged", "abandoned", "unknown"
|
|
dev_holds_percentage: float = 0.0 # Dev's holding at time of outcome
|
|
is_renounced: bool = False # Did dev renounce ownership?
|
|
has_locked_liquidity: bool = False
|
|
|
|
|
|
@dataclass
|
|
class DevReputationReport:
|
|
dev_wallet: str
|
|
chains: list[str]
|
|
total_launches: int
|
|
rugged_count: int
|
|
rug_rate: float # 0.0-1.0
|
|
avg_lifespan_days: float
|
|
avg_max_mcap: float
|
|
retention_rate: float # % of launches where dev still holds tokens
|
|
serial_patterns: list[str] # Detected serial rugger patterns
|
|
cross_chain_addresses: list[dict[str, str]] # Same dev on other chains
|
|
launches: list[TokenLaunch] = field(default_factory=list)
|
|
dev_threat_index: float = 0.0 # (Holdings% x RugRate) / LockDays
|
|
reputation_score: int = 50 # 0-100, higher = safer
|
|
reputation_level: ReputationLevel = ReputationLevel.NEUTRAL
|
|
confidence: str = "LOW" # LOW, MEDIUM, HIGH
|
|
warnings: list[str] = field(default_factory=list)
|
|
|
|
|
|
class DevReputationEngine:
|
|
"""Calculates developer reputation based on launch history and patterns.
|
|
|
|
Uses direct API calls:
|
|
- DexScreener for token launch discovery (free, no key)
|
|
- Etherscan-family for EVM transaction history
|
|
- Moralis for cross-chain wallet data
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.client = httpx.AsyncClient(timeout=15.0)
|
|
|
|
# ── DexScreener helpers ────────────────────────────────────
|
|
|
|
async def _dexscreener_search(self, query: str) -> dict | None:
|
|
"""Search DexScreener for pairs matching a query (deployer address, etc.)."""
|
|
try:
|
|
resp = await self.client.get(f"{DEXSCREENER_URL}/latest/dex/search", params={"q": query})
|
|
if resp.status_code == 200:
|
|
return resp.json()
|
|
except Exception as e:
|
|
logger.warning(f"DexScreener search failed for '{query}': {e}")
|
|
return None
|
|
|
|
async def _dexscreener_token_info(self, token_address: str) -> dict | None:
|
|
"""Get DexScreener pair data for a specific token."""
|
|
try:
|
|
resp = await self.client.get(f"{DEXSCREENER_URL}/latest/dex/tokens/{token_address}")
|
|
if resp.status_code == 200:
|
|
return resp.json()
|
|
except Exception as e:
|
|
logger.warning(f"DexScreener token lookup failed for {token_address}: {e}")
|
|
return None
|
|
|
|
# ── Etherscan helpers ──────────────────────────────────────
|
|
|
|
async def _etherscan_get(self, chain: str, params: dict) -> dict | None:
|
|
"""Make an Etherscan-family API call."""
|
|
net = ETHERSCAN_NETWORKS.get(chain.lower())
|
|
if not net:
|
|
return None
|
|
api_key = net["key"]
|
|
if not api_key:
|
|
logger.warning(f"No Etherscan API key for {chain}")
|
|
return None
|
|
params["apikey"] = api_key
|
|
try:
|
|
resp = await self.client.get(net["url"], params=params)
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
if data.get("status") == "1":
|
|
return data.get("result")
|
|
return data
|
|
except Exception as e:
|
|
logger.warning(f"Etherscan call failed for {chain}: {e}")
|
|
return None
|
|
|
|
# ── Moralis helpers ────────────────────────────────────────
|
|
|
|
def _moralis_chain(self, chain: str) -> str:
|
|
"""Convert chain name to Moralis chain param."""
|
|
return MORALIS_CHAIN_MAP.get(chain.lower(), "eth")
|
|
|
|
async def _moralis_get(self, url: str) -> dict | None:
|
|
"""Make a Moralis Data API call."""
|
|
if not MORALIS_API_KEY:
|
|
logger.warning("MORALIS_API_KEY not set")
|
|
return None
|
|
try:
|
|
resp = await self.client.get(url, headers={"X-API-Key": MORALIS_API_KEY, "Accept": "application/json"})
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
if isinstance(data, list):
|
|
return data
|
|
return data.get("result", data.get("data", data))
|
|
except Exception as e:
|
|
logger.warning(f"Moralis call failed: {e}")
|
|
return None
|
|
|
|
# ── Core logic ──────────────────────────────────────────
|
|
|
|
def calculate_reputation(self, launches: list[TokenLaunch], dev_wallet: str) -> DevReputationReport:
|
|
"""Calculate reputation from known token launches."""
|
|
if not launches:
|
|
return DevReputationReport(
|
|
dev_wallet=dev_wallet,
|
|
chains=[],
|
|
total_launches=0,
|
|
rugged_count=0,
|
|
rug_rate=0.0,
|
|
avg_lifespan_days=0.0,
|
|
avg_max_mcap=0.0,
|
|
retention_rate=0.0,
|
|
serial_patterns=[],
|
|
cross_chain_addresses=[],
|
|
reputation_score=50,
|
|
reputation_level=ReputationLevel.NEUTRAL,
|
|
confidence="LOW",
|
|
warnings=["New deployer - no history"],
|
|
)
|
|
|
|
# Calculate core metrics
|
|
total = len(launches)
|
|
rugged = sum(1 for line in launches if line.outcome == "rugged")
|
|
rug_rate = rugged / total if total else 0
|
|
|
|
avg_lifespan = sum(line.lifespan_days for line in launches) / total if total else 0
|
|
avg_mcap = sum(line.max_market_cap for line in launches) / total if total else 0
|
|
retention = sum(1 for line in launches if line.dev_holds_percentage > 0) / total if total else 0
|
|
|
|
# Detect serial patterns
|
|
patterns = self._detect_serial_patterns(launches)
|
|
|
|
# Calculate reputation score
|
|
score, level, confidence = self._calculate_score(total, rugged, rug_rate, avg_lifespan, retention, patterns)
|
|
|
|
# Calculate Dev Threat Index: (Holdings% x Reputation) / LockDays
|
|
avg_holdings = sum(line.dev_holds_percentage for line in launches) / total if total else 0
|
|
avg_lock_days = max(
|
|
sum(line.lifespan_days for line in launches if line.has_locked_liquidity)
|
|
/ max(sum(1 for line in launches if line.has_locked_liquidity), 1),
|
|
1,
|
|
)
|
|
threat_index = (avg_holdings * max(rug_rate, 0.1)) / max(avg_lock_days, 1)
|
|
|
|
# Build warnings
|
|
warnings = []
|
|
if rug_rate >= 0.8 and total >= 3:
|
|
warnings.append(f"SERIAL RUGGER: {rugged}/{total} projects rugged")
|
|
elif rug_rate >= 0.5:
|
|
warnings.append(f"HIGH RUG RATE: {rugged}/{total} projects rugged ({rug_rate * 100:.0f}%)")
|
|
|
|
if avg_lifespan < 7 and total >= 2:
|
|
warnings.append(f"SHORT-LIVED: Average lifespan {avg_lifespan:.1f} days - quick-exit pattern")
|
|
|
|
if retention < 0.2 and total >= 2:
|
|
warnings.append(f"DUMP & RUN: Dev holds 0% in {(1 - retention) * 100:.0f}% of projects")
|
|
|
|
for pattern in patterns:
|
|
warnings.append(f"PATTERN: {pattern}")
|
|
|
|
chains = list({line.chain for line in launches})
|
|
|
|
report = DevReputationReport(
|
|
dev_wallet=dev_wallet,
|
|
chains=chains,
|
|
total_launches=total,
|
|
rugged_count=rugged,
|
|
rug_rate=round(rug_rate, 3),
|
|
avg_lifespan_days=round(avg_lifespan, 1),
|
|
avg_max_mcap=round(avg_mcap, 0),
|
|
retention_rate=round(retention, 3),
|
|
serial_patterns=patterns,
|
|
cross_chain_addresses=[], # Filled by cross-chain lookup
|
|
launches=launches,
|
|
dev_threat_index=round(min(threat_index, 100), 2),
|
|
reputation_score=score,
|
|
reputation_level=level,
|
|
confidence=confidence,
|
|
warnings=warnings,
|
|
)
|
|
|
|
return report
|
|
|
|
def _detect_serial_patterns(self, launches: list[TokenLaunch]) -> list[str]:
|
|
"""Detect common serial rugger patterns."""
|
|
patterns = []
|
|
|
|
if not launches:
|
|
return patterns
|
|
|
|
# Pattern 1: High rug rate (3+ launches, 80%+ rugged)
|
|
total = len(launches)
|
|
rugged = sum(1 for line in launches if line.outcome == "rugged")
|
|
if total >= 3 and rugged / total >= 0.8:
|
|
patterns.append(f"Serial rugger: {rugged}/{total} projects rugged")
|
|
|
|
# Pattern 2: Short-lived projects (avg < 7 days)
|
|
avg_lifespan = sum(line.lifespan_days for line in launches) / total
|
|
if total >= 2 and avg_lifespan < 7:
|
|
patterns.append(f"Short-lived projects (avg {avg_lifespan:.1f} days)")
|
|
|
|
# Pattern 3: Dev dumps entire allocation before rug
|
|
zero_hold = sum(1 for line in launches if line.outcome == "rugged" and line.dev_holds_percentage < 1)
|
|
if zero_hold >= 2:
|
|
patterns.append(f"Dev dumps before rug ({zero_hold} times)")
|
|
|
|
# Pattern 4: Similar naming/metadata patterns
|
|
names = [line.name.lower() for line in launches if line.name]
|
|
if len(set(names)) < len(names) * 0.5 and len(names) >= 3:
|
|
patterns.append("Similar naming pattern across launches")
|
|
|
|
# Pattern 5: No renunciation across all launches
|
|
if total >= 3 and not any(line.is_renounced for line in launches):
|
|
patterns.append("No authority renunciation in any launch")
|
|
|
|
# Pattern 6: Same launch timing (multiple launches in same week)
|
|
timestamps = [line.launch_timestamp for line in launches if line.launch_timestamp > 0]
|
|
if len(timestamps) >= 3:
|
|
timestamps.sort()
|
|
close_launches = sum(
|
|
1 for i in range(1, len(timestamps)) if (timestamps[i] - timestamps[i - 1]) < 7 * 86400000
|
|
)
|
|
if close_launches >= 2:
|
|
patterns.append(f"Rapid-fire launches ({close_launches + 1} in same week)")
|
|
|
|
return patterns
|
|
|
|
def _calculate_score(
|
|
self,
|
|
total: int,
|
|
rugged: int,
|
|
rug_rate: float,
|
|
avg_lifespan: float,
|
|
retention: float,
|
|
patterns: list[str],
|
|
) -> tuple:
|
|
"""Calculate reputation score (0-100, higher = safer)."""
|
|
|
|
# Base score starts at 70 (neutral for new deployers)
|
|
score = 70
|
|
|
|
# Adjust for rug rate
|
|
if rug_rate >= 0.8 and total >= 3:
|
|
score = max(0, 20 - total * 2) # Serial rugger: 0-20
|
|
elif rug_rate >= 0.5:
|
|
score = 30 # High risk
|
|
elif avg_lifespan < 7 and total >= 2:
|
|
score = 35 # Short-lived
|
|
else:
|
|
score = 70 + int((1 - rug_rate) * 30) # 70-100
|
|
|
|
# Adjust for retention (dev holding tokens = less likely to rug)
|
|
if retention >= 0.5:
|
|
score = min(100, score + 10)
|
|
elif retention < 0.1:
|
|
score = max(0, score - 15)
|
|
|
|
# Adjust for pattern count
|
|
score = max(0, score - len(patterns) * 5)
|
|
|
|
score = max(0, min(100, score))
|
|
|
|
if score >= 70:
|
|
level = ReputationLevel.TRUSTED
|
|
confidence = "HIGH"
|
|
elif score >= 40:
|
|
level = ReputationLevel.NEUTRAL
|
|
confidence = "MEDIUM"
|
|
elif score >= 20:
|
|
level = ReputationLevel.SUSPICIOUS
|
|
confidence = "HIGH"
|
|
else:
|
|
level = ReputationLevel.DANGEROUS
|
|
confidence = "VERY_HIGH"
|
|
|
|
return score, level, confidence
|
|
|
|
async def analyze(self, dev_wallet: str, chains: list[str] | None = None) -> DevReputationReport:
|
|
"""Full dev reputation analysis.
|
|
|
|
Steps:
|
|
1. Fetch all tokens launched by dev_wallet (DexScreener search)
|
|
2. For each, determine outcome (active/rugged/abandoned)
|
|
3. Check dev holding percentage at rug time
|
|
4. Detect serial patterns
|
|
5. Cross-chain address lookup (Moralis)
|
|
"""
|
|
if chains is None:
|
|
chains = list(CHAINS.keys())[:4]
|
|
|
|
# Fetch token launches from DexScreener
|
|
launches = []
|
|
for chain in chains:
|
|
chain_launches = await self._fetch_launches(dev_wallet, chain)
|
|
launches.extend(chain_launches)
|
|
|
|
# Build and return reputation report
|
|
report = self.calculate_reputation(launches, dev_wallet)
|
|
|
|
# Cross-chain address lookup via Moralis
|
|
report.cross_chain_addresses = await self._cross_chain_lookup(dev_wallet)
|
|
|
|
return report
|
|
|
|
async def _fetch_launches(self, dev_wallet: str, chain: str) -> list[TokenLaunch]:
|
|
"""Fetch all tokens launched by dev on a specific chain.
|
|
|
|
Uses DexScreener to find pairs where the deployer matches.
|
|
DexScreener's /latest/dex/search?q=<address> returns pairs
|
|
whose baseToken or quoteToken involves the address. We filter
|
|
for pairs where the creator/deployer matches.
|
|
"""
|
|
launches = []
|
|
|
|
# ── Strategy 1: DexScreener search by deployer address ──
|
|
search_data = await self._dexscreener_search(dev_wallet)
|
|
if search_data and search_data.get("pairs"):
|
|
chain_id = DEXSCREENER_CHAIN_MAP.get(chain, chain)
|
|
for pair in search_data.get("pairs", [])[:25]:
|
|
# Filter by chain
|
|
if pair.get("chainId") != chain_id and chain != "all":
|
|
continue
|
|
|
|
# Extract deployer/creator info
|
|
pair_creator = pair.get("info", {}).get("deployer", "")
|
|
base_addr = pair.get("baseToken", {}).get("address", "")
|
|
base_name = pair.get("baseToken", {}).get("name", "")
|
|
base_symbol = pair.get("baseToken", {}).get("symbol", "")
|
|
created_at = pair.get("pairCreatedAt", 0)
|
|
|
|
# Accept pairs where the deployer matches, or the address
|
|
# appears as base/quote token (indirect match)
|
|
is_creator = pair_creator.lower() == dev_wallet.lower() if pair_creator else False
|
|
is_match = dev_wallet.lower() in (
|
|
base_addr.lower(),
|
|
pair.get("quoteToken", {}).get("address", "").lower(),
|
|
)
|
|
|
|
if not is_creator and not is_match:
|
|
continue
|
|
|
|
# Determine outcome from liquidity and volume
|
|
liquidity = float(pair.get("liquidity", {}).get("usd", 0) or 0)
|
|
volume_24h = float(pair.get("volume", {}).get("h24", 0) or 0)
|
|
mcap = float(pair.get("marketCap", 0) or pair.get("mc", 0) or 0)
|
|
|
|
# Heuristic outcome determination
|
|
now_ms = int(datetime.now(UTC).timestamp() * 1000)
|
|
age_days = ((now_ms - created_at) / 86400000) if created_at else 0
|
|
|
|
if liquidity < 100 and age_days > 7:
|
|
outcome = "rugged"
|
|
elif volume_24h == 0 and age_days > 30:
|
|
outcome = "abandoned"
|
|
elif mcap > 0 or liquidity > 0:
|
|
outcome = "active"
|
|
else:
|
|
outcome = "unknown"
|
|
|
|
launches.append(
|
|
TokenLaunch(
|
|
address=base_addr,
|
|
chain=chain,
|
|
name=base_name,
|
|
symbol=base_symbol,
|
|
launch_timestamp=int(created_at / 1000) if created_at else 0,
|
|
max_market_cap=mcap,
|
|
lifespan_days=round(age_days, 1),
|
|
outcome=outcome,
|
|
dev_holds_percentage=0, # Requires deeper analysis
|
|
is_renounced=False, # Requires contract verification
|
|
has_locked_liquidity=liquidity > 0,
|
|
)
|
|
)
|
|
|
|
# ── Strategy 2 (EVM only): Etherscan contract creation ──
|
|
if chain in ETHERSCAN_NETWORKS:
|
|
etherscan_launches = await self._fetch_launches_etherscan(dev_wallet, chain)
|
|
# Merge, avoiding duplicates by address
|
|
existing_addrs = {line.address.lower() for line in launches}
|
|
for launch in etherscan_launches:
|
|
if launch.address.lower() not in existing_addrs:
|
|
launches.append(launch)
|
|
existing_addrs.add(launch.address.lower())
|
|
|
|
return launches
|
|
|
|
async def _fetch_launches_etherscan(self, dev_wallet: str, chain: str) -> list[TokenLaunch]:
|
|
"""Fetch contract creations by dev_wallet from Etherscan-family APIs.
|
|
|
|
Uses the 'txlist' action to find contract creation transactions.
|
|
"""
|
|
launches = []
|
|
|
|
tx_list = await self._etherscan_get(
|
|
chain,
|
|
{
|
|
"module": "account",
|
|
"action": "txlist",
|
|
"address": dev_wallet,
|
|
"startblock": 0,
|
|
"endblock": 99999999,
|
|
"page": 1,
|
|
"offset": 50,
|
|
"sort": "asc",
|
|
},
|
|
)
|
|
|
|
if tx_list and isinstance(tx_list, list):
|
|
for tx in tx_list[:25]:
|
|
# Contract creation: to is empty or starts with "0x0" and input is non-empty
|
|
to_addr = tx.get("to", "")
|
|
tx_input = tx.get("input", "")
|
|
is_creation = to_addr == "" or tx.get("contractAddress", "") != ""
|
|
|
|
if is_creation and tx_input and len(tx_input) > 10:
|
|
contract_addr = tx.get("contractAddress", "")
|
|
if not contract_addr:
|
|
continue
|
|
|
|
timestamp = int(tx.get("timeStamp", 0))
|
|
launches.append(
|
|
TokenLaunch(
|
|
address=contract_addr,
|
|
chain=chain,
|
|
name="", # Would need separate contract ABI call
|
|
symbol="",
|
|
launch_timestamp=timestamp,
|
|
max_market_cap=0,
|
|
lifespan_days=0,
|
|
outcome="unknown",
|
|
dev_holds_percentage=0,
|
|
)
|
|
)
|
|
|
|
return launches
|
|
|
|
async def _cross_chain_lookup(self, dev_wallet: str) -> list[dict[str, str]]:
|
|
"""Find the same dev address on other chains via Moralis wallet data.
|
|
|
|
Checks if the wallet has activity (tokens, NFTs, transactions)
|
|
on other EVM chains.
|
|
"""
|
|
if not MORALIS_API_KEY:
|
|
return []
|
|
|
|
cross_chain = []
|
|
for chain_key in ["eth", "bsc", "polygon", "arbitrum", "base", "avalanche"]:
|
|
try:
|
|
url = f"{MORALIS_DATA_URL}/{dev_wallet}/erc20?chain={chain_key}&limit=5"
|
|
result = await self._moralis_get(url)
|
|
if result and isinstance(result, list) and len(result) > 0:
|
|
chain_name = MORALIS_CHAIN_MAP.get(chain_key, chain_key)
|
|
# Only include if different from the chains already known
|
|
cross_chain.append(
|
|
{
|
|
"chain": chain_name,
|
|
"address": dev_wallet,
|
|
"token_count": str(len(result)),
|
|
"has_activity": "true",
|
|
}
|
|
)
|
|
except Exception:
|
|
continue
|
|
|
|
return cross_chain
|