File size: 27,155 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
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
"""
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