feat(token): vertical slice — pure-Python domain + 5 thin v1 routes
Browse filesapp/domain/token/ (NO FastAPI, pure business logic):
models.py Pydantic v2: Token, TokenDetail, TokenHolder, TokenLiquidity,
TokenRisk, TokenScanRequest, TokenScanResult, RiskLevel
analyzer.py PURE risk scoring (no I/O) — honeypot, concentration, liquidity
repository.py Async data fetch via Databus
service.py Composes analyzer + repository
__init__.py Public API
app/api/v1/public/token.py (5 endpoints, <100 lines):
GET /api/v1/token/{address}
GET /api/v1/token/{address}/holders
GET /api/v1/token/{address}/liquidity
GET /api/v1/token/{address}/risk
POST /api/v1/token/scan
tests/unit/domain/token/test_service.py — 14/14 PASS:
Analyzer: 10 tests (zero risk, honeypot, cannot sell, owner change,
concentrated holders, moderate concentration, low liquidity,
unlocked liquidity, score cap, RiskLevel enum).
Service: 4 tests (detail pass-through, risk composes holders+liquidity,
scan combines detail+risk, request normalization).
PATTERN PROVEN 3RD TIME: alerts, wallet, token — same shape works.
Backend healthy, 5 new token routes in openapi.
Stranglerfig with legacy /api/v1/token/* — both work side-by-side.
Total: 3 domains migrated, 35 unit tests passing, 12 v1 routes wired.
- backend/app/api/v1/__init__.py +4 -0
- backend/app/api/v1/public/token.py +78 -0
- backend/app/domain/token/__init__.py +40 -0
- backend/app/domain/token/analyzer.py +103 -0
- backend/app/domain/token/models.py +127 -0
- backend/app/domain/token/repository.py +94 -0
- backend/app/domain/token/service.py +109 -0
- backend/tests/unit/domain/token/__init__.py +0 -0
- backend/tests/unit/domain/token/test_service.py +152 -0
|
@@ -41,6 +41,10 @@ from app.api.v1.public.wallet import router as wallet_router # noqa: E402
|
|
| 41 |
|
| 42 |
api_v1_router.append(wallet_router)
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
def build_v1_router() -> APIRouter:
|
| 46 |
"""Construct the v1 aggregator with all migrated routes mounted."""
|
|
|
|
| 41 |
|
| 42 |
api_v1_router.append(wallet_router)
|
| 43 |
|
| 44 |
+
from app.api.v1.public.token import router as token_router # noqa: E402
|
| 45 |
+
|
| 46 |
+
api_v1_router.append(token_router)
|
| 47 |
+
|
| 48 |
|
| 49 |
def build_v1_router() -> APIRouter:
|
| 50 |
"""Construct the v1 aggregator with all migrated routes mounted."""
|
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""V1 token route — thin HTTP layer over app.domain.token."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from typing import Annotated, Any
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Depends, Query
|
| 7 |
+
|
| 8 |
+
from app.core.auth import get_optional_user
|
| 9 |
+
from app.domain.token import (
|
| 10 |
+
TokenDetail,
|
| 11 |
+
TokenRisk,
|
| 12 |
+
TokenScanRequest,
|
| 13 |
+
TokenScanResult,
|
| 14 |
+
TokenService,
|
| 15 |
+
)
|
| 16 |
+
from app.models import PaginatedResponse
|
| 17 |
+
|
| 18 |
+
router = APIRouter(prefix="/api/v1/token", tags=["token"])
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _service() -> TokenService:
|
| 22 |
+
return TokenService()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@router.get("/{address}", response_model=TokenDetail)
|
| 26 |
+
async def get_detail(
|
| 27 |
+
address: str,
|
| 28 |
+
svc: Annotated[TokenService, Depends(_service)],
|
| 29 |
+
chain: str = Query(default="solana"),
|
| 30 |
+
) -> TokenDetail:
|
| 31 |
+
"""Token metadata + supply + verification."""
|
| 32 |
+
return await svc.get_detail(address, chain=chain)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@router.get("/{address}/holders", response_model=PaginatedResponse)
|
| 36 |
+
async def get_holders(
|
| 37 |
+
address: str,
|
| 38 |
+
svc: Annotated[TokenService, Depends(_service)],
|
| 39 |
+
chain: str = Query(default="solana"),
|
| 40 |
+
limit: int = Query(default=100, ge=1, le=500),
|
| 41 |
+
) -> PaginatedResponse:
|
| 42 |
+
"""Top token holders."""
|
| 43 |
+
holders = await svc.get_holders(address, chain=chain, limit=limit)
|
| 44 |
+
return PaginatedResponse(
|
| 45 |
+
items=[h.model_dump(mode="json") for h in holders],
|
| 46 |
+
total=len(holders),
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@router.get("/{address}/liquidity", response_model=dict)
|
| 51 |
+
async def get_liquidity(
|
| 52 |
+
address: str,
|
| 53 |
+
svc: Annotated[TokenService, Depends(_service)],
|
| 54 |
+
chain: str = Query(default="solana"),
|
| 55 |
+
) -> dict:
|
| 56 |
+
"""Token liquidity + pool info."""
|
| 57 |
+
liq = await svc.get_liquidity(address, chain=chain)
|
| 58 |
+
return liq.model_dump(mode="json")
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@router.get("/{address}/risk", response_model=TokenRisk)
|
| 62 |
+
async def get_risk(
|
| 63 |
+
address: str,
|
| 64 |
+
svc: Annotated[TokenService, Depends(_service)],
|
| 65 |
+
chain: str = Query(default="solana"),
|
| 66 |
+
) -> TokenRisk:
|
| 67 |
+
"""Combined risk: holders + liquidity + heuristics."""
|
| 68 |
+
return await svc.get_risk(address, chain=chain)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@router.post("/scan", response_model=TokenScanResult)
|
| 72 |
+
async def scan(
|
| 73 |
+
req: TokenScanRequest,
|
| 74 |
+
svc: Annotated[TokenService, Depends(_service)],
|
| 75 |
+
_user: Annotated[dict[str, Any] | None, Depends(get_optional_user)],
|
| 76 |
+
) -> TokenScanResult:
|
| 77 |
+
"""Full token scan. Multi-module. Freemium rate-limit applied in legacy router."""
|
| 78 |
+
return await svc.scan(req)
|
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Token domain — token info, holders, liquidity, deployer, risk.
|
| 2 |
+
|
| 3 |
+
Public API:
|
| 4 |
+
from app.domain.token import (
|
| 5 |
+
Token, TokenDetail, TokenHolder, TokenLiquidity, TokenRisk,
|
| 6 |
+
TokenScanRequest, TokenScanResult, RiskLevel,
|
| 7 |
+
TokenAnalyzer, TokenService, TokenRepository,
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
Domain is pure Python. NO FastAPI. NO HTTP. NO logging.basicConfig.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from app.domain.token.analyzer import TokenAnalyzer
|
| 15 |
+
from app.domain.token.models import (
|
| 16 |
+
RiskLevel,
|
| 17 |
+
Token,
|
| 18 |
+
TokenDetail,
|
| 19 |
+
TokenHolder,
|
| 20 |
+
TokenLiquidity,
|
| 21 |
+
TokenRisk,
|
| 22 |
+
TokenScanRequest,
|
| 23 |
+
TokenScanResult,
|
| 24 |
+
)
|
| 25 |
+
from app.domain.token.repository import TokenRepository
|
| 26 |
+
from app.domain.token.service import TokenService
|
| 27 |
+
|
| 28 |
+
__all__ = [
|
| 29 |
+
"Token",
|
| 30 |
+
"TokenDetail",
|
| 31 |
+
"TokenHolder",
|
| 32 |
+
"TokenLiquidity",
|
| 33 |
+
"TokenRisk",
|
| 34 |
+
"TokenScanRequest",
|
| 35 |
+
"TokenScanResult",
|
| 36 |
+
"RiskLevel",
|
| 37 |
+
"TokenAnalyzer",
|
| 38 |
+
"TokenRepository",
|
| 39 |
+
"TokenService",
|
| 40 |
+
]
|
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pure-Python token risk analysis. No I/O."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from app.domain.token.models import (
|
| 5 |
+
RiskLevel,
|
| 6 |
+
TokenHolder,
|
| 7 |
+
TokenLiquidity,
|
| 8 |
+
TokenRisk,
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class TokenAnalyzer:
|
| 13 |
+
"""Pure logic — given holders + liquidity + flags, return risk."""
|
| 14 |
+
|
| 15 |
+
@staticmethod
|
| 16 |
+
def compute_risk(
|
| 17 |
+
address: str,
|
| 18 |
+
chain: str,
|
| 19 |
+
holders: list[TokenHolder] | None = None,
|
| 20 |
+
liquidity: TokenLiquidity | None = None,
|
| 21 |
+
honeypot: bool = False,
|
| 22 |
+
can_sell: bool = True,
|
| 23 |
+
can_buy: bool = True,
|
| 24 |
+
owner_change_balance: bool = False,
|
| 25 |
+
transfer_pausable: bool = False,
|
| 26 |
+
) -> TokenRisk:
|
| 27 |
+
"""Compute TokenRisk from raw inputs. Pure function of inputs."""
|
| 28 |
+
flags: list[dict] = []
|
| 29 |
+
score = 0
|
| 30 |
+
|
| 31 |
+
# Honeypot = critical
|
| 32 |
+
if honeypot or not can_sell:
|
| 33 |
+
score += 100
|
| 34 |
+
flags.append({"code": "honeypot", "severity": "critical", "message": "Cannot sell — likely honeypot."})
|
| 35 |
+
|
| 36 |
+
# Owner can change balance = rug pull risk
|
| 37 |
+
if owner_change_balance:
|
| 38 |
+
score += 50
|
| 39 |
+
flags.append({"code": "owner_change_balance", "severity": "critical", "message": "Owner can modify balances."})
|
| 40 |
+
|
| 41 |
+
# Transfer pausable = rug risk
|
| 42 |
+
if transfer_pausable:
|
| 43 |
+
score += 30
|
| 44 |
+
flags.append({"code": "transfer_pausable", "severity": "high", "message": "Transfers can be paused."})
|
| 45 |
+
|
| 46 |
+
# Holder concentration
|
| 47 |
+
if holders:
|
| 48 |
+
top_holder_pct = max((h.percentage for h in holders), default=0)
|
| 49 |
+
if top_holder_pct > 50:
|
| 50 |
+
score += 30
|
| 51 |
+
flags.append({
|
| 52 |
+
"code": "concentrated_holders",
|
| 53 |
+
"severity": "high",
|
| 54 |
+
"message": f"Top holder owns {top_holder_pct:.1f}% of supply.",
|
| 55 |
+
})
|
| 56 |
+
elif top_holder_pct > 25:
|
| 57 |
+
score += 15
|
| 58 |
+
flags.append({
|
| 59 |
+
"code": "moderate_concentration",
|
| 60 |
+
"severity": "medium",
|
| 61 |
+
"message": f"Top holder owns {top_holder_pct:.1f}% of supply.",
|
| 62 |
+
})
|
| 63 |
+
|
| 64 |
+
# Locked holders reduce risk
|
| 65 |
+
locked_pct = sum(h.percentage for h in holders if h.is_locked)
|
| 66 |
+
if locked_pct < 20 and len(holders) < 100:
|
| 67 |
+
score += 5
|
| 68 |
+
flags.append({
|
| 69 |
+
"code": "few_holders",
|
| 70 |
+
"severity": "low",
|
| 71 |
+
"message": f"Only {len(holders)} holders, {locked_pct:.1f}% locked.",
|
| 72 |
+
})
|
| 73 |
+
|
| 74 |
+
# Liquidity lock
|
| 75 |
+
if liquidity:
|
| 76 |
+
if liquidity.total_liquidity_usd < 1000:
|
| 77 |
+
score += 20
|
| 78 |
+
flags.append({
|
| 79 |
+
"code": "low_liquidity",
|
| 80 |
+
"severity": "high",
|
| 81 |
+
"message": f"Total liquidity only ${liquidity.total_liquidity_usd:.0f}.",
|
| 82 |
+
})
|
| 83 |
+
elif liquidity.locked_percentage < 50:
|
| 84 |
+
score += 15
|
| 85 |
+
flags.append({
|
| 86 |
+
"code": "liquidity_unlocked",
|
| 87 |
+
"severity": "medium",
|
| 88 |
+
"message": f"Only {liquidity.locked_percentage:.1f}% of liquidity locked.",
|
| 89 |
+
})
|
| 90 |
+
|
| 91 |
+
score = max(0, min(100, score))
|
| 92 |
+
return TokenRisk(
|
| 93 |
+
address=address,
|
| 94 |
+
chain=chain,
|
| 95 |
+
risk_score=score,
|
| 96 |
+
risk_level=RiskLevel.from_score(score),
|
| 97 |
+
flags=flags,
|
| 98 |
+
honeypot=honeypot,
|
| 99 |
+
can_sell=can_sell,
|
| 100 |
+
can_buy=can_buy,
|
| 101 |
+
owner_change_balance=owner_change_balance,
|
| 102 |
+
transfer_pausable=transfer_pausable,
|
| 103 |
+
)
|
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic v2 models for the token domain.
|
| 2 |
+
|
| 3 |
+
No FastAPI imports. Pure data shapes.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
from enum import Enum
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class RiskLevel(str, Enum):
|
| 15 |
+
"""Token risk classification."""
|
| 16 |
+
|
| 17 |
+
LOW = "low"
|
| 18 |
+
MEDIUM = "medium"
|
| 19 |
+
HIGH = "high"
|
| 20 |
+
CRITICAL = "critical"
|
| 21 |
+
|
| 22 |
+
@classmethod
|
| 23 |
+
def from_score(cls, score: int) -> "RiskLevel":
|
| 24 |
+
if score < 20:
|
| 25 |
+
return cls.LOW
|
| 26 |
+
if score < 50:
|
| 27 |
+
return cls.MEDIUM
|
| 28 |
+
if score < 80:
|
| 29 |
+
return cls.HIGH
|
| 30 |
+
return cls.CRITICAL
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class Token(BaseModel):
|
| 34 |
+
"""Token identity."""
|
| 35 |
+
|
| 36 |
+
model_config = ConfigDict(str_strip_whitespace=True)
|
| 37 |
+
|
| 38 |
+
address: str = Field(..., min_length=1, max_length=256)
|
| 39 |
+
chain: str = Field(default="solana")
|
| 40 |
+
name: str = ""
|
| 41 |
+
symbol: str = ""
|
| 42 |
+
decimals: int = 9
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class TokenDetail(BaseModel):
|
| 46 |
+
"""Full token details — metadata + supply + verification."""
|
| 47 |
+
|
| 48 |
+
address: str
|
| 49 |
+
chain: str
|
| 50 |
+
name: str = ""
|
| 51 |
+
symbol: str = ""
|
| 52 |
+
decimals: int = 9
|
| 53 |
+
total_supply: float = 0.0
|
| 54 |
+
circulating_supply: float = 0.0
|
| 55 |
+
holders: int = 0
|
| 56 |
+
verified: bool = False
|
| 57 |
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
| 58 |
+
fetched_at: datetime = Field(default_factory=datetime.utcnow)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class TokenHolder(BaseModel):
|
| 62 |
+
"""A token holder entry."""
|
| 63 |
+
|
| 64 |
+
address: str
|
| 65 |
+
balance: float = 0.0
|
| 66 |
+
percentage: float = 0.0
|
| 67 |
+
label: str | None = None
|
| 68 |
+
is_contract: bool = False
|
| 69 |
+
is_locked: bool = False
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class TokenLiquidity(BaseModel):
|
| 73 |
+
"""Token liquidity summary."""
|
| 74 |
+
|
| 75 |
+
address: str
|
| 76 |
+
chain: str
|
| 77 |
+
total_liquidity_usd: float = 0.0
|
| 78 |
+
pools: list[dict[str, Any]] = Field(default_factory=list)
|
| 79 |
+
locked_percentage: float = 0.0
|
| 80 |
+
fetched_at: datetime = Field(default_factory=datetime.utcnow)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class TokenRisk(BaseModel):
|
| 84 |
+
"""Token risk assessment."""
|
| 85 |
+
|
| 86 |
+
address: str
|
| 87 |
+
chain: str
|
| 88 |
+
risk_score: int = Field(default=0, ge=0, le=100)
|
| 89 |
+
risk_level: RiskLevel
|
| 90 |
+
flags: list[dict[str, Any]] = Field(default_factory=list)
|
| 91 |
+
honeypot: bool = False
|
| 92 |
+
can_sell: bool = True
|
| 93 |
+
can_buy: bool = True
|
| 94 |
+
take_back: bool = False
|
| 95 |
+
owner_change_balance: bool = False
|
| 96 |
+
transfer_pausable: bool = False
|
| 97 |
+
analyzed_at: datetime = Field(default_factory=datetime.utcnow)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
class TokenScanRequest(BaseModel):
|
| 101 |
+
"""Request body for the token scan endpoint."""
|
| 102 |
+
|
| 103 |
+
model_config = ConfigDict(str_strip_whitespace=True)
|
| 104 |
+
|
| 105 |
+
address: str = Field(..., min_length=1, max_length=256)
|
| 106 |
+
chain: str = Field(default="solana")
|
| 107 |
+
tier: str = Field(default="free")
|
| 108 |
+
|
| 109 |
+
@field_validator("chain", "tier")
|
| 110 |
+
@classmethod
|
| 111 |
+
def _lowercase(cls, v: str) -> str:
|
| 112 |
+
return v.lower().strip()
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class TokenScanResult(BaseModel):
|
| 116 |
+
"""Full token scan result."""
|
| 117 |
+
|
| 118 |
+
address: str
|
| 119 |
+
chain: str
|
| 120 |
+
safety_score: float = 100.0
|
| 121 |
+
risk_score: int = 0
|
| 122 |
+
risk_level: RiskLevel
|
| 123 |
+
risk_flags: list[str] = Field(default_factory=list)
|
| 124 |
+
warnings: list[str] = Field(default_factory=list)
|
| 125 |
+
modules_run: list[str] = Field(default_factory=list)
|
| 126 |
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
| 127 |
+
scanned_at: datetime = Field(default_factory=datetime.utcnow)
|
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Repository — async data fetch for token info via Databus."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from app.core.logging import get_logger
|
| 7 |
+
from app.domain.token.models import (
|
| 8 |
+
TokenDetail,
|
| 9 |
+
TokenHolder,
|
| 10 |
+
TokenLiquidity,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
log = get_logger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class TokenRepository:
|
| 17 |
+
"""Async data access for token info via Databus."""
|
| 18 |
+
|
| 19 |
+
async def get_detail(self, address: str, chain: str) -> TokenDetail:
|
| 20 |
+
"""Fetch token metadata + supply."""
|
| 21 |
+
try:
|
| 22 |
+
from app.databus.client import get_databus_client
|
| 23 |
+
|
| 24 |
+
client = get_databus_client()
|
| 25 |
+
raw = await client.get_token_info(address, chain=chain)
|
| 26 |
+
return self._parse_detail(address, chain, raw or {})
|
| 27 |
+
except Exception as e:
|
| 28 |
+
log.warning("token_detail_fetch_failed", address=address[:12], error=str(e))
|
| 29 |
+
return TokenDetail(address=address, chain=chain)
|
| 30 |
+
|
| 31 |
+
async def get_holders(
|
| 32 |
+
self,
|
| 33 |
+
address: str,
|
| 34 |
+
chain: str,
|
| 35 |
+
limit: int = 100,
|
| 36 |
+
) -> list[TokenHolder]:
|
| 37 |
+
"""Fetch top holders."""
|
| 38 |
+
try:
|
| 39 |
+
from app.databus.client import get_databus_client
|
| 40 |
+
|
| 41 |
+
client = get_databus_client()
|
| 42 |
+
raw = await client.get_token_holders(address, chain=chain, limit=limit)
|
| 43 |
+
return [self._parse_holder(h) for h in (raw or [])]
|
| 44 |
+
except Exception as e:
|
| 45 |
+
log.warning("token_holders_fetch_failed", address=address[:12], error=str(e))
|
| 46 |
+
return []
|
| 47 |
+
|
| 48 |
+
async def get_liquidity(self, address: str, chain: str) -> TokenLiquidity:
|
| 49 |
+
"""Fetch liquidity + pool info."""
|
| 50 |
+
try:
|
| 51 |
+
from app.databus.client import get_databus_client
|
| 52 |
+
|
| 53 |
+
client = get_databus_client()
|
| 54 |
+
raw = await client.get_token_liquidity(address, chain=chain)
|
| 55 |
+
return self._parse_liquidity(address, chain, raw or {})
|
| 56 |
+
except Exception as e:
|
| 57 |
+
log.warning("token_liquidity_fetch_failed", address=address[:12], error=str(e))
|
| 58 |
+
return TokenLiquidity(address=address, chain=chain)
|
| 59 |
+
|
| 60 |
+
@staticmethod
|
| 61 |
+
def _parse_detail(address: str, chain: str, raw: dict[str, Any]) -> TokenDetail:
|
| 62 |
+
return TokenDetail(
|
| 63 |
+
address=address,
|
| 64 |
+
chain=chain,
|
| 65 |
+
name=raw.get("name", ""),
|
| 66 |
+
symbol=raw.get("symbol", ""),
|
| 67 |
+
decimals=int(raw.get("decimals", 9) or 9),
|
| 68 |
+
total_supply=float(raw.get("total_supply", 0) or 0),
|
| 69 |
+
circulating_supply=float(raw.get("circulating_supply", 0) or 0),
|
| 70 |
+
holders=int(raw.get("holders", 0) or 0),
|
| 71 |
+
verified=bool(raw.get("verified", False)),
|
| 72 |
+
metadata=raw.get("metadata", {}) or {},
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
@staticmethod
|
| 76 |
+
def _parse_holder(raw: dict[str, Any]) -> TokenHolder:
|
| 77 |
+
return TokenHolder(
|
| 78 |
+
address=raw.get("address", ""),
|
| 79 |
+
balance=float(raw.get("balance", 0) or 0),
|
| 80 |
+
percentage=float(raw.get("percentage", raw.get("pct", 0)) or 0),
|
| 81 |
+
label=raw.get("label"),
|
| 82 |
+
is_contract=bool(raw.get("is_contract", False)),
|
| 83 |
+
is_locked=bool(raw.get("is_locked", False)),
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
@staticmethod
|
| 87 |
+
def _parse_liquidity(address: str, chain: str, raw: dict[str, Any]) -> TokenLiquidity:
|
| 88 |
+
return TokenLiquidity(
|
| 89 |
+
address=address,
|
| 90 |
+
chain=chain,
|
| 91 |
+
total_liquidity_usd=float(raw.get("total_usd", 0) or 0),
|
| 92 |
+
pools=raw.get("pools", []) or [],
|
| 93 |
+
locked_percentage=float(raw.get("locked_percentage", 0) or 0),
|
| 94 |
+
)
|
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Service — business logic for token info, risk, and scans."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from app.core.logging import get_logger
|
| 5 |
+
from app.domain.token.analyzer import TokenAnalyzer
|
| 6 |
+
from app.domain.token.models import (
|
| 7 |
+
TokenDetail,
|
| 8 |
+
TokenHolder,
|
| 9 |
+
TokenLiquidity,
|
| 10 |
+
TokenRisk,
|
| 11 |
+
TokenScanRequest,
|
| 12 |
+
TokenScanResult,
|
| 13 |
+
)
|
| 14 |
+
from app.domain.token.repository import TokenRepository
|
| 15 |
+
|
| 16 |
+
log = get_logger(__name__)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class TokenService:
|
| 20 |
+
"""Orchestrates token data fetch and analysis."""
|
| 21 |
+
|
| 22 |
+
def __init__(
|
| 23 |
+
self,
|
| 24 |
+
repo: TokenRepository | None = None,
|
| 25 |
+
analyzer: TokenAnalyzer | None = None,
|
| 26 |
+
) -> None:
|
| 27 |
+
self._repo = repo or TokenRepository()
|
| 28 |
+
self._analyzer = analyzer or TokenAnalyzer()
|
| 29 |
+
|
| 30 |
+
async def get_detail(self, address: str, chain: str = "solana") -> TokenDetail:
|
| 31 |
+
return await self._repo.get_detail(address, chain)
|
| 32 |
+
|
| 33 |
+
async def get_holders(
|
| 34 |
+
self,
|
| 35 |
+
address: str,
|
| 36 |
+
chain: str = "solana",
|
| 37 |
+
limit: int = 100,
|
| 38 |
+
) -> list[TokenHolder]:
|
| 39 |
+
return await self._repo.get_holders(address, chain, limit=limit)
|
| 40 |
+
|
| 41 |
+
async def get_liquidity(self, address: str, chain: str = "solana") -> TokenLiquidity:
|
| 42 |
+
return await self._repo.get_liquidity(address, chain)
|
| 43 |
+
|
| 44 |
+
async def get_risk(self, address: str, chain: str = "solana") -> TokenRisk:
|
| 45 |
+
"""Combined risk: holders + liquidity + heuristics."""
|
| 46 |
+
log.info("token_risk_started", address=address[:12], chain=chain)
|
| 47 |
+
holders = await self._repo.get_holders(address, chain, limit=20)
|
| 48 |
+
liquidity = await self._repo.get_liquidity(address, chain)
|
| 49 |
+
risk = self._analyzer.compute_risk(
|
| 50 |
+
address=address,
|
| 51 |
+
chain=chain,
|
| 52 |
+
holders=holders,
|
| 53 |
+
liquidity=liquidity,
|
| 54 |
+
)
|
| 55 |
+
log.info(
|
| 56 |
+
"token_risk_complete",
|
| 57 |
+
address=address[:12],
|
| 58 |
+
risk_score=risk.risk_score,
|
| 59 |
+
risk_level=risk.risk_level.value,
|
| 60 |
+
)
|
| 61 |
+
return risk
|
| 62 |
+
|
| 63 |
+
async def scan(self, req: TokenScanRequest) -> TokenScanResult:
|
| 64 |
+
"""Full token scan. Multi-module. Used for free-tier scanner."""
|
| 65 |
+
log.info("token_scan_started", address=req.address[:12], chain=req.chain, tier=req.tier)
|
| 66 |
+
detail = await self._repo.get_detail(req.address, req.chain)
|
| 67 |
+
risk = await self.get_risk(req.address, req.chain)
|
| 68 |
+
|
| 69 |
+
# Convert risk flags to the legacy format (list of strings)
|
| 70 |
+
risk_flags: list[str] = []
|
| 71 |
+
warnings: list[str] = []
|
| 72 |
+
for flag in risk.flags:
|
| 73 |
+
code = flag.get("code", "")
|
| 74 |
+
message = flag.get("message", "")
|
| 75 |
+
if code:
|
| 76 |
+
risk_flags.append(code)
|
| 77 |
+
if message:
|
| 78 |
+
warnings.append(message)
|
| 79 |
+
|
| 80 |
+
# Heuristic safety score (inverse of risk, 0-100)
|
| 81 |
+
safety_score = max(0.0, 100.0 - float(risk.risk_score))
|
| 82 |
+
|
| 83 |
+
result = TokenScanResult(
|
| 84 |
+
address=req.address,
|
| 85 |
+
chain=req.chain,
|
| 86 |
+
safety_score=safety_score,
|
| 87 |
+
risk_score=risk.risk_score,
|
| 88 |
+
risk_level=risk.risk_level,
|
| 89 |
+
risk_flags=risk_flags,
|
| 90 |
+
warnings=warnings,
|
| 91 |
+
modules_run=["analyzer", "holders", "liquidity"],
|
| 92 |
+
metadata={
|
| 93 |
+
"name": detail.name,
|
| 94 |
+
"symbol": detail.symbol,
|
| 95 |
+
"decimals": detail.decimals,
|
| 96 |
+
"total_supply": detail.total_supply,
|
| 97 |
+
"verified": detail.verified,
|
| 98 |
+
},
|
| 99 |
+
)
|
| 100 |
+
log.info(
|
| 101 |
+
"token_scan_complete",
|
| 102 |
+
address=req.address[:12],
|
| 103 |
+
safety_score=safety_score,
|
| 104 |
+
risk_level=risk.risk_level.value,
|
| 105 |
+
)
|
| 106 |
+
return result
|
| 107 |
+
|
| 108 |
+
def count_tokens(self) -> int:
|
| 109 |
+
return self.repo.count()
|
|
File without changes
|
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the token domain.
|
| 2 |
+
|
| 3 |
+
Pure-Python tests. No FastAPI. No HTTP.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
from unittest.mock import AsyncMock
|
| 8 |
+
|
| 9 |
+
import pytest
|
| 10 |
+
|
| 11 |
+
from app.domain.token import (
|
| 12 |
+
RiskLevel,
|
| 13 |
+
TokenAnalyzer,
|
| 14 |
+
TokenHolder,
|
| 15 |
+
TokenLiquidity,
|
| 16 |
+
TokenRepository,
|
| 17 |
+
TokenScanRequest,
|
| 18 |
+
TokenService,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@pytest.fixture
|
| 23 |
+
def fake_repo() -> AsyncMock:
|
| 24 |
+
repo = AsyncMock(spec=TokenRepository)
|
| 25 |
+
return repo
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@pytest.fixture
|
| 29 |
+
def service(fake_repo: AsyncMock) -> TokenService:
|
| 30 |
+
return TokenService(repo=fake_repo, analyzer=TokenAnalyzer())
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ── TokenAnalyzer (pure logic) ──────────────────────────────────────
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_analyzer_zero_inputs_zero_risk():
|
| 37 |
+
r = TokenAnalyzer.compute_risk("0xtoken", "solana")
|
| 38 |
+
assert r.risk_score == 0
|
| 39 |
+
assert r.risk_level == RiskLevel.LOW
|
| 40 |
+
assert r.honeypot is False
|
| 41 |
+
assert r.can_sell is True
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_analyzer_honeypot_is_critical():
|
| 45 |
+
r = TokenAnalyzer.compute_risk("0xtoken", "solana", honeypot=True)
|
| 46 |
+
assert r.risk_score == 100
|
| 47 |
+
assert r.risk_level == RiskLevel.CRITICAL
|
| 48 |
+
assert any(f["code"] == "honeypot" for f in r.flags)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_analyzer_cant_sell_is_critical():
|
| 52 |
+
r = TokenAnalyzer.compute_risk("0xtoken", "solana", can_sell=False)
|
| 53 |
+
assert r.risk_score == 100
|
| 54 |
+
assert r.honeypot is False
|
| 55 |
+
assert r.can_sell is False
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_analyzer_owner_change_balance_critical():
|
| 59 |
+
r = TokenAnalyzer.compute_risk("0xtoken", "solana", owner_change_balance=True)
|
| 60 |
+
assert r.risk_score == 50
|
| 61 |
+
assert r.risk_level == RiskLevel.HIGH
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def test_analyzer_concentrated_holders_high():
|
| 65 |
+
holders = [TokenHolder(address="0xa", percentage=70.0)]
|
| 66 |
+
r = TokenAnalyzer.compute_risk("0xtoken", "solana", holders=holders)
|
| 67 |
+
assert any(f["code"] == "concentrated_holders" for f in r.flags)
|
| 68 |
+
assert r.risk_score >= 30
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_analyzer_moderate_concentration():
|
| 72 |
+
holders = [TokenHolder(address="0xa", percentage=30.0)]
|
| 73 |
+
r = TokenAnalyzer.compute_risk("0xtoken", "solana", holders=holders)
|
| 74 |
+
assert any(f["code"] == "moderate_concentration" for f in r.flags)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_analyzer_low_liquidity_flag():
|
| 78 |
+
liq = TokenLiquidity(address="0xt", chain="solana", total_liquidity_usd=500.0)
|
| 79 |
+
r = TokenAnalyzer.compute_risk("0xtoken", "solana", liquidity=liq)
|
| 80 |
+
assert any(f["code"] == "low_liquidity" for f in r.flags)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def test_analyzer_liquidity_unlocked():
|
| 84 |
+
liq = TokenLiquidity(address="0xt", chain="solana", total_liquidity_usd=100000.0, locked_percentage=10.0)
|
| 85 |
+
r = TokenAnalyzer.compute_risk("0xtoken", "solana", liquidity=liq)
|
| 86 |
+
assert any(f["code"] == "liquidity_unlocked" for f in r.flags)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def test_analyzer_score_capped_at_100():
|
| 90 |
+
holders = [TokenHolder(address="0xa", percentage=80.0)]
|
| 91 |
+
liq = TokenLiquidity(address="0xt", chain="solana", total_liquidity_usd=100.0, locked_percentage=0.0)
|
| 92 |
+
r = TokenAnalyzer.compute_risk(
|
| 93 |
+
"0xtoken", "solana",
|
| 94 |
+
holders=holders, liquidity=liq,
|
| 95 |
+
honeypot=True, owner_change_balance=True, transfer_pausable=True,
|
| 96 |
+
)
|
| 97 |
+
assert r.risk_score == 100
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_risk_level_from_score():
|
| 101 |
+
assert RiskLevel.from_score(0) == RiskLevel.LOW
|
| 102 |
+
assert RiskLevel.from_score(50) == RiskLevel.HIGH
|
| 103 |
+
assert RiskLevel.from_score(80) == RiskLevel.CRITICAL
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# ── TokenService (composed) ─────────────────────────────────────────
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
async def test_service_get_detail_passes_through(service, fake_repo):
|
| 110 |
+
from app.domain.token.models import TokenDetail
|
| 111 |
+
expected = TokenDetail(address="0xt", chain="solana", name="Test", symbol="TST")
|
| 112 |
+
fake_repo.get_detail = AsyncMock(return_value=expected)
|
| 113 |
+
result = await service.get_detail("0xt", "solana")
|
| 114 |
+
assert result is expected
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
async def test_service_get_risk_composes_holder_and_liquidity(service, fake_repo):
|
| 118 |
+
fake_repo.get_holders = AsyncMock(return_value=[
|
| 119 |
+
TokenHolder(address="0xa", percentage=10.0),
|
| 120 |
+
TokenHolder(address="0xb", percentage=5.0),
|
| 121 |
+
])
|
| 122 |
+
fake_repo.get_liquidity = AsyncMock(return_value=TokenLiquidity(
|
| 123 |
+
address="0xt", chain="solana", total_liquidity_usd=50000.0, locked_percentage=80.0,
|
| 124 |
+
))
|
| 125 |
+
r = await service.get_risk("0xt", "solana")
|
| 126 |
+
assert r.risk_level in (RiskLevel.LOW, RiskLevel.MEDIUM)
|
| 127 |
+
fake_repo.get_holders.assert_awaited_once()
|
| 128 |
+
fake_repo.get_liquidity.assert_awaited_once()
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
async def test_service_scan_combines_detail_and_risk(service, fake_repo):
|
| 132 |
+
from app.domain.token.models import TokenDetail
|
| 133 |
+
fake_repo.get_detail = AsyncMock(return_value=TokenDetail(
|
| 134 |
+
address="0xt", chain="solana", name="Test", symbol="TST", verified=True,
|
| 135 |
+
))
|
| 136 |
+
fake_repo.get_holders = AsyncMock(return_value=[])
|
| 137 |
+
fake_repo.get_liquidity = AsyncMock(return_value=TokenLiquidity(
|
| 138 |
+
address="0xt", chain="solana", total_liquidity_usd=100000.0, locked_percentage=90.0,
|
| 139 |
+
))
|
| 140 |
+
req = TokenScanRequest(address="0xt", chain="solana", tier="pro")
|
| 141 |
+
result = await service.scan(req)
|
| 142 |
+
assert result.address == "0xt"
|
| 143 |
+
assert result.modules_run == ["analyzer", "holders", "liquidity"]
|
| 144 |
+
assert result.metadata["symbol"] == "TST"
|
| 145 |
+
assert result.safety_score >= 80 # low risk → high safety
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
async def test_scan_request_normalizes_inputs():
|
| 149 |
+
req = TokenScanRequest(address="0xT", chain="ETHEREUM", tier="ELITE")
|
| 150 |
+
assert req.chain == "ethereum"
|
| 151 |
+
assert req.tier == "elite"
|
| 152 |
+
assert req.address == "0xT"
|