File size: 18,357 Bytes
bde2f3a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
SENTINEL Contract Diff — bytecode hash comparison against known rug patterns
==============================================================================
Compares a token's contract bytecode/ABI against a database of known rug contracts.
Detects clones, forks, and near-identical contracts by hashing function selectors,
bytecode sections, and metadata patterns.

Key features:
- EVM bytecode hashing (creation code + runtime code)
- Function selector fingerprinting (4byte directory style)
- Known-rug bytecode database via RAG
- Similarity scoring against known malicious contracts
- Contract clone detection (same creator, same code, different name)
"""

import hashlib
import logging
import re
from dataclasses import dataclass, field
from typing import Any

logger = logging.getLogger("contract_diff")


@dataclass
class ContractDiffReport:
    """Result of contract diff analysis."""

    token_address: str
    chain: str
    risk_score: float = 0.0
    risk_level: str = "LOW"

    # Contract identification
    is_verified: bool = False
    contract_name: str | None = None
    bytecode_hash: str | None = None
    runtime_bytecode_hash: str | None = None

    # Function selectors
    function_selectors: list[str] = field(default_factory=list)
    selector_count: int = 0

    # Dangerous function signatures found
    dangerous_functions: list[str] = field(default_factory=list)

    # Clone detection
    is_clone: bool = False
    clone_of: str | None = None  # Address of matching contract
    clone_similarity: float = 0.0

    # Known rug matches
    known_rug_matches: list[dict[str, Any]] = field(default_factory=list)

    # RAG citations
    citations: list[dict[str, Any]] = field(default_factory=list)

    warnings: list[str] = field(default_factory=list)


# Dangerous function selectors (4-byte signatures of known rug functions)
DANGEROUS_SELECTORS = {
    "0x3ccfd60b": "withdraw()",
    "0x2e1a7d4d": "withdraw(uint256)",
    "0x51cff8d9": "withdrawAll()",
    "0x1e83409a": "emergencyWithdraw()",
    "0x715018a6": "renounceOwnership()",
    "0xf2fde38b": "transferOwnership(address)",
    "0x8da5cb5b": "owner()",
    "0x7f4e2f42": "setOwner(address)",
    "0x42966c68": "burn(uint256)",
    "0xa9059cbb": "transfer(address,uint256)",
    "0x095ea7b3": "approve(address,uint256)",
    "0x23b872dd": "transferFrom(address,address,uint256)",
    "0x40c10f19": "mint(address,uint256)",
    "0xa0712d68": "mint(uint256)",
    "0x6a627842": "mint(address)",
    "0x00fdd58e": "mint(address,uint256)",
    "0x4f1ef286": "upgradeToAndCall(address,bytes)",
    "0x3659cfe6": "upgradeTo(address)",
    "0x5c60da1b": "implementation()",
    "0x8f283970": "drain()",
    "0x3f4ba83a": "unpause()",
    "0x8456cb59": "pause()",
    "0xf48f7360": "setFee(uint256)",
    "0x092f3ee0": "setMaxTxAmount(uint256)",
    "0xc8b3bf68": "setWalletLimit(uint256)",
    "0x6080c494": "disableLimits()",
    "0xd8a9be58": "enableTrading()",
    "0x4a8b62d1": "setSwapBackSettings(uint256,bool)",
}

# Patterns that strongly indicate rug contracts
RUG_BYTECODE_PATTERNS = [
    # Hidden mint functions
    rb"hidden\s+mint",
    rb"airdropClaim",
    rb"airdrop_register",
    # Fee manipulation
    rb"setFeeRate",
    rb"setTaxFee",
    rb"setMaxFee",
    # Anti-selling mechanisms
    rb"isBlacklisted",
    rb"_blacklist",
    rb"blacklisted",
    rb"tradingOpen",
    rb"tradingEnabled",
    rb"launchedAt",
]


class ContractDiffAnalyzer:
    """Compare contract bytecode against known rug patterns and clone databases."""

    def __init__(self):
        self._initialized = False
        self._rug_hashes: dict[str, dict[str, Any]] = {}
        self._selector_map: dict[str, list[str]] = {}  # selector -> contract addresses

    async def _ensure_init(self):
        """Load known rug patterns from RAG on first use."""
        if self._initialized:
            return

        try:
            from app.scanners.rag_citations import query_rag_citations

            # Load known rug contract hashes from RAG
            cits = await query_rag_citations(
                topic="known rug contract bytecode hash clone",
                chain="ethereum",
                scanner_type="contract_diff",
                max_citations=20,
                min_similarity=0.3,
            )
            for c in cits:
                meta = c.get("metadata", {})
                addr = meta.get("address", "")
                bhash = meta.get("bytecode_hash", "")
                if addr and bhash:
                    self._rug_hashes[bhash] = {
                        "address": addr,
                        "name": meta.get("name", "unknown"),
                        "flagged_as": meta.get("flagged_as", "rug"),
                        "similarity": c.get("similarity", 0),
                        "collection": c.get("collection", ""),
                        "reference": c.get("reference", ""),
                    }

            self._initialized = True
            if self._rug_hashes:
                logger.info(f"Loaded {len(self._rug_hashes)} known rug bytecode hashes")
        except Exception as e:
            logger.warning(f"Could not load rug hashes: {e}")
            self._initialized = True  # Don't retry on every call

    def _hash_bytecode(self, bytecode: str) -> str:
        """Hash bytecode for comparison. Strip metadata hash first."""
        # Remove CBOR metadata from end of bytecode (introduced in Solidity 0.6+)
        # The metadata hash is after the last STOP (0x00) opcode boundary
        clean = bytecode.lower().replace("0x", "")

        # Try to strip Solidity metadata (CBOR-encoded)
        # Look for known metadata markers
        for marker in ["a1", "a2", "65766d", "627a6574"]:  # CBOR + solc markers
            idx = clean.rfind(marker)
            if idx > len(clean) // 2:  # Only strip if near the end
                clean = clean[:idx]

        return hashlib.sha256(clean.encode()).hexdigest()

    def _extract_selectors(self, bytecode: str) -> list[str]:
        """Extract 4-byte function selectors from bytecode using PUSH4 pattern."""
        selectors = []
        clean = bytecode.lower().replace("0x", "")

        # PUSH4 = 0x63, followed by 4 bytes, then typically EQ (0x14) or other comparison
        pattern = re.compile(r"63([0-9a-f]{8})14")
        for m in pattern.finditer(clean):
            sel = "0x" + m.group(1)
            if sel not in selectors:
                selectors.append(sel)

        return selectors

    def _find_dangerous_functions(self, selectors: list[str]) -> list[str]:
        """Check selectors against known dangerous function signatures."""
        dangerous = []
        for sel in selectors:
            name = DANGEROUS_SELECTORS.get(sel)
            if name:
                dangerous.append(f"{sel}{name}")
        return dangerous

    def _scan_rug_patterns(self, bytecode: str) -> list[str]:
        """Scan bytecode for known rug contract patterns."""
        found = []
        raw = bytecode.lower().encode() if not bytecode.startswith("0x") else bytecode[2:].lower().encode()

        for pattern in RUG_BYTECODE_PATTERNS:
            try:
                if pattern.lower() in raw:
                    found.append(pattern.decode(errors="replace"))
            except Exception:
                pass
        return found

    def _compute_similarity(self, selectors_a: list[str], selectors_b: list[str]) -> float:
        """Compute Jaccard similarity between two selector sets."""
        if not selectors_a or not selectors_b:
            return 0.0
        set_a = set(selectors_a)
        set_b = set(selectors_b)
        intersection = set_a & set_b
        union = set_a | set_b
        return len(intersection) / len(union) if union else 0.0

    async def analyze(self, token_address: str, chain: str) -> ContractDiffReport:
        """Run contract diff analysis on a token."""
        await self._ensure_init()

        report = ContractDiffReport(token_address=token_address, chain=chain)

        try:
            bytecode = await self._fetch_bytecode(token_address, chain)
            if not bytecode:
                report.warnings.append("Could not fetch contract bytecode")
                return report

            # Hash the bytecode
            report.bytecode_hash = self._hash_bytecode(bytecode)
            report.runtime_bytecode_hash = hashlib.sha256(bytecode.lower().replace("0x", "").encode()).hexdigest()

            # Extract function selectors
            report.function_selectors = self._extract_selectors(bytecode)
            report.selector_count = len(report.function_selectors)

            # Check for dangerous functions
            report.dangerous_functions = self._find_dangerous_functions(report.function_selectors)

            # Check against known rug patterns
            rug_patterns = self._scan_rug_patterns(bytecode)

            # Check bytecode hash against known rug contracts
            if report.bytecode_hash in self._rug_hashes:
                match = self._rug_hashes[report.bytecode_hash]
                report.is_clone = True
                report.clone_of = match["address"]
                report.clone_similarity = 1.0
                report.known_rug_matches.append(
                    {
                        "type": "exact_hash_match",
                        "address": match["address"],
                        "name": match.get("name", "unknown"),
                        "flagged_as": match.get("flagged_as", "rug"),
                        "collection": match.get("collection", ""),
                    }
                )

            # Compute selector similarity against known rug contracts
            for bhash, info in self._rug_hashes.items():
                if bhash == report.bytecode_hash:
                    continue  # Already handled above
                other_selectors = info.get("selectors", [])
                if other_selectors:
                    sim = self._compute_similarity(report.function_selectors, other_selectors)
                    if sim > 0.7:  # High similarity threshold
                        report.known_rug_matches.append(
                            {
                                "type": "selector_similarity",
                                "address": info["address"],
                                "name": info.get("name", "unknown"),
                                "flagged_as": info.get("flagged_as", "rug"),
                                "similarity": round(sim, 3),
                                "collection": info.get("collection", ""),
                            }
                        )
                        if sim > report.clone_similarity:
                            report.clone_similarity = sim
                            report.clone_of = info["address"]

            # RAG citations
            try:
                from app.scanners.rag_citations import query_rag_citations

                report.citations = await query_rag_citations(
                    topic=f"contract clone rug pattern {report.bytecode_hash[:8]}",
                    chain=chain,
                    scanner_type="contract_diff",
                    max_citations=3,
                    min_similarity=0.3,
                )
            except Exception as e:
                logger.debug(f"Citation lookup failed: {e}")

            # Compute risk score
            risk_score = 0.0

            # Exact hash match = instant high risk
            if report.is_clone and any(m.get("type") == "exact_hash_match" for m in report.known_rug_matches):
                risk_score += 60.0

            # High selector similarity
            if report.clone_similarity > 0.8:
                risk_score += 30.0
            elif report.clone_similarity > 0.5:
                risk_score += 15.0

            # Dangerous functions
            danger_weight = {
                "withdraw": 10,
                "withdrawAll": 15,
                "drain": 20,
                "setOwner": 10,
                "mint": 8,
                "upgradeTo": 12,
            }
            for df in report.dangerous_functions:
                for key, weight in danger_weight.items():
                    if key.lower() in df.lower():
                        risk_score += weight
                        break

            # Rug bytecode patterns
            if rug_patterns:
                risk_score += min(len(rug_patterns) * 5, 25)

            # Multiple known rug matches
            if len(report.known_rug_matches) >= 3:
                risk_score += 15

            report.risk_score = min(risk_score, 100)

            if report.risk_score >= 70:
                report.risk_level = "CRITICAL"
            elif report.risk_score >= 40:
                report.risk_level = "HIGH"
            elif report.risk_score >= 20:
                report.risk_level = "MEDIUM"
            else:
                report.risk_level = "LOW"

            # Warnings
            if report.is_clone:
                report.warnings.append(
                    f"Contract bytecode matches known {report.known_rug_matches[0].get('flagged_as', 'rug')} "
                    f"contract at {report.clone_of}"
                )
            if report.clone_similarity > 0.7 and not report.is_clone:
                report.warnings.append(
                    f"Contract selectors {report.clone_similarity:.0%} similar to known rug at {report.clone_of}"
                )
            if report.dangerous_functions:
                report.warnings.append(f"Dangerous functions detected: {', '.join(report.dangerous_functions[:3])}")
            if rug_patterns:
                report.warnings.append(f"Rug-pattern bytecode markers found: {', '.join(rug_patterns[:3])}")

            # Enhance warnings with RAG citations
            try:
                from app.scanners.rag_citations import build_citation_string

                if report.citations and report.warnings:
                    cit_str = build_citation_string(report.citations)
                    report.warnings[-1] += f" {cit_str}"
            except Exception:
                pass

        except Exception as e:
            logger.error(f"Contract diff analysis failed: {e}")
            report.warnings.append(f"Analysis error: {str(e)[:100]}")

        return report

    # Chain name → chain ID mapping for EVM chains
    CHAIN_TO_ID = {
        "ethereum": 1,
        "eth": 1,
        "bsc": 56,
        "bnb": 56,
        "polygon": 137,
        "matic": 137,
        "base": 8453,
        "arbitrum": 42161,
        "arb": 42161,
        "optimism": 10,
        "op": 10,
        "avalanche": 43114,
        "avax": 43114,
        "fantom": 250,
        "ftm": 250,
    }

    async def _fetch_bytecode(self, address: str, chain: str) -> str | None:
        """Fetch contract bytecode from RPC using consensus client."""
        chain_lower = chain.lower()

        # Solana: program data via cached consensus RPC
        if chain_lower == "solana":
            try:
                from app.caching_shield.rpc_cache import get_rpc_cache

                cache = get_rpc_cache()
                result = await cache.get_account_info(address, chain="solana")
                if result and result.value:
                    # Solana account data is base64-encoded in value.data[0]
                    account_data = result.value
                    if isinstance(account_data, dict):
                        data_arr = account_data.get("data", [])
                        if isinstance(data_arr, list) and data_arr:
                            # Return the base64-encoded program data
                            return data_arr[0] if data_arr[1] == "base64" else str(data_arr[0])
                    return str(account_data)
            except Exception as e:
                logger.debug(f"Solana account info fetch failed: {e}")
            return None

        # EVM chains: eth_getCode via consensus RPC
        chain_id = self.CHAIN_TO_ID.get(chain_lower)
        if not chain_id:
            logger.warning(f"Unknown EVM chain for bytecode fetch: {chain}")
            return None

        try:
            from app.caching_shield.rpc_cache import get_rpc_cache

            cache = get_rpc_cache()
            result = await cache.query_with_cache(
                method="eth_getCode",
                params=[address, "latest"],
                chain=str(chain_id),
            )
            code_hex = result.value if result else None
            if code_hex and code_hex != "0x" and len(str(code_hex)) > 10:
                logger.info(
                    f"Got bytecode for {address} on {chain} (confidence={result.confidence}%, sources={result.response_count})"
                )
                return code_hex
            logger.debug(f"eth_getCode returned empty for {address} on {chain}")
        except Exception as e:
            logger.debug(f"Consensus RPC bytecode fetch failed: {e}")

        # Fallback: direct public RPC call
        try:
            import httpx

            rpc_urls = {
                1: "https://ethereum-rpc.publicnode.com",
                56: "https://bsc-rpc.publicnode.com",
                137: "https://polygon-rpc.publicnode.com",
                8453: "https://base-rpc.publicnode.com",
                42161: "https://arbitrum-rpc.publicnode.com",
                10: "https://optimism-rpc.publicnode.com",
            }
            url = rpc_urls.get(chain_id)
            if url:
                async with httpx.AsyncClient(timeout=15) as client:
                    resp = await client.post(
                        url,
                        json={
                            "jsonrpc": "2.0",
                            "method": "eth_getCode",
                            "params": [address, "latest"],
                            "id": 1,
                        },
                    )
                    data = resp.json()
                    code = data.get("result", "0x")
                    if code and code != "0x" and len(code) > 10:
                        logger.info(f"Got bytecode via fallback RPC for {address}")
                        return code
        except Exception as e:
            logger.debug(f"Fallback RPC failed: {e}")

        return None