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
|
@@ -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())
|
|
@@ -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
|