@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,270 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
DEX Factory Addresses
|
|
4
|
+
|
|
5
|
+
Factory contract addresses for detecting new pairs.
|
|
6
|
+
|
|
7
|
+
Author: Jeremy Longshore <jeremy@intentsolutions.io>
|
|
8
|
+
Version: 1.0.0
|
|
9
|
+
License: MIT
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Dict, List
|
|
14
|
+
|
|
15
|
+
# PairCreated event signature for Uniswap V2 style
|
|
16
|
+
PAIR_CREATED_TOPIC = "0x0d3648bd0f6ba80134a33ba9275ac585d9d315f0ad8355cddefde31afa28d0e9"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class DexFactory:
|
|
21
|
+
"""DEX factory configuration."""
|
|
22
|
+
name: str
|
|
23
|
+
address: str
|
|
24
|
+
version: str # v2 or v3
|
|
25
|
+
pair_created_topic: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ChainConfig:
|
|
30
|
+
"""Chain configuration."""
|
|
31
|
+
name: str
|
|
32
|
+
chain_id: int
|
|
33
|
+
rpc_url: str
|
|
34
|
+
native_symbol: str
|
|
35
|
+
wrapped_native: str
|
|
36
|
+
block_time: float
|
|
37
|
+
explorer_url: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Chain configurations
|
|
41
|
+
CHAINS: Dict[str, ChainConfig] = {
|
|
42
|
+
"ethereum": ChainConfig(
|
|
43
|
+
name="Ethereum",
|
|
44
|
+
chain_id=1,
|
|
45
|
+
rpc_url="https://eth.llamarpc.com",
|
|
46
|
+
native_symbol="ETH",
|
|
47
|
+
wrapped_native="0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
|
|
48
|
+
block_time=12.0,
|
|
49
|
+
explorer_url="https://etherscan.io",
|
|
50
|
+
),
|
|
51
|
+
"bsc": ChainConfig(
|
|
52
|
+
name="BNB Chain",
|
|
53
|
+
chain_id=56,
|
|
54
|
+
rpc_url="https://bsc-dataseed1.binance.org",
|
|
55
|
+
native_symbol="BNB",
|
|
56
|
+
wrapped_native="0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c",
|
|
57
|
+
block_time=3.0,
|
|
58
|
+
explorer_url="https://bscscan.com",
|
|
59
|
+
),
|
|
60
|
+
"arbitrum": ChainConfig(
|
|
61
|
+
name="Arbitrum One",
|
|
62
|
+
chain_id=42161,
|
|
63
|
+
rpc_url="https://arb1.arbitrum.io/rpc",
|
|
64
|
+
native_symbol="ETH",
|
|
65
|
+
wrapped_native="0x82aF49447D8a07e3bd95BD0d56f35241523fBab1",
|
|
66
|
+
block_time=0.25,
|
|
67
|
+
explorer_url="https://arbiscan.io",
|
|
68
|
+
),
|
|
69
|
+
"base": ChainConfig(
|
|
70
|
+
name="Base",
|
|
71
|
+
chain_id=8453,
|
|
72
|
+
rpc_url="https://mainnet.base.org",
|
|
73
|
+
native_symbol="ETH",
|
|
74
|
+
wrapped_native="0x4200000000000000000000000000000000000006",
|
|
75
|
+
block_time=2.0,
|
|
76
|
+
explorer_url="https://basescan.org",
|
|
77
|
+
),
|
|
78
|
+
"polygon": ChainConfig(
|
|
79
|
+
name="Polygon",
|
|
80
|
+
chain_id=137,
|
|
81
|
+
rpc_url="https://polygon-rpc.com",
|
|
82
|
+
native_symbol="MATIC",
|
|
83
|
+
wrapped_native="0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270",
|
|
84
|
+
block_time=2.0,
|
|
85
|
+
explorer_url="https://polygonscan.com",
|
|
86
|
+
),
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# DEX factory addresses per chain
|
|
91
|
+
DEX_FACTORIES: Dict[str, Dict[str, DexFactory]] = {
|
|
92
|
+
"ethereum": {
|
|
93
|
+
"uniswap_v2": DexFactory(
|
|
94
|
+
name="Uniswap V2",
|
|
95
|
+
address="0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f",
|
|
96
|
+
version="v2",
|
|
97
|
+
pair_created_topic=PAIR_CREATED_TOPIC,
|
|
98
|
+
),
|
|
99
|
+
"uniswap_v3": DexFactory(
|
|
100
|
+
name="Uniswap V3",
|
|
101
|
+
address="0x1F98431c8aD98523631AE4a59f267346ea31F984",
|
|
102
|
+
version="v3",
|
|
103
|
+
pair_created_topic="0x783cca1c0412dd0d695e784568c96da2e9c22ff989357a2e8b1d9b2b4e6b7118",
|
|
104
|
+
),
|
|
105
|
+
"sushiswap": DexFactory(
|
|
106
|
+
name="SushiSwap",
|
|
107
|
+
address="0xC0AEe478e3658e2610c5F7A4A2E1777cE9e4f2Ac",
|
|
108
|
+
version="v2",
|
|
109
|
+
pair_created_topic=PAIR_CREATED_TOPIC,
|
|
110
|
+
),
|
|
111
|
+
},
|
|
112
|
+
"bsc": {
|
|
113
|
+
"pancakeswap_v2": DexFactory(
|
|
114
|
+
name="PancakeSwap V2",
|
|
115
|
+
address="0xcA143Ce32Fe78f1f7019d7d551a6402fC5350c73",
|
|
116
|
+
version="v2",
|
|
117
|
+
pair_created_topic=PAIR_CREATED_TOPIC,
|
|
118
|
+
),
|
|
119
|
+
"pancakeswap_v3": DexFactory(
|
|
120
|
+
name="PancakeSwap V3",
|
|
121
|
+
address="0x0BFbCF9fa4f9C56B0F40a671Ad40E0805A091865",
|
|
122
|
+
version="v3",
|
|
123
|
+
pair_created_topic="0x783cca1c0412dd0d695e784568c96da2e9c22ff989357a2e8b1d9b2b4e6b7118",
|
|
124
|
+
),
|
|
125
|
+
},
|
|
126
|
+
"arbitrum": {
|
|
127
|
+
"uniswap_v3": DexFactory(
|
|
128
|
+
name="Uniswap V3",
|
|
129
|
+
address="0x1F98431c8aD98523631AE4a59f267346ea31F984",
|
|
130
|
+
version="v3",
|
|
131
|
+
pair_created_topic="0x783cca1c0412dd0d695e784568c96da2e9c22ff989357a2e8b1d9b2b4e6b7118",
|
|
132
|
+
),
|
|
133
|
+
"camelot": DexFactory(
|
|
134
|
+
name="Camelot",
|
|
135
|
+
address="0x6EcCab422D763aC031210895C81787E87B43A652",
|
|
136
|
+
version="v2",
|
|
137
|
+
pair_created_topic=PAIR_CREATED_TOPIC,
|
|
138
|
+
),
|
|
139
|
+
"sushiswap": DexFactory(
|
|
140
|
+
name="SushiSwap",
|
|
141
|
+
address="0xc35DADB65012eC5796536bD9864eD8773aBc74C4",
|
|
142
|
+
version="v2",
|
|
143
|
+
pair_created_topic=PAIR_CREATED_TOPIC,
|
|
144
|
+
),
|
|
145
|
+
},
|
|
146
|
+
"base": {
|
|
147
|
+
"uniswap_v3": DexFactory(
|
|
148
|
+
name="Uniswap V3",
|
|
149
|
+
address="0x33128a8fC17869897dcE68Ed026d694621f6FDfD",
|
|
150
|
+
version="v3",
|
|
151
|
+
pair_created_topic="0x783cca1c0412dd0d695e784568c96da2e9c22ff989357a2e8b1d9b2b4e6b7118",
|
|
152
|
+
),
|
|
153
|
+
"aerodrome": DexFactory(
|
|
154
|
+
name="Aerodrome",
|
|
155
|
+
address="0x420DD381b31aEf6683db6B902084cB0FFECe40Da",
|
|
156
|
+
version="v2",
|
|
157
|
+
pair_created_topic=PAIR_CREATED_TOPIC,
|
|
158
|
+
),
|
|
159
|
+
},
|
|
160
|
+
"polygon": {
|
|
161
|
+
"uniswap_v3": DexFactory(
|
|
162
|
+
name="Uniswap V3",
|
|
163
|
+
address="0x1F98431c8aD98523631AE4a59f267346ea31F984",
|
|
164
|
+
version="v3",
|
|
165
|
+
pair_created_topic="0x783cca1c0412dd0d695e784568c96da2e9c22ff989357a2e8b1d9b2b4e6b7118",
|
|
166
|
+
),
|
|
167
|
+
"quickswap": DexFactory(
|
|
168
|
+
name="QuickSwap",
|
|
169
|
+
address="0x5757371414417b8C6CAad45bAeF941aBc7d3Ab32",
|
|
170
|
+
version="v2",
|
|
171
|
+
pair_created_topic=PAIR_CREATED_TOPIC,
|
|
172
|
+
),
|
|
173
|
+
"sushiswap": DexFactory(
|
|
174
|
+
name="SushiSwap",
|
|
175
|
+
address="0xc35DADB65012eC5796536bD9864eD8773aBc74C4",
|
|
176
|
+
version="v2",
|
|
177
|
+
pair_created_topic=PAIR_CREATED_TOPIC,
|
|
178
|
+
),
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# Common stablecoins per chain (for base pair detection)
|
|
184
|
+
STABLECOINS: Dict[str, List[str]] = {
|
|
185
|
+
"ethereum": [
|
|
186
|
+
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", # USDC
|
|
187
|
+
"0xdAC17F958D2ee523a2206206994597C13D831ec7", # USDT
|
|
188
|
+
"0x6B175474E89094C44Da98b954E2deC563975aA61", # DAI
|
|
189
|
+
],
|
|
190
|
+
"bsc": [
|
|
191
|
+
"0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", # USDC
|
|
192
|
+
"0x55d398326f99059fF775485246999027B3197955", # USDT
|
|
193
|
+
"0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56", # BUSD
|
|
194
|
+
],
|
|
195
|
+
"arbitrum": [
|
|
196
|
+
"0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8", # USDC.e
|
|
197
|
+
"0xaf88d065e77c8cC2239327C5EDb3A432268e5831", # USDC
|
|
198
|
+
"0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", # USDT
|
|
199
|
+
],
|
|
200
|
+
"base": [
|
|
201
|
+
"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", # USDC
|
|
202
|
+
"0xd9aAEc86B65D86f6A7B5B1b0c42FFA531710b6CA", # USDbC
|
|
203
|
+
],
|
|
204
|
+
"polygon": [
|
|
205
|
+
"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", # USDC
|
|
206
|
+
"0xc2132D05D31c914a87C6611C10748AEb04B58e8F", # USDT
|
|
207
|
+
"0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063", # DAI
|
|
208
|
+
],
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def get_chain_config(chain: str) -> ChainConfig:
|
|
213
|
+
"""Get chain configuration."""
|
|
214
|
+
chain = chain.lower()
|
|
215
|
+
if chain not in CHAINS:
|
|
216
|
+
raise ValueError(f"Unsupported chain: {chain}. Supported: {list(CHAINS.keys())}")
|
|
217
|
+
return CHAINS[chain]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def get_dex_factories(chain: str) -> Dict[str, DexFactory]:
|
|
221
|
+
"""Get DEX factories for a chain."""
|
|
222
|
+
chain = chain.lower()
|
|
223
|
+
return DEX_FACTORIES.get(chain, {})
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def get_all_factory_addresses(chain: str) -> List[str]:
|
|
227
|
+
"""Get all factory addresses for a chain."""
|
|
228
|
+
factories = get_dex_factories(chain)
|
|
229
|
+
return [f.address.lower() for f in factories.values()]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def identify_dex(chain: str, factory_address: str) -> str:
|
|
233
|
+
"""Identify DEX by factory address."""
|
|
234
|
+
factories = get_dex_factories(chain)
|
|
235
|
+
factory_address = factory_address.lower()
|
|
236
|
+
|
|
237
|
+
for name, factory in factories.items():
|
|
238
|
+
if factory.address.lower() == factory_address:
|
|
239
|
+
return factory.name
|
|
240
|
+
|
|
241
|
+
return "Unknown DEX"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def is_base_token(chain: str, address: str) -> bool:
|
|
245
|
+
"""Check if address is a base token (stablecoin or wrapped native)."""
|
|
246
|
+
address = address.lower()
|
|
247
|
+
chain_config = get_chain_config(chain)
|
|
248
|
+
|
|
249
|
+
if address == chain_config.wrapped_native.lower():
|
|
250
|
+
return True
|
|
251
|
+
|
|
252
|
+
stables = STABLECOINS.get(chain, [])
|
|
253
|
+
return address in [s.lower() for s in stables]
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def main():
|
|
257
|
+
"""CLI entry point for testing."""
|
|
258
|
+
print("=== Supported Chains ===")
|
|
259
|
+
for chain_id, config in CHAINS.items():
|
|
260
|
+
print(f" {chain_id}: {config.name} (Chain ID: {config.chain_id})")
|
|
261
|
+
|
|
262
|
+
print("\n=== DEX Factories ===")
|
|
263
|
+
for chain_id, factories in DEX_FACTORIES.items():
|
|
264
|
+
print(f"\n{chain_id.upper()}:")
|
|
265
|
+
for name, factory in factories.items():
|
|
266
|
+
print(f" {factory.name} ({factory.version}): {factory.address[:20]}...")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
if __name__ == "__main__":
|
|
270
|
+
main()
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Blockchain Event Monitor
|
|
4
|
+
|
|
5
|
+
Monitor DEX factory events for new pair creations.
|
|
6
|
+
|
|
7
|
+
Author: Jeremy Longshore <jeremy@intentsolutions.io>
|
|
8
|
+
Version: 1.0.0
|
|
9
|
+
License: MIT
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Dict, Any, List, Optional
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import requests
|
|
19
|
+
except ImportError:
|
|
20
|
+
requests = None
|
|
21
|
+
|
|
22
|
+
from dex_sources import (
|
|
23
|
+
get_chain_config,
|
|
24
|
+
get_dex_factories,
|
|
25
|
+
identify_dex,
|
|
26
|
+
is_base_token,
|
|
27
|
+
PAIR_CREATED_TOPIC,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class PairCreated:
|
|
33
|
+
"""New trading pair event."""
|
|
34
|
+
block_number: int
|
|
35
|
+
tx_hash: str
|
|
36
|
+
timestamp: int
|
|
37
|
+
pair_address: str
|
|
38
|
+
token0: str
|
|
39
|
+
token1: str
|
|
40
|
+
dex: str
|
|
41
|
+
chain: str
|
|
42
|
+
factory_address: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class TokenBasicInfo:
|
|
47
|
+
"""Basic token info from RPC."""
|
|
48
|
+
address: str
|
|
49
|
+
name: str
|
|
50
|
+
symbol: str
|
|
51
|
+
decimals: int
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class EventMonitor:
|
|
55
|
+
"""Monitor blockchain events for new pairs."""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
chain: str = "ethereum",
|
|
60
|
+
rpc_url: str = None,
|
|
61
|
+
verbose: bool = False
|
|
62
|
+
):
|
|
63
|
+
"""Initialize event monitor.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
chain: Chain to monitor
|
|
67
|
+
rpc_url: Custom RPC URL
|
|
68
|
+
verbose: Enable verbose output
|
|
69
|
+
"""
|
|
70
|
+
self.chain = chain.lower()
|
|
71
|
+
self.config = get_chain_config(chain)
|
|
72
|
+
self.rpc_url = rpc_url or os.environ.get(
|
|
73
|
+
f"{chain.upper()}_RPC_URL",
|
|
74
|
+
self.config.rpc_url
|
|
75
|
+
)
|
|
76
|
+
self.verbose = verbose
|
|
77
|
+
# Use a size-limited cache to prevent memory leaks on long-running instances
|
|
78
|
+
# Keeps most recent 1000 block timestamps
|
|
79
|
+
self._block_cache = {}
|
|
80
|
+
self._block_cache_max_size = 1000
|
|
81
|
+
|
|
82
|
+
def _rpc_call(self, method: str, params: List = None) -> Any:
|
|
83
|
+
"""Make JSON-RPC call."""
|
|
84
|
+
if not requests:
|
|
85
|
+
raise ImportError("requests library required")
|
|
86
|
+
|
|
87
|
+
if self.verbose:
|
|
88
|
+
print(f"RPC: {method}")
|
|
89
|
+
|
|
90
|
+
response = requests.post(
|
|
91
|
+
self.rpc_url,
|
|
92
|
+
json={
|
|
93
|
+
"jsonrpc": "2.0",
|
|
94
|
+
"method": method,
|
|
95
|
+
"params": params or [],
|
|
96
|
+
"id": 1,
|
|
97
|
+
},
|
|
98
|
+
headers={"Content-Type": "application/json"},
|
|
99
|
+
timeout=30,
|
|
100
|
+
)
|
|
101
|
+
response.raise_for_status()
|
|
102
|
+
|
|
103
|
+
result = response.json()
|
|
104
|
+
if "error" in result:
|
|
105
|
+
raise Exception(f"RPC error: {result['error']}")
|
|
106
|
+
|
|
107
|
+
return result.get("result")
|
|
108
|
+
|
|
109
|
+
def _get_block_timestamp(self, block_number: int) -> int:
|
|
110
|
+
"""Get block timestamp with size-limited caching."""
|
|
111
|
+
if block_number in self._block_cache:
|
|
112
|
+
return self._block_cache[block_number]
|
|
113
|
+
|
|
114
|
+
block = self._rpc_call(
|
|
115
|
+
"eth_getBlockByNumber",
|
|
116
|
+
[hex(block_number), False]
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if block:
|
|
120
|
+
timestamp = int(block.get("timestamp", "0x0"), 16)
|
|
121
|
+
|
|
122
|
+
# Evict old entries if cache is full
|
|
123
|
+
if len(self._block_cache) >= self._block_cache_max_size:
|
|
124
|
+
# Remove oldest entries (first 100)
|
|
125
|
+
oldest_keys = sorted(self._block_cache.keys())[:100]
|
|
126
|
+
for key in oldest_keys:
|
|
127
|
+
del self._block_cache[key]
|
|
128
|
+
|
|
129
|
+
self._block_cache[block_number] = timestamp
|
|
130
|
+
return timestamp
|
|
131
|
+
|
|
132
|
+
return int(time.time())
|
|
133
|
+
|
|
134
|
+
def get_current_block(self) -> int:
|
|
135
|
+
"""Get current block number."""
|
|
136
|
+
result = self._rpc_call("eth_blockNumber")
|
|
137
|
+
return int(result, 16)
|
|
138
|
+
|
|
139
|
+
def get_recent_pairs(
|
|
140
|
+
self,
|
|
141
|
+
hours: int = 24,
|
|
142
|
+
dex: str = None
|
|
143
|
+
) -> List[PairCreated]:
|
|
144
|
+
"""Get recently created pairs.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
hours: Hours to look back
|
|
148
|
+
dex: Filter by specific DEX
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
List of PairCreated events
|
|
152
|
+
"""
|
|
153
|
+
current_block = self.get_current_block()
|
|
154
|
+
blocks_per_hour = int(3600 / self.config.block_time)
|
|
155
|
+
from_block = current_block - (blocks_per_hour * hours)
|
|
156
|
+
|
|
157
|
+
factories = get_dex_factories(self.chain)
|
|
158
|
+
factory_addresses = []
|
|
159
|
+
|
|
160
|
+
for name, factory in factories.items():
|
|
161
|
+
if dex is None or dex.lower() in name.lower():
|
|
162
|
+
factory_addresses.append(factory.address)
|
|
163
|
+
|
|
164
|
+
if not factory_addresses:
|
|
165
|
+
return []
|
|
166
|
+
|
|
167
|
+
pairs = []
|
|
168
|
+
|
|
169
|
+
for factory_addr in factory_addresses:
|
|
170
|
+
try:
|
|
171
|
+
logs = self._rpc_call("eth_getLogs", [{
|
|
172
|
+
"fromBlock": hex(from_block),
|
|
173
|
+
"toBlock": "latest",
|
|
174
|
+
"address": factory_addr,
|
|
175
|
+
"topics": [PAIR_CREATED_TOPIC],
|
|
176
|
+
}])
|
|
177
|
+
|
|
178
|
+
for log in logs or []:
|
|
179
|
+
pair = self._parse_pair_created(log, factory_addr)
|
|
180
|
+
if pair:
|
|
181
|
+
pairs.append(pair)
|
|
182
|
+
|
|
183
|
+
except Exception as e:
|
|
184
|
+
if self.verbose:
|
|
185
|
+
print(f"Error fetching logs from {factory_addr}: {e}")
|
|
186
|
+
|
|
187
|
+
# Sort by block number descending (newest first)
|
|
188
|
+
pairs.sort(key=lambda x: x.block_number, reverse=True)
|
|
189
|
+
|
|
190
|
+
return pairs
|
|
191
|
+
|
|
192
|
+
def _parse_pair_created(
|
|
193
|
+
self,
|
|
194
|
+
log: Dict,
|
|
195
|
+
factory_address: str
|
|
196
|
+
) -> Optional[PairCreated]:
|
|
197
|
+
"""Parse PairCreated event log."""
|
|
198
|
+
try:
|
|
199
|
+
block_number = int(log["blockNumber"], 16)
|
|
200
|
+
tx_hash = log["transactionHash"]
|
|
201
|
+
topics = log.get("topics", [])
|
|
202
|
+
data = log.get("data", "0x")
|
|
203
|
+
|
|
204
|
+
# For Uniswap V2 style: topics[1] = token0, topics[2] = token1
|
|
205
|
+
# data contains pair address and pair index
|
|
206
|
+
if len(topics) >= 3:
|
|
207
|
+
token0 = "0x" + topics[1][-40:]
|
|
208
|
+
token1 = "0x" + topics[2][-40:]
|
|
209
|
+
|
|
210
|
+
# Pair address is first 32 bytes of data
|
|
211
|
+
pair_address = "0x" + data[26:66]
|
|
212
|
+
|
|
213
|
+
timestamp = self._get_block_timestamp(block_number)
|
|
214
|
+
dex = identify_dex(self.chain, factory_address)
|
|
215
|
+
|
|
216
|
+
return PairCreated(
|
|
217
|
+
block_number=block_number,
|
|
218
|
+
tx_hash=tx_hash,
|
|
219
|
+
timestamp=timestamp,
|
|
220
|
+
pair_address=pair_address,
|
|
221
|
+
token0=token0,
|
|
222
|
+
token1=token1,
|
|
223
|
+
dex=dex,
|
|
224
|
+
chain=self.chain,
|
|
225
|
+
factory_address=factory_address,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
except Exception as e:
|
|
229
|
+
if self.verbose:
|
|
230
|
+
print(f"Error parsing log: {e}")
|
|
231
|
+
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
def get_token_info(self, address: str) -> Optional[TokenBasicInfo]:
|
|
235
|
+
"""Get basic token info via RPC calls.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
address: Token contract address
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
TokenBasicInfo or None
|
|
242
|
+
"""
|
|
243
|
+
try:
|
|
244
|
+
# ERC20 function signatures
|
|
245
|
+
name_sig = "0x06fdde03" # name()
|
|
246
|
+
symbol_sig = "0x95d89b41" # symbol()
|
|
247
|
+
decimals_sig = "0x313ce567" # decimals()
|
|
248
|
+
|
|
249
|
+
name = self._call_contract(address, name_sig)
|
|
250
|
+
symbol = self._call_contract(address, symbol_sig)
|
|
251
|
+
decimals = self._call_contract(address, decimals_sig)
|
|
252
|
+
|
|
253
|
+
return TokenBasicInfo(
|
|
254
|
+
address=address,
|
|
255
|
+
name=self._decode_string(name) if name else "Unknown",
|
|
256
|
+
symbol=self._decode_string(symbol) if symbol else "???",
|
|
257
|
+
decimals=int(decimals, 16) if decimals else 18,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
except Exception as e:
|
|
261
|
+
if self.verbose:
|
|
262
|
+
print(f"Error getting token info for {address}: {e}")
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
def _call_contract(self, address: str, data: str) -> Optional[str]:
|
|
266
|
+
"""Make eth_call to contract."""
|
|
267
|
+
try:
|
|
268
|
+
result = self._rpc_call("eth_call", [
|
|
269
|
+
{"to": address, "data": data},
|
|
270
|
+
"latest"
|
|
271
|
+
])
|
|
272
|
+
return result if result and result != "0x" else None
|
|
273
|
+
except Exception:
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
def _decode_string(self, data: str) -> str:
|
|
277
|
+
"""Decode string from ABI-encoded data."""
|
|
278
|
+
if not data or data == "0x":
|
|
279
|
+
return ""
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
# Remove 0x prefix
|
|
283
|
+
data = data[2:]
|
|
284
|
+
|
|
285
|
+
# If short string (< 32 bytes, not ABI encoded)
|
|
286
|
+
if len(data) <= 64:
|
|
287
|
+
return bytes.fromhex(data).decode("utf-8", errors="ignore").strip("\x00")
|
|
288
|
+
|
|
289
|
+
# ABI encoded string: offset (32 bytes) + length (32 bytes) + data
|
|
290
|
+
if len(data) >= 128:
|
|
291
|
+
length = int(data[64:128], 16)
|
|
292
|
+
string_data = data[128:128 + length * 2]
|
|
293
|
+
return bytes.fromhex(string_data).decode("utf-8", errors="ignore")
|
|
294
|
+
|
|
295
|
+
return bytes.fromhex(data).decode("utf-8", errors="ignore").strip("\x00")
|
|
296
|
+
|
|
297
|
+
except Exception:
|
|
298
|
+
return "Unknown"
|
|
299
|
+
|
|
300
|
+
def identify_new_token(self, pair: PairCreated) -> str:
|
|
301
|
+
"""Identify which token in the pair is the new one.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
pair: PairCreated event
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
Address of the new token
|
|
308
|
+
"""
|
|
309
|
+
# If one is a base token (WETH, stablecoin), the other is new
|
|
310
|
+
if is_base_token(self.chain, pair.token0):
|
|
311
|
+
return pair.token1
|
|
312
|
+
if is_base_token(self.chain, pair.token1):
|
|
313
|
+
return pair.token0
|
|
314
|
+
|
|
315
|
+
# Otherwise, consider both as potentially new
|
|
316
|
+
return pair.token0
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def main():
|
|
320
|
+
"""CLI entry point for testing."""
|
|
321
|
+
monitor = EventMonitor(chain="ethereum", verbose=True)
|
|
322
|
+
|
|
323
|
+
print("=== Recent Pairs (Last 1 Hour) ===")
|
|
324
|
+
pairs = monitor.get_recent_pairs(hours=1)
|
|
325
|
+
|
|
326
|
+
if not pairs:
|
|
327
|
+
print("No new pairs found")
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
for pair in pairs[:10]:
|
|
331
|
+
print(f"\n{pair.dex} on {pair.chain}")
|
|
332
|
+
print(f" Block: {pair.block_number}")
|
|
333
|
+
print(f" Pair: {pair.pair_address[:20]}...")
|
|
334
|
+
print(f" Token0: {pair.token0[:20]}...")
|
|
335
|
+
print(f" Token1: {pair.token1[:20]}...")
|
|
336
|
+
|
|
337
|
+
# Get token info
|
|
338
|
+
new_token = monitor.identify_new_token(pair)
|
|
339
|
+
token_info = monitor.get_token_info(new_token)
|
|
340
|
+
if token_info:
|
|
341
|
+
print(f" New Token: {token_info.symbol} ({token_info.name})")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
if __name__ == "__main__":
|
|
345
|
+
main()
|