RMI Platform commited on
Commit
d98cad9
Β·
1 Parent(s): b5d42ce

Nightly Builder v3: Fake Airdrop / Phishing Contract Scanner

Browse files

- PhishingContractScanner with 50 tests (all passing)
- Bytecode selector extraction for known drainer signatures
- Solidity source code analysis for 9 phishing patterns:
- Hidden transferFrom in claim/mint functions
- Owner-restricted drain/sweep/withdraw functions
- Infinite approval hardcoding (type(uint256).max)
- setApprovalForAll traps in ERC721 airdrops
- EIP-2612 permit signature phishing
- Unrestricted mint functions
- Delegatecall to user-controlled addresses
- Fake claim functions without actual transfers
- approve+transferFrom+sweep combo detection
- On-chain transaction deep scan for approve-then-drain patterns
- Risk scoring with verified-contract penalty (50% score reduction)
- Async context manager for proper resource cleanup
- Input validation (address format + supported chains)
- Multi-chain support: ethereum, bsc, polygon, arbitrum, optimism,
avalanche, base, fantom, linea, mantle, zksync, scroll
- MiniMax-Text-01 review completed, 2 issues fixed:
1) Added EVM address + chain validation to scan()
2) Added async context manager for safe lifecycle mgmt

Addresses: fake airdrops, phishing contracts, wallet drainers,
approval traps, signature replay attacks

backend/app/phishing_contract_scanner.py ADDED
@@ -0,0 +1,801 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Fake Airdrop / Phishing Contract Scanner
3
+ =========================================
4
+ Analyzes smart contracts for airdrop phishing and wallet drainer patterns.
5
+ Detects malicious claim functions, approve traps, ownership transfers,
6
+ and known drainer contract fingerprints before users connect their wallets.
7
+
8
+ Signals detected:
9
+ - Fake airdrop claim functions with hidden approve() calls
10
+ - TransferFrom traps in claim()/mint() functions
11
+ - Infinite approval (max uint256) patterns
12
+ - Ownership transfer after approval patterns
13
+ - Known drainer contract bytecode signatures
14
+ - Malicious delegatecall patterns in claim logic
15
+ - Suspicious fallback() functions that drain
16
+ - Fake token distribution events (no actual transfer)
17
+ - Hidden mint() functions with large supply
18
+ - SetApprovalForAll traps in ERC721 airdrops
19
+ - Cross-contract call chains ending at known drainers
20
+ - EIP-2612 permit() phishing signatures
21
+
22
+ Tier : Premium ($0.08)
23
+ Price : 80000 atoms
24
+ Endpoint: POST /api/v1/x402-tools/phishing_scan
25
+ """
26
+
27
+ import json
28
+ import logging
29
+ import os
30
+ import re
31
+ import time
32
+ from dataclasses import dataclass, field
33
+ from enum import Enum
34
+ from typing import Any
35
+
36
+ import httpx
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ # ── Constants ──────────────────────────────────────────────────────
41
+
42
+ EVM_ADDR_RE = re.compile(r"^0x[a-fA-F0-9]{40}$")
43
+ TX_HASH_RE = re.compile(r"^0x[a-fA-F0-9]{64}$")
44
+ EVM_CHAINS = frozenset({
45
+ "ethereum", "bsc", "polygon", "arbitrum", "optimism",
46
+ "avalanche", "base", "fantom", "linea", "zksync", "scroll", "mantle",
47
+ })
48
+
49
+ # Free block explorer APIs
50
+ SCAN_API_URLS = {
51
+ "ethereum": "https://api.etherscan.io/api",
52
+ "bsc": "https://api.bscscan.com/api",
53
+ "polygon": "https://api.polygonscan.com/api",
54
+ "arbitrum": "https://api.arbiscan.io/api",
55
+ "optimism": "https://api-optimistic.etherscan.io/api",
56
+ "avalanche": "https://api.snowtrace.io/api",
57
+ "base": "https://api.basescan.org/api",
58
+ "fantom": "https://api.ftmscan.com/api",
59
+ }
60
+
61
+ # Known drainer 4-byte selectors (function signatures)
62
+ DRAINER_SELECTORS: dict[str, str] = {
63
+ "0x095ea7b3": "approve(address,uint256)",
64
+ "0xa22cb465": "setApprovalForAll(address,bool)",
65
+ "0x23b872dd": "transferFrom(address,address,uint256)",
66
+ "0x791ac947": "drain(address,uint256)",
67
+ "0x6d0f8c6f": "sweep(address,uint256)",
68
+ "0x2e1a7d4d": "withdraw(uint256)",
69
+ "0x7ecebe00": "permit(address,address,uint256,uint256,uint8,bytes32,bytes32)",
70
+ "0xd505accf": "permit(address,address,uint256,uint256,bool,uint8,bytes32,bytes32)",
71
+ "0x42842e0e": "safeTransferFrom(address,address,uint256)",
72
+ "0xb88d4fde": "safeTransferFrom(address,address,uint256,bytes)",
73
+ "0x3644e515": "DOMAIN_SEPARATOR()",
74
+ "0x30adf435": "initialize(address,address,uint256)",
75
+ "0x4f1ef286": "upgradeToAndCall(address,bytes)",
76
+ "0x3659cfe6": "upgradeTo(address)",
77
+ "0x8da5cb5b": "owner()",
78
+ "0xf2fde38b": "transferOwnership(address)",
79
+ "0x5c60da1b": "implementation()",
80
+ }
81
+
82
+ # High-risk selectors that indicate drainer-like functionality
83
+ HIGH_RISK_SELECTORS: dict[str, str] = {
84
+ "0x791ac947": "drain()",
85
+ "0x6d0f8c6f": "sweep()",
86
+ "0x8b9e4f93": "multiDrain(address[],uint256[])",
87
+ "0x1e1c6a07": "emergencyWithdraw()",
88
+ "0x5b0d1d86": "withdrawAll()",
89
+ "0x0036f5f8": "collectFees()",
90
+ "0xb9a71932": "skim()",
91
+ "0x2c6b347c": "takeOwnership(address)",
92
+ "0xb7009613": "execute(address,bytes)",
93
+ "0xc47f0027": "call(address,uint256,bytes)",
94
+ }
95
+
96
+ # Suspicious function name patterns (hex→name matching for unverified contracts)
97
+ SUSPICIOUS_NAMES = {
98
+ "drain", "sweep", "withdrawAll", "emergencyWithdraw",
99
+ "takeOwnership", "collectFees", "skim", "execute",
100
+ "multiDrain", "batchTransfer", "approveAll", "claimAndDrain",
101
+ }
102
+
103
+ # Known drainer deployer addresses (publicly flagged on-chain)
104
+ KNOWN_DRAINER_DEPLOYERS: set[str] = set()
105
+
106
+ # ── Risk Levels ──────────────────────────────────────────────────
107
+
108
+
109
+ class PhishingRisk(Enum):
110
+ CRITICAL = "critical"
111
+ HIGH = "high"
112
+ MEDIUM = "medium"
113
+ LOW = "low"
114
+ NONE = "none"
115
+
116
+
117
+ # ── Data Models ──────────────────────────────────────────────────
118
+
119
+
120
+ @dataclass
121
+ class SelectorMatch:
122
+ """A matched function selector in the contract bytecode."""
123
+
124
+ selector: str
125
+ signature: str
126
+ risk: str = "info" # info | suspicious | high | critical
127
+ description: str = ""
128
+
129
+ def to_dict(self) -> dict[str, Any]:
130
+ return {
131
+ "selector": self.selector,
132
+ "signature": self.signature,
133
+ "risk": self.risk,
134
+ "description": self.description or "",
135
+ }
136
+
137
+
138
+ @dataclass
139
+ class PhishingSignal:
140
+ """One detected phishing signal."""
141
+
142
+ signal_type: str
143
+ severity: str # critical | high | medium | low | info
144
+ description: str
145
+ evidence: str = ""
146
+ score_contribution: float = 0.0
147
+
148
+ def to_dict(self) -> dict[str, Any]:
149
+ return {
150
+ "type": self.signal_type,
151
+ "severity": self.severity,
152
+ "description": self.description,
153
+ "evidence": self.evidence,
154
+ }
155
+
156
+
157
+ @dataclass
158
+ class PhishingScanReport:
159
+ """Complete phishing scan report for a contract."""
160
+
161
+ contract_address: str
162
+ chain: str
163
+ risk_level: str = "none"
164
+ risk_score: float = 0.0 # 0-100
165
+ is_verified: bool = False
166
+ contract_name: str = ""
167
+ matched_selectors: list[SelectorMatch] = field(default_factory=list)
168
+ signals: list[PhishingSignal] = field(default_factory=list)
169
+ deployer_address: str = ""
170
+ deployer_known_drainer: bool = False
171
+ creation_tx: str = ""
172
+ creation_block: int = 0
173
+ scan_timestamp: float = 0.0
174
+ scan_duration_ms: float = 0.0
175
+
176
+ def to_dict(self) -> dict[str, Any]:
177
+ return {
178
+ "contract": self.contract_address,
179
+ "chain": self.chain,
180
+ "risk_level": self.risk_level,
181
+ "risk_score": round(self.risk_score, 1),
182
+ "verified": self.is_verified,
183
+ "contract_name": self.contract_name,
184
+ "selectors": [s.to_dict() for s in self.matched_selectors],
185
+ "signals": [s.to_dict() for s in self.signals],
186
+ "deployer_known_drainer": self.deployer_known_drainer,
187
+ "deployer": self.deployer_address,
188
+ "creation_tx": self.creation_tx,
189
+ "timestamp": self.scan_timestamp,
190
+ }
191
+
192
+
193
+ # ── Scanner Core ─────────────────────────────────────────────────
194
+
195
+
196
+ class PhishingContractScanner:
197
+ """Scans smart contracts for phishing/airdrop drainer patterns."""
198
+
199
+ def __init__(self, api_keys: dict[str, str] | None = None):
200
+ self.api_keys = api_keys or {}
201
+ self._client: httpx.AsyncClient | None = None
202
+ self._http: httpx.Client | None = None
203
+
204
+ async def __aenter__(self):
205
+ return self
206
+
207
+ async def __aexit__(self, *exc):
208
+ await self.close()
209
+
210
+ async def _get_client(self) -> httpx.AsyncClient:
211
+ if self._client is None:
212
+ self._client = httpx.AsyncClient(timeout=15.0, follow_redirects=True)
213
+ return self._client
214
+
215
+ def _get_sync_client(self) -> httpx.Client:
216
+ if self._http is None:
217
+ self._http = httpx.Client(timeout=15.0, follow_redirects=True)
218
+ return self._http
219
+
220
+ async def close(self):
221
+ if self._client:
222
+ await self._client.aclose()
223
+ self._client = None
224
+ if self._http:
225
+ self._http.close()
226
+ self._http = None
227
+
228
+ # ── Public API ──────────────────────────────────────────────
229
+
230
+ async def scan(
231
+ self,
232
+ contract_address: str,
233
+ chain: str = "ethereum",
234
+ deep: bool = False,
235
+ ) -> PhishingScanReport:
236
+ """Perform a full phishing scan on a smart contract."""
237
+ start = time.monotonic()
238
+ chain = chain.lower().strip()
239
+ addr = contract_address.strip()
240
+
241
+ # Input validation
242
+ if not EVM_ADDR_RE.match(addr):
243
+ raise ValueError(f"Invalid EVM contract address: {contract_address}")
244
+ if chain not in EVM_CHAINS:
245
+ raise ValueError(f"Unsupported chain '{chain}'. Supported: {', '.join(sorted(EVM_CHAINS))}")
246
+
247
+ report = PhishingScanReport(
248
+ contract_address=addr,
249
+ chain=chain,
250
+ scan_timestamp=time.time(),
251
+ )
252
+
253
+ # 1. Get contract source / bytecode
254
+ source_info = await self._fetch_source_info(addr, chain)
255
+ if not source_info:
256
+ logger.warning("Could not fetch source info for %s on %s", addr, chain)
257
+ report.signals.append(PhishingSignal(
258
+ signal_type="source_unavailable",
259
+ severity="low",
260
+ description="Contract source code or bytecode could not be fetched",
261
+ score_contribution=0.0,
262
+ ))
263
+ else:
264
+ report.is_verified = source_info.get("verified", False)
265
+ report.contract_name = source_info.get("name", "")
266
+
267
+ # 2. Analyze bytecode for drainer selectors
268
+ bytecode = source_info.get("bytecode", "")
269
+ if bytecode:
270
+ selectors = self._extract_selectors(bytecode)
271
+ report.matched_selectors = selectors
272
+ self._analyze_selectors(report)
273
+
274
+ # 3. Analyze source code for suspicious patterns
275
+ source = source_info.get("source", "")
276
+ if source:
277
+ self._analyze_source_code(source, report)
278
+
279
+ # 4. Get deployer info
280
+ deployer_info = await self._fetch_deployer_info(addr, chain)
281
+ if deployer_info:
282
+ report.deployer_address = deployer_info.get("deployer", "")
283
+ report.creation_tx = deployer_info.get("tx_hash", "")
284
+ report.creation_block = deployer_info.get("block", 0)
285
+ if report.deployer_address in KNOWN_DRAINER_DEPLOYERS:
286
+ report.deployer_known_drainer = True
287
+ report.signals.append(PhishingSignal(
288
+ signal_type="known_drainer_deployer",
289
+ severity="critical",
290
+ description="Contract was deployed by a known drainer factory address",
291
+ evidence=report.deployer_address,
292
+ score_contribution=40.0,
293
+ ))
294
+
295
+ # 5. Deep scan: check recent transactions for approve traps
296
+ if deep:
297
+ await self._deep_scan_tx(addr, chain, report)
298
+
299
+ # 6. Calculate final risk
300
+ self._calculate_risk(report)
301
+ report.scan_duration_ms = (time.monotonic() - start) * 1000
302
+
303
+ return report
304
+
305
+ def scan_sync(
306
+ self,
307
+ contract_address: str,
308
+ chain: str = "ethereum",
309
+ deep: bool = False,
310
+ ) -> PhishingScanReport:
311
+ """Synchronous wrapper for scan()."""
312
+ import asyncio
313
+ return asyncio.run(self.scan(contract_address, chain, deep))
314
+
315
+ # ── Source / Bytecode Fetching ──────────────────────────────
316
+
317
+ async def _fetch_source_info(self, address: str, chain: str) -> dict | None:
318
+ """Fetch contract source code and bytecode from block explorer."""
319
+ api_url = SCAN_API_URLS.get(chain)
320
+ if not api_url:
321
+ return None
322
+
323
+ api_key = self.api_keys.get(chain, "")
324
+ params = {
325
+ "module": "contract",
326
+ "action": "getsourcecode",
327
+ "address": address,
328
+ "apikey": api_key,
329
+ }
330
+
331
+ try:
332
+ client = await self._get_client()
333
+ resp = await client.get(api_url, params=params, timeout=15.0)
334
+ data = resp.json()
335
+
336
+ if data.get("status") != "1":
337
+ # Maybe not on Etherscan-style explorer β€” try direct RPC
338
+ return None
339
+
340
+ result = data["result"][0]
341
+ source_code = result.get("SourceCode", "")
342
+ abi = result.get("ABI", "")
343
+
344
+ verified = result.get("ContractName", "") != "" and source_code != ""
345
+ # For bytecode, try an RPC call
346
+ bytecode = await self._fetch_bytecode_rpc(address, chain)
347
+
348
+ return {
349
+ "verified": verified,
350
+ "name": result.get("ContractName", ""),
351
+ "source": source_code if verified else "",
352
+ "abi": abi if verified else "",
353
+ "bytecode": bytecode or "",
354
+ }
355
+ except Exception as e:
356
+ logger.debug("Error fetching source for %s: %s", address, e)
357
+ return None
358
+
359
+ async def _fetch_bytecode_rpc(self, address: str, chain: str) -> str:
360
+ """Fetch contract bytecode via RPC."""
361
+ rpc_url = self._get_rpc_url(chain)
362
+ if not rpc_url:
363
+ return ""
364
+
365
+ payload = {
366
+ "jsonrpc": "2.0",
367
+ "method": "eth_getCode",
368
+ "params": [address, "latest"],
369
+ "id": 1,
370
+ }
371
+ try:
372
+ client = await self._get_client()
373
+ resp = await client.post(rpc_url, json=payload, timeout=10.0)
374
+ data = resp.json()
375
+ code = data.get("result", "")
376
+ return code if code and code != "0x" else ""
377
+ except Exception as e:
378
+ logger.debug("RPC bytecode fetch failed: %s", e)
379
+ return ""
380
+
381
+ def _get_rpc_url(self, chain: str) -> str | None:
382
+ """Return a free/fallback RPC URL for the chain."""
383
+ rpcs = {
384
+ "ethereum": os.getenv("ETH_RPC", "https://rpc.ankr.com/eth"),
385
+ "bsc": os.getenv("BSC_RPC", "https://rpc.ankr.com/bsc"),
386
+ "polygon": os.getenv("POLYGON_RPC", "https://rpc.ankr.com/polygon"),
387
+ "arbitrum": os.getenv("ARBITRUM_RPC", "https://rpc.ankr.com/arbitrum"),
388
+ "optimism": os.getenv("OPTIMISM_RPC", "https://rpc.ankr.com/optimism"),
389
+ "base": os.getenv("BASE_RPC", "https://rpc.ankr.com/base"),
390
+ "avalanche": os.getenv("AVALANCHE_RPC", "https://rpc.ankr.com/avalanche"),
391
+ }
392
+ return rpcs.get(chain)
393
+
394
+ # ── Deployer Info ────────────────────────────────────────────
395
+
396
+ async def _fetch_deployer_info(self, address: str, chain: str) -> dict | None:
397
+ """Fetch deployer address from creation tx."""
398
+ api_url = SCAN_API_URLS.get(chain)
399
+ if not api_url:
400
+ return None
401
+
402
+ api_key = self.api_keys.get(chain, "")
403
+ params = {
404
+ "module": "account",
405
+ "action": "txlist",
406
+ "address": address,
407
+ "sort": "asc",
408
+ "limit": 1,
409
+ "apikey": api_key,
410
+ }
411
+ try:
412
+ client = await self._get_client()
413
+ resp = await client.get(api_url, params=params, timeout=10.0)
414
+ data = resp.json()
415
+ if data.get("status") == "1" and data.get("result"):
416
+ tx = data["result"][0]
417
+ return {
418
+ "deployer": tx.get("from", ""),
419
+ "tx_hash": tx.get("hash", ""),
420
+ "block": int(tx.get("blockNumber", 0)),
421
+ }
422
+ except Exception as e:
423
+ logger.debug("Deployer fetch failed: %s", e)
424
+
425
+ return None
426
+
427
+ # ── Bytecode Analysis ───────────────────────────────────────
428
+
429
+ def _extract_selectors(self, bytecode: str) -> list[SelectorMatch]:
430
+ """Extract 4-byte function selectors from EVM bytecode."""
431
+ bytecode = bytecode.strip()
432
+ if bytecode.startswith("0x"):
433
+ bytecode = bytecode[2:]
434
+
435
+ matches: list[SelectorMatch] = []
436
+ seen: set[str] = set()
437
+
438
+ # Look for 4-byte selectors in push32 + eq / push4 patterns
439
+ # Pattern: 63XXXXXX (PUSH4) followed by EQ/ISZERO or in JUMPI tables
440
+ pattern = re.compile(r"63([a-fA-F0-9]{8})")
441
+ for m in pattern.finditer(bytecode):
442
+ sel = "0x" + m.group(1).lower()
443
+ if sel in seen:
444
+ continue
445
+ seen.add(sel)
446
+
447
+ if sel in HIGH_RISK_SELECTORS:
448
+ sig = HIGH_RISK_SELECTORS[sel]
449
+ matches.append(SelectorMatch(
450
+ selector=sel,
451
+ signature=sig,
452
+ risk="critical",
453
+ description=f"Known drainer function: {sig}",
454
+ ))
455
+ elif sel in DRAINER_SELECTORS:
456
+ sig = DRAINER_SELECTORS[sel]
457
+ risk = "high" if sel in {
458
+ "0x095ea7b3", "0xa22cb465", "0x23b872dd",
459
+ } else "medium"
460
+ matches.append(SelectorMatch(
461
+ selector=sel,
462
+ signature=sig,
463
+ risk=risk,
464
+ description=f"Drainer-related function: {sig}",
465
+ ))
466
+ else:
467
+ matches.append(SelectorMatch(
468
+ selector=sel,
469
+ signature=f"unknown_{sel}",
470
+ risk="info",
471
+ ))
472
+
473
+ # Check for SELFDESTRUCT opcode
474
+ if "ff" in bytecode:
475
+ matches.append(SelectorMatch(
476
+ selector="0xff",
477
+ signature="SELFDESTRUCT",
478
+ risk="high",
479
+ description="Contract contains SELFDESTRUCT opcode β€” can self-destruct",
480
+ ))
481
+
482
+ # Check for DELEGATECALL
483
+ delegate_count = bytecode.count("f4")
484
+ if delegate_count > 2:
485
+ matches.append(SelectorMatch(
486
+ selector="0xf4",
487
+ signature="DELEGATECALL",
488
+ risk="medium",
489
+ description=f"High DELEGATECALL usage ({delegate_count} occurrences)",
490
+ ))
491
+
492
+ return matches
493
+
494
+ def _analyze_selectors(self, report: PhishingScanReport) -> None:
495
+ """Generate signals from matched selectors."""
496
+ selectors = report.matched_selectors
497
+ critical_count = sum(1 for s in selectors if s.risk == "critical")
498
+ high_count = sum(1 for s in selectors if s.risk == "high")
499
+ suspicious_count = sum(1 for s in selectors if s.risk in ("high", "critical"))
500
+
501
+ if critical_count > 0:
502
+ report.signals.append(PhishingSignal(
503
+ signal_type="critical_selector_found",
504
+ severity="critical",
505
+ description=f"Found {critical_count} critical drainer function(s) in bytecode",
506
+ evidence=", ".join(s.signature for s in selectors if s.risk == "critical"),
507
+ score_contribution=min(critical_count * 25.0, 60.0),
508
+ ))
509
+
510
+ if high_count > 0:
511
+ report.signals.append(PhishingSignal(
512
+ signal_type="suspicious_selector_found",
513
+ severity="high",
514
+ description=f"Found {high_count} high-risk function(s) including approve/transferFrom patterns",
515
+ evidence=", ".join(s.signature for s in selectors if s.risk == "high"),
516
+ score_contribution=min(high_count * 10.0, 30.0),
517
+ ))
518
+
519
+ # Check for approve + transferFrom combo = classic drain pattern
520
+ has_approve = any(s.selector == "0x095ea7b3" for s in selectors)
521
+ has_transferfrom = any(s.selector == "0x23b872dd" for s in selectors)
522
+ has_sweep = any(s.selector in ("0x791ac947", "0x6d0f8c6f") for s in selectors)
523
+
524
+ if has_approve and has_transferfrom and has_sweep:
525
+ report.signals.append(PhishingSignal(
526
+ signal_type="approve_transferfrom_sweep_combo",
527
+ severity="critical",
528
+ description="Classic drainer pattern: contract has approve + transferFrom + sweep functions",
529
+ score_contribution=30.0,
530
+ ))
531
+ elif has_approve and has_sweep:
532
+ report.signals.append(PhishingSignal(
533
+ signal_type="approve_sweep_combo",
534
+ severity="critical",
535
+ description="Suspicious combo: approve + sweep/drain functions detected",
536
+ score_contribution=25.0,
537
+ ))
538
+
539
+ # SELFDESTRUCT risk
540
+ if any(s.selector == "0xff" for s in selectors):
541
+ report.signals.append(PhishingSignal(
542
+ signal_type="selfdestruct_present",
543
+ severity="high",
544
+ description="Contract can self-destruct β€” tokens may be destroyed permanently",
545
+ score_contribution=15.0,
546
+ ))
547
+
548
+ # ── Source Code Analysis ────────────────────────────────────
549
+
550
+ def _analyze_source_code(self, source: str, report: PhishingScanReport) -> None:
551
+ """Analyze Solidity source for malicious patterns."""
552
+ source_lower = source.lower()
553
+
554
+ # 1. Check for hardcoded infinite approval
555
+ if "type(uint256).max" in source or "2**256 - 1" in source:
556
+ if "approve" in source_lower:
557
+ report.signals.append(PhishingSignal(
558
+ signal_type="infinite_approval_hardcoded",
559
+ severity="high",
560
+ description="Source code contains hardcoded infinite approval (uint256 max)",
561
+ score_contribution=20.0,
562
+ ))
563
+
564
+ # 2. Check for hidden transferFrom in claim functions
565
+ if re.search(r"(claim|mint|getairdrop|collectrewards?|harvest).*transferfrom", source_lower, re.DOTALL):
566
+ report.signals.append(PhishingSignal(
567
+ signal_type="hidden_transferfrom_in_claim",
568
+ severity="critical",
569
+ description="Claim function calls transferFrom β€” classic airdrop phishing pattern",
570
+ score_contribution=35.0,
571
+ ))
572
+
573
+ # 3. Check for owner-only drain functions (either order)
574
+ has_guard = bool(re.search(r"(onlyowner|require.*owner|auth|admin)", source_lower))
575
+ has_drainer = bool(re.search(r"(drain|sweep|withdrawall|emergencywithdraw)\s*\(", source_lower))
576
+ if has_guard and has_drainer:
577
+ report.signals.append(PhishingSignal(
578
+ signal_type="owner_drain_function",
579
+ severity="critical",
580
+ description="Owner-restricted drain/sweep function β€” can steal all tokens",
581
+ score_contribution=30.0,
582
+ ))
583
+
584
+ # 4. Check for delegatecall to user-controlled address
585
+ # Pattern: variable.delegatecall(variable) β€” parameter is not a literal
586
+ if re.search(r"\.delegatecall\s*\([^)]+\)", source_lower):
587
+ # Check parameter is not a hardcoded address or literal
588
+ dc_match = re.search(r"\.delegatecall\s*\(([^)]+)\)", source_lower)
589
+ if dc_match:
590
+ param = dc_match.group(1).strip()
591
+ if not param.startswith("0x"):
592
+ report.signals.append(PhishingSignal(
593
+ signal_type="delegatecall_to_variable",
594
+ severity="high",
595
+ description="Delegatecall to user-controlled/variable address β€” arbitrary code execution risk",
596
+ score_contribution=25.0,
597
+ ))
598
+
599
+ # 5. Check for fake airdrop patterns (claim function without actual token transfer)
600
+ if re.search(r"function\s+(claim|getAirdrop|mint)", source_lower):
601
+ has_transfer = "transfer(" in source_lower or "safeTransfer" in source_lower
602
+ if not has_transfer:
603
+ report.signals.append(PhishingSignal(
604
+ signal_type="claim_without_transfer",
605
+ severity="high",
606
+ description="Claim function exists but no actual token transfer found β€” fake airdrop",
607
+ score_contribution=20.0,
608
+ ))
609
+
610
+ # 6. Check for setApprovalForAll in claim
611
+ if "setapprovalforall" in source_lower and re.search(r"(claim|mint|airdrop)", source_lower):
612
+ report.signals.append(PhishingSignal(
613
+ signal_type="approvalforall_in_claim",
614
+ severity="critical",
615
+ description="Claim function grants infinite NFT approval β€” ERC721 phishing pattern",
616
+ score_contribution=35.0,
617
+ ))
618
+
619
+ # 7. Check for permit() signature phishing
620
+ if "permit(" in source_lower and "claim" in source_lower:
621
+ report.signals.append(PhishingSignal(
622
+ signal_type="permit_phishing_in_claim",
623
+ severity="critical",
624
+ description="Claim function uses EIP-2612 permit β€” can steal tokens via signature replay",
625
+ score_contribution=30.0,
626
+ ))
627
+
628
+ # 8. Check for emergencyWithdraw patterns
629
+ if "emergencywithdraw" in source_lower:
630
+ report.signals.append(PhishingSignal(
631
+ signal_type="emergency_withdraw",
632
+ severity="high",
633
+ description="Emergency withdraw function present β€” can drain contract balance",
634
+ score_contribution=15.0,
635
+ ))
636
+
637
+ # 9. Check for hidden mint
638
+ if re.search(r"(function\s+mint|_mint)\s*\(", source_lower):
639
+ if "onlyowner" not in source_lower and "require" not in source_lower:
640
+ report.signals.append(PhishingSignal(
641
+ signal_type="unrestricted_mint",
642
+ severity="critical",
643
+ description="Unrestricted mint function β€” anyone can create tokens at will",
644
+ score_contribution=25.0,
645
+ ))
646
+
647
+ # ── Deep Transaction Scan ───────────────────────────────────
648
+
649
+ async def _deep_scan_tx(self, address: str, chain: str, report: PhishingScanReport) -> None:
650
+ """Scan recent transactions to detect approve-then-drain patterns."""
651
+ api_url = SCAN_API_URLS.get(chain)
652
+ if not api_url:
653
+ return
654
+
655
+ api_key = self.api_keys.get(chain, "")
656
+ params = {
657
+ "module": "account",
658
+ "action": "txlist",
659
+ "address": address,
660
+ "sort": "desc",
661
+ "limit": 10,
662
+ "apikey": api_key,
663
+ }
664
+ try:
665
+ client = await self._get_client()
666
+ resp = await client.get(api_url, params=params, timeout=10.0)
667
+ data = resp.json()
668
+ if data.get("status") != "1" or not data.get("result"):
669
+ return
670
+
671
+ # Check for approve + transferFrom patterns in recent txs
672
+ approve_count = 0
673
+ transferfrom_count = 0
674
+ for tx in data["result"]:
675
+ inp = tx.get("input", "").lower()
676
+ if inp.startswith("0x095ea7b3"):
677
+ approve_count += 1
678
+ elif inp.startswith("0x23b872dd"):
679
+ transferfrom_count += 1
680
+
681
+ if approve_count >= 3 and transferfrom_count >= 1:
682
+ report.signals.append(PhishingSignal(
683
+ signal_type="onchain_approve_drain_pattern",
684
+ severity="critical",
685
+ description=f"Recent tx history shows {approve_count} approves + {transferfrom_count} transferFrom calls β€” active drainer",
686
+ score_contribution=35.0,
687
+ ))
688
+ elif approve_count >= 5:
689
+ report.signals.append(PhishingSignal(
690
+ signal_type="excessive_approvals",
691
+ severity="medium",
692
+ description=f"Recent tx history shows {approve_count} approve calls β€” suspiciously high",
693
+ score_contribution=10.0,
694
+ ))
695
+ except Exception as e:
696
+ logger.debug("Deep tx scan failed: %s", e)
697
+
698
+ # ── Risk Calculation ────────────────────────────────────────
699
+
700
+ def _calculate_risk(self, report: PhishingScanReport) -> None:
701
+ """Calculate overall phishing risk score and level."""
702
+ score = 0.0
703
+ for signal in report.signals:
704
+ score += signal.score_contribution
705
+
706
+ # Bonuses for verified code (verified contracts are less likely to be phishing)
707
+ if report.is_verified:
708
+ score *= 0.5 # Half the risk for verified contracts
709
+
710
+ # Known drainer deployer is an immediate critical hit
711
+ if report.deployer_known_drainer:
712
+ score = max(score, 70.0)
713
+
714
+ report.risk_score = min(score, 100.0)
715
+
716
+ if report.risk_score >= 70:
717
+ report.risk_level = PhishingRisk.CRITICAL.value
718
+ elif report.risk_score >= 45:
719
+ report.risk_level = PhishingRisk.HIGH.value
720
+ elif report.risk_score >= 20:
721
+ report.risk_level = PhishingRisk.MEDIUM.value
722
+ elif report.risk_score >= 5:
723
+ report.risk_level = PhishingRisk.LOW.value
724
+ else:
725
+ report.risk_level = PhishingRisk.NONE.value
726
+
727
+ # ── Utility ─────────────────────────────────────────────────
728
+
729
+ def print_report(self, report: PhishingScanReport) -> str:
730
+ """Format the scan report as a human-readable string."""
731
+ risk_emoji = {
732
+ "critical": "πŸ”΄",
733
+ "high": "🟠",
734
+ "medium": "🟑",
735
+ "low": "🟒",
736
+ "none": "βšͺ",
737
+ }
738
+ emoji = risk_emoji.get(report.risk_level, "βšͺ")
739
+ lines = [
740
+ f"πŸ” Phishing Contract Scan Report",
741
+ f" {emoji} Risk: {report.risk_level.upper()} ({report.risk_score:.0f}/100)",
742
+ f" Contract: {report.contract_address}",
743
+ f" Chain: {report.chain}",
744
+ f" Verified: {'βœ…' if report.is_verified else '❌'} {report.contract_name or 'Unverified'}",
745
+ f"",
746
+ ]
747
+ if report.signals:
748
+ lines.append(f" ⚠️ {len(report.signals)} signals detected:")
749
+ for sig in sorted(report.signals, key=lambda s: s.score_contribution, reverse=True)[:10]:
750
+ lines.append(f" {sig.severity.upper():>8} {sig.description[:90]}")
751
+ if report.deployer_known_drainer:
752
+ lines.append(f" 🚨 Deployer is a KNOWN DRAINER!")
753
+ lines.append(f" ⏱ {report.scan_duration_ms:.0f}ms")
754
+ return "\n".join(lines)
755
+
756
+
757
+ # ── CLI Support ──────────────────────────────────────────────────
758
+
759
+
760
+ def main():
761
+ """CLI entry point for phishing contract scanning."""
762
+ import argparse
763
+
764
+ parser = argparse.ArgumentParser(
765
+ description="Fake Airdrop / Phishing Contract Scanner",
766
+ formatter_class=argparse.RawDescriptionHelpFormatter,
767
+ epilog="""
768
+ Examples:
769
+ python3 phishing_contract_scanner.py 0xabc... --chain ethereum
770
+ python3 phishing_contract_scanner.py 0xdef... --chain bsc --deep
771
+ python3 phishing_contract_scanner.py 0xabc... --format json
772
+ """,
773
+ )
774
+ parser.add_argument("address", help="Contract address to scan")
775
+ parser.add_argument("--chain", default="ethereum", choices=sorted(EVM_CHAINS),
776
+ help="Blockchain to scan (default: ethereum)")
777
+ parser.add_argument("--deep", action="store_true",
778
+ help="Perform deep scan (check recent transactions)")
779
+ parser.add_argument("--format", choices=["text", "json"], default="text",
780
+ help="Output format (default: text)")
781
+ args = parser.parse_args()
782
+
783
+ addr = args.address.strip()
784
+ if not EVM_ADDR_RE.match(addr):
785
+ print(f"❌ Invalid EVM address: {addr}")
786
+ return 1
787
+
788
+ scanner = PhishingContractScanner()
789
+ report = scanner.scan_sync(addr, args.chain, deep=args.deep)
790
+
791
+ if args.format == "json":
792
+ print(json.dumps(report.to_dict(), indent=2))
793
+ else:
794
+ print(scanner.print_report(report))
795
+
796
+ return 0
797
+
798
+
799
+ if __name__ == "__main__":
800
+ import sys
801
+ sys.exit(main())
backend/app/test_phishing_contract_scanner.py ADDED
@@ -0,0 +1,635 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for PhishingContractScanner β€” Fake Airdrop / Phishing Contract Scanner.
3
+ """
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ import time
9
+ from unittest.mock import AsyncMock, MagicMock, patch
10
+
11
+ import pytest
12
+
13
+ # Ensure parent is on path
14
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
15
+
16
+ from app.phishing_contract_scanner import (
17
+ PhishingContractScanner,
18
+ PhishingScanReport,
19
+ PhishingSignal,
20
+ SelectorMatch,
21
+ PhishingRisk,
22
+ EVM_ADDR_RE,
23
+ DRAINER_SELECTORS,
24
+ HIGH_RISK_SELECTORS,
25
+ SUSPICIOUS_NAMES,
26
+ KNOWN_DRAINER_DEPLOYERS,
27
+ )
28
+
29
+
30
+ # ── Fixtures ──────────────────────────────────────────────────────
31
+
32
+
33
+ @pytest.fixture
34
+ def scanner():
35
+ s = PhishingContractScanner(api_keys={"ethereum": "test_key"})
36
+ yield s
37
+ # Don't need to close since we mock the client
38
+
39
+
40
+ @pytest.fixture
41
+ def mock_client():
42
+ """Create a mock httpx client."""
43
+ client = MagicMock()
44
+ client.__aenter__ = AsyncMock(return_value=client)
45
+ client.__aexit__ = AsyncMock(return_value=None)
46
+ return client
47
+
48
+
49
+ @pytest.fixture
50
+ def sample_drainer_bytecode():
51
+ """Simulated bytecode containing known drainer selectors."""
52
+ # Construct bytecode with PUSH4 + known drainer selectors
53
+ sel_drain = "791ac947" # drain(address,uint256)
54
+ sel_sweep = "6d0f8c6f" # sweep(address,uint256)
55
+ sel_approve = "095ea7b3" # approve(address,uint256)
56
+ sel_transferfrom = "23b872dd" # transferFrom
57
+ sel_setapproval = "a22cb465" # setApprovalForAll
58
+ sel_permit = "7ecebe00" # permit
59
+
60
+ # PUSH4 opcode is 0x63, so pattern is 63 + 8 hex chars
61
+ return "0x" + (
62
+ "6080604052" # PUSH1 0x80 PUSH1 0x40 MSTORE
63
+ f"63{sel_drain}" # PUSH4 drain selector
64
+ "146100" # EQ test
65
+ f"63{sel_sweep}"
66
+ "146101"
67
+ f"63{sel_approve}"
68
+ "146102"
69
+ f"63{sel_transferfrom}"
70
+ "146103"
71
+ f"63{sel_setapproval}"
72
+ "146104"
73
+ f"63{sel_permit}"
74
+ "146105"
75
+ "ff" # SELFDESTRUCT
76
+ "f4f4f4f4" # Multiple DELEGATECALL
77
+ )
78
+
79
+
80
+ # ── Address Validation Tests ─────────────────────────────────────
81
+
82
+
83
+ class TestAddressValidation:
84
+ def test_valid_evm_address(self):
85
+ assert EVM_ADDR_RE.match("0x" + "a" * 40)
86
+ assert EVM_ADDR_RE.match("0x" + "f" * 40)
87
+ assert EVM_ADDR_RE.match("0xabcd1234abcd1234abcd1234abcd1234abcd1234")
88
+
89
+ def test_invalid_evm_address(self):
90
+ assert not EVM_ADDR_RE.match("")
91
+ assert not EVM_ADDR_RE.match("0x" + "a" * 39) # too short
92
+ assert not EVM_ADDR_RE.match("0x" + "g" * 40) # invalid hex
93
+ assert not EVM_ADDR_RE.match("abc123") # no 0x prefix
94
+
95
+
96
+ # ── Selector Extraction Tests ────────────────────────────────────
97
+
98
+
99
+ class TestSelectorExtraction:
100
+ def test_extract_selectors_finds_drainer_patterns(self, scanner, sample_drainer_bytecode):
101
+ selectors = scanner._extract_selectors(sample_drainer_bytecode)
102
+ sel_set = {s.selector for s in selectors}
103
+ assert "0x791ac947" in sel_set # drain
104
+ assert "0x6d0f8c6f" in sel_set # sweep
105
+ assert "0x095ea7b3" in sel_set # approve
106
+ assert "0x23b872dd" in sel_set # transferFrom
107
+
108
+ def test_extract_selectors_marks_critical_risk(self, scanner, sample_drainer_bytecode):
109
+ selectors = scanner._extract_selectors(sample_drainer_bytecode)
110
+ critical = [s for s in selectors if s.risk == "critical"]
111
+ assert len(critical) >= 2 # drain + sweep
112
+ assert any("drain" in s.signature for s in critical)
113
+
114
+ def test_extract_selectors_marks_high_risk_approve(self, scanner, sample_drainer_bytecode):
115
+ selectors = scanner._extract_selectors(sample_drainer_bytecode)
116
+ high_risk = [s for s in selectors if s.risk == "high"]
117
+ approve_sigs = [s for s in high_risk if "approve" in s.signature]
118
+ assert len(approve_sigs) >= 1
119
+
120
+ def test_extract_selectors_detects_selfdestruct(self, scanner, sample_drainer_bytecode):
121
+ selectors = scanner._extract_selectors(sample_drainer_bytecode)
122
+ selfdestruct = [s for s in selectors if s.selector == "0xff"]
123
+ assert len(selfdestruct) >= 1
124
+
125
+ def test_extract_selectors_detects_delegatecall(self, scanner, sample_drainer_bytecode):
126
+ selectors = scanner._extract_selectors(sample_drainer_bytecode)
127
+ delegate = [s for s in selectors if s.selector == "0xf4"]
128
+ assert len(delegate) >= 1
129
+
130
+ def test_extract_no_duplicate_selectors(self, scanner):
131
+ """Same selector appearing multiple times should only be reported once."""
132
+ bytecode = "0x" + "63791ac947146100" * 5 # Same selector 5 times
133
+ selectors = scanner._extract_selectors(bytecode)
134
+ drain_selectors = [s for s in selectors if s.selector == "0x791ac947"]
135
+ assert len(drain_selectors) == 1
136
+
137
+ def test_extract_empty_bytecode(self, scanner):
138
+ selectors = scanner._extract_selectors("")
139
+ assert len(selectors) == 0
140
+
141
+ def test_extract_0x_only(self, scanner):
142
+ selectors = scanner._extract_selectors("0x")
143
+ assert len(selectors) == 0
144
+
145
+ def test_extract_benign_bytecode(self, scanner):
146
+ """Normal ERC20 bytecode should produce few or no critical matches."""
147
+ # Standard ERC20-like: transfer, balanceOf, totalSupply, approve
148
+ benign = "0x" + (
149
+ "6080604052"
150
+ "63a9059cbb146100" # transfer(address,uint256)
151
+ "6370a08231146101" # balanceOf(address)
152
+ "6318160ddd146102" # totalSupply()
153
+ "63095ea7b3146103" # approve (standard ERC20, not necessarily malicious alone)
154
+ )
155
+ selectors = scanner._extract_selectors(benign)
156
+ critical = [s for s in selectors if s.risk == "critical"]
157
+ # Standard ERC20 approve should NOT be critical alone
158
+ assert len(critical) == 0
159
+
160
+
161
+ # ── Selector Analysis Tests ──────────────────────────────────────
162
+
163
+
164
+ class TestSelectorAnalysis:
165
+ def test_critical_selectors_add_signal(self, scanner):
166
+ report = PhishingScanReport(
167
+ contract_address="0xabc",
168
+ chain="ethereum",
169
+ matched_selectors=[
170
+ SelectorMatch(selector="0x791ac947", signature="drain()", risk="critical"),
171
+ SelectorMatch(selector="0x6d0f8c6f", signature="sweep()", risk="critical"),
172
+ ],
173
+ )
174
+ scanner._analyze_selectors(report)
175
+ critical_signals = [s for s in report.signals if s.severity == "critical"]
176
+ assert len(critical_signals) >= 1
177
+
178
+ def test_approve_transferfrom_sweep_combo(self, scanner):
179
+ report = PhishingScanReport(
180
+ contract_address="0xabc",
181
+ chain="ethereum",
182
+ matched_selectors=[
183
+ SelectorMatch(selector="0x095ea7b3", signature="approve", risk="high"),
184
+ SelectorMatch(selector="0x23b872dd", signature="transferFrom", risk="high"),
185
+ SelectorMatch(selector="0x791ac947", signature="drain()", risk="critical"),
186
+ ],
187
+ )
188
+ scanner._analyze_selectors(report)
189
+ combo = [s for s in report.signals if "approve_transferfrom_sweep_combo" in s.signal_type]
190
+ assert len(combo) == 1
191
+ assert combo[0].severity == "critical"
192
+
193
+ def test_no_false_positive_on_benign(self, scanner):
194
+ """A normal ERC20 with only approve() should not trigger high-risk signals."""
195
+ report = PhishingScanReport(
196
+ contract_address="0xabc",
197
+ chain="ethereum",
198
+ matched_selectors=[
199
+ SelectorMatch(selector="0x095ea7b3", signature="approve", risk="high"),
200
+ SelectorMatch(selector="0xa9059cbb", signature="transfer", risk="info"),
201
+ ],
202
+ )
203
+ scanner._analyze_selectors(report)
204
+ critical_signals = [s for s in report.signals if s.severity == "critical"]
205
+ assert len(critical_signals) == 0
206
+
207
+
208
+ # ── Source Code Analysis Tests ───────────────────────────────────
209
+
210
+
211
+ class TestSourceCodeAnalysis:
212
+ def test_hidden_transferfrom_in_claim(self, scanner):
213
+ report = PhishingScanReport(contract_address="0xabc", chain="ethereum")
214
+ scanner._analyze_source_code("""
215
+ function claim(address to, uint256 amount) public {
216
+ token.transferFrom(to, address(this), amount);
217
+ }
218
+ """, report)
219
+ found = [s for s in report.signals if s.signal_type == "hidden_transferfrom_in_claim"]
220
+ assert len(found) == 1
221
+ assert found[0].severity == "critical"
222
+
223
+ def test_owner_drain_function(self, scanner):
224
+ report = PhishingScanReport(contract_address="0xabc", chain="ethereum")
225
+ scanner._analyze_source_code("""
226
+ function drain(address token, uint256 amount) external onlyOwner {
227
+ token.transfer(owner, amount);
228
+ }
229
+ """, report)
230
+ found = [s for s in report.signals if s.signal_type == "owner_drain_function"]
231
+ assert len(found) == 1
232
+ assert found[0].severity == "critical"
233
+
234
+ def test_claim_without_transfer(self, scanner):
235
+ report = PhishingScanReport(contract_address="0xabc", chain="ethereum")
236
+ scanner._analyze_source_code("""
237
+ function claim() public {
238
+ emit AirdropClaimed(msg.sender, 1000);
239
+ }
240
+ """, report)
241
+ found = [s for s in report.signals if s.signal_type == "claim_without_transfer"]
242
+ assert len(found) == 1
243
+ assert found[0].severity == "high"
244
+
245
+ def test_claim_with_transfer_no_signal(self, scanner):
246
+ report = PhishingScanReport(contract_address="0xabc", chain="ethereum")
247
+ scanner._analyze_source_code("""
248
+ function claim() public {
249
+ token.transfer(msg.sender, 1000);
250
+ }
251
+ """, report)
252
+ found = [s for s in report.signals if s.signal_type == "claim_without_transfer"]
253
+ assert len(found) == 0
254
+
255
+ def test_setapprovalforall_in_claim(self, scanner):
256
+ report = PhishingScanReport(contract_address="0xabc", chain="ethereum")
257
+ scanner._analyze_source_code("""
258
+ function claim(address operator) public {
259
+ nft.setApprovalForAll(operator, true);
260
+ }
261
+ """, report)
262
+ found = [s for s in report.signals if s.signal_type == "approvalforall_in_claim"]
263
+ assert len(found) == 1
264
+ assert found[0].severity == "critical"
265
+
266
+ def test_unrestricted_mint(self, scanner):
267
+ report = PhishingScanReport(contract_address="0xabc", chain="ethereum")
268
+ scanner._analyze_source_code("""
269
+ function mint(address to, uint256 amount) public {
270
+ _mint(to, amount);
271
+ }
272
+ """, report)
273
+ found = [s for s in report.signals if s.signal_type == "unrestricted_mint"]
274
+ assert len(found) == 1
275
+ assert found[0].severity == "critical"
276
+
277
+ def test_restricted_mint_no_signal(self, scanner):
278
+ report = PhishingScanReport(contract_address="0xabc", chain="ethereum")
279
+ scanner._analyze_source_code("""
280
+ function mint(address to, uint256 amount) public onlyOwner {
281
+ _mint(to, amount);
282
+ }
283
+ """, report)
284
+ found = [s for s in report.signals if s.signal_type == "unrestricted_mint"]
285
+ assert len(found) == 0
286
+
287
+ def test_permit_phishing_in_claim(self, scanner):
288
+ report = PhishingScanReport(contract_address="0xabc", chain="ethereum")
289
+ scanner._analyze_source_code("""
290
+ function claim(address owner, uint256 value, uint8 v, bytes32 r, bytes32 s) public {
291
+ token.permit(owner, address(this), value, type(uint256).max, v, r, s);
292
+ }
293
+ """, report)
294
+ found = [s for s in report.signals if s.signal_type == "permit_phishing_in_claim"]
295
+ assert len(found) == 1
296
+
297
+ def test_infinite_approval_hardcoded(self, scanner):
298
+ report = PhishingScanReport(contract_address="0xabc", chain="ethereum")
299
+ scanner._analyze_source_code("""
300
+ function approveAll(address spender) public {
301
+ token.approve(spender, type(uint256).max);
302
+ }
303
+ """, report)
304
+ found = [s for s in report.signals if s.signal_type == "infinite_approval_hardcoded"]
305
+ assert len(found) == 1
306
+ assert found[0].severity == "high"
307
+
308
+ def test_delegatecall_to_variable(self, scanner):
309
+ report = PhishingScanReport(contract_address="0xabc", chain="ethereum")
310
+ scanner._analyze_source_code("""
311
+ function execute(address target, bytes memory data) public {
312
+ target.delegatecall(data);
313
+ }
314
+ """, report)
315
+ found = [s for s in report.signals if s.signal_type == "delegatecall_to_variable"]
316
+ assert len(found) == 1
317
+
318
+ def test_benign_code_no_signals(self, scanner):
319
+ report = PhishingScanReport(contract_address="0xabc", chain="ethereum")
320
+ scanner._analyze_source_code("""
321
+ pragma solidity ^0.8.0;
322
+ contract StandardToken {
323
+ string public name = "Test";
324
+ string public symbol = "TST";
325
+ uint8 public decimals = 18;
326
+
327
+ mapping(address => uint256) balances;
328
+
329
+ function transfer(address to, uint256 amount) public returns (bool) {
330
+ balances[msg.sender] -= amount;
331
+ balances[to] += amount;
332
+ return true;
333
+ }
334
+ }
335
+ """, report)
336
+ # Should have no phishing signals from benign code
337
+ suspicious_signals = [s for s in report.signals if s.score_contribution > 0]
338
+ assert len(suspicious_signals) == 0
339
+
340
+
341
+ # ── Risk Calculation Tests ───────────────────────────────────────
342
+
343
+
344
+ class TestRiskCalculation:
345
+ def test_no_signals_no_risk(self, scanner):
346
+ report = PhishingScanReport(contract_address="0xabc", chain="ethereum")
347
+ scanner._calculate_risk(report)
348
+ assert report.risk_level == "none"
349
+ assert report.risk_score == 0.0
350
+
351
+ def test_low_risk_threshold(self, scanner):
352
+ report = PhishingScanReport(
353
+ contract_address="0xabc",
354
+ chain="ethereum",
355
+ signals=[PhishingSignal(
356
+ signal_type="test", severity="low",
357
+ description="Minor finding", score_contribution=10.0,
358
+ )],
359
+ )
360
+ scanner._calculate_risk(report)
361
+ assert report.risk_level == "low"
362
+
363
+ def test_medium_risk_threshold(self, scanner):
364
+ report = PhishingScanReport(
365
+ contract_address="0xabc",
366
+ chain="ethereum",
367
+ signals=[PhishingSignal(
368
+ signal_type="test", severity="medium",
369
+ description="Moderate finding", score_contribution=25.0,
370
+ )],
371
+ )
372
+ scanner._calculate_risk(report)
373
+ assert report.risk_level == "medium"
374
+
375
+ def test_high_risk_threshold(self, scanner):
376
+ report = PhishingScanReport(
377
+ contract_address="0xabc",
378
+ chain="ethereum",
379
+ signals=[PhishingSignal(
380
+ signal_type="test", severity="high",
381
+ description="Serious finding", score_contribution=50.0,
382
+ )],
383
+ )
384
+ scanner._calculate_risk(report)
385
+ assert report.risk_level == "high"
386
+
387
+ def test_critical_risk_threshold(self, scanner):
388
+ report = PhishingScanReport(
389
+ contract_address="0xabc",
390
+ chain="ethereum",
391
+ signals=[PhishingSignal(
392
+ signal_type="test", severity="critical",
393
+ description="Critical finding", score_contribution=75.0,
394
+ )],
395
+ )
396
+ scanner._calculate_risk(report)
397
+ assert report.risk_level == "critical"
398
+
399
+ def test_verified_contract_halves_score(self, scanner):
400
+ report = PhishingScanReport(
401
+ contract_address="0xabc",
402
+ chain="ethereum",
403
+ is_verified=True,
404
+ signals=[PhishingSignal(
405
+ signal_type="test", severity="high",
406
+ description="Finding", score_contribution=50.0,
407
+ )],
408
+ )
409
+ scanner._calculate_risk(report)
410
+ # 50 * 0.5 = 25 β†’ medium
411
+ assert report.risk_score == 25.0
412
+ assert report.risk_level == "medium"
413
+
414
+ def test_known_drainer_deployer_floor_70(self, scanner):
415
+ report = PhishingScanReport(
416
+ contract_address="0xabc",
417
+ chain="ethereum",
418
+ deployer_known_drainer=True,
419
+ signals=[PhishingSignal(
420
+ signal_type="known_drainer_deployer", severity="critical",
421
+ description="Known drainer deployer", score_contribution=40.0,
422
+ )],
423
+ )
424
+ scanner._calculate_risk(report)
425
+ assert report.risk_score >= 70.0
426
+
427
+
428
+ # ── Report Formatting Tests ──────────────────────────────────────
429
+
430
+
431
+ class TestReportFormatting:
432
+ def test_print_report_contains_address_and_chain(self, scanner):
433
+ report = PhishingScanReport(
434
+ contract_address="0xabc",
435
+ chain="ethereum",
436
+ risk_level="high",
437
+ risk_score=55.0,
438
+ )
439
+ output = scanner.print_report(report)
440
+ assert "0xabc" in output
441
+ assert "ethereum" in output
442
+ assert "HIGH" in output
443
+
444
+ def test_print_report_shows_signals(self, scanner):
445
+ report = PhishingScanReport(
446
+ contract_address="0xabc",
447
+ chain="ethereum",
448
+ risk_level="critical",
449
+ risk_score=80.0,
450
+ signals=[
451
+ PhishingSignal(
452
+ signal_type="test", severity="critical",
453
+ description="Critical drainer function found",
454
+ score_contribution=50.0,
455
+ ),
456
+ ],
457
+ )
458
+ output = scanner.print_report(report)
459
+ assert "Critical drainer function" in output
460
+
461
+
462
+ # ── Data Model Tests ─────────────────────────────────────────────
463
+
464
+
465
+ class TestDataModels:
466
+ def test_selector_match_to_dict(self):
467
+ sm = SelectorMatch(
468
+ selector="0x095ea7b3",
469
+ signature="approve(address,uint256)",
470
+ risk="high",
471
+ description="Dangerous approve function",
472
+ )
473
+ d = sm.to_dict()
474
+ assert d["selector"] == "0x095ea7b3"
475
+ assert d["risk"] == "high"
476
+ assert d["description"] == "Dangerous approve function"
477
+
478
+ def test_phishing_signal_to_dict(self):
479
+ sig = PhishingSignal(
480
+ signal_type="test_signal",
481
+ severity="critical",
482
+ description="Critical issue",
483
+ evidence="tx hash",
484
+ score_contribution=50.0,
485
+ )
486
+ d = sig.to_dict()
487
+ assert d["type"] == "test_signal"
488
+ assert d["severity"] == "critical"
489
+ assert d["evidence"] == "tx hash"
490
+
491
+ def test_phishing_scan_report_to_dict(self):
492
+ report = PhishingScanReport(
493
+ contract_address="0xabc",
494
+ chain="ethereum",
495
+ risk_level="high",
496
+ risk_score=55.0,
497
+ is_verified=False,
498
+ contract_name="",
499
+ deployer_known_drainer=True,
500
+ )
501
+ d = report.to_dict()
502
+ assert d["contract"] == "0xabc"
503
+ assert d["chain"] == "ethereum"
504
+ assert d["risk_level"] == "high"
505
+ assert d["deployer_known_drainer"] is True
506
+
507
+ def test_report_to_dict_includes_selectors_and_signals(self):
508
+ report = PhishingScanReport(
509
+ contract_address="0xabc",
510
+ chain="ethereum",
511
+ matched_selectors=[
512
+ SelectorMatch(selector="0x791ac947", signature="drain()", risk="critical"),
513
+ ],
514
+ signals=[
515
+ PhishingSignal(signal_type="t", severity="high", description="d", score_contribution=30.0),
516
+ ],
517
+ )
518
+ d = report.to_dict()
519
+ assert len(d["selectors"]) == 1
520
+ assert len(d["signals"]) == 1
521
+
522
+
523
+ # ── Constants Tests ──────────────────────────────────────────────
524
+
525
+
526
+ class TestConstants:
527
+ def test_drainer_selectors_not_empty(self):
528
+ assert len(DRAINER_SELECTORS) >= 10
529
+
530
+ def test_high_risk_selectors_not_empty(self):
531
+ assert len(HIGH_RISK_SELECTORS) >= 5
532
+
533
+ def test_suspicious_names_populated(self):
534
+ assert len(SUSPICIOUS_NAMES) >= 5
535
+ assert "drain" in SUSPICIOUS_NAMES
536
+ assert "sweep" in SUSPICIOUS_NAMES
537
+
538
+ def test_known_drainer_deployers_is_set(self):
539
+ assert isinstance(KNOWN_DRAINER_DEPLOYERS, set)
540
+
541
+ def test_evm_chains_populated(self):
542
+ from app.phishing_contract_scanner import EVM_CHAINS
543
+ assert "ethereum" in EVM_CHAINS
544
+ assert "bsc" in EVM_CHAINS
545
+ assert "polygon" in EVM_CHAINS
546
+
547
+
548
+ # ── API Key & Network Tests ──────────────────────────────────────
549
+
550
+
551
+ class TestNetworkCalls:
552
+ def test_fetch_source_info_unavailable_chain(self, scanner):
553
+ import asyncio
554
+ result = asyncio.run(scanner._fetch_source_info("0xabc", "nonexistent"))
555
+ assert result is None
556
+
557
+ def test_rpc_url_returns_correct_url(self, scanner):
558
+ url = scanner._get_rpc_url("ethereum")
559
+ assert url is not None
560
+ assert "rpc" in url.lower() or "eth" in url.lower()
561
+
562
+ url = scanner._get_rpc_url("nonexistent")
563
+ assert url is None
564
+
565
+ def test_sync_scan_raises_on_invalid_address(self, scanner):
566
+ with pytest.raises(ValueError, match="Invalid EVM"):
567
+ scanner.scan_sync("invalid", "ethereum")
568
+
569
+ def test_sync_scan_raises_on_unsupported_chain(self, scanner):
570
+ with pytest.raises(ValueError, match="Unsupported chain"):
571
+ scanner.scan_sync("0x" + "a" * 40, "unsupported")
572
+
573
+ def test_async_context_manager(self):
574
+ """Test that async context manager properly handles lifecycle."""
575
+ import asyncio
576
+ async def _run():
577
+ async with PhishingContractScanner(api_keys={"ethereum": "test"}) as scanner:
578
+ assert scanner is not None
579
+ client = await scanner._get_client()
580
+ assert client is not None
581
+ await scanner.close()
582
+ asyncio.run(_run())
583
+
584
+
585
+ # ── End-to-End Style Tests ───────────────────────────────────────
586
+
587
+
588
+ class TestEndToEnd:
589
+ def test_complete_scan_report_structure(self, scanner):
590
+ """Test that scan produces properly structured report."""
591
+ report = PhishingScanReport(
592
+ contract_address="0x" + "a" * 40,
593
+ chain="bsc",
594
+ is_verified=False,
595
+ matched_selectors=[
596
+ SelectorMatch(selector="0x095ea7b3", signature="approve", risk="high"),
597
+ SelectorMatch(selector="0x23b872dd", signature="transferFrom", risk="high"),
598
+ SelectorMatch(selector="0x791ac947", signature="drain()", risk="critical"),
599
+ ],
600
+ signals=[
601
+ PhishingSignal(
602
+ signal_type="critical_selector_found",
603
+ severity="critical",
604
+ description="Critical drainer function found",
605
+ score_contribution=50.0,
606
+ ),
607
+ ],
608
+ deployer_known_drainer=True,
609
+ deployer_address="0xdead",
610
+ creation_tx="0xtxhash",
611
+ scan_timestamp=time.time(),
612
+ scan_duration_ms=1234,
613
+ )
614
+ scanner._calculate_risk(report)
615
+ d = report.to_dict()
616
+
617
+ assert d["risk_level"] in ("critical", "high", "medium", "low", "none")
618
+ assert 0 <= d["risk_score"] <= 100
619
+ assert d["chain"] == "bsc"
620
+ assert d["deployer_known_drainer"] is True
621
+ assert len(d["selectors"]) >= 3
622
+ assert len(d["signals"]) >= 1
623
+
624
+ def test_empty_report(self, scanner):
625
+ """Test report with no data still produces valid output."""
626
+ report = PhishingScanReport(
627
+ contract_address="0x" + "a" * 40,
628
+ chain="ethereum",
629
+ )
630
+ scanner._calculate_risk(report)
631
+ d = report.to_dict()
632
+ assert d["risk_level"] == "none"
633
+ assert d["risk_score"] == 0.0
634
+ assert d["verified"] is False
635
+ assert d["deployer_known_drainer"] is False