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

feat(wallet): vertical slice — pure-Python domain + 4 thin v1 routes

Browse files

app/domain/wallet/ (NO FastAPI imports, pure business logic):
models.py Pydantic v2: Wallet, Balance, TokenHolding, Transaction,
WalletAnalysis, ScanRequest, ScanResult, ScanFlag, RiskLevel
analyzer.py PURE risk scoring (no I/O) — unit-testable in isolation
repository.py Async data fetch via Databus + Redis (core/redis)
service.py Composes analyzer + repository, returns WalletAnalysis
__init__.py Public API surface

app/api/v1/public/wallet.py (THIN route, <100 lines, 4 endpoints):
GET /api/v1/wallet/{address}/analysis
GET /api/v1/wallet/{address}/balance
GET /api/v1/wallet/{address}/transactions
POST /api/v1/wallet/scan

app/api/v1/__init__.py — aggregator mounts wallet alongside alerts.

tests/unit/domain/wallet/test_service.py — 14/14 PASS:
Analyzer (pure logic): 8 tests (truncation, scoring, dust detection,
suspicious labels, score capping, RiskLevel enum).
Service (composed): 5 tests (analyze composes repo+analyzer,
scan returns ScanResult, risk pass-through, balance pass-through,
transactions respect limit).
Models: 1 test (chain/tier normalization in ScanRequest).

PATTERN PROVEN AGAIN:
- Domain layer is unit-testable WITHOUT spinning up FastAPI.
- 14 unit tests cover pure logic + composition in 0.43s.
- Single source of truth: core/redis, core/logging, core/auth, core/errors.
- Thin route: parse → call service → return, NO business logic in HTTP.
- <500 lines per file (largest is models.py at ~140 lines).
- Pydantic v2 with proper validators, no dict types.

Backend healthy, 4 new routes in openapi alongside legacy.
Stranglerfig with legacy /api/v1/wallet/* — both registered, legacy wins
on path conflict until cutover.

Combined with alerts slice: 2 domains migrated, 21 unit tests passing,
7 v1 routes wired.

backend/app/api/v1/__init__.py CHANGED
@@ -28,7 +28,7 @@ router = APIRouter(prefix="/api/v1", tags=["v1"])
28
  # Each migrated domain is imported here. The router exposes endpoints
29
  # at /api/v1/<domain>/* (path defined per-router).
30
  #
31
- # During strangelfig, the LEGACY /api/v1/alerts/* endpoints remain
32
  # mounted in main.py. The new v1 router is mounted at the same path
33
  # (FastAPI handles prefix-based routing) — first match wins, so the
34
  # legacy stays until we explicitly remove it.
@@ -37,6 +37,10 @@ from app.api.v1.auth.alerts import router as alerts_router # noqa: E402
37
 
38
  api_v1_router.append(alerts_router)
39
 
 
 
 
 
40
 
41
  def build_v1_router() -> APIRouter:
42
  """Construct the v1 aggregator with all migrated routes mounted."""
 
28
  # Each migrated domain is imported here. The router exposes endpoints
29
  # at /api/v1/<domain>/* (path defined per-router).
30
  #
31
+ # During strangelfig, the LEGACY /api/v1/<domain>/* endpoints remain
32
  # mounted in main.py. The new v1 router is mounted at the same path
33
  # (FastAPI handles prefix-based routing) — first match wins, so the
34
  # legacy stays until we explicitly remove it.
 
37
 
38
  api_v1_router.append(alerts_router)
39
 
40
+ 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."""
backend/app/api/v1/public/wallet.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """V1 wallet route — thin HTTP layer over app.domain.wallet.
2
+
3
+ Rules (per 2026 standards):
4
+ - Parse request → call service → return response. No business logic.
5
+ - Pydantic models defined in app.domain.wallet.models.
6
+ - No direct Redis/DB access. Goes through WalletService.
7
+ - Errors raised as AppError subclasses, handled by core.errors.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import Annotated, Any
12
+
13
+ from fastapi import APIRouter, Depends, Query
14
+
15
+ from app.core.auth import get_optional_user
16
+ from app.domain.wallet import (
17
+ Balance,
18
+ ScanRequest,
19
+ ScanResult,
20
+ WalletAnalysis,
21
+ WalletService,
22
+ )
23
+ from app.models import PaginatedResponse
24
+
25
+ router = APIRouter(prefix="/api/v1/wallet", tags=["wallet"])
26
+
27
+
28
+ def _service() -> WalletService:
29
+ return WalletService()
30
+
31
+
32
+ @router.get("/{address}/analysis", response_model=WalletAnalysis)
33
+ async def get_analysis(
34
+ address: str,
35
+ svc: Annotated[WalletService, Depends(_service)],
36
+ chain: str = Query(default="solana"),
37
+ ) -> WalletAnalysis:
38
+ """Full wallet analysis: risk score + tokens + recent transactions."""
39
+ return await svc.analyze(address, chain=chain)
40
+
41
+
42
+ @router.get("/{address}/balance", response_model=Balance)
43
+ async def get_balance(
44
+ address: str,
45
+ svc: Annotated[WalletService, Depends(_service)],
46
+ chain: str = Query(default="solana"),
47
+ ) -> Balance:
48
+ """Wallet balance + token holdings."""
49
+ return await svc.get_balance(address, chain=chain)
50
+
51
+
52
+ @router.get("/{address}/transactions", response_model=PaginatedResponse)
53
+ async def get_transactions(
54
+ address: str,
55
+ svc: Annotated[WalletService, Depends(_service)],
56
+ chain: str = Query(default="solana"),
57
+ limit: int = Query(default=50, ge=1, le=200),
58
+ ) -> PaginatedResponse:
59
+ """Recent transactions for a wallet."""
60
+ txs = await svc.get_transactions(address, chain=chain, limit=limit)
61
+ return PaginatedResponse(
62
+ items=[t.model_dump(mode="json") for t in txs],
63
+ total=len(txs),
64
+ )
65
+
66
+
67
+ @router.post("/scan", response_model=ScanResult)
68
+ async def scan(
69
+ req: ScanRequest,
70
+ svc: Annotated[WalletService, Depends(_service)],
71
+ _user: Annotated[dict[str, Any] | None, Depends(get_optional_user)],
72
+ ) -> ScanResult:
73
+ """Multi-chain threat scan. Auth optional — freemium rate-limit applied in legacy router."""
74
+ return await svc.scan(req)
backend/app/domain/wallet/__init__.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Wallet domain — analysis, balance, transactions, threat scan.
2
+
3
+ Public API:
4
+ from app.domain.wallet import (
5
+ Wallet, WalletAnalysis, Balance, TokenHolding, Transaction,
6
+ RiskLevel, ScanFlag, ScanRequest, ScanResult,
7
+ WalletAnalyzer, WalletService, WalletRepository,
8
+ )
9
+
10
+ Domain is pure Python. NO FastAPI. NO HTTP. NO logging.basicConfig.
11
+ This module is the only thing the api/ layer should import.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from app.domain.wallet.analyzer import WalletAnalyzer
16
+ from app.domain.wallet.models import (
17
+ Balance,
18
+ RiskLevel,
19
+ ScanFlag,
20
+ ScanRequest,
21
+ ScanResult,
22
+ TokenHolding,
23
+ Transaction,
24
+ Wallet,
25
+ WalletAnalysis,
26
+ )
27
+ from app.domain.wallet.repository import WalletRepository
28
+ from app.domain.wallet.service import WalletService
29
+
30
+ __all__ = [
31
+ "Wallet",
32
+ "WalletAnalysis",
33
+ "Balance",
34
+ "TokenHolding",
35
+ "Transaction",
36
+ "RiskLevel",
37
+ "ScanFlag",
38
+ "ScanRequest",
39
+ "ScanResult",
40
+ "WalletAnalyzer",
41
+ "WalletRepository",
42
+ "WalletService",
43
+ ]
backend/app/domain/wallet/analyzer.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure risk-scoring logic. No I/O, no external calls.
2
+
3
+ This is the testable, deterministic core. Given a portfolio + recent
4
+ transactions + known labels, return a risk score and flags.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from app.domain.wallet.models import (
9
+ RiskLevel,
10
+ ScanFlag,
11
+ TokenHolding,
12
+ Transaction,
13
+ WalletAnalysis,
14
+ )
15
+
16
+
17
+ class WalletAnalyzer:
18
+ """Pure-Python risk analysis. No external dependencies."""
19
+
20
+ # Truncation for display: first 6 + "..." + last 4
21
+ TRUNCATE_PREFIX = 6
22
+ TRUNCATE_SUFFIX = 4
23
+
24
+ def analyze(
25
+ self,
26
+ address: str,
27
+ chain: str,
28
+ tokens: list[TokenHolding] | None = None,
29
+ recent_transactions: list[Transaction] | None = None,
30
+ labels: list[str] | None = None,
31
+ ) -> WalletAnalysis:
32
+ """Produce a full WalletAnalysis from raw inputs.
33
+
34
+ Pure function of inputs. No I/O. Easily unit-testable.
35
+ """
36
+ tokens = tokens or []
37
+ recent = recent_transactions or []
38
+ labels = labels or []
39
+
40
+ risk_score = self._compute_risk_score(tokens, recent, labels)
41
+ risk_level = RiskLevel.from_score(risk_score)
42
+ flags = self._compute_flags(tokens, recent, labels, risk_score)
43
+ total_value = sum(t.value_usd for t in tokens)
44
+
45
+ return WalletAnalysis(
46
+ address=address,
47
+ chain=chain,
48
+ truncated_address=self._truncate(address),
49
+ risk_score=risk_score,
50
+ risk_level=risk_level,
51
+ tokens=tokens,
52
+ recent_transactions=recent,
53
+ labels=labels,
54
+ flags=flags,
55
+ total_value_usd=round(total_value, 2),
56
+ )
57
+
58
+ @staticmethod
59
+ def _truncate(address: str) -> str:
60
+ if len(address) <= WalletAnalyzer.TRUNCATE_PREFIX + WalletAnalyzer.TRUNCATE_SUFFIX + 3:
61
+ return address
62
+ return (
63
+ address[: WalletAnalyzer.TRUNCATE_PREFIX]
64
+ + "..."
65
+ + address[-WalletAnalyzer.TRUNCATE_SUFFIX :]
66
+ )
67
+
68
+ @staticmethod
69
+ def _compute_risk_score(
70
+ tokens: list[TokenHolding],
71
+ recent: list[Transaction],
72
+ labels: list[str],
73
+ ) -> int:
74
+ """Score 0-100. Higher = riskier.
75
+
76
+ Heuristic:
77
+ - Each token = +2 (diversification proxy)
78
+ - Each recent tx = +1 (activity proxy)
79
+ - Each suspicious label (mixer, drainer, exploit) = +30
80
+ - Negative balances / huge values = clamp 0-100
81
+ """
82
+ score = len(tokens) * 2 + len(recent)
83
+ suspicious = {"mixer", "drainer", "exploit", "hack", "phishing", "ransomware"}
84
+ for label in labels:
85
+ if any(s in label.lower() for s in suspicious):
86
+ score += 30
87
+ return max(0, min(100, score))
88
+
89
+ @staticmethod
90
+ def _compute_flags(
91
+ tokens: list[TokenHolding],
92
+ recent: list[Transaction],
93
+ labels: list[str],
94
+ risk_score: int,
95
+ ) -> list[ScanFlag]:
96
+ """Generate human-readable risk flags from inputs."""
97
+ flags: list[ScanFlag] = []
98
+
99
+ if risk_score >= 80:
100
+ flags.append(ScanFlag(
101
+ code="critical_risk",
102
+ severity=RiskLevel.CRITICAL,
103
+ message="Wallet shows patterns consistent with high-risk activity.",
104
+ ))
105
+ elif risk_score >= 50:
106
+ flags.append(ScanFlag(
107
+ code="elevated_risk",
108
+ severity=RiskLevel.HIGH,
109
+ message="Elevated risk indicators present.",
110
+ ))
111
+
112
+ suspicious = {"mixer", "drainer", "exploit", "hack", "phishing", "ransomware"}
113
+ for label in labels:
114
+ if any(s in label.lower() for s in suspicious):
115
+ flags.append(ScanFlag(
116
+ code="flagged_entity",
117
+ severity=RiskLevel.CRITICAL,
118
+ message=f"Wallet linked to flagged entity: {label}",
119
+ evidence={"label": label},
120
+ ))
121
+
122
+ # Dust tokens (many low-value tokens) signal airdrop farming or spam
123
+ dust_count = sum(1 for t in tokens if 0 < t.value_usd < 1)
124
+ if dust_count >= 10:
125
+ flags.append(ScanFlag(
126
+ code="dust_tokens",
127
+ severity=RiskLevel.MEDIUM,
128
+ message=f"{dust_count} dust tokens detected — possible airdrop farm or spam exposure.",
129
+ evidence={"dust_count": dust_count},
130
+ ))
131
+
132
+ return flags
backend/app/domain/wallet/models.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic v2 models for the wallet 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
+ """Wallet 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 Wallet(BaseModel):
34
+ """A wallet identity (address + chain)."""
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", description="solana, ethereum, bsc, base, ...")
40
+ label: str | None = Field(default=None, description="Human-readable label, e.g. 'Binance Hot Wallet'")
41
+
42
+
43
+ class TokenHolding(BaseModel):
44
+ """A single token holding within a wallet's portfolio."""
45
+
46
+ symbol: str
47
+ address: str = ""
48
+ amount: float = 0.0
49
+ price_usd: float = 0.0
50
+ value_usd: float = 0.0
51
+
52
+
53
+ class Transaction(BaseModel):
54
+ """A wallet transaction summary."""
55
+
56
+ hash: str
57
+ type: str = "transfer"
58
+ amount_usd: float = 0.0
59
+ timestamp: int = 0
60
+ block_time: datetime | None = None
61
+ direction: str | None = Field(default=None, description="in | out | self")
62
+
63
+
64
+ class Balance(BaseModel):
65
+ """Wallet balance + token holdings."""
66
+
67
+ model_config = ConfigDict(str_strip_whitespace=True)
68
+
69
+ address: str
70
+ chain: str
71
+ native_balance: float = 0.0
72
+ native_symbol: str = ""
73
+ total_value_usd: float = 0.0
74
+ tokens: list[TokenHolding] = Field(default_factory=list)
75
+ fetched_at: datetime = Field(default_factory=datetime.utcnow)
76
+
77
+
78
+ class ScanFlag(BaseModel):
79
+ """A risk flag raised by a scan."""
80
+
81
+ code: str
82
+ severity: RiskLevel
83
+ message: str
84
+ evidence: dict[str, Any] = Field(default_factory=dict)
85
+
86
+
87
+ class WalletAnalysis(BaseModel):
88
+ """Full wallet analysis — risk + tokens + recent activity."""
89
+
90
+ address: str
91
+ chain: str
92
+ truncated_address: str
93
+ risk_score: int = Field(default=0, ge=0, le=100)
94
+ risk_level: RiskLevel
95
+ tokens: list[TokenHolding] = Field(default_factory=list)
96
+ recent_transactions: list[Transaction] = Field(default_factory=list)
97
+ labels: list[str] = Field(default_factory=list, description="Known labels (e.g. 'Binance', 'Vitalik')")
98
+ flags: list[ScanFlag] = Field(default_factory=list)
99
+ total_value_usd: float = 0.0
100
+ analyzed_at: datetime = Field(default_factory=datetime.utcnow)
101
+
102
+
103
+ class ScanRequest(BaseModel):
104
+ """Request body for the wallet scan endpoint."""
105
+
106
+ model_config = ConfigDict(str_strip_whitespace=True)
107
+
108
+ address: str = Field(..., min_length=1, max_length=256)
109
+ chain: str = Field(default="solana")
110
+ tier: str = Field(default="free", description="free | pro | elite | internal")
111
+
112
+ @field_validator("chain")
113
+ @classmethod
114
+ def _chain_lowercase(cls, v: str) -> str:
115
+ return v.lower().strip()
116
+
117
+ @field_validator("tier")
118
+ @classmethod
119
+ def _tier_lowercase(cls, v: str) -> str:
120
+ return v.lower().strip()
121
+
122
+
123
+ class ScanResult(BaseModel):
124
+ """Threat scan result."""
125
+
126
+ address: str
127
+ chain: str
128
+ risk_score: int = Field(default=0, ge=0, le=100)
129
+ risk_level: RiskLevel
130
+ flags: list[ScanFlag] = Field(default_factory=list)
131
+ modules_run: list[str] = Field(default_factory=list)
132
+ scanned_at: datetime = Field(default_factory=datetime.utcnow)
backend/app/domain/wallet/repository.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Repository — async data fetch for wallet info.
2
+
3
+ Backed by:
4
+ - Databus (chain-agnostic on-chain data) for balances + transactions
5
+ - Redis (cached labels) for known wallet labels
6
+
7
+ This is a thin async wrapper. No business logic.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from app.core.logging import get_logger
14
+ from app.domain.wallet.models import (
15
+ Balance,
16
+ TokenHolding,
17
+ Transaction,
18
+ Wallet,
19
+ )
20
+
21
+ log = get_logger(__name__)
22
+
23
+ LABELS_HASH_KEY = "rmi:wallet_labels"
24
+
25
+
26
+ class WalletRepository:
27
+ """Async data access for wallet info."""
28
+
29
+ async def get_balance(self, wallet: Wallet) -> Balance:
30
+ """Fetch native + token balances for a wallet.
31
+
32
+ Backed by Databus. Falls back to empty balance on error.
33
+ """
34
+ try:
35
+ from app.databus.client import get_databus_client # local import to avoid cycles
36
+
37
+ client = get_databus_client()
38
+ holdings = await client.get_wallet_balance(wallet.address, chain=wallet.chain)
39
+ return self._parse_balance(wallet, holdings)
40
+ except Exception as e:
41
+ log.warning("wallet_balance_fetch_failed", address=wallet.address[:12], error=str(e))
42
+ return Balance(address=wallet.address, chain=wallet.chain)
43
+
44
+ async def get_transactions(
45
+ self,
46
+ wallet: Wallet,
47
+ limit: int = 50,
48
+ ) -> list[Transaction]:
49
+ """Fetch recent transactions for a wallet."""
50
+ try:
51
+ from app.databus.client import get_databus_client
52
+
53
+ client = get_databus_client()
54
+ txs = await client.get_wallet_transactions(wallet.address, chain=wallet.chain, limit=limit)
55
+ return [self._parse_tx(tx) for tx in (txs or [])]
56
+ except Exception as e:
57
+ log.warning("wallet_tx_fetch_failed", address=wallet.address[:12], error=str(e))
58
+ return []
59
+
60
+ async def get_labels(self, wallet: Wallet) -> list[str]:
61
+ """Look up known labels for a wallet from Redis."""
62
+ try:
63
+ from app.core.redis import get_redis_async
64
+
65
+ r = get_redis_async()
66
+ raw: str | None = await r.hget(LABELS_HASH_KEY, wallet.address.lower())
67
+ if raw is None:
68
+ return []
69
+ import json
70
+ data = json.loads(raw)
71
+ return data.get("labels", []) if isinstance(data, dict) else []
72
+ except Exception as e:
73
+ log.warning("wallet_label_fetch_failed", address=wallet.address[:12], error=str(e))
74
+ return []
75
+
76
+ @staticmethod
77
+ def _parse_balance(wallet: Wallet, raw: dict[str, Any]) -> Balance:
78
+ tokens: list[TokenHolding] = []
79
+ total = 0.0
80
+ for h in (raw or {}).get("tokens", [])[:20]:
81
+ token_info = h.get("token", {}) or {}
82
+ decimals = int(token_info.get("decimals", 0) or 0) or 1
83
+ amount = float(h.get("amount", 0) or 0) / (10**decimals)
84
+ price = float(h.get("priceUsdt", h.get("price_usd", 0)) or 0)
85
+ value = amount * price
86
+ total += value
87
+ tokens.append(TokenHolding(
88
+ symbol=token_info.get("symbol", "?"),
89
+ address=token_info.get("address", ""),
90
+ amount=round(amount, 4),
91
+ price_usd=price,
92
+ value_usd=round(value, 2),
93
+ ))
94
+ return Balance(
95
+ address=wallet.address,
96
+ chain=wallet.chain,
97
+ native_balance=float((raw or {}).get("native_balance", 0) or 0),
98
+ native_symbol=(raw or {}).get("native_symbol", ""),
99
+ total_value_usd=round(total, 2),
100
+ tokens=tokens,
101
+ )
102
+
103
+ @staticmethod
104
+ def _parse_tx(tx: dict[str, Any]) -> Transaction:
105
+ block_time = tx.get("block_time")
106
+ return Transaction(
107
+ hash=str(tx.get("trans_id") or tx.get("tx_hash") or tx.get("hash", ""))[:64],
108
+ type=str(tx.get("flow") or tx.get("type") or "transfer"),
109
+ amount_usd=float(tx.get("change_amount") or tx.get("amount_usd", 0) or 0),
110
+ timestamp=int(block_time or 0),
111
+ direction=tx.get("direction"),
112
+ )
backend/app/domain/wallet/service.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Service — business logic for wallet analysis and scans.
2
+
3
+ Composes the repository (data access) and the analyzer (pure logic).
4
+ This is the only thing the api/ layer should call.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from app.core.logging import get_logger
9
+ from app.domain.wallet.analyzer import WalletAnalyzer
10
+ from app.domain.wallet.models import (
11
+ Balance,
12
+ RiskLevel,
13
+ ScanFlag,
14
+ ScanRequest,
15
+ ScanResult,
16
+ Transaction,
17
+ Wallet,
18
+ WalletAnalysis,
19
+ )
20
+ from app.domain.wallet.repository import WalletRepository
21
+
22
+ log = get_logger(__name__)
23
+
24
+
25
+ class WalletService:
26
+ """Orchestrates wallet data fetch and analysis."""
27
+
28
+ def __init__(
29
+ self,
30
+ repo: WalletRepository | None = None,
31
+ analyzer: WalletAnalyzer | None = None,
32
+ ) -> None:
33
+ self._repo = repo or WalletRepository()
34
+ self._analyzer = analyzer or WalletAnalyzer()
35
+
36
+ async def analyze(
37
+ self,
38
+ address: str,
39
+ chain: str = "solana",
40
+ ) -> WalletAnalysis:
41
+ """Full wallet analysis: balance + transactions + labels → risk-scored result."""
42
+ wallet = Wallet(address=address, chain=chain)
43
+ log.info("wallet_analysis_started", address=address[:12], chain=chain)
44
+ balance = await self._repo.get_balance(wallet)
45
+ txs = await self._repo.get_transactions(wallet, limit=10)
46
+ labels = await self._repo.get_labels(wallet)
47
+ analysis = self._analyzer.analyze(
48
+ address=address,
49
+ chain=chain,
50
+ tokens=balance.tokens,
51
+ recent_transactions=txs,
52
+ labels=labels,
53
+ )
54
+ # Override total value with the balance's authoritative value
55
+ analysis.total_value_usd = balance.total_value_usd
56
+ log.info(
57
+ "wallet_analysis_complete",
58
+ address=address[:12],
59
+ risk_score=analysis.risk_score,
60
+ risk_level=analysis.risk_level.value,
61
+ )
62
+ return analysis
63
+
64
+ async def get_balance(self, address: str, chain: str = "solana") -> Balance:
65
+ """Just the balance."""
66
+ return await self._repo.get_balance(Wallet(address=address, chain=chain))
67
+
68
+ async def get_transactions(
69
+ self,
70
+ address: str,
71
+ chain: str = "solana",
72
+ limit: int = 50,
73
+ ) -> list[Transaction]:
74
+ """Just the transactions."""
75
+ return await self._repo.get_transactions(
76
+ Wallet(address=address, chain=chain),
77
+ limit=limit,
78
+ )
79
+
80
+ async def scan(self, req: ScanRequest) -> ScanResult:
81
+ """Threat scan. Multi-module. Returns a ScanResult.
82
+
83
+ The legacy /api/v1/wallet/scan does a freemium rate-limit check
84
+ before calling the scanner. That check lives in the api/ layer
85
+ (HTTP concern), not here.
86
+ """
87
+ log.info("wallet_scan_started", address=req.address[:12], chain=req.chain, tier=req.tier)
88
+ # For now: the scan reuses analyze() + adds module-level flags.
89
+ analysis = await self.analyze(req.address, req.chain)
90
+ result = ScanResult(
91
+ address=analysis.address,
92
+ chain=analysis.chain,
93
+ risk_score=analysis.risk_score,
94
+ risk_level=analysis.risk_level,
95
+ flags=analysis.flags,
96
+ modules_run=["analyzer"],
97
+ )
98
+ log.info(
99
+ "wallet_scan_complete",
100
+ address=req.address[:12],
101
+ risk_score=result.risk_score,
102
+ risk_level=result.risk_level.value,
103
+ flag_count=len(result.flags),
104
+ )
105
+ return result
backend/tests/unit/domain/wallet/__init__.py ADDED
File without changes
backend/tests/unit/domain/wallet/test_service.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for the wallet domain.
2
+
3
+ Pure-Python tests. No FastAPI. No HTTP. Tests the analyzer (pure logic)
4
+ and service (composed). Repository is mocked.
5
+
6
+ This is the test that proves the architecture: domain code is
7
+ unit-testable without spinning up the API.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from unittest.mock import AsyncMock
12
+
13
+ import pytest
14
+
15
+ from app.domain.wallet import (
16
+ Balance,
17
+ RiskLevel,
18
+ ScanRequest,
19
+ ScanResult,
20
+ TokenHolding,
21
+ Transaction,
22
+ Wallet,
23
+ WalletAnalyzer,
24
+ WalletRepository,
25
+ WalletService,
26
+ )
27
+
28
+
29
+ @pytest.fixture
30
+ def fake_repo() -> AsyncMock:
31
+ repo = AsyncMock(spec=WalletRepository)
32
+ repo.get_balance = AsyncMock(return_value=Balance(address="0xabc", chain="solana"))
33
+ repo.get_transactions = AsyncMock(return_value=[])
34
+ repo.get_labels = AsyncMock(return_value=[])
35
+ return repo
36
+
37
+
38
+ @pytest.fixture
39
+ def analyzer() -> WalletAnalyzer:
40
+ return WalletAnalyzer()
41
+
42
+
43
+ @pytest.fixture
44
+ def service(fake_repo: AsyncMock, analyzer: WalletAnalyzer) -> WalletService:
45
+ return WalletService(repo=fake_repo, analyzer=analyzer)
46
+
47
+
48
+ # ── WalletAnalyzer (pure logic) ──────────────────────────────────────
49
+
50
+
51
+ def test_analyzer_truncates_long_address(analyzer):
52
+ a = analyzer.analyze("0x1234567890abcdef1234567890abcdef12345678", "ethereum")
53
+ assert a.truncated_address == "0x1234...5678"
54
+ assert a.risk_level == RiskLevel.LOW
55
+
56
+
57
+ def test_analyzer_short_address_unchanged(analyzer):
58
+ a = analyzer.analyze("short", "solana")
59
+ assert a.truncated_address == "short"
60
+
61
+
62
+ def test_analyzer_zero_inputs_yields_zero_risk(analyzer):
63
+ a = analyzer.analyze("0xabc", "solana")
64
+ assert a.risk_score == 0
65
+ assert a.risk_level == RiskLevel.LOW
66
+ assert a.flags == []
67
+
68
+
69
+ def test_analyzer_score_increases_with_tokens(analyzer):
70
+ a = analyzer.analyze(
71
+ "0xabc", "solana",
72
+ tokens=[TokenHolding(symbol=f"T{i}", value_usd=10.0) for i in range(20)],
73
+ )
74
+ assert a.risk_score == 40 # 20 tokens * 2
75
+
76
+
77
+ def test_analyzer_suspicious_label_critical(analyzer):
78
+ a = analyzer.analyze("0xabc", "ethereum", labels=["Tornado Cash Mixer"])
79
+ assert a.risk_score == 30
80
+ assert a.risk_level == RiskLevel.MEDIUM
81
+ assert any(f.code == "flagged_entity" for f in a.flags)
82
+ assert any(f.severity == RiskLevel.CRITICAL for f in a.flags)
83
+
84
+
85
+ def test_analyzer_dust_token_flag(analyzer):
86
+ a = analyzer.analyze(
87
+ "0xabc", "solana",
88
+ tokens=[TokenHolding(symbol=f"T{i}", value_usd=0.5) for i in range(15)],
89
+ )
90
+ assert any(f.code == "dust_tokens" for f in a.flags)
91
+
92
+
93
+ def test_analyzer_score_capped_at_100(analyzer):
94
+ a = analyzer.analyze(
95
+ "0xabc", "solana",
96
+ tokens=[TokenHolding(symbol=f"T{i}", value_usd=10.0) for i in range(200)],
97
+ labels=["mixer", "exploit"],
98
+ )
99
+ assert a.risk_score == 100 # 400 + 60 → capped
100
+ assert a.risk_level == RiskLevel.CRITICAL
101
+
102
+
103
+ def test_risk_level_from_score():
104
+ assert RiskLevel.from_score(0) == RiskLevel.LOW
105
+ assert RiskLevel.from_score(19) == RiskLevel.LOW
106
+ assert RiskLevel.from_score(20) == RiskLevel.MEDIUM
107
+ assert RiskLevel.from_score(49) == RiskLevel.MEDIUM
108
+ assert RiskLevel.from_score(50) == RiskLevel.HIGH
109
+ assert RiskLevel.from_score(79) == RiskLevel.HIGH
110
+ assert RiskLevel.from_score(80) == RiskLevel.CRITICAL
111
+ assert RiskLevel.from_score(100) == RiskLevel.CRITICAL
112
+
113
+
114
+ # ── WalletService (composed) ─────────────────────────────────────────
115
+
116
+
117
+ async def test_service_analyze_composes_repo_and_analyzer(service, fake_repo):
118
+ fake_repo.get_balance = AsyncMock(return_value=Balance(
119
+ address="0xabc", chain="solana",
120
+ tokens=[TokenHolding(symbol="SOL", value_usd=100.0)],
121
+ total_value_usd=100.0,
122
+ ))
123
+ fake_repo.get_transactions = AsyncMock(return_value=[
124
+ Transaction(hash="0x123", type="transfer", amount_usd=10.0),
125
+ ])
126
+ fake_repo.get_labels = AsyncMock(return_value=["Binance Hot Wallet"])
127
+
128
+ result = await service.analyze("0xabc", "solana")
129
+
130
+ assert result.address == "0xabc"
131
+ assert result.total_value_usd == 100.0
132
+ assert result.risk_score > 0
133
+ assert "Binance Hot Wallet" in result.labels
134
+ fake_repo.get_balance.assert_awaited_once()
135
+ fake_repo.get_transactions.assert_awaited_once()
136
+ fake_repo.get_labels.assert_awaited_once()
137
+
138
+
139
+ async def test_service_scan_returns_scan_result(service, fake_repo):
140
+ fake_repo.get_balance = AsyncMock(return_value=Balance(address="0xabc", chain="solana"))
141
+ req = ScanRequest(address="0xabc", chain="solana", tier="free")
142
+ result = await service.scan(req)
143
+ assert isinstance(result, ScanResult)
144
+ assert result.modules_run == ["analyzer"]
145
+
146
+
147
+ async def test_service_scan_passes_through_risk(service, fake_repo):
148
+ fake_repo.get_balance = AsyncMock(return_value=Balance(
149
+ address="0xabc", chain="ethereum",
150
+ tokens=[TokenHolding(symbol=f"T{i}", value_usd=0.1) for i in range(30)],
151
+ ))
152
+ fake_repo.get_labels = AsyncMock(return_value=["Drainer Wallet"])
153
+ req = ScanRequest(address="0xabc", chain="ethereum", tier="pro")
154
+ result = await service.scan(req)
155
+ assert result.risk_level == RiskLevel.CRITICAL
156
+ assert any(f.code == "flagged_entity" for f in result.flags)
157
+ assert any(f.code == "dust_tokens" for f in result.flags)
158
+
159
+
160
+ async def test_service_balance_passes_through(service, fake_repo):
161
+ expected = Balance(address="0xabc", chain="solana", total_value_usd=42.0)
162
+ fake_repo.get_balance = AsyncMock(return_value=expected)
163
+ result = await service.get_balance("0xabc", "solana")
164
+ assert result is expected
165
+
166
+
167
+ async def test_service_transactions_respects_limit(service, fake_repo):
168
+ fake_repo.get_transactions = AsyncMock(return_value=[
169
+ Transaction(hash=f"0x{i}", type="transfer") for i in range(10)
170
+ ])
171
+ result = await service.get_transactions("0xabc", "solana", limit=10)
172
+ assert len(result) == 10
173
+ fake_repo.get_transactions.assert_awaited_once()
174
+ call_args = fake_repo.get_transactions.await_args
175
+ assert call_args.kwargs.get("limit") == 10
176
+
177
+
178
+ async def test_scan_request_normalizes_chain_and_tier():
179
+ req = ScanRequest(address="0xabc", chain="SOLANA ", tier="PRO")
180
+ assert req.chain == "solana"
181
+ assert req.tier == "pro"