File size: 12,053 Bytes
6993919
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
"""T27A β€” Canonical entity models for the RMI data catalog.

Pydantic v2 (per ADR-0002). Every entity is persisted in exactly one primary
store, with cross-store references (string IDs) to related entities in other
stores. CatalogService resolves these references transparently.

Cross-store ID conventions:
  Token, Wallet, Contract:  f"{chain.value}:{address}"
  Entity, Alert, NewsItem, RAGFinding, ScanReport: UUID4 hex
  Qdrant point_id:         16-byte UUID hex (matches RAG engine)

Persistence (per v4.0 Β§T27 "Why each store exists"):
  Redis:        hot token data, rate limits, alert state, cron locks
  Postgres:     users, api_keys, subscriptions, x402 receipts, audit
                alerts, news_items, scan_reports
  Neo4j:        entities, wallets, deployers, contracts, entity labels
                (graph traversal, Cypher)
  Qdrant:       RAG embeddings, token similarity, news embeddings
  MinIO:        news raw HTML, report markdown, RAG source docs
  Filesystem:   ingest tmp, log rotation (transient)
"""
from __future__ import annotations

from datetime import UTC, datetime
from enum import StrEnum
from typing import Any, Literal

from pydantic import BaseModel, ConfigDict, Field, HttpUrl, field_validator


# ── Enums ───────────────────────────────────────────────────────────
class Chain(StrEnum):
    """All chains the platform indexes. Per v4.0 Β§T27."""

    SOLANA = "solana"
    ETHEREUM = "ethereum"
    BASE = "base"
    ARBITRUM = "arbitrum"
    OPTIMISM = "optimism"
    POLYGON = "polygon"
    BSC = "bsc"
    TRON = "tron"
    BITCOIN = "bitcoin"
    AVALANCHE = "avalanche"
    FANTOM = "fantom"
    GNOSIS = "gnosis"
    # EVM subnets (sample β€” full 96 in CHAIN_REGISTRY)
    SEPOLIA = "sepolia"
    LINEA = "linea"
    SCROLL = "scroll"
    ZKSYNC = "zksync"
    BLAST = "blast"
    MANTLE = "mantle"


# Full chain registry β€” v4.0 says 96 chains. We track the canonical 20 here
# plus extend via CHAIN_REGISTRY for the remaining 76. Adding a chain is a
# one-line edit.
CHAIN_REGISTRY: dict[str, dict[str, str]] = {
    "solana": {"name": "Solana", "type": "svm", "native": "SOL", "explorer": "https://solscan.io"},
    "ethereum": {"name": "Ethereum", "type": "evm", "native": "ETH", "explorer": "https://etherscan.io"},
    "base": {"name": "Base", "type": "evm", "native": "ETH", "explorer": "https://basescan.org"},
    "arbitrum": {"name": "Arbitrum One", "type": "evm", "native": "ETH", "explorer": "https://arbiscan.io"},
    "optimism": {"name": "Optimism", "type": "evm", "native": "ETH", "explorer": "https://optimistic.etherscan.io"},
    "polygon": {"name": "Polygon", "type": "evm", "native": "MATIC", "explorer": "https://polygonscan.com"},
    "bsc": {"name": "BNB Smart Chain", "type": "evm", "native": "BNB", "explorer": "https://bscscan.com"},
    "tron": {"name": "TRON", "type": "tvm", "native": "TRX", "explorer": "https://tronscan.org"},
    "bitcoin": {"name": "Bitcoin", "type": "utxo", "native": "BTC", "explorer": "https://mempool.space"},
    "avalanche": {"name": "Avalanche C-Chain", "type": "evm", "native": "AVAX", "explorer": "https://snowtrace.io"},
    "fantom": {"name": "Fantom Opera", "type": "evm", "native": "FTM", "explorer": "https://ftmscan.com"},
    "gnosis": {"name": "Gnosis Chain", "type": "evm", "native": "xDAI", "explorer": "https://gnosisscan.io"},
}


class RiskTier(StrEnum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"
    CRITICAL = "critical"


# ── Entities (Neo4j primary) ───────────────────────────────────────
class Entity(BaseModel):
    """A logical entity resolved across chains. Neo4j primary."""

    model_config = ConfigDict(extra="ignore")

    entity_id: str = Field(..., description="UUID, Neo4j primary key")
    label: str | None = None
    aliases: list[str] = Field(default_factory=list)
    first_seen: datetime
    last_seen: datetime
    risk_score: int | None = Field(None, ge=0, le=100)
    tags: list[str] = Field(default_factory=list)
    notes: str | None = None
    store: Literal["neo4j"] = "neo4j"


class EntityLabel(BaseModel):
    """A label attached to an entity. Neo4j primary."""

    model_config = ConfigDict(extra="ignore")

    entity_id: str
    label: str
    source: Literal["manual", "heuristic", "third_party"]
    confidence: float = Field(ge=0.0, le=1.0)
    added_at: datetime
    added_by: str
    store: Literal["neo4j"] = "neo4j"


# ── Wallets + Deployers (Neo4j primary) ───────────────────────────
class Wallet(BaseModel):
    """An on-chain wallet. Neo4j primary; linked to Entity."""

    model_config = ConfigDict(extra="ignore")

    wallet_id: str = Field(..., description='"chain:address", e.g. "solana:7Np41..."')
    chain: Chain
    address: str
    entity_id: str | None = None
    first_seen: datetime
    last_seen: datetime
    tx_count: int = 0
    total_volume_usd: float = 0.0
    is_deployer: bool = False
    is_known_exchange: bool = False
    is_suspicious: bool = False
    reputation_score: int | None = Field(None, ge=0, le=100)
    store: Literal["neo4j"] = "neo4j"

    @field_validator("wallet_id")
    @classmethod
    def _check_id_format(cls, v: str) -> str:
        if ":" not in v:
            raise ValueError("wallet_id must be 'chain:address'")
        chain, _ = v.split(":", 1)
        if chain not in {c.value for c in Chain}:
            # Allow unknown chains (forward compat) but flag them
            pass
        return v


class Deployer(Wallet):
    """A wallet that has deployed at least one token. Extends Wallet."""

    model_config = ConfigDict(extra="ignore")

    deployments: list[str] = Field(default_factory=list, description="token_ids")
    rug_count: int = 0
    legit_count: int = 0
    avg_token_lifetime_days: float = 0.0
    reputation_score: int | None = Field(
        None,
        ge=0,
        le=100,
        description="Weighted: legit_count * 1.0 - rug_count * 3.0 + age_bonus - news_penalty. Cached in Redis TTL 1h.",
    )


# ── Tokens (Postgres primary) ──────────────────────────────────────
class Token(BaseModel):
    """A token contract. Postgres primary; references Deployer in Neo4j."""

    model_config = ConfigDict(extra="ignore")

    token_id: str = Field(..., description='"chain:address"')
    chain: Chain
    address: str
    symbol: str
    name: str
    decimals: int
    deployer_wallet_id: str | None = Field(
        None, description="Cross-store ref to Wallet (Neo4j)"
    )
    deployed_at: datetime
    initial_supply: int
    current_supply: int | None = None
    is_honeypot: bool | None = None
    is_mintable: bool | None = None
    is_proxy: bool | None = None
    tax_buy_bps: int | None = None
    tax_sell_bps: int | None = None
    risk_tier: RiskTier | None = None
    risk_score: int | None = Field(None, ge=0, le=100)
    risk_factors: list[str] = Field(default_factory=list)
    rag_embedding_id: str | None = Field(
        None, description="Cross-store ref to Qdrant point (16-byte hex)"
    )
    store: Literal["postgres"] = "postgres"


# ── Alerts (Postgres primary) ──────────────────────────────────────
class Alert(BaseModel):
    """A risk alert. Postgres primary."""

    model_config = ConfigDict(extra="ignore")

    alert_id: str = Field(..., description="UUID")
    token_id: str | None = None
    wallet_id: str | None = None
    chain: Chain | None = None
    alert_type: Literal[
        "rug_detected",
        "deployer_history",
        "liquidity_drain",
        "honeypot_detected",
        "high_tax_change",
        "whale_dump",
    ]
    severity: Literal["info", "warning", "critical"]
    title: str
    description: str
    evidence: dict[str, Any] = Field(default_factory=dict)
    created_at: datetime
    resolved_at: datetime | None = None
    store: Literal["postgres"] = "postgres"


# ── News (Postgres primary + Qdrant embeddings) ────────────────────
class NewsItem(BaseModel):
    """A news article from RSS. Postgres primary; embeddings in Qdrant."""

    model_config = ConfigDict(extra="ignore")

    news_id: str = Field(..., description="UUID")
    url: HttpUrl
    title: str
    summary: str
    body_markdown: str | None = None
    source: str
    published_at: datetime
    ingested_at: datetime
    chains_mentioned: list[Chain] = Field(default_factory=list)
    tokens_mentioned: list[str] = Field(default_factory=list)
    wallets_mentioned: list[str] = Field(default_factory=list)
    sentiment_score: float | None = Field(None, ge=-1.0, le=1.0)
    ai_analysis: str | None = None
    rag_embedding_id: str | None = None
    store: Literal["postgres"] = "postgres"


# ── RAG Findings (Qdrant primary + Postgres metadata) ───────────────
class RAGFinding(BaseModel):
    """A fact extracted by RAG. Qdrant primary (vector); metadata in Postgres."""

    model_config = ConfigDict(extra="ignore")

    finding_id: str = Field(..., description="UUID")
    source_type: Literal["news", "onchain", "audit", "social", "manual"]
    source_url: HttpUrl | None = None
    source_token_id: str | None = None
    source_wallet_id: str | None = None
    claim: str
    confidence: float = Field(ge=0.0, le=1.0)
    extracted_at: datetime
    qdrant_point_id: str
    store: Literal["qdrant"] = "qdrant"


# ── Reports (Postgres + MinIO) ──────────────────────────────────────
class ScanReport(BaseModel):
    """A research report. Postgres primary; markdown in MinIO."""

    model_config = ConfigDict(extra="ignore")

    report_id: str = Field(..., description="UUID")
    subject_type: Literal["token", "wallet", "deployer"]
    subject_id: str
    generated_at: datetime
    generated_by_model: str
    risk_score: int = Field(ge=0, le=100)
    risk_tier: RiskTier
    sections: dict[str, str] = Field(default_factory=dict)
    markdown_url: HttpUrl | None = None
    paid_via_x402: str | None = None
    store: Literal["postgres", "minio"] = "postgres"

    def to_markdown(self) -> str:
        """Render report sections to a single Markdown document."""
        parts = [
            f"# Research Report: {self.subject_type.title()} `{self.subject_id}`",
            "",
            f"**Generated:** {self.generated_at.isoformat()}",
            f"**Generated by:** {self.generated_by_model}",
            f"**Risk score:** {self.risk_score}/100 ({self.risk_tier.value.upper()})",
            "",
        ]
        for section, body in self.sections.items():
            parts.append(f"## {section.replace('_', ' ').title()}")
            parts.append("")
            parts.append(body)
            parts.append("")
        parts.append("---")
        parts.append(f"*Report ID: {self.report_id}*")
        return "\n".join(parts)


# ── Wire-format helpers ─────────────────────────────────────────────
def utcnow() -> datetime:
    """Timezone-aware UTC now. Pydantic serializes to ISO 8601."""
    return datetime.now(UTC)


# ── RAG engine collections (kept here so catalog + RAG share the list) ─
COLLECTIONS: list[str] = [
    # Per v4.0 catalog/RAG bridge β€” these are the canonical 13 RAG
    # collections that also have Token/Wallet/etc cross-refs.
    "scam_intel",
    "deployer_history",
    "wallet_labels",
    "contract_audit",
    "phishing_db",
    "defi_hacks",
    "rug_timeline",
    "vuln_patterns",
    "crime_reports",
    "transaction_patterns",
    "known_scams",
    "token_analysis",
    "market_intel",
]