cryptorugmuncher commited on
Commit
b9cd83f
·
1 Parent(s): 35287c0

feat(token): vertical slice — pure-Python domain + 5 thin v1 routes

Browse files

app/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 CHANGED
@@ -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."""
backend/app/api/v1/public/token.py ADDED
@@ -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)
backend/app/domain/token/__init__.py ADDED
@@ -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
+ ]
backend/app/domain/token/analyzer.py ADDED
@@ -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
+ )
backend/app/domain/token/models.py ADDED
@@ -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)
backend/app/domain/token/repository.py ADDED
@@ -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
+ )
backend/app/domain/token/service.py ADDED
@@ -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()
backend/tests/unit/domain/token/__init__.py ADDED
File without changes
backend/tests/unit/domain/token/test_service.py ADDED
@@ -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"