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)
618 lines
24 KiB
Python
618 lines
24 KiB
Python
from typing import ClassVar
|
|
|
|
"""
|
|
SENTINEL - Liquidity Lock Verification
|
|
========================================
|
|
Verifies LP token locks against known locker platforms, detects fake locks,
|
|
split-lock scams, and monitors lock expiry dates.
|
|
|
|
Supported platforms:
|
|
- UNCX Network (ETH, BSC, Polygon, Arbitrum)
|
|
- Team Finance (ETH, BSC, Polygon, Avalanche)
|
|
- PinkLock (multi-chain)
|
|
- Mudra (BSC)
|
|
- Solana LP Burn verification
|
|
|
|
Data sources (direct API calls - no self.api_base):
|
|
EVM chains: Etherscan-family APIs (token holders, contract verification)
|
|
Moralis (token holders, wallet balances)
|
|
Solana: Helius DAS API (token account info, owner verification)
|
|
Helius Enhanced Transactions (LP burn events)
|
|
"""
|
|
|
|
import logging # noqa: E402
|
|
import os # noqa: E402
|
|
from dataclasses import dataclass, field # noqa: E402
|
|
from datetime import UTC, datetime # noqa: E402
|
|
from enum import Enum # noqa: E402
|
|
|
|
import httpx # noqa: E402
|
|
|
|
from app.chain_registry import get_known_lockers, is_solana # noqa: E402
|
|
|
|
logger = logging.getLogger("liquidity_verifier")
|
|
|
|
# ── 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", "")
|
|
HELIUS_API_KEY = os.getenv("HELIUS_API_KEY", "")
|
|
|
|
# Etherscan-family network configs
|
|
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_DATA_URL = "https://deep-index.moralis.io/api/v2.2"
|
|
HELIUS_RPC_URL = "https://mainnet.helius-rpc.com"
|
|
HELIUS_API_URL = "https://api.helius.xyz"
|
|
|
|
|
|
class LockStatus(Enum):
|
|
LOCKED = "locked"
|
|
BURNED = "burned"
|
|
UNLOCKED = "unlocked"
|
|
PARTIAL = "partial" # Split lock (e.g., 80% locked, 20% unlocked)
|
|
EXPIRED = "expired"
|
|
UNKNOWN = "unknown"
|
|
|
|
|
|
@dataclass
|
|
class LockDetail:
|
|
locker_address: str
|
|
platform: str # "UNCX", "TeamFinance", "PinkLock", "Mudra", "SolanaBurn"
|
|
lock_id: str = ""
|
|
amount_locked: float = 0.0
|
|
total_supply: float = 0.0
|
|
percentage_locked: float = 0.0
|
|
lock_expiry: int | None = None # unix timestamp
|
|
is_legit: bool = True # False if locker is a fake/unverified contract
|
|
is_burned: bool = False
|
|
unlock_in_days: int | None = None
|
|
|
|
|
|
@dataclass
|
|
class LockReport:
|
|
token_address: str
|
|
chain: str
|
|
status: LockStatus
|
|
total_locked_percentage: float # % of LP locked or burned
|
|
lock_details: list[LockDetail]
|
|
is_legitimate_locker: bool # True if all lockers are verified platforms
|
|
has_split_lock: bool # True if partial lock detected
|
|
upcoming_unlocks: list[LockDetail] # Locks expiring within 7 days
|
|
fake_lock_detected: bool # True if a fake/unverified locker is found
|
|
lp_burned: bool # True if LP tokens are burned (Solana)
|
|
risk_score: int = 0 # 0-100
|
|
risk_level: str = "LOW" # LOW, MEDIUM, HIGH, CRITICAL
|
|
warnings: list[str] = field(default_factory=list)
|
|
|
|
|
|
# Dead/burn addresses for Solana
|
|
SOLANA_BURN_ADDRESSES = {
|
|
"1nc1nerator11111111111111111111111111111111",
|
|
"11111111111111111111111111111111",
|
|
}
|
|
|
|
|
|
class LiquidityVerifier:
|
|
"""Verifies liquidity locks, detects fake locks, monitors expiry.
|
|
|
|
Uses direct API calls:
|
|
- Etherscan-family for EVM LP holder queries
|
|
- Moralis for EVM token holder data
|
|
- Helius for Solana LP burn verification
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.client = httpx.AsyncClient(timeout=15.0)
|
|
|
|
# ── Etherscan helpers ────────────────────────────────────
|
|
|
|
def _etherscan_net(self, chain: str) -> dict:
|
|
"""Get Etherscan-family config for a chain."""
|
|
net_key = chain.lower()
|
|
if net_key == "solana":
|
|
return {} # Not applicable
|
|
return ETHERSCAN_NETWORKS.get(net_key, ETHERSCAN_NETWORKS["ethereum"])
|
|
|
|
async def _etherscan_get(self, chain: str, params: dict) -> dict | None:
|
|
"""Make a rate-limited Etherscan-family API call."""
|
|
net = self._etherscan_net(chain)
|
|
if not net:
|
|
return None
|
|
api_key = net["key"]
|
|
if not api_key:
|
|
logger.warning(f"No Etherscan API key configured 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
|
|
elif resp.status_code == 429:
|
|
logger.warning(f"Etherscan rate limited for {chain}")
|
|
return None
|
|
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."""
|
|
mapping = {
|
|
"ethereum": "eth",
|
|
"eth": "eth",
|
|
"bsc": "bsc",
|
|
"polygon": "polygon",
|
|
"avalanche": "avalanche",
|
|
"fantom": "fantom",
|
|
"arbitrum": "arbitrum",
|
|
"optimism": "optimism",
|
|
"base": "base",
|
|
}
|
|
return mapping.get(chain.lower(), "eth")
|
|
|
|
async def _moralis_get(self, url: str) -> dict | None:
|
|
"""Make a Moralis Data API call with key rotation."""
|
|
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))
|
|
elif resp.status_code == 429:
|
|
logger.warning("Moralis rate limited")
|
|
except Exception as e:
|
|
logger.warning(f"Moralis call failed: {e}")
|
|
return None
|
|
|
|
# ── Helius helpers ────────────────────────────────────────
|
|
|
|
async def _helius_rpc(self, method: str, params: list | None = None) -> dict | None:
|
|
"""Make a Helius JSON-RPC call."""
|
|
if not HELIUS_API_KEY:
|
|
logger.warning("HELIUS_API_KEY not set")
|
|
return None
|
|
try:
|
|
resp = await self.client.post(
|
|
f"{HELIUS_RPC_URL}/?api-key={HELIUS_API_KEY}",
|
|
json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params or []},
|
|
)
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
return data.get("result")
|
|
except Exception as e:
|
|
logger.warning(f"Helius RPC call failed: {e}")
|
|
return None
|
|
|
|
async def _helius_das_get_asset(self, mint_address: str) -> dict | None:
|
|
"""Get DAS asset info for a Solana token."""
|
|
if not HELIUS_API_KEY:
|
|
return None
|
|
try:
|
|
resp = await self.client.post(
|
|
f"{HELIUS_API_URL}/v0/token-metadata?api-key={HELIUS_API_KEY}",
|
|
json={"mintAccounts": [mint_address]},
|
|
)
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
if isinstance(data, list) and len(data) > 0:
|
|
return data[0]
|
|
return data
|
|
except Exception as e:
|
|
logger.warning(f"Helius DAS asset lookup failed for {mint_address}: {e}")
|
|
return None
|
|
|
|
async def _helius_get_token_accounts(self, owner: str) -> list | None:
|
|
"""Get all token accounts for a Solana wallet via Helius."""
|
|
result = await self._helius_rpc(
|
|
"getTokenAccountsByOwner",
|
|
[
|
|
owner,
|
|
{"programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"},
|
|
{"encoding": "jsonParsed"},
|
|
],
|
|
)
|
|
if result and isinstance(result, dict):
|
|
return result.get("value", [])
|
|
return None
|
|
|
|
# ── Core verification logic ──────────────────────────────
|
|
|
|
def is_known_locker(self, address: str, chain: str) -> str | None:
|
|
"""Check if address is a known legitimate locker contract."""
|
|
chain_lockers = get_known_lockers(chain)
|
|
return chain_lockers.get(address.lower() if not is_solana(chain) else address)
|
|
|
|
# Known stablecoin / wrapped token symbols - LP lock check is irrelevant
|
|
STABLECOIN_SYMBOLS: ClassVar[dict] ={
|
|
"USDC",
|
|
"USDT",
|
|
"DAI",
|
|
"BUSD",
|
|
"TUSD",
|
|
"USDP",
|
|
"GUSD",
|
|
"HUSD",
|
|
"USDD",
|
|
"FRAX",
|
|
"LUSD",
|
|
"SUSD",
|
|
"USDS",
|
|
"USDJ",
|
|
"USDX",
|
|
"EURS",
|
|
"WETH",
|
|
"WBTC",
|
|
"WBNB",
|
|
"WMATIC",
|
|
"WAVAX",
|
|
"WFTM",
|
|
"WGLMR",
|
|
"SOL",
|
|
"WSOL",
|
|
"BSOL",
|
|
"MSOL",
|
|
}
|
|
|
|
def _is_stablecoin(self, symbol: str) -> bool:
|
|
"""Check if token is a known stablecoin or wrapped asset."""
|
|
if not symbol:
|
|
return False
|
|
sym = symbol.upper().strip()
|
|
return sym in self.STABLECOIN_SYMBOLS or sym.startswith("W") or sym.startswith("ST")
|
|
|
|
def is_dead_address(self, address: str, chain: str) -> bool:
|
|
"""Check if address is a known burn/dead address."""
|
|
if is_solana(chain):
|
|
return address in SOLANA_BURN_ADDRESSES
|
|
return address.lower() in {
|
|
"0x000000000000000000000000000000000000dEaD".lower(),
|
|
"0x0000000000000000000000000000000000000000".lower(),
|
|
}
|
|
|
|
async def verify_lock(self, token_address: str, chain: str, lp_token: str = "", symbol: str = "") -> LockReport:
|
|
"""Full liquidity verification for a token.
|
|
|
|
Steps:
|
|
1. Get LP token address (if not provided)
|
|
2. Get top LP holders
|
|
3. Check each holder against known locker contracts
|
|
4. Verify locker authenticity (not a fake deployed by dev)
|
|
5. Check lock expiry dates
|
|
6. Detect split-lock scams
|
|
7. For Solana: check LP burn
|
|
|
|
NOTE: Stablecoins and wrapped tokens are skipped - LP lock is irrelevant.
|
|
"""
|
|
# Skip stablecoins/wrapped tokens - they don't need LP locks
|
|
if self._is_stablecoin(symbol):
|
|
return LockReport(
|
|
token_address=token_address,
|
|
chain=chain,
|
|
status=LockStatus.LOCKED,
|
|
total_locked_percentage=100.0,
|
|
lock_details=[
|
|
LockDetail(
|
|
locker_address="stablecoin",
|
|
platform="N/A",
|
|
percentage_locked=100.0,
|
|
is_legit=True,
|
|
)
|
|
],
|
|
is_legitimate_locker=True,
|
|
has_split_lock=False,
|
|
upcoming_unlocks=[],
|
|
fake_lock_detected=False,
|
|
lp_burned=False,
|
|
risk_score=0,
|
|
risk_level="LOW",
|
|
warnings=["Stablecoin/wrapped token - LP lock check not applicable"],
|
|
)
|
|
|
|
lock_details = []
|
|
upcoming_unlocks = []
|
|
total_locked_pct = 0.0
|
|
has_split_lock = False
|
|
fake_lock = False
|
|
all_legit = True
|
|
|
|
# For Solana, check LP burn (simplest and safest mechanism)
|
|
if is_solana(chain):
|
|
lp_burned = await self._check_solana_lp_burn(token_address)
|
|
if lp_burned:
|
|
return LockReport(
|
|
token_address=token_address,
|
|
chain=chain,
|
|
status=LockStatus.BURNED,
|
|
total_locked_percentage=100.0,
|
|
lock_details=[
|
|
LockDetail(
|
|
locker_address="burn",
|
|
platform="Solana Burn",
|
|
percentage_locked=100.0,
|
|
is_burned=True,
|
|
)
|
|
],
|
|
is_legitimate_locker=True,
|
|
has_split_lock=False,
|
|
upcoming_unlocks=[],
|
|
fake_lock_detected=False,
|
|
lp_burned=True,
|
|
risk_score=0,
|
|
risk_level="LOW",
|
|
warnings=["LP tokens burned - safest mechanism"],
|
|
)
|
|
|
|
# For EVM chains, query LP holders and check lockers
|
|
lp_holders = await self._get_lp_holders(token_address, chain, lp_token)
|
|
|
|
for holder in lp_holders:
|
|
locker = self.is_known_locker(holder.get("address", ""), chain)
|
|
|
|
if locker:
|
|
lock_info = LockDetail(
|
|
locker_address=holder.get("address", ""),
|
|
platform=locker,
|
|
percentage_locked=holder.get("percentage", 0),
|
|
is_legit=True,
|
|
)
|
|
lock_details.append(lock_info)
|
|
total_locked_pct += holder.get("percentage", 0)
|
|
elif self.is_dead_address(holder.get("address", ""), chain):
|
|
# LP burned
|
|
lock_details.append(
|
|
LockDetail(
|
|
locker_address=holder.get("address", ""),
|
|
platform="Burn",
|
|
percentage_locked=holder.get("percentage", 0),
|
|
is_burned=True,
|
|
)
|
|
)
|
|
total_locked_pct += holder.get("percentage", 0)
|
|
elif "lock" in holder.get("label", "").lower() and not self.is_known_locker(
|
|
holder.get("address", ""), chain
|
|
):
|
|
# Unknown/unverified locker - potential fake
|
|
lock_details.append(
|
|
LockDetail(
|
|
locker_address=holder.get("address", ""),
|
|
platform="Unknown (UNVERIFIED)",
|
|
percentage_locked=holder.get("percentage", 0),
|
|
is_legit=False,
|
|
)
|
|
)
|
|
total_locked_pct += holder.get("percentage", 0)
|
|
fake_lock = True
|
|
all_legit = False
|
|
|
|
# Check for approaching unlocks
|
|
now = datetime.now(UTC)
|
|
for detail in lock_details:
|
|
if detail.lock_expiry:
|
|
days_until = (datetime.fromtimestamp(detail.lock_expiry, tz=UTC) - now).days
|
|
detail.unlock_in_days = days_until
|
|
if 0 < days_until < 7:
|
|
upcoming_unlocks.append(detail)
|
|
|
|
# Detect split locks (e.g., 80% locked, 20% unlocked)
|
|
if lock_details:
|
|
max_lock = max(d.percentage_locked for d in lock_details)
|
|
other_locks = [d for d in lock_details if d.percentage_locked < max_lock and not d.is_burned]
|
|
if other_locks and max_lock < 90:
|
|
has_split_lock = True
|
|
|
|
# Determine overall status
|
|
if total_locked_pct >= 95:
|
|
status = LockStatus.BURNED if any(d.is_burned for d in lock_details) else LockStatus.LOCKED
|
|
elif total_locked_pct >= 70:
|
|
status = LockStatus.LOCKED
|
|
elif total_locked_pct >= 30:
|
|
status = LockStatus.PARTIAL
|
|
else:
|
|
status = LockStatus.UNLOCKED
|
|
|
|
# Calculate risk
|
|
risk_score, risk_level, warnings = self._calculate_risk(
|
|
total_locked_pct, all_legit, fake_lock, has_split_lock, upcoming_unlocks
|
|
)
|
|
|
|
return LockReport(
|
|
token_address=token_address,
|
|
chain=chain,
|
|
status=status,
|
|
total_locked_percentage=round(total_locked_pct, 2),
|
|
lock_details=lock_details,
|
|
is_legitimate_locker=all_legit,
|
|
has_split_lock=has_split_lock,
|
|
upcoming_unlocks=upcoming_unlocks,
|
|
fake_lock_detected=fake_lock,
|
|
lp_burned=False,
|
|
risk_score=risk_score,
|
|
risk_level=risk_level,
|
|
warnings=warnings,
|
|
)
|
|
|
|
async def _check_solana_lp_burn(self, token_address: str) -> bool:
|
|
"""Check if Solana LP tokens are burned using Helius.
|
|
|
|
Strategy:
|
|
1. Look up the token's DexScreener pair to find the LP mint address.
|
|
2. Query Helius DAS API / RPC for the LP token account balance
|
|
at the burn address (1nc1nerator11111111111111111111111111111111).
|
|
3. If burn address holds the LP tokens, they're burned.
|
|
"""
|
|
# Try DexScreener to find the LP pair info
|
|
try:
|
|
resp = await self.client.get(f"https://api.dexscreener.com/latest/dex/tokens/{token_address}")
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
pairs = data.get("pairs") or []
|
|
if pairs:
|
|
pair = pairs[0]
|
|
lp_address = pair.get("pairAddress", "")
|
|
if not lp_address:
|
|
return False
|
|
|
|
# Check Helius: get token accounts at burn addresses
|
|
for burn_addr in SOLANA_BURN_ADDRESSES:
|
|
accounts = await self._helius_get_token_accounts(burn_addr)
|
|
if accounts:
|
|
for acct in accounts:
|
|
info = acct.get("account", {}).get("data", {}).get("parsed", {}).get("info", {})
|
|
mint = info.get("mint", "")
|
|
if mint == lp_address:
|
|
return True
|
|
except Exception as e:
|
|
logger.debug(f"Solana LP burn check failed: {e}")
|
|
|
|
# Fallback: check token metadata for LP burn indication
|
|
try:
|
|
asset = await self._helius_das_get_asset(token_address)
|
|
if asset:
|
|
metadata = asset.get("metadata", {}) or asset.get("onChainMetadata", {}) or {}
|
|
# Some tokens embed "LP Burned" in metadata
|
|
description = (metadata.get("description", "") or "").lower()
|
|
if "lp burned" in description or "liquidity burned" in description:
|
|
# Weak signal - corroborate with burn-address check
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
return False
|
|
|
|
async def _get_lp_holders(self, token_address: str, chain: str, lp_token: str = "") -> list:
|
|
"""Get top LP holders for a token using Etherscan / Moralis.
|
|
|
|
Strategy:
|
|
1. If lp_token is provided, query its holders directly.
|
|
2. Otherwise, use Moralis token holders API.
|
|
3. Fallback to Etherscan tokentx to identify LP contract.
|
|
"""
|
|
holders = []
|
|
|
|
# ── EVM chains: Moralis / Etherscan ──
|
|
target_addr = lp_token or token_address
|
|
moralis_chain = self._moralis_chain(chain)
|
|
|
|
# Try Moralis first (has holder percentage data)
|
|
try:
|
|
url = f"{MORALIS_DATA_URL}/erc20/{target_addr}/owners?chain={moralis_chain}&limit=25"
|
|
result = await self._moralis_get(url)
|
|
if result and isinstance(result, list):
|
|
for entry in result:
|
|
owner = entry.get("owner_address", "") or entry.get("address", "")
|
|
balance = float(entry.get("balance", "0") or "0")
|
|
pct = float(entry.get("percentage_relative_to_total", 0) or 0)
|
|
holders.append(
|
|
{
|
|
"address": owner,
|
|
"balance": balance,
|
|
"percentage": pct,
|
|
"label": "",
|
|
}
|
|
)
|
|
if holders:
|
|
return holders
|
|
except Exception as e:
|
|
logger.debug(f"Moralis holders query failed: {e}")
|
|
|
|
# Fallback: Etherscan token holders
|
|
net_key = chain.lower()
|
|
if net_key in ETHERSCAN_NETWORKS:
|
|
result = await self._etherscan_get(
|
|
net_key,
|
|
{
|
|
"module": "account",
|
|
"action": "tokentx",
|
|
"address": target_addr,
|
|
"page": 1,
|
|
"offset": 50,
|
|
"sort": "desc",
|
|
},
|
|
)
|
|
if result and isinstance(result, list):
|
|
# Aggregate unique holders from recent transfers
|
|
holder_totals: dict[str, float] = {}
|
|
for tx in result:
|
|
addr = tx.get("to", "").lower()
|
|
value = float(tx.get("value", "0") or "0")
|
|
holder_totals[addr] = holder_totals.get(addr, 0) + value
|
|
if holder_totals:
|
|
total = sum(holder_totals.values()) or 1
|
|
sorted_holders = sorted(holder_totals.items(), key=lambda x: x[1], reverse=True)[:25]
|
|
for addr, bal in sorted_holders:
|
|
holders.append(
|
|
{
|
|
"address": addr,
|
|
"balance": bal,
|
|
"percentage": round(bal / total * 100, 2),
|
|
"label": "",
|
|
}
|
|
)
|
|
return holders
|
|
|
|
return holders
|
|
|
|
def _calculate_risk(
|
|
self, locked_pct: float, all_legit: bool, fake_lock: bool, has_split: bool, upcoming: list
|
|
) -> tuple:
|
|
"""Calculate risk score and warnings for liquidity lock status."""
|
|
score = 0
|
|
warnings = []
|
|
|
|
if locked_pct >= 95:
|
|
score += 0 # Fully locked - safest
|
|
if all_legit:
|
|
warnings.append("SAFE: LP fully locked on verified platform")
|
|
else:
|
|
score += 20
|
|
warnings.append("WARNING: LP fully locked but on UNVERIFIED platform")
|
|
elif locked_pct >= 70:
|
|
score += 15
|
|
warnings.append(f"MODERATE: LP {locked_pct:.1f}% locked - significant lockup")
|
|
elif locked_pct >= 30:
|
|
score += 35
|
|
warnings.append(f"HIGH: LP only {locked_pct:.1f}% locked - moderate rug risk")
|
|
else:
|
|
score += 55
|
|
warnings.append(f"CRITICAL: LP only {locked_pct:.1f}% locked - HIGH rug pull risk")
|
|
|
|
if fake_lock:
|
|
score += 40
|
|
warnings.append("CRITICAL: Unverified/fake locker contract detected")
|
|
|
|
if has_split:
|
|
score += 15
|
|
warnings.append("SPLIT LOCK: Partial lock detected (e.g., 80% locked, 20% unlocked)")
|
|
|
|
if upcoming:
|
|
score += 10
|
|
for u in upcoming:
|
|
warnings.append(f"UNLOCK SOON: Lock expires in {u.unlock_in_days} days")
|
|
|
|
score = min(100, score)
|
|
level = "CRITICAL" if score >= 70 else "HIGH" if score >= 40 else "MEDIUM" if score >= 20 else "LOW"
|
|
|
|
return score, level, warnings
|