- 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>
726 lines
26 KiB
Python
726 lines
26 KiB
Python
"""
|
|
Wallet Memory Storage - ClickHouse + Redis unified interface.
|
|
=============================================================
|
|
ClickHouse: persistent analytical store (transfers, entities, risk scores).
|
|
Redis: hot cache for real-time lookups (5min TTL on profiles, 1hr on labels).
|
|
|
|
Graceful degradation: if ClickHouse is unavailable, falls back to Redis-only
|
|
(what the old system did). If Redis is down, falls back to ClickHouse direct.
|
|
If both are down, returns empty results with a warning.
|
|
"""
|
|
|
|
import contextlib
|
|
import json
|
|
import logging
|
|
import os
|
|
from datetime import UTC, datetime
|
|
|
|
import redis.asyncio as aioredis
|
|
|
|
logger = logging.getLogger("wallet_memory.storage")
|
|
|
|
# ── ClickHouse connection (lazy, optional) ──────────────────────────
|
|
|
|
_ch_client = None
|
|
|
|
|
|
async def _get_clickhouse():
|
|
"""Lazy-init ClickHouse client. Returns None if unavailable."""
|
|
global _ch_client
|
|
if _ch_client is not None:
|
|
return _ch_client
|
|
try:
|
|
import clickhouse_driver
|
|
|
|
ch_host = os.getenv("CLICKHOUSE_HOST", "localhost")
|
|
ch_port = int(os.getenv("CLICKHOUSE_PORT", "9000"))
|
|
ch_user = os.getenv("CLICKHOUSE_USER", "default")
|
|
ch_pass = os.getenv("CLICKHOUSE_PASSWORD", "")
|
|
ch_db = os.getenv("CLICKHOUSE_DATABASE", "wallet_memory")
|
|
_ch_client = clickhouse_driver.Client(
|
|
host=ch_host,
|
|
port=ch_port,
|
|
user=ch_user,
|
|
password=ch_pass,
|
|
database=ch_db,
|
|
connect_timeout=5,
|
|
send_receive_timeout=30,
|
|
)
|
|
# Test connection
|
|
_ch_client.execute("SELECT 1")
|
|
logger.info(f"ClickHouse connected: {ch_host}:{ch_port}/{ch_db}")
|
|
return _ch_client
|
|
except Exception as e:
|
|
logger.warning(f"ClickHouse unavailable, running Redis-only: {e}")
|
|
_ch_client = None
|
|
return None
|
|
|
|
|
|
# ── Redis connection (lazy) ─────────────────────────────────────────
|
|
|
|
_redis_client: aioredis.Redis | None = None
|
|
|
|
|
|
async def _get_redis() -> aioredis.Redis | None:
|
|
"""Lazy-init Redis client. Returns None if unavailable."""
|
|
global _redis_client
|
|
if _redis_client is not None:
|
|
return _redis_client
|
|
try:
|
|
redis_url = os.getenv("REDIS_URL", "")
|
|
if redis_url and "://" in redis_url:
|
|
url = redis_url
|
|
else:
|
|
r_host = os.getenv("REDIS_HOST", "localhost")
|
|
r_port = os.getenv("REDIS_PORT", "6379")
|
|
r_db = os.getenv("REDIS_DB", "0")
|
|
r_pass = os.getenv("REDIS_PASSWORD", "")
|
|
url = f"redis://:{r_pass}@{r_host}:{r_port}/{r_db}" if r_pass else f"redis://{r_host}:{r_port}/{r_db}"
|
|
_redis_client = aioredis.from_url(url, decode_responses=True)
|
|
await _redis_client.ping()
|
|
logger.info(f"Redis connected for wallet memory: {url.split('@')[-1]}")
|
|
return _redis_client
|
|
except Exception as e:
|
|
logger.warning(f"Redis unavailable for wallet memory: {e}")
|
|
_redis_client = None
|
|
return None
|
|
|
|
|
|
# ── Schema DDL ──────────────────────────────────────────────────────
|
|
|
|
SCHEMA_SQL = """
|
|
-- Core fact table: all token transfers across all chains
|
|
CREATE TABLE IF NOT EXISTS token_transfers (
|
|
chain_id String,
|
|
block_number UInt64,
|
|
block_timestamp DateTime,
|
|
transaction_hash String,
|
|
log_index UInt32,
|
|
from_address String,
|
|
to_address String,
|
|
token_address String,
|
|
value_raw String,
|
|
value_decimal Float64,
|
|
transfer_type LowCardinality(String), -- erc20, erc721, native, spl
|
|
is_scam_related UInt8 DEFAULT 0
|
|
) ENGINE = MergeTree()
|
|
PARTITION BY toYYYYMM(block_timestamp)
|
|
ORDER BY (chain_id, block_number, transaction_hash, log_index);
|
|
|
|
-- Entity resolution: clustered wallets
|
|
CREATE TABLE IF NOT EXISTS wallet_entities (
|
|
entity_id String,
|
|
wallet_address String,
|
|
chain_id String,
|
|
heuristic_type LowCardinality(String),
|
|
confidence_score Float32,
|
|
entity_label String DEFAULT '',
|
|
entity_category LowCardinality(String) DEFAULT 'unknown',
|
|
first_linked_at DateTime DEFAULT now(),
|
|
last_updated_at DateTime DEFAULT now()
|
|
) ENGINE = ReplacingMergeTree(last_updated_at)
|
|
ORDER BY (wallet_address, chain_id);
|
|
|
|
-- Scam intelligence
|
|
CREATE TABLE IF NOT EXISTS scam_addresses (
|
|
address String,
|
|
chain_id String,
|
|
source LowCardinality(String),
|
|
threat_type LowCardinality(String),
|
|
confidence Float32,
|
|
first_seen_at DateTime,
|
|
evidence String DEFAULT ''
|
|
) ENGINE = MergeTree()
|
|
ORDER BY (address, chain_id);
|
|
|
|
-- Aggregated wallet profiles (materialized)
|
|
CREATE TABLE IF NOT EXISTS wallet_profiles (
|
|
wallet_address String,
|
|
chain_id String,
|
|
entity_id String DEFAULT '',
|
|
entity_label String DEFAULT '',
|
|
entity_category LowCardinality(String) DEFAULT 'unknown',
|
|
total_transactions UInt64 DEFAULT 0,
|
|
total_volume Float64 DEFAULT 0,
|
|
unique_counterparties UInt32 DEFAULT 0,
|
|
first_seen_at DateTime,
|
|
last_seen_at DateTime,
|
|
risk_score Float32 DEFAULT 0,
|
|
risk_level LowCardinality(String) DEFAULT 'unknown',
|
|
updated_at DateTime DEFAULT now()
|
|
) ENGINE = ReplacingMergeTree(updated_at)
|
|
ORDER BY (wallet_address, chain_id);
|
|
|
|
-- Deployer history: tokens deployed by a wallet
|
|
CREATE TABLE IF NOT EXISTS deployer_history (
|
|
deployer_address String,
|
|
chain_id String,
|
|
token_address String,
|
|
token_name String DEFAULT '',
|
|
token_symbol String DEFAULT '',
|
|
deployed_at DateTime,
|
|
outcome LowCardinality(String) DEFAULT 'unknown',
|
|
lifespan_days Float32 DEFAULT 0,
|
|
max_market_cap Float64 DEFAULT 0,
|
|
is_scam_related UInt8 DEFAULT 0,
|
|
scan_risk_score Float32 DEFAULT 0
|
|
) ENGINE = MergeTree()
|
|
PARTITION BY toYYYYMM(deployed_at)
|
|
ORDER BY (deployer_address, chain_id, deployed_at);
|
|
|
|
-- Cross-chain bridge mappings
|
|
CREATE TABLE IF NOT EXISTS bridge_mappings (
|
|
source_address String,
|
|
source_chain String,
|
|
dest_address String,
|
|
dest_chain String,
|
|
bridge_name LowCardinality(String),
|
|
tx_hash String,
|
|
bridged_at DateTime,
|
|
confidence Float32 DEFAULT 1.0
|
|
) ENGINE = MergeTree()
|
|
ORDER BY (source_address, source_chain);
|
|
"""
|
|
|
|
|
|
async def ensure_schema():
|
|
"""Create ClickHouse tables if they don't exist. Safe to call on startup."""
|
|
ch = await _get_clickhouse()
|
|
if not ch:
|
|
logger.warning("Cannot ensure ClickHouse schema - ClickHouse unavailable")
|
|
return False
|
|
try:
|
|
# Create database first
|
|
ch.execute("CREATE DATABASE IF NOT EXISTS wallet_memory")
|
|
for stmt in SCHEMA_SQL.split(";"):
|
|
stmt = stmt.strip()
|
|
if stmt and not stmt.startswith("--"):
|
|
ch.execute(stmt)
|
|
logger.info("ClickHouse schema ensured")
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"ClickHouse schema creation failed: {e}")
|
|
return False
|
|
|
|
|
|
# ── Storage interface ────────────────────────────────────────────────
|
|
|
|
|
|
class WalletStorage:
|
|
"""Unified storage with ClickHouse + Redis, transparent fallback."""
|
|
|
|
# Cache TTLs
|
|
PROFILE_TTL = 300 # 5 minutes for wallet profiles
|
|
LABEL_TTL = 3600 # 1 hour for labels
|
|
RISK_TTL = 300 # 5 minutes for risk scores
|
|
ENTITY_TTL = 600 # 10 minutes for entity lookups
|
|
|
|
def __init__(self):
|
|
self._ch = None
|
|
self._redis = None
|
|
|
|
async def _ensure_connections(self):
|
|
"""Lazy connect on first use."""
|
|
if self._ch is None:
|
|
self._ch = await _get_clickhouse()
|
|
if self._redis is None:
|
|
self._redis = await _get_redis()
|
|
|
|
# ── Wallet profile ───────────────────────────────────────────
|
|
|
|
async def get_wallet_profile(self, address: str, chain_id: str) -> dict | None:
|
|
"""Get wallet profile with cache-first strategy."""
|
|
await self._ensure_connections()
|
|
cache_key = f"wm:profile:{chain_id}:{address.lower()}"
|
|
|
|
# Try Redis cache
|
|
if self._redis:
|
|
try:
|
|
cached = await self._redis.get(cache_key)
|
|
if cached:
|
|
return json.loads(cached)
|
|
except Exception:
|
|
pass
|
|
|
|
# Try ClickHouse
|
|
profile = None
|
|
if self._ch:
|
|
try:
|
|
rows = self._ch.execute(
|
|
"SELECT * FROM wallet_profiles WHERE wallet_address = %(addr)s AND chain_id = %(chain)s",
|
|
{"addr": address.lower(), "chain": chain_id},
|
|
)
|
|
if rows:
|
|
cols = [d[0] for d in self._ch.execute("DESCRIBE wallet_profiles")]
|
|
profile = dict(zip(cols, rows[0], strict=False))
|
|
except Exception as e:
|
|
logger.debug(f"ClickHouse profile query failed: {e}")
|
|
|
|
# Cache the result (even None for negative caching, short TTL)
|
|
if self._redis:
|
|
try:
|
|
ttl = self.PROFILE_TTL if profile else 60 # Negative cache: 1 min
|
|
await self._redis.set(cache_key, json.dumps(profile), ex=ttl)
|
|
except Exception:
|
|
pass
|
|
|
|
return profile
|
|
|
|
async def save_wallet_profile(self, profile: dict) -> bool:
|
|
"""Persist wallet profile to both ClickHouse and Redis cache."""
|
|
await self._ensure_connections()
|
|
addr = profile.get("wallet_address", "").lower()
|
|
chain = profile.get("chain_id", "")
|
|
|
|
# Save to ClickHouse
|
|
if self._ch:
|
|
try:
|
|
self._ch.execute(
|
|
"""INSERT INTO wallet_profiles
|
|
(wallet_address, chain_id, entity_id, entity_label, entity_category,
|
|
total_transactions, total_volume, unique_counterparties,
|
|
first_seen_at, last_seen_at, risk_score, risk_level, updated_at)
|
|
VALUES""",
|
|
[
|
|
(
|
|
addr,
|
|
chain,
|
|
profile.get("entity_id", ""),
|
|
profile.get("entity_label", ""),
|
|
profile.get("entity_category", "unknown"),
|
|
profile.get("total_transactions", 0),
|
|
profile.get("total_volume", 0.0),
|
|
profile.get("unique_counterparties", 0),
|
|
profile.get("first_seen_at", datetime(2020, 1, 1)),
|
|
profile.get("last_seen_at", datetime.now(UTC)),
|
|
profile.get("risk_score", 0.0),
|
|
profile.get("risk_level", "unknown"),
|
|
datetime.now(UTC),
|
|
)
|
|
],
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"ClickHouse profile save failed: {e}")
|
|
|
|
# Update Redis cache
|
|
if self._redis:
|
|
try:
|
|
cache_key = f"wm:profile:{chain}:{addr}"
|
|
await self._redis.set(cache_key, json.dumps(profile), ex=self.PROFILE_TTL)
|
|
except Exception:
|
|
pass
|
|
|
|
return True
|
|
|
|
# ── Entity lookups ──────────────────────────────────────────
|
|
|
|
async def get_entity_for_wallet(self, address: str, chain_id: str) -> dict | None:
|
|
"""Get entity membership for a wallet address."""
|
|
await self._ensure_connections()
|
|
cache_key = f"wm:entity:{chain_id}:{address.lower()}"
|
|
|
|
if self._redis:
|
|
try:
|
|
cached = await self._redis.get(cache_key)
|
|
if cached:
|
|
return json.loads(cached)
|
|
except Exception:
|
|
pass
|
|
|
|
result = None
|
|
if self._ch:
|
|
try:
|
|
rows = self._ch.execute(
|
|
"""SELECT entity_id, entity_label, entity_category, confidence_score, heuristic_type
|
|
FROM wallet_entities
|
|
WHERE wallet_address = %(addr)s AND chain_id = %(chain)s
|
|
ORDER BY confidence_score DESC LIMIT 1""",
|
|
{"addr": address.lower(), "chain": chain_id},
|
|
)
|
|
if rows:
|
|
result = {
|
|
"entity_id": rows[0][0],
|
|
"entity_label": rows[0][1],
|
|
"entity_category": rows[0][2],
|
|
"confidence_score": rows[0][3],
|
|
"heuristic_type": rows[0][4],
|
|
}
|
|
except Exception as e:
|
|
logger.debug(f"ClickHouse entity query failed: {e}")
|
|
|
|
if self._redis and result:
|
|
with contextlib.suppress(Exception):
|
|
await self._redis.set(cache_key, json.dumps(result), ex=self.ENTITY_TTL)
|
|
|
|
return result
|
|
|
|
async def get_entity_wallets(self, entity_id: str) -> list[dict]:
|
|
"""Get all wallets linked to an entity across all chains."""
|
|
await self._ensure_connections()
|
|
if not self._ch:
|
|
return []
|
|
|
|
try:
|
|
rows = self._ch.execute(
|
|
"""SELECT wallet_address, chain_id, confidence_score, heuristic_type, entity_category
|
|
FROM wallet_entities WHERE entity_id = %(eid)s
|
|
ORDER BY confidence_score DESC""",
|
|
{"eid": entity_id},
|
|
)
|
|
return [
|
|
{
|
|
"wallet_address": r[0],
|
|
"chain_id": r[1],
|
|
"confidence_score": r[2],
|
|
"heuristic_type": r[3],
|
|
"entity_category": r[4],
|
|
}
|
|
for r in rows
|
|
]
|
|
except Exception as e:
|
|
logger.debug(f"ClickHouse entity wallets query failed: {e}")
|
|
return []
|
|
|
|
async def save_entity_link(
|
|
self,
|
|
entity_id: str,
|
|
wallet_address: str,
|
|
chain_id: str,
|
|
heuristic_type: str,
|
|
confidence: float,
|
|
label: str = "",
|
|
category: str = "unknown",
|
|
) -> bool:
|
|
"""Save an entity-wallet link to ClickHouse."""
|
|
if not self._ch:
|
|
# Fallback: store in Redis set
|
|
if self._redis:
|
|
try:
|
|
key = f"wm:entity_members:{entity_id}"
|
|
await self._redis.sadd(
|
|
key,
|
|
json.dumps(
|
|
{
|
|
"wallet_address": wallet_address.lower(),
|
|
"chain_id": chain_id,
|
|
"heuristic_type": heuristic_type,
|
|
"confidence": confidence,
|
|
}
|
|
),
|
|
)
|
|
except Exception:
|
|
pass
|
|
return True
|
|
|
|
try:
|
|
self._ch.execute(
|
|
"""INSERT INTO wallet_entities
|
|
(entity_id, wallet_address, chain_id, heuristic_type,
|
|
confidence_score, entity_label, entity_category, first_linked_at, last_updated_at)
|
|
VALUES""",
|
|
[
|
|
(
|
|
entity_id,
|
|
wallet_address.lower(),
|
|
chain_id,
|
|
heuristic_type,
|
|
confidence,
|
|
label,
|
|
category,
|
|
datetime.now(UTC),
|
|
datetime.now(UTC),
|
|
)
|
|
],
|
|
)
|
|
# Invalidate cache
|
|
if self._redis:
|
|
try:
|
|
cache_key = f"wm:entity:{chain_id}:{wallet_address.lower()}"
|
|
await self._redis.delete(cache_key)
|
|
except Exception:
|
|
pass
|
|
return True
|
|
except Exception as e:
|
|
logger.warning(f"Entity link save failed: {e}")
|
|
return False
|
|
|
|
# ── Scam addresses ───────────────────────────────────────────
|
|
|
|
async def check_scam_address(self, address: str, chain_id: str) -> dict | None:
|
|
"""Check if an address is in the scam database."""
|
|
await self._ensure_connections()
|
|
cache_key = f"wm:scam:{chain_id}:{address.lower()}"
|
|
|
|
if self._redis:
|
|
try:
|
|
cached = await self._redis.get(cache_key)
|
|
if cached:
|
|
return json.loads(cached)
|
|
except Exception:
|
|
pass
|
|
|
|
result = None
|
|
if self._ch:
|
|
try:
|
|
rows = self._ch.execute(
|
|
"""SELECT source, threat_type, confidence, first_seen_at, evidence
|
|
FROM scam_addresses
|
|
WHERE address = %(addr)s AND chain_id = %(chain)s""",
|
|
{"addr": address.lower(), "chain": chain_id},
|
|
)
|
|
if rows:
|
|
result = {
|
|
"source": rows[0][0],
|
|
"threat_type": rows[0][1],
|
|
"confidence": rows[0][2],
|
|
"first_seen_at": str(rows[0][3]),
|
|
"evidence": rows[0][4],
|
|
}
|
|
except Exception as e:
|
|
logger.debug(f"ClickHouse scam query failed: {e}")
|
|
|
|
if self._redis:
|
|
with contextlib.suppress(Exception):
|
|
await self._redis.set(cache_key, json.dumps(result), ex=self.LABEL_TTL)
|
|
|
|
return result
|
|
|
|
async def save_scam_address(
|
|
self,
|
|
address: str,
|
|
chain_id: str,
|
|
source: str,
|
|
threat_type: str,
|
|
confidence: float = 1.0,
|
|
evidence: str = "",
|
|
) -> bool:
|
|
"""Add address to scam database."""
|
|
if self._ch:
|
|
try:
|
|
self._ch.execute(
|
|
"""INSERT INTO scam_addresses
|
|
(address, chain_id, source, threat_type, confidence, first_seen_at, evidence)
|
|
VALUES""",
|
|
[
|
|
(
|
|
address.lower(),
|
|
chain_id,
|
|
source,
|
|
threat_type,
|
|
confidence,
|
|
datetime.now(UTC),
|
|
evidence,
|
|
)
|
|
],
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Scam address save failed: {e}")
|
|
|
|
# Update Redis
|
|
if self._redis:
|
|
try:
|
|
cache_key = f"wm:scam:{chain_id}:{address.lower()}"
|
|
data = {"source": source, "threat_type": threat_type, "confidence": confidence}
|
|
await self._redis.set(cache_key, json.dumps(data), ex=self.LABEL_TTL)
|
|
# Also add to Redis set for fast batch checks
|
|
await self._redis.sadd(f"wm:scam_set:{chain_id}", address.lower())
|
|
except Exception:
|
|
pass
|
|
return True
|
|
|
|
# ── Deployer history ─────────────────────────────────────────
|
|
|
|
async def get_deployer_history(self, address: str, chain_id: str = "") -> list[dict]:
|
|
"""Get all tokens deployed by a wallet across chains."""
|
|
await self._ensure_connections()
|
|
if not self._ch:
|
|
return []
|
|
|
|
try:
|
|
if chain_id:
|
|
rows = self._ch.execute(
|
|
"""SELECT chain_id, token_address, token_name, token_symbol,
|
|
deployed_at, outcome, lifespan_days, max_market_cap,
|
|
is_scam_related, scan_risk_score
|
|
FROM deployer_history
|
|
WHERE deployer_address = %(addr)s AND chain_id = %(chain)s
|
|
ORDER BY deployed_at DESC LIMIT 50""",
|
|
{"addr": address.lower(), "chain": chain_id},
|
|
)
|
|
else:
|
|
rows = self._ch.execute(
|
|
"""SELECT chain_id, token_address, token_name, token_symbol,
|
|
deployed_at, outcome, lifespan_days, max_market_cap,
|
|
is_scam_related, scan_risk_score
|
|
FROM deployer_history
|
|
WHERE deployer_address = %(addr)s
|
|
ORDER BY deployed_at DESC LIMIT 100""",
|
|
{"addr": address.lower()},
|
|
)
|
|
return [
|
|
{
|
|
"chain_id": r[0],
|
|
"token_address": r[1],
|
|
"token_name": r[2],
|
|
"token_symbol": r[3],
|
|
"deployed_at": str(r[4]),
|
|
"outcome": r[5],
|
|
"lifespan_days": r[6],
|
|
"max_market_cap": r[7],
|
|
"is_scam_related": r[8],
|
|
"scan_risk_score": r[9],
|
|
}
|
|
for r in rows
|
|
]
|
|
except Exception as e:
|
|
logger.debug(f"ClickHouse deployer history query failed: {e}")
|
|
return []
|
|
|
|
async def save_deployer_event(
|
|
self,
|
|
deployer: str,
|
|
chain_id: str,
|
|
token_address: str,
|
|
token_name: str = "",
|
|
token_symbol: str = "",
|
|
deployed_at: datetime | None = None,
|
|
outcome: str = "unknown",
|
|
is_scam: bool = False,
|
|
risk_score: float = 0.0,
|
|
) -> bool:
|
|
"""Record a token deployment by a wallet."""
|
|
if self._ch:
|
|
try:
|
|
self._ch.execute(
|
|
"""INSERT INTO deployer_history
|
|
(deployer_address, chain_id, token_address, token_name, token_symbol,
|
|
deployed_at, outcome, is_scam_related, scan_risk_score)
|
|
VALUES""",
|
|
[
|
|
(
|
|
deployer.lower(),
|
|
chain_id,
|
|
token_address,
|
|
token_name,
|
|
token_symbol,
|
|
deployed_at or datetime.now(UTC),
|
|
outcome,
|
|
1 if is_scam else 0,
|
|
risk_score,
|
|
)
|
|
],
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Deployer event save failed: {e}")
|
|
return True
|
|
|
|
# ── Bridge mappings ──────────────────────────────────────────
|
|
|
|
async def get_bridge_links(self, address: str, chain_id: str) -> list[dict]:
|
|
"""Get cross-chain bridge mappings for a wallet."""
|
|
await self._ensure_connections()
|
|
if not self._ch:
|
|
return []
|
|
try:
|
|
rows = self._ch.execute(
|
|
"""SELECT dest_address, dest_chain, bridge_name, tx_hash, bridged_at, confidence
|
|
FROM bridge_mappings
|
|
WHERE source_address = %(addr)s AND source_chain = %(chain)s""",
|
|
{"addr": address.lower(), "chain": chain_id},
|
|
)
|
|
return [
|
|
{
|
|
"dest_address": r[0],
|
|
"dest_chain": r[1],
|
|
"bridge_name": r[2],
|
|
"tx_hash": r[3],
|
|
"bridged_at": str(r[4]),
|
|
"confidence": r[5],
|
|
}
|
|
for r in rows
|
|
]
|
|
except Exception as e:
|
|
logger.debug(f"ClickHouse bridge query failed: {e}")
|
|
return []
|
|
|
|
async def save_bridge_link(
|
|
self,
|
|
source_addr: str,
|
|
source_chain: str,
|
|
dest_addr: str,
|
|
dest_chain: str,
|
|
bridge_name: str,
|
|
tx_hash: str,
|
|
confidence: float = 1.0,
|
|
) -> bool:
|
|
"""Save a cross-chain bridge mapping."""
|
|
if self._ch:
|
|
try:
|
|
self._ch.execute(
|
|
"""INSERT INTO bridge_mappings
|
|
(source_address, source_chain, dest_address, dest_chain,
|
|
bridge_name, tx_hash, bridged_at, confidence)
|
|
VALUES""",
|
|
[
|
|
(
|
|
source_addr.lower(),
|
|
source_chain,
|
|
dest_addr.lower(),
|
|
dest_chain,
|
|
bridge_name,
|
|
tx_hash,
|
|
datetime.now(UTC),
|
|
confidence,
|
|
)
|
|
],
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Bridge link save failed: {e}")
|
|
return True
|
|
|
|
# ── Quick Redis-only lookups (backward compat) ────────────────
|
|
|
|
async def redis_get(self, key: str) -> str | None:
|
|
"""Direct Redis get for backward compatibility with wallet_label_loader."""
|
|
if self._redis:
|
|
try:
|
|
return await self._redis.get(key)
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
async def redis_set(self, key: str, value: str, ttl: int = 0) -> bool:
|
|
"""Direct Redis set with optional TTL."""
|
|
if self._redis:
|
|
try:
|
|
if ttl:
|
|
await self._redis.set(key, value, ex=ttl)
|
|
else:
|
|
await self._redis.set(key, value)
|
|
return True
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
async def redis_sadd(self, key: str, *values: str) -> bool:
|
|
"""Add to Redis set."""
|
|
if self._redis:
|
|
try:
|
|
await self._redis.sadd(key, *values)
|
|
return True
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
|
|
# ── Global singleton ─────────────────────────────────────────────────
|
|
|
|
_storage: WalletStorage | None = None
|
|
|
|
|
|
def get_storage() -> WalletStorage:
|
|
"""Get global storage instance."""
|
|
global _storage
|
|
if _storage is None:
|
|
_storage = WalletStorage()
|
|
return _storage
|