rmi-backend/app/wallet_memory/engine.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- Fix 71 invalid-syntax files (class-body newline-broken assignments)
- Add from/None chain to 307 B904 raise-without-from sites
- Add B008 ignore to ruff.toml (already in pyproject.toml)
- Noqa F401 on __init__.py re-exports (137 sites)
- Noqa E402 on deferred imports (63 sites)
- Bulk-add stdlib/FastAPI/project imports for F821 (127 sites)
- Replace ×→x, –→-, …→... in docstrings (4093 chars)
- Manual refactor of 5 SIM103/SIM116 patterns

Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py)
Co-authored-by: opencode <opencode@rugmunch.io>
2026-07-06 15:43:20 +02:00

472 lines
19 KiB
Python

"""
Wallet Memory Engine - Orchestrator for the Wallet Memory Bank.
================================================================
Single entry point for all wallet intelligence queries. Combines:
- Storage (ClickHouse + Redis)
- Clustering (6 heuristics, Union-Find)
- Labels (6 live sources + static databases)
- Risk scoring (composite: label + cluster + deployer history)
- Entity resolution (cross-chain linking)
Primary API:
get_deployer_intelligence(address, chain) - what SENTINEL calls
get_wallet_profile(address, chain) - what WalletSafe frontend calls
flag_deployer(address, chain, reason) - the data flywheel from scans
"""
import logging
from datetime import UTC, datetime
from typing import Any
from .clustering import (
WalletClusteringEngine,
)
from .entity_resolver import EntityResolver
from .ingestion import get_ingestion_pipeline
from .labels import get_label_service
from .risk_scoring import RiskScorer
from .storage import WalletStorage, get_storage
logger = logging.getLogger("wallet_memory.engine")
class WalletMemoryEngine:
"""
The brain of the Wallet Memory Bank.
Coordinates storage, clustering, labels, risk scoring, and entity resolution.
"""
def __init__(self, storage: WalletStorage | None = None):
self.storage = storage or get_storage()
self.clustering = WalletClusteringEngine()
self.labels = get_label_service()
self.risk_scorer = RiskScorer(self.storage, self.labels, self.clustering)
self.entity_resolver = EntityResolver(self.storage)
self.ingestion = get_ingestion_pipeline(engine=self, storage=self.storage)
# ── Primary API: Deployer Intelligence (SENTINEL calls this) ───
async def get_deployer_intelligence(self, address: str, chain: str) -> dict[str, Any]:
"""
One-shot call returning everything the token scanner needs about a deployer.
Returns:
{
"address": str,
"chain": str,
"entity_id": str | None,
"entity_label": str,
"entity_category": str,
"linked_wallets": [...],
"risk_score": float (0-100),
"risk_level": str,
"scam_associations": [...],
"deployer_history": [...],
"cross_chain_presence": [...],
"labels": [...],
"confidence": float,
}
"""
addr = address.lower().strip()
result: dict[str, Any] = {
"address": addr,
"chain": chain,
"entity_id": None,
"entity_label": "",
"entity_category": "unknown",
"linked_wallets": [],
"risk_score": 0.0,
"risk_level": "unknown",
"scam_associations": [],
"deployer_history": [],
"cross_chain_presence": [],
"labels": [],
"confidence": 0.0,
}
# 1. Labels (from 6 live sources + static databases)
try:
label_report = await self.labels.resolve(addr, chain)
if label_report:
result["labels"] = [
{
"source": line.get("source", ""),
"label": line.get("label", ""),
"category": line.get("category", ""),
"confidence": line.get("confidence", 1.0),
}
for line in label_report.get("labels", [])
]
result["entity_label"] = label_report.get("primary_label", "")
result["entity_category"] = label_report.get("primary_category", "")
if label_report.get("is_known_scam"):
result["scam_associations"].append(
{
"source": "address_labeler",
"type": "known_scam",
"label": label_report.get("primary_label", ""),
}
)
if label_report.get("is_sanctioned"):
result["scam_associations"].append(
{
"source": "ofac",
"type": "sanctioned",
"label": "OFAC sanctioned entity",
}
)
except Exception as e:
logger.debug(f"Label resolution failed for {addr}: {e}")
# 2. Entity from storage (ClickHouse / Redis)
try:
entity = await self.storage.get_entity_for_wallet(addr, chain)
if entity:
result["entity_id"] = entity.get("entity_id")
if not result["entity_label"] and entity.get("entity_label"):
result["entity_label"] = entity["entity_label"]
if entity.get("entity_category") and result["entity_category"] == "unknown":
result["entity_category"] = entity["entity_category"]
result["confidence"] = entity.get("confidence_score", 0.0)
# Get linked wallets in this entity
if entity.get("entity_id"):
linked = await self.storage.get_entity_wallets(entity["entity_id"])
result["linked_wallets"] = [
{
"address": w["wallet_address"],
"chain": w["chain_id"],
"confidence": w["confidence_score"],
}
for w in linked
if w["wallet_address"].lower() != addr
]
except Exception as e:
logger.debug(f"Entity lookup failed for {addr}: {e}")
# 3. Scam database check
try:
scam = await self.storage.check_scam_address(addr, chain)
if scam:
result["scam_associations"].append(
{
"source": scam.get("source", "unknown"),
"type": scam.get("threat_type", "unknown"),
"confidence": scam.get("confidence", 1.0),
}
)
except Exception as e:
logger.debug(f"Scam check failed for {addr}: {e}")
# 4. Deployer history
try:
history = await self.storage.get_deployer_history(addr, chain)
result["deployer_history"] = history
except Exception as e:
logger.debug(f"Deployer history failed for {addr}: {e}")
# 5. Cross-chain presence (bridge mappings)
try:
bridges = await self.storage.get_bridge_links(addr, chain)
result["cross_chain_presence"] = bridges
except Exception as e:
logger.debug(f"Bridge lookup failed for {addr}: {e}")
# 6. Composite risk score
try:
risk = await self.risk_scorer.score(addr, chain, result)
result["risk_score"] = risk.get("score", 0.0)
result["risk_level"] = risk.get("level", "unknown")
except Exception as e:
logger.debug(f"Risk scoring failed for {addr}: {e}")
# 7. Cluster from in-memory engine (if data was fed)
try:
cluster = self.clustering.get_cluster_for_wallet(addr)
if cluster and len(cluster.wallets) > 1:
# Merge cluster members into linked_wallets if not already present
existing_addrs = {w["address"].lower() for w in result["linked_wallets"]}
for w in cluster.wallets:
if w.lower() != addr and w.lower() not in existing_addrs:
result["linked_wallets"].append(
{
"address": w,
"chain": chain,
"confidence": round(cluster.confidence, 3),
"heuristic": "clustering",
}
)
# Boost confidence from multiple detection methods
if cluster.detection_methods:
method_bonus = min(0.2, len(cluster.detection_methods) * 0.05)
result["confidence"] = min(0.99, result["confidence"] + method_bonus)
except Exception as e:
logger.debug(f"Cluster lookup failed for {addr}: {e}")
return result
# ── Primary API: Wallet Profile (WalletSafe frontend calls this) ─
async def get_wallet_profile(self, address: str, chain: str, depth: int = 1) -> dict[str, Any]:
"""
Full wallet profile for the WalletSafe product.
Includes entity info, risk, labels, cluster, and connected wallets.
Auto-triggers ingestion pipeline when profile is empty or stale.
"""
addr = address.lower().strip()
# Check storage first (may have been enriched before)
stored = await self.storage.get_wallet_profile(addr, chain)
# ── Auto-ingestion: fetch fresh data if profile is empty or stale ──
if self.ingestion and self.ingestion.should_ingest(addr, chain, stored):
try:
ingest_ok = await self.ingestion.ingest_if_needed(addr, chain)
if ingest_ok:
logger.info(f"Ingestion triggered for {addr} on {chain}")
# Re-fetch stored profile after ingestion may have updated it
stored = await self.storage.get_wallet_profile(addr, chain)
except Exception as e:
logger.debug(f"Ingestion trigger failed for {addr}: {e}")
# Build fresh profile
intel = await self.get_deployer_intelligence(addr, chain)
profile = {
"address": addr,
"chain": chain,
"entity_id": intel.get("entity_id"),
"entity_label": intel.get("entity_label"),
"entity_category": intel.get("entity_category"),
"risk_score": intel.get("risk_score", 0),
"risk_level": intel.get("risk_level", "unknown"),
"labels": intel.get("labels", []),
"scam_associations": intel.get("scam_associations", []),
"deployer_history": intel.get("deployer_history", []),
"cross_chain_presence": intel.get("cross_chain_presence", []),
"linked_wallets": intel.get("linked_wallets", []),
"confidence": intel.get("confidence", 0),
# From stored profile if available
"total_transactions": stored.get("total_transactions", 0) if stored else 0,
"total_volume": stored.get("total_volume", 0) if stored else 0,
"first_seen": stored.get("first_seen_at") if stored else None,
"last_seen": stored.get("last_seen_at") if stored else None,
}
# Save/refresh the profile
try:
await self._persist_profile(profile)
except Exception as e:
logger.debug(f"Profile persist failed: {e}")
return profile
# ── Data flywheel: flag deployer from scan results ────────────
async def flag_deployer(
self,
address: str,
chain: str,
reason: str = "high_risk_token",
token: str = "",
score: float = 0.0,
) -> bool:
"""
Feed scan results back into the Wallet Memory Bank.
This is the data flywheel: scans produce intelligence that makes future scans better.
"""
addr = address.lower().strip()
# Save scam address if high risk
if score >= 70:
try:
await self.storage.save_scam_address(
address=addr,
chain_id=chain,
source="sentinel_scan",
threat_type=reason,
confidence=round(min(1.0, score / 100), 2),
evidence=f"token={token},risk_score={score:.1f}",
)
except Exception as e:
logger.warning(f"Failed to flag deployer {addr}: {e}")
# Record the deployment event
if token:
try:
is_scam = score >= 70
await self.storage.save_deployer_event(
deployer=addr,
chain_id=chain,
token_address=token,
outcome="flagged" if is_scam else "unknown",
is_scam=is_scam,
risk_score=round(score / 100, 2),
deployed_at=datetime.now(UTC),
)
except Exception as e:
logger.warning(f"Failed to save deployer event for {addr}: {e}")
# Update the wallet profile with new risk info
try:
stored = await self.storage.get_wallet_profile(addr, chain) or {}
new_risk = max(stored.get("risk_score", 0) or 0, score)
await self.storage.save_wallet_profile(
{
"wallet_address": addr,
"chain_id": chain,
"risk_score": round(new_risk / 100, 2),
"risk_level": _risk_level(new_risk),
"entity_id": stored.get("entity_id", ""),
"entity_label": stored.get("entity_label", ""),
"entity_category": stored.get("entity_category", "unknown"),
"total_transactions": stored.get("total_transactions", 0),
"total_volume": stored.get("total_volume", 0),
"unique_counterparties": stored.get("unique_counterparties", 0),
"first_seen_at": stored.get("first_seen_at", datetime.now(UTC)),
"last_seen_at": datetime.now(UTC),
}
)
except Exception as e:
logger.debug(f"Profile update after flag failed: {e}")
logger.info(f"Flagged {addr} on {chain}: reason={reason}, score={score:.1f}")
return True
# ── Search across all wallets ────────────────────────────────
async def search_wallets(self, query: str, chain: str = "", limit: int = 20) -> list[dict]:
"""
Search for wallets by address prefix, label, or entity name.
Uses Redis search first, then ClickHouse if available.
"""
results = []
q = query.lower().strip()
# Try Redis key prefix scan
try:
redis = self.storage._redis
if not redis:
await self.storage._ensure_connections()
redis = self.storage._redis
if redis:
# Scan profile keys
pattern = f"wm:profile:*{q}*"
async for key in redis.scan_iter(match=pattern, count=100):
cached = await redis.get(key)
if cached:
import json
profile = json.loads(cached)
if len(results) < limit:
results.append(profile)
except Exception as e:
logger.debug(f"Redis search failed: {e}")
return results[:limit]
# ── Feed transactions into clustering engine ─────────────────
def feed_transactions(self, transactions: list[dict], chain_id: str = ""):
"""
Feed transaction data into the in-memory clustering engine.
Call this when pulling data from Helius/Etherscan for a wallet.
Expected transaction dict keys:
address, counterparty, timestamp (datetime or ISO string), amount, program
"""
for tx in transactions:
addr = tx.get("address", tx.get("from", "")).lower()
cp = tx.get("counterparty", tx.get("to", "")).lower()
ts = tx.get("timestamp")
if isinstance(ts, str):
from datetime import datetime as dt
ts = dt.fromisoformat(ts.replace("Z", "+00:00"))
elif not isinstance(ts, datetime):
ts = datetime.now(UTC)
self.clustering.add_transaction(
address=addr,
counterparty=cp,
timestamp=ts,
amount=tx.get("amount", 0),
chain_id=chain_id,
program=tx.get("program", ""),
)
# ── Persist clusters to storage ──────────────────────────────
async def persist_clusters(self) -> int:
"""Run all heuristics and persist resulting clusters to ClickHouse/Redis."""
clusters = self.clustering.run_all_heuristics()
persisted = 0
for cluster in clusters:
for wallet in cluster.wallets:
try:
await self.storage.save_entity_link(
entity_id=cluster.cluster_id,
wallet_address=wallet,
chain_id="", # Cross-chain cluster
heuristic_type=",".join(cluster.detection_methods),
confidence=cluster.confidence,
label=cluster.label,
category=cluster.category,
)
persisted += 1
except Exception as e:
logger.debug(f"Entity link persist failed for {wallet}: {e}")
logger.info(f"Persisted {persisted} entity links from {len(clusters)} clusters")
return persisted
# ── Internal ─────────────────────────────────────────────────
async def _persist_profile(self, profile: dict):
"""Save a wallet profile to storage."""
await self.storage.save_wallet_profile(
{
"wallet_address": profile["address"],
"chain_id": profile["chain"],
"entity_id": profile.get("entity_id", ""),
"entity_label": profile.get("entity_label", ""),
"entity_category": profile.get("entity_category", "unknown"),
"risk_score": profile.get("risk_score", 0),
"risk_level": profile.get("risk_level", "unknown"),
"total_transactions": profile.get("total_transactions", 0),
"total_volume": profile.get("total_volume", 0),
"unique_counterparties": 0,
"first_seen_at": profile.get("first_seen_at") or datetime.now(UTC),
"last_seen_at": datetime.now(UTC),
}
)
# ── Risk level helper ───────────────────────────────────────────
def _risk_level(score: float) -> str:
if score >= 80:
return "critical"
elif score >= 60:
return "high"
elif score >= 40:
return "medium"
elif score >= 20:
return "low"
return "minimal"
# ── Global singleton ────────────────────────────────────────────
_engine: WalletMemoryEngine | None = None
def get_wallet_engine() -> WalletMemoryEngine:
"""Get global wallet memory engine instance."""
global _engine
if _engine is None:
_engine = WalletMemoryEngine()
return _engine