"""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", ]