merge: chore/cleanup-remove-bloat-and-secrets into main
This commit is contained in:
commit
bde2f3a97d
1173 changed files with 437609 additions and 0 deletions
51
app/domain/token/__init__.py
Normal file
51
app/domain/token/__init__.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""Token domain — public API + health check."""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core import health as health_mod
|
||||
from app.core.health import DomainHealth
|
||||
|
||||
|
||||
async def _health_check() -> DomainHealth:
|
||||
"""Token health: DataBus is importable."""
|
||||
try:
|
||||
import app.databus # noqa: F401
|
||||
return DomainHealth(
|
||||
name="token",
|
||||
healthy=True,
|
||||
details={"databus": "importable"},
|
||||
)
|
||||
except Exception as e:
|
||||
return DomainHealth(name="token", healthy=False, error=str(e))
|
||||
|
||||
|
||||
health_mod.register_health_check("token", _health_check)
|
||||
|
||||
|
||||
# Public API
|
||||
from app.domain.token.analyzer import TokenAnalyzer
|
||||
from app.domain.token.models import (
|
||||
RiskLevel,
|
||||
Token,
|
||||
TokenDetail,
|
||||
TokenHolder,
|
||||
TokenLiquidity,
|
||||
TokenRisk,
|
||||
TokenScanRequest,
|
||||
TokenScanResult,
|
||||
)
|
||||
from app.domain.token.repository import TokenRepository
|
||||
from app.domain.token.service import TokenService
|
||||
|
||||
__all__ = [
|
||||
"RiskLevel",
|
||||
"Token",
|
||||
"TokenAnalyzer",
|
||||
"TokenDetail",
|
||||
"TokenHolder",
|
||||
"TokenLiquidity",
|
||||
"TokenRepository",
|
||||
"TokenRisk",
|
||||
"TokenScanRequest",
|
||||
"TokenScanResult",
|
||||
"TokenService",
|
||||
]
|
||||
103
app/domain/token/analyzer.py
Normal file
103
app/domain/token/analyzer.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""Pure-Python token risk analysis. No I/O."""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.domain.token.models import (
|
||||
RiskLevel,
|
||||
TokenHolder,
|
||||
TokenLiquidity,
|
||||
TokenRisk,
|
||||
)
|
||||
|
||||
|
||||
class TokenAnalyzer:
|
||||
"""Pure logic — given holders + liquidity + flags, return risk."""
|
||||
|
||||
@staticmethod
|
||||
def compute_risk(
|
||||
address: str,
|
||||
chain: str,
|
||||
holders: list[TokenHolder] | None = None,
|
||||
liquidity: TokenLiquidity | None = None,
|
||||
honeypot: bool = False,
|
||||
can_sell: bool = True,
|
||||
can_buy: bool = True,
|
||||
owner_change_balance: bool = False,
|
||||
transfer_pausable: bool = False,
|
||||
) -> TokenRisk:
|
||||
"""Compute TokenRisk from raw inputs. Pure function of inputs."""
|
||||
flags: list[dict] = []
|
||||
score = 0
|
||||
|
||||
# Honeypot = critical
|
||||
if honeypot or not can_sell:
|
||||
score += 100
|
||||
flags.append({"code": "honeypot", "severity": "critical", "message": "Cannot sell — likely honeypot."})
|
||||
|
||||
# Owner can change balance = rug pull risk
|
||||
if owner_change_balance:
|
||||
score += 50
|
||||
flags.append({"code": "owner_change_balance", "severity": "critical", "message": "Owner can modify balances."})
|
||||
|
||||
# Transfer pausable = rug risk
|
||||
if transfer_pausable:
|
||||
score += 30
|
||||
flags.append({"code": "transfer_pausable", "severity": "high", "message": "Transfers can be paused."})
|
||||
|
||||
# Holder concentration
|
||||
if holders:
|
||||
top_holder_pct = max((h.percentage for h in holders), default=0)
|
||||
if top_holder_pct > 50:
|
||||
score += 30
|
||||
flags.append({
|
||||
"code": "concentrated_holders",
|
||||
"severity": "high",
|
||||
"message": f"Top holder owns {top_holder_pct:.1f}% of supply.",
|
||||
})
|
||||
elif top_holder_pct > 25:
|
||||
score += 15
|
||||
flags.append({
|
||||
"code": "moderate_concentration",
|
||||
"severity": "medium",
|
||||
"message": f"Top holder owns {top_holder_pct:.1f}% of supply.",
|
||||
})
|
||||
|
||||
# Locked holders reduce risk
|
||||
locked_pct = sum(h.percentage for h in holders if h.is_locked)
|
||||
if locked_pct < 20 and len(holders) < 100:
|
||||
score += 5
|
||||
flags.append({
|
||||
"code": "few_holders",
|
||||
"severity": "low",
|
||||
"message": f"Only {len(holders)} holders, {locked_pct:.1f}% locked.",
|
||||
})
|
||||
|
||||
# Liquidity lock
|
||||
if liquidity:
|
||||
if liquidity.total_liquidity_usd < 1000:
|
||||
score += 20
|
||||
flags.append({
|
||||
"code": "low_liquidity",
|
||||
"severity": "high",
|
||||
"message": f"Total liquidity only ${liquidity.total_liquidity_usd:.0f}.",
|
||||
})
|
||||
elif liquidity.locked_percentage < 50:
|
||||
score += 15
|
||||
flags.append({
|
||||
"code": "liquidity_unlocked",
|
||||
"severity": "medium",
|
||||
"message": f"Only {liquidity.locked_percentage:.1f}% of liquidity locked.",
|
||||
})
|
||||
|
||||
score = max(0, min(100, score))
|
||||
return TokenRisk(
|
||||
address=address,
|
||||
chain=chain,
|
||||
risk_score=score,
|
||||
risk_level=RiskLevel.from_score(score),
|
||||
flags=flags,
|
||||
honeypot=honeypot,
|
||||
can_sell=can_sell,
|
||||
can_buy=can_buy,
|
||||
owner_change_balance=owner_change_balance,
|
||||
transfer_pausable=transfer_pausable,
|
||||
)
|
||||
127
app/domain/token/models.py
Normal file
127
app/domain/token/models.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"""Pydantic v2 models for the token domain.
|
||||
|
||||
No FastAPI imports. Pure data shapes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class RiskLevel(StrEnum):
|
||||
"""Token risk classification."""
|
||||
|
||||
LOW = "low"
|
||||
MEDIUM = "medium"
|
||||
HIGH = "high"
|
||||
CRITICAL = "critical"
|
||||
|
||||
@classmethod
|
||||
def from_score(cls, score: int) -> RiskLevel:
|
||||
if score < 20:
|
||||
return cls.LOW
|
||||
if score < 50:
|
||||
return cls.MEDIUM
|
||||
if score < 80:
|
||||
return cls.HIGH
|
||||
return cls.CRITICAL
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
"""Token identity."""
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
address: str = Field(..., min_length=1, max_length=256)
|
||||
chain: str = Field(default="solana")
|
||||
name: str = ""
|
||||
symbol: str = ""
|
||||
decimals: int = 9
|
||||
|
||||
|
||||
class TokenDetail(BaseModel):
|
||||
"""Full token details — metadata + supply + verification."""
|
||||
|
||||
address: str
|
||||
chain: str
|
||||
name: str = ""
|
||||
symbol: str = ""
|
||||
decimals: int = 9
|
||||
total_supply: float = 0.0
|
||||
circulating_supply: float = 0.0
|
||||
holders: int = 0
|
||||
verified: bool = False
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
fetched_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class TokenHolder(BaseModel):
|
||||
"""A token holder entry."""
|
||||
|
||||
address: str
|
||||
balance: float = 0.0
|
||||
percentage: float = 0.0
|
||||
label: str | None = None
|
||||
is_contract: bool = False
|
||||
is_locked: bool = False
|
||||
|
||||
|
||||
class TokenLiquidity(BaseModel):
|
||||
"""Token liquidity summary."""
|
||||
|
||||
address: str
|
||||
chain: str
|
||||
total_liquidity_usd: float = 0.0
|
||||
pools: list[dict[str, Any]] = Field(default_factory=list)
|
||||
locked_percentage: float = 0.0
|
||||
fetched_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class TokenRisk(BaseModel):
|
||||
"""Token risk assessment."""
|
||||
|
||||
address: str
|
||||
chain: str
|
||||
risk_score: int = Field(default=0, ge=0, le=100)
|
||||
risk_level: RiskLevel
|
||||
flags: list[dict[str, Any]] = Field(default_factory=list)
|
||||
honeypot: bool = False
|
||||
can_sell: bool = True
|
||||
can_buy: bool = True
|
||||
take_back: bool = False
|
||||
owner_change_balance: bool = False
|
||||
transfer_pausable: bool = False
|
||||
analyzed_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class TokenScanRequest(BaseModel):
|
||||
"""Request body for the token scan endpoint."""
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
address: str = Field(..., min_length=1, max_length=256)
|
||||
chain: str = Field(default="solana")
|
||||
tier: str = Field(default="free")
|
||||
|
||||
@field_validator("chain", "tier")
|
||||
@classmethod
|
||||
def _lowercase(cls, v: str) -> str:
|
||||
return v.lower().strip()
|
||||
|
||||
|
||||
class TokenScanResult(BaseModel):
|
||||
"""Full token scan result."""
|
||||
|
||||
address: str
|
||||
chain: str
|
||||
safety_score: float = 100.0
|
||||
risk_score: int = 0
|
||||
risk_level: RiskLevel
|
||||
risk_flags: list[str] = Field(default_factory=list)
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
modules_run: list[str] = Field(default_factory=list)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
scanned_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
94
app/domain/token/repository.py
Normal file
94
app/domain/token/repository.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""Repository — async data fetch for token info via Databus."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.domain.token.models import (
|
||||
TokenDetail,
|
||||
TokenHolder,
|
||||
TokenLiquidity,
|
||||
)
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
class TokenRepository:
|
||||
"""Async data access for token info via Databus."""
|
||||
|
||||
async def get_detail(self, address: str, chain: str) -> TokenDetail:
|
||||
"""Fetch token metadata + supply."""
|
||||
try:
|
||||
from app.databus.client import get_databus_client
|
||||
|
||||
client = get_databus_client()
|
||||
raw = await client.get_token_info(address, chain=chain)
|
||||
return self._parse_detail(address, chain, raw or {})
|
||||
except Exception as e:
|
||||
log.warning("token_detail_fetch_failed", address=address[:12], error=str(e))
|
||||
return TokenDetail(address=address, chain=chain)
|
||||
|
||||
async def get_holders(
|
||||
self,
|
||||
address: str,
|
||||
chain: str,
|
||||
limit: int = 100,
|
||||
) -> list[TokenHolder]:
|
||||
"""Fetch top holders."""
|
||||
try:
|
||||
from app.databus.client import get_databus_client
|
||||
|
||||
client = get_databus_client()
|
||||
raw = await client.get_token_holders(address, chain=chain, limit=limit)
|
||||
return [self._parse_holder(h) for h in (raw or [])]
|
||||
except Exception as e:
|
||||
log.warning("token_holders_fetch_failed", address=address[:12], error=str(e))
|
||||
return []
|
||||
|
||||
async def get_liquidity(self, address: str, chain: str) -> TokenLiquidity:
|
||||
"""Fetch liquidity + pool info."""
|
||||
try:
|
||||
from app.databus.client import get_databus_client
|
||||
|
||||
client = get_databus_client()
|
||||
raw = await client.get_token_liquidity(address, chain=chain)
|
||||
return self._parse_liquidity(address, chain, raw or {})
|
||||
except Exception as e:
|
||||
log.warning("token_liquidity_fetch_failed", address=address[:12], error=str(e))
|
||||
return TokenLiquidity(address=address, chain=chain)
|
||||
|
||||
@staticmethod
|
||||
def _parse_detail(address: str, chain: str, raw: dict[str, Any]) -> TokenDetail:
|
||||
return TokenDetail(
|
||||
address=address,
|
||||
chain=chain,
|
||||
name=raw.get("name", ""),
|
||||
symbol=raw.get("symbol", ""),
|
||||
decimals=int(raw.get("decimals", 9) or 9),
|
||||
total_supply=float(raw.get("total_supply", 0) or 0),
|
||||
circulating_supply=float(raw.get("circulating_supply", 0) or 0),
|
||||
holders=int(raw.get("holders", 0) or 0),
|
||||
verified=bool(raw.get("verified", False)),
|
||||
metadata=raw.get("metadata", {}) or {},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_holder(raw: dict[str, Any]) -> TokenHolder:
|
||||
return TokenHolder(
|
||||
address=raw.get("address", ""),
|
||||
balance=float(raw.get("balance", 0) or 0),
|
||||
percentage=float(raw.get("percentage", raw.get("pct", 0)) or 0),
|
||||
label=raw.get("label"),
|
||||
is_contract=bool(raw.get("is_contract", False)),
|
||||
is_locked=bool(raw.get("is_locked", False)),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_liquidity(address: str, chain: str, raw: dict[str, Any]) -> TokenLiquidity:
|
||||
return TokenLiquidity(
|
||||
address=address,
|
||||
chain=chain,
|
||||
total_liquidity_usd=float(raw.get("total_usd", 0) or 0),
|
||||
pools=raw.get("pools", []) or [],
|
||||
locked_percentage=float(raw.get("locked_percentage", 0) or 0),
|
||||
)
|
||||
106
app/domain/token/service.py
Normal file
106
app/domain/token/service.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
"""Service — business logic for token info, risk, and scans."""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.domain.token.analyzer import TokenAnalyzer
|
||||
from app.domain.token.models import (
|
||||
TokenDetail,
|
||||
TokenHolder,
|
||||
TokenLiquidity,
|
||||
TokenRisk,
|
||||
TokenScanRequest,
|
||||
TokenScanResult,
|
||||
)
|
||||
from app.domain.token.repository import TokenRepository
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
class TokenService:
|
||||
"""Orchestrates token data fetch and analysis."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repo: TokenRepository | None = None,
|
||||
analyzer: TokenAnalyzer | None = None,
|
||||
) -> None:
|
||||
self._repo = repo or TokenRepository()
|
||||
self._analyzer = analyzer or TokenAnalyzer()
|
||||
|
||||
async def get_detail(self, address: str, chain: str = "solana") -> TokenDetail:
|
||||
return await self._repo.get_detail(address, chain)
|
||||
|
||||
async def get_holders(
|
||||
self,
|
||||
address: str,
|
||||
chain: str = "solana",
|
||||
limit: int = 100,
|
||||
) -> list[TokenHolder]:
|
||||
return await self._repo.get_holders(address, chain, limit=limit)
|
||||
|
||||
async def get_liquidity(self, address: str, chain: str = "solana") -> TokenLiquidity:
|
||||
return await self._repo.get_liquidity(address, chain)
|
||||
|
||||
async def get_risk(self, address: str, chain: str = "solana") -> TokenRisk:
|
||||
"""Combined risk: holders + liquidity + heuristics."""
|
||||
log.info("token_risk_started", address=address[:12], chain=chain)
|
||||
holders = await self._repo.get_holders(address, chain, limit=20)
|
||||
liquidity = await self._repo.get_liquidity(address, chain)
|
||||
risk = self._analyzer.compute_risk(
|
||||
address=address,
|
||||
chain=chain,
|
||||
holders=holders,
|
||||
liquidity=liquidity,
|
||||
)
|
||||
log.info(
|
||||
"token_risk_complete",
|
||||
address=address[:12],
|
||||
risk_score=risk.risk_score,
|
||||
risk_level=risk.risk_level.value,
|
||||
)
|
||||
return risk
|
||||
|
||||
async def scan(self, req: TokenScanRequest) -> TokenScanResult:
|
||||
"""Full token scan. Multi-module. Used for free-tier scanner."""
|
||||
log.info("token_scan_started", address=req.address[:12], chain=req.chain, tier=req.tier)
|
||||
detail = await self._repo.get_detail(req.address, req.chain)
|
||||
risk = await self.get_risk(req.address, req.chain)
|
||||
|
||||
# Convert risk flags to the legacy format (list of strings)
|
||||
risk_flags: list[str] = []
|
||||
warnings: list[str] = []
|
||||
for flag in risk.flags:
|
||||
code = flag.get("code", "")
|
||||
message = flag.get("message", "")
|
||||
if code:
|
||||
risk_flags.append(code)
|
||||
if message:
|
||||
warnings.append(message)
|
||||
|
||||
# Heuristic safety score (inverse of risk, 0-100)
|
||||
safety_score = max(0.0, 100.0 - float(risk.risk_score))
|
||||
|
||||
result = TokenScanResult(
|
||||
address=req.address,
|
||||
chain=req.chain,
|
||||
safety_score=safety_score,
|
||||
risk_score=risk.risk_score,
|
||||
risk_level=risk.risk_level,
|
||||
risk_flags=risk_flags,
|
||||
warnings=warnings,
|
||||
modules_run=["analyzer", "holders", "liquidity"],
|
||||
metadata={
|
||||
"name": detail.name,
|
||||
"symbol": detail.symbol,
|
||||
"decimals": detail.decimals,
|
||||
"total_supply": detail.total_supply,
|
||||
"verified": detail.verified,
|
||||
},
|
||||
)
|
||||
log.info(
|
||||
"token_scan_complete",
|
||||
address=req.address[:12],
|
||||
safety_score=safety_score,
|
||||
risk_level=risk.risk_level.value,
|
||||
)
|
||||
return result
|
||||
Loading…
Add table
Add a link
Reference in a new issue