@intentsolutionsio/token-launch-tracker 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +22 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/agents/launch-tracker-agent.md +338 -0
- package/package.json +43 -0
- package/skills/skill-adapter/assets/README.md +5 -0
- package/skills/skill-adapter/assets/config-template.json +32 -0
- package/skills/skill-adapter/assets/skill-schema.json +28 -0
- package/skills/skill-adapter/assets/test-data.json +27 -0
- package/skills/skill-adapter/references/README.md +4 -0
- package/skills/skill-adapter/references/best-practices.md +69 -0
- package/skills/skill-adapter/references/examples.md +73 -0
- package/skills/skill-adapter/scripts/README.md +8 -0
- package/skills/skill-adapter/scripts/helper-template.sh +42 -0
- package/skills/skill-adapter/scripts/validation.sh +32 -0
- package/skills/tracking-token-launches/ARD.md +183 -0
- package/skills/tracking-token-launches/PRD.md +66 -0
- package/skills/tracking-token-launches/SKILL.md +161 -0
- package/skills/tracking-token-launches/config/settings.yaml +166 -0
- package/skills/tracking-token-launches/references/errors.md +167 -0
- package/skills/tracking-token-launches/references/examples.md +292 -0
- package/skills/tracking-token-launches/references/implementation.md +36 -0
- package/skills/tracking-token-launches/scripts/dex_sources.py +270 -0
- package/skills/tracking-token-launches/scripts/event_monitor.py +345 -0
- package/skills/tracking-token-launches/scripts/formatters.py +279 -0
- package/skills/tracking-token-launches/scripts/launch_tracker.py +572 -0
- package/skills/tracking-token-launches/scripts/token_analyzer.py +417 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Token Contract Analyzer
|
|
4
|
+
|
|
5
|
+
Analyze token contracts for risk indicators.
|
|
6
|
+
|
|
7
|
+
Author: Jeremy Longshore <jeremy@intentsolutions.io>
|
|
8
|
+
Version: 1.0.0
|
|
9
|
+
License: MIT
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any, List, Optional
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import requests
|
|
18
|
+
except ImportError:
|
|
19
|
+
requests = None
|
|
20
|
+
|
|
21
|
+
from dex_sources import get_chain_config
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class TokenInfo:
|
|
26
|
+
"""Complete token information."""
|
|
27
|
+
address: str
|
|
28
|
+
name: str
|
|
29
|
+
symbol: str
|
|
30
|
+
decimals: int
|
|
31
|
+
total_supply: int
|
|
32
|
+
owner: Optional[str] = None
|
|
33
|
+
is_verified: bool = False
|
|
34
|
+
creation_block: Optional[int] = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class RiskIndicator:
|
|
39
|
+
"""Single risk indicator."""
|
|
40
|
+
name: str
|
|
41
|
+
detected: bool
|
|
42
|
+
severity: str # high, medium, low, info
|
|
43
|
+
description: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class ContractAnalysis:
|
|
48
|
+
"""Complete contract risk analysis."""
|
|
49
|
+
address: str
|
|
50
|
+
risk_score: int # 0-100 (higher = riskier)
|
|
51
|
+
indicators: List[RiskIndicator] = field(default_factory=list)
|
|
52
|
+
bytecode_size: int = 0
|
|
53
|
+
is_proxy: bool = False
|
|
54
|
+
ownership_renounced: bool = False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# Known risky function signatures
|
|
58
|
+
# Note: 0xa9059cbb is the standard ERC20 transfer function, not blacklist
|
|
59
|
+
RISKY_FUNCTIONS = {
|
|
60
|
+
"0x40c10f19": ("mint", "high", "Contract has mint function"),
|
|
61
|
+
"0x44337ea1": ("blacklist", "medium", "Contract has blacklist functionality"), # addToBlacklist(address)
|
|
62
|
+
"0x42966c68": ("burn", "info", "Contract has burn function"),
|
|
63
|
+
"0x23b872dd": ("transferFrom", "info", "Standard ERC20 transferFrom"),
|
|
64
|
+
"0x8da5cb5b": ("owner", "info", "Contract has owner"),
|
|
65
|
+
"0x715018a6": ("renounceOwnership", "info", "Can renounce ownership"),
|
|
66
|
+
"0xf2fde38b": ("transferOwnership", "low", "Can transfer ownership"),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# Bytecode patterns for suspicious contracts
|
|
70
|
+
SUSPICIOUS_PATTERNS = [
|
|
71
|
+
("73757370656e64", "medium", "Contains 'suspend' string"),
|
|
72
|
+
("626c61636b6c697374", "medium", "Contains 'blacklist' string"),
|
|
73
|
+
("77686974656c697374", "low", "Contains 'whitelist' string"),
|
|
74
|
+
("6f6e6c794f776e6572", "info", "Contains 'onlyOwner' modifier"),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TokenAnalyzer:
|
|
79
|
+
"""Analyze token contracts for risks."""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
chain: str = "ethereum",
|
|
84
|
+
rpc_url: str = None,
|
|
85
|
+
etherscan_api_key: str = None,
|
|
86
|
+
verbose: bool = False
|
|
87
|
+
):
|
|
88
|
+
"""Initialize token analyzer.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
chain: Chain to analyze on
|
|
92
|
+
rpc_url: Custom RPC URL
|
|
93
|
+
etherscan_api_key: Etherscan API key for verification check
|
|
94
|
+
verbose: Enable verbose output
|
|
95
|
+
"""
|
|
96
|
+
self.chain = chain.lower()
|
|
97
|
+
self.config = get_chain_config(chain)
|
|
98
|
+
self.rpc_url = rpc_url or os.environ.get(
|
|
99
|
+
f"{chain.upper()}_RPC_URL",
|
|
100
|
+
self.config.rpc_url
|
|
101
|
+
)
|
|
102
|
+
self.etherscan_key = etherscan_api_key or os.environ.get("ETHERSCAN_API_KEY", "")
|
|
103
|
+
self.verbose = verbose
|
|
104
|
+
|
|
105
|
+
def _rpc_call(self, method: str, params: List = None) -> Any:
|
|
106
|
+
"""Make JSON-RPC call."""
|
|
107
|
+
if not requests:
|
|
108
|
+
raise ImportError("requests library required")
|
|
109
|
+
|
|
110
|
+
response = requests.post(
|
|
111
|
+
self.rpc_url,
|
|
112
|
+
json={
|
|
113
|
+
"jsonrpc": "2.0",
|
|
114
|
+
"method": method,
|
|
115
|
+
"params": params or [],
|
|
116
|
+
"id": 1,
|
|
117
|
+
},
|
|
118
|
+
headers={"Content-Type": "application/json"},
|
|
119
|
+
timeout=30,
|
|
120
|
+
)
|
|
121
|
+
response.raise_for_status()
|
|
122
|
+
|
|
123
|
+
result = response.json()
|
|
124
|
+
if "error" in result:
|
|
125
|
+
raise Exception(f"RPC error: {result['error']}")
|
|
126
|
+
|
|
127
|
+
return result.get("result")
|
|
128
|
+
|
|
129
|
+
def _call_contract(self, address: str, data: str) -> Optional[str]:
|
|
130
|
+
"""Make eth_call."""
|
|
131
|
+
try:
|
|
132
|
+
result = self._rpc_call("eth_call", [
|
|
133
|
+
{"to": address, "data": data},
|
|
134
|
+
"latest"
|
|
135
|
+
])
|
|
136
|
+
return result if result and result != "0x" else None
|
|
137
|
+
except Exception:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
def get_token_info(self, address: str) -> TokenInfo:
|
|
141
|
+
"""Get complete token information.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
address: Token contract address
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
TokenInfo object
|
|
148
|
+
"""
|
|
149
|
+
# ERC20 function signatures
|
|
150
|
+
name_result = self._call_contract(address, "0x06fdde03")
|
|
151
|
+
symbol_result = self._call_contract(address, "0x95d89b41")
|
|
152
|
+
decimals_result = self._call_contract(address, "0x313ce567")
|
|
153
|
+
supply_result = self._call_contract(address, "0x18160ddd")
|
|
154
|
+
owner_result = self._call_contract(address, "0x8da5cb5b")
|
|
155
|
+
|
|
156
|
+
return TokenInfo(
|
|
157
|
+
address=address,
|
|
158
|
+
name=self._decode_string(name_result) if name_result else "Unknown",
|
|
159
|
+
symbol=self._decode_string(symbol_result) if symbol_result else "???",
|
|
160
|
+
decimals=int(decimals_result, 16) if decimals_result else 18,
|
|
161
|
+
total_supply=int(supply_result, 16) if supply_result else 0,
|
|
162
|
+
owner="0x" + owner_result[-40:] if owner_result and len(owner_result) >= 42 else None,
|
|
163
|
+
is_verified=self._check_verified(address),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def _decode_string(self, data: str) -> str:
|
|
167
|
+
"""Decode ABI-encoded string."""
|
|
168
|
+
if not data or data == "0x":
|
|
169
|
+
return ""
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
data = data[2:]
|
|
173
|
+
|
|
174
|
+
if len(data) <= 64:
|
|
175
|
+
return bytes.fromhex(data).decode("utf-8", errors="ignore").strip("\x00")
|
|
176
|
+
|
|
177
|
+
if len(data) >= 128:
|
|
178
|
+
length = int(data[64:128], 16)
|
|
179
|
+
string_data = data[128:128 + length * 2]
|
|
180
|
+
return bytes.fromhex(string_data).decode("utf-8", errors="ignore")
|
|
181
|
+
|
|
182
|
+
return bytes.fromhex(data).decode("utf-8", errors="ignore").strip("\x00")
|
|
183
|
+
|
|
184
|
+
except Exception:
|
|
185
|
+
return "Unknown"
|
|
186
|
+
|
|
187
|
+
def _check_verified(self, address: str) -> bool:
|
|
188
|
+
"""Check if contract is verified on block explorer."""
|
|
189
|
+
if not requests or not self.etherscan_key:
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
# Map chain to explorer API
|
|
194
|
+
explorer_apis = {
|
|
195
|
+
"ethereum": "https://api.etherscan.io/api",
|
|
196
|
+
"bsc": "https://api.bscscan.com/api",
|
|
197
|
+
"arbitrum": "https://api.arbiscan.io/api",
|
|
198
|
+
"base": "https://api.basescan.org/api",
|
|
199
|
+
"polygon": "https://api.polygonscan.com/api",
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
api_url = explorer_apis.get(self.chain)
|
|
203
|
+
if not api_url:
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
response = requests.get(api_url, params={
|
|
207
|
+
"module": "contract",
|
|
208
|
+
"action": "getsourcecode",
|
|
209
|
+
"address": address,
|
|
210
|
+
"apikey": self.etherscan_key,
|
|
211
|
+
}, timeout=10)
|
|
212
|
+
|
|
213
|
+
data = response.json()
|
|
214
|
+
if data.get("status") == "1" and data.get("result"):
|
|
215
|
+
source = data["result"][0].get("SourceCode", "")
|
|
216
|
+
return bool(source and source != "")
|
|
217
|
+
|
|
218
|
+
except Exception as e:
|
|
219
|
+
if self.verbose:
|
|
220
|
+
print(f"Verification check error: {e}")
|
|
221
|
+
|
|
222
|
+
return False
|
|
223
|
+
|
|
224
|
+
def analyze_contract(self, address: str) -> ContractAnalysis:
|
|
225
|
+
"""Analyze contract for risk indicators.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
address: Contract address
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
ContractAnalysis with risk score and indicators
|
|
232
|
+
"""
|
|
233
|
+
indicators = []
|
|
234
|
+
risk_score = 0
|
|
235
|
+
|
|
236
|
+
# Get bytecode
|
|
237
|
+
bytecode = self._rpc_call("eth_getCode", [address, "latest"])
|
|
238
|
+
bytecode_size = (len(bytecode) - 2) // 2 if bytecode else 0
|
|
239
|
+
|
|
240
|
+
# Check if proxy
|
|
241
|
+
is_proxy = self._detect_proxy(address, bytecode)
|
|
242
|
+
if is_proxy:
|
|
243
|
+
indicators.append(RiskIndicator(
|
|
244
|
+
name="Proxy Contract",
|
|
245
|
+
detected=True,
|
|
246
|
+
severity="medium",
|
|
247
|
+
description="Contract is a proxy - implementation can be changed"
|
|
248
|
+
))
|
|
249
|
+
risk_score += 20
|
|
250
|
+
|
|
251
|
+
# Check ownership
|
|
252
|
+
owner_result = self._call_contract(address, "0x8da5cb5b")
|
|
253
|
+
owner = "0x" + owner_result[-40:] if owner_result and len(owner_result) >= 42 else None
|
|
254
|
+
ownership_renounced = owner and owner.lower() == "0x0000000000000000000000000000000000000000"
|
|
255
|
+
|
|
256
|
+
if owner:
|
|
257
|
+
if ownership_renounced:
|
|
258
|
+
indicators.append(RiskIndicator(
|
|
259
|
+
name="Ownership Renounced",
|
|
260
|
+
detected=True,
|
|
261
|
+
severity="info",
|
|
262
|
+
description="Ownership has been renounced"
|
|
263
|
+
))
|
|
264
|
+
else:
|
|
265
|
+
indicators.append(RiskIndicator(
|
|
266
|
+
name="Has Owner",
|
|
267
|
+
detected=True,
|
|
268
|
+
severity="low",
|
|
269
|
+
description=f"Contract has active owner: {owner[:20]}..."
|
|
270
|
+
))
|
|
271
|
+
risk_score += 10
|
|
272
|
+
|
|
273
|
+
# Check for risky function signatures in bytecode
|
|
274
|
+
for sig, (name, severity, desc) in RISKY_FUNCTIONS.items():
|
|
275
|
+
if sig[2:] in bytecode.lower():
|
|
276
|
+
indicators.append(RiskIndicator(
|
|
277
|
+
name=f"Has {name}",
|
|
278
|
+
detected=True,
|
|
279
|
+
severity=severity,
|
|
280
|
+
description=desc
|
|
281
|
+
))
|
|
282
|
+
if severity == "high":
|
|
283
|
+
risk_score += 30
|
|
284
|
+
elif severity == "medium":
|
|
285
|
+
risk_score += 15
|
|
286
|
+
elif severity == "low":
|
|
287
|
+
risk_score += 5
|
|
288
|
+
|
|
289
|
+
# Check for suspicious patterns
|
|
290
|
+
for pattern, severity, desc in SUSPICIOUS_PATTERNS:
|
|
291
|
+
if pattern in bytecode.lower():
|
|
292
|
+
indicators.append(RiskIndicator(
|
|
293
|
+
name="Suspicious Pattern",
|
|
294
|
+
detected=True,
|
|
295
|
+
severity=severity,
|
|
296
|
+
description=desc
|
|
297
|
+
))
|
|
298
|
+
if severity == "high":
|
|
299
|
+
risk_score += 25
|
|
300
|
+
elif severity == "medium":
|
|
301
|
+
risk_score += 10
|
|
302
|
+
|
|
303
|
+
# Check verification
|
|
304
|
+
is_verified = self._check_verified(address)
|
|
305
|
+
if not is_verified:
|
|
306
|
+
indicators.append(RiskIndicator(
|
|
307
|
+
name="Not Verified",
|
|
308
|
+
detected=True,
|
|
309
|
+
severity="medium",
|
|
310
|
+
description="Contract source code not verified"
|
|
311
|
+
))
|
|
312
|
+
risk_score += 15
|
|
313
|
+
|
|
314
|
+
# Very small bytecode might be suspicious
|
|
315
|
+
if bytecode_size < 500:
|
|
316
|
+
indicators.append(RiskIndicator(
|
|
317
|
+
name="Small Contract",
|
|
318
|
+
detected=True,
|
|
319
|
+
severity="info",
|
|
320
|
+
description=f"Bytecode is only {bytecode_size} bytes"
|
|
321
|
+
))
|
|
322
|
+
|
|
323
|
+
# Cap at 100
|
|
324
|
+
risk_score = min(risk_score, 100)
|
|
325
|
+
|
|
326
|
+
return ContractAnalysis(
|
|
327
|
+
address=address,
|
|
328
|
+
risk_score=risk_score,
|
|
329
|
+
indicators=indicators,
|
|
330
|
+
bytecode_size=bytecode_size,
|
|
331
|
+
is_proxy=is_proxy,
|
|
332
|
+
ownership_renounced=ownership_renounced,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
def _detect_proxy(self, address: str, bytecode: str) -> bool:
|
|
336
|
+
"""Detect if contract is a proxy.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
address: Contract address
|
|
340
|
+
bytecode: Contract bytecode
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
True if proxy detected
|
|
344
|
+
"""
|
|
345
|
+
# Common proxy patterns
|
|
346
|
+
proxy_patterns = [
|
|
347
|
+
"363d3d373d3d3d363d73", # Minimal proxy
|
|
348
|
+
"5155f3", # DELEGATECALL
|
|
349
|
+
"363d3d373d3d3d363d", # EIP-1167
|
|
350
|
+
]
|
|
351
|
+
|
|
352
|
+
bytecode_lower = bytecode.lower()
|
|
353
|
+
for pattern in proxy_patterns:
|
|
354
|
+
if pattern in bytecode_lower:
|
|
355
|
+
return True
|
|
356
|
+
|
|
357
|
+
# Check EIP-1967 implementation slot
|
|
358
|
+
impl_slot = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"
|
|
359
|
+
try:
|
|
360
|
+
storage = self._rpc_call("eth_getStorageAt", [address, impl_slot, "latest"])
|
|
361
|
+
if storage and storage != "0x" + "0" * 64:
|
|
362
|
+
return True
|
|
363
|
+
except Exception:
|
|
364
|
+
pass # Storage read failed - likely not a proxy or access issue
|
|
365
|
+
|
|
366
|
+
return False
|
|
367
|
+
|
|
368
|
+
def get_risk_summary(self, analysis: ContractAnalysis) -> str:
|
|
369
|
+
"""Get human-readable risk summary.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
analysis: ContractAnalysis object
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
Risk level string
|
|
376
|
+
"""
|
|
377
|
+
if analysis.risk_score >= 70:
|
|
378
|
+
return "HIGH RISK"
|
|
379
|
+
elif analysis.risk_score >= 40:
|
|
380
|
+
return "MEDIUM RISK"
|
|
381
|
+
elif analysis.risk_score >= 20:
|
|
382
|
+
return "LOW RISK"
|
|
383
|
+
else:
|
|
384
|
+
return "MINIMAL RISK"
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def main():
|
|
388
|
+
"""CLI entry point for testing."""
|
|
389
|
+
analyzer = TokenAnalyzer(chain="ethereum", verbose=True)
|
|
390
|
+
|
|
391
|
+
# Test with a known token (USDC)
|
|
392
|
+
test_address = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
|
|
393
|
+
|
|
394
|
+
print("=== Token Info ===")
|
|
395
|
+
info = analyzer.get_token_info(test_address)
|
|
396
|
+
print(f"Name: {info.name}")
|
|
397
|
+
print(f"Symbol: {info.symbol}")
|
|
398
|
+
print(f"Decimals: {info.decimals}")
|
|
399
|
+
print(f"Supply: {info.total_supply / 10**info.decimals:,.0f}")
|
|
400
|
+
print(f"Owner: {info.owner}")
|
|
401
|
+
print(f"Verified: {info.is_verified}")
|
|
402
|
+
|
|
403
|
+
print("\n=== Contract Analysis ===")
|
|
404
|
+
analysis = analyzer.analyze_contract(test_address)
|
|
405
|
+
print(f"Risk Score: {analysis.risk_score}/100")
|
|
406
|
+
print(f"Summary: {analyzer.get_risk_summary(analysis)}")
|
|
407
|
+
print(f"Bytecode Size: {analysis.bytecode_size} bytes")
|
|
408
|
+
print(f"Is Proxy: {analysis.is_proxy}")
|
|
409
|
+
print(f"Ownership Renounced: {analysis.ownership_renounced}")
|
|
410
|
+
|
|
411
|
+
print("\nIndicators:")
|
|
412
|
+
for ind in analysis.indicators:
|
|
413
|
+
print(f" [{ind.severity.upper()}] {ind.name}: {ind.description}")
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
if __name__ == "__main__":
|
|
417
|
+
main()
|