@intentsolutionsio/mempool-analyzer 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 +97 -0
- package/agents/mempool-agent.md +158 -0
- package/package.json +43 -0
- package/skills/analyzing-mempool/ARD.md +146 -0
- package/skills/analyzing-mempool/PRD.md +71 -0
- package/skills/analyzing-mempool/SKILL.md +110 -0
- package/skills/analyzing-mempool/config/settings.yaml +43 -0
- package/skills/analyzing-mempool/references/errors.md +122 -0
- package/skills/analyzing-mempool/references/examples.md +189 -0
- package/skills/analyzing-mempool/references/implementation.md +67 -0
- package/skills/analyzing-mempool/scripts/formatters.py +244 -0
- package/skills/analyzing-mempool/scripts/gas_analyzer.py +299 -0
- package/skills/analyzing-mempool/scripts/mempool_analyzer.py +320 -0
- package/skills/analyzing-mempool/scripts/mev_detector.py +387 -0
- package/skills/analyzing-mempool/scripts/rpc_client.py +311 -0
- package/skills/analyzing-mempool/scripts/tx_decoder.py +273 -0
- package/skills/skill-adapter/assets/README.md +6 -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
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
MEV Opportunity Detector
|
|
4
|
+
|
|
5
|
+
Detect potential MEV opportunities in pending transactions.
|
|
6
|
+
|
|
7
|
+
Author: Jeremy Longshore <jeremy@intentsolutions.io>
|
|
8
|
+
Version: 1.0.0
|
|
9
|
+
License: MIT
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from typing import Dict, Any, List, Optional
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import yaml
|
|
18
|
+
HAS_YAML = True
|
|
19
|
+
except ImportError:
|
|
20
|
+
HAS_YAML = False
|
|
21
|
+
|
|
22
|
+
from tx_decoder import TransactionDecoder
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class MEVOpportunity:
|
|
27
|
+
"""Detected MEV opportunity."""
|
|
28
|
+
opportunity_type: str # sandwich, arbitrage, liquidation, backrun
|
|
29
|
+
target_tx: str # Target transaction hash
|
|
30
|
+
estimated_profit_usd: float
|
|
31
|
+
required_capital_usd: float
|
|
32
|
+
risk_level: str # low, medium, high
|
|
33
|
+
confidence: float # 0.0 to 1.0
|
|
34
|
+
details: Dict[str, Any]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class PendingSwap:
|
|
39
|
+
"""Detected pending swap transaction."""
|
|
40
|
+
tx_hash: str
|
|
41
|
+
dex: str
|
|
42
|
+
amount_in: Optional[int]
|
|
43
|
+
amount_out_min: Optional[int]
|
|
44
|
+
gas_price: int
|
|
45
|
+
from_address: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MEVDetector:
|
|
49
|
+
"""Detect MEV opportunities in pending transactions."""
|
|
50
|
+
|
|
51
|
+
# Default thresholds (can be overridden via config)
|
|
52
|
+
DEFAULT_MIN_SWAP_VALUE_USD = 10000 # $10k minimum swap
|
|
53
|
+
DEFAULT_MIN_PROFIT_USD = 100 # $100 minimum profit
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
verbose: bool = False,
|
|
58
|
+
min_swap_value_usd: Optional[float] = None,
|
|
59
|
+
min_profit_usd: Optional[float] = None,
|
|
60
|
+
config_path: Optional[str] = None,
|
|
61
|
+
):
|
|
62
|
+
"""Initialize MEV detector.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
verbose: Enable verbose output
|
|
66
|
+
min_swap_value_usd: Minimum swap value for detection (overrides config)
|
|
67
|
+
min_profit_usd: Minimum profit for detection (overrides config)
|
|
68
|
+
config_path: Path to settings.yaml config file
|
|
69
|
+
"""
|
|
70
|
+
self.verbose = verbose
|
|
71
|
+
self.decoder = TransactionDecoder(verbose=verbose)
|
|
72
|
+
|
|
73
|
+
# Load config if available
|
|
74
|
+
config = self._load_config(config_path)
|
|
75
|
+
|
|
76
|
+
# Set thresholds with priority: explicit arg > config > default
|
|
77
|
+
config_mev = config.get("mev", {}) if config else {}
|
|
78
|
+
self.min_swap_value_usd = (
|
|
79
|
+
min_swap_value_usd
|
|
80
|
+
if min_swap_value_usd is not None
|
|
81
|
+
else config_mev.get("min_swap_value_usd", self.DEFAULT_MIN_SWAP_VALUE_USD)
|
|
82
|
+
)
|
|
83
|
+
self.min_profit_usd = (
|
|
84
|
+
min_profit_usd
|
|
85
|
+
if min_profit_usd is not None
|
|
86
|
+
else config_mev.get("min_profit_usd", self.DEFAULT_MIN_PROFIT_USD)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def _load_config(self, config_path: Optional[str] = None) -> Optional[Dict]:
|
|
90
|
+
"""Load configuration from YAML file.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
config_path: Explicit path to config, or None to use defaults
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Config dict or None if not found/loaded
|
|
97
|
+
"""
|
|
98
|
+
if not HAS_YAML:
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
# Search paths for config
|
|
102
|
+
search_paths = []
|
|
103
|
+
if config_path:
|
|
104
|
+
search_paths.append(config_path)
|
|
105
|
+
|
|
106
|
+
# Default locations
|
|
107
|
+
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
108
|
+
search_paths.extend([
|
|
109
|
+
os.path.join(script_dir, "..", "config", "settings.yaml"),
|
|
110
|
+
os.path.expanduser("~/.mempool_analyzer.yaml"),
|
|
111
|
+
])
|
|
112
|
+
|
|
113
|
+
for path in search_paths:
|
|
114
|
+
if os.path.exists(path):
|
|
115
|
+
try:
|
|
116
|
+
with open(path) as f:
|
|
117
|
+
return yaml.safe_load(f)
|
|
118
|
+
except Exception as e:
|
|
119
|
+
if self.verbose:
|
|
120
|
+
print(f"Warning: Could not load config from {path}: {e}")
|
|
121
|
+
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
def detect_pending_swaps(
|
|
125
|
+
self,
|
|
126
|
+
pending_txs: List[Any],
|
|
127
|
+
eth_price: float = 3000.0
|
|
128
|
+
) -> List[PendingSwap]:
|
|
129
|
+
"""Identify pending swap transactions.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
pending_txs: List of pending transactions
|
|
133
|
+
eth_price: Current ETH price for USD estimation
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
List of detected pending swaps
|
|
137
|
+
"""
|
|
138
|
+
swaps = []
|
|
139
|
+
|
|
140
|
+
for tx in pending_txs:
|
|
141
|
+
# Get transaction fields
|
|
142
|
+
if hasattr(tx, "input_data"):
|
|
143
|
+
input_data = tx.input_data
|
|
144
|
+
to_address = tx.to_address
|
|
145
|
+
tx_hash = tx.hash
|
|
146
|
+
gas_price = tx.gas_price
|
|
147
|
+
from_address = tx.from_address
|
|
148
|
+
else:
|
|
149
|
+
input_data = tx.get("input", "")
|
|
150
|
+
to_address = tx.get("to", "")
|
|
151
|
+
tx_hash = tx.get("hash", "")
|
|
152
|
+
gas_price = tx.get("gasPrice", 0)
|
|
153
|
+
from_address = tx.get("from", "")
|
|
154
|
+
if isinstance(gas_price, str):
|
|
155
|
+
gas_price = int(gas_price, 16)
|
|
156
|
+
|
|
157
|
+
# Try to identify as swap
|
|
158
|
+
swap_info = self.decoder.identify_dex_swap(input_data, to_address)
|
|
159
|
+
if swap_info:
|
|
160
|
+
swaps.append(PendingSwap(
|
|
161
|
+
tx_hash=tx_hash,
|
|
162
|
+
dex=swap_info.dex,
|
|
163
|
+
amount_in=swap_info.amount_in,
|
|
164
|
+
amount_out_min=swap_info.amount_out_min,
|
|
165
|
+
gas_price=gas_price,
|
|
166
|
+
from_address=from_address,
|
|
167
|
+
))
|
|
168
|
+
|
|
169
|
+
return swaps
|
|
170
|
+
|
|
171
|
+
def detect_sandwich_opportunities(
|
|
172
|
+
self,
|
|
173
|
+
pending_swaps: List[PendingSwap],
|
|
174
|
+
eth_price: float = 3000.0
|
|
175
|
+
) -> List[MEVOpportunity]:
|
|
176
|
+
"""Detect potential sandwich attack opportunities.
|
|
177
|
+
|
|
178
|
+
Sandwich: Front-run a large swap, then back-run after price moves.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
pending_swaps: List of detected pending swaps
|
|
182
|
+
eth_price: Current ETH price
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
List of potential sandwich opportunities
|
|
186
|
+
"""
|
|
187
|
+
opportunities = []
|
|
188
|
+
|
|
189
|
+
for swap in pending_swaps:
|
|
190
|
+
# Skip small swaps
|
|
191
|
+
if not swap.amount_in:
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
# Estimate swap value (simplified - assumes ETH value)
|
|
195
|
+
value_eth = swap.amount_in / 10**18
|
|
196
|
+
value_usd = value_eth * eth_price
|
|
197
|
+
|
|
198
|
+
if value_usd < self.min_swap_value_usd:
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
# Estimate potential profit (very simplified)
|
|
202
|
+
# Real sandwich profit depends on pool liquidity, slippage, etc.
|
|
203
|
+
estimated_slippage = min(value_usd / 1000000, 0.05) # Up to 5% for large swaps
|
|
204
|
+
estimated_profit = value_usd * estimated_slippage * 0.3 # Capture 30% of slippage
|
|
205
|
+
|
|
206
|
+
if estimated_profit < self.min_profit_usd:
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
opportunities.append(MEVOpportunity(
|
|
210
|
+
opportunity_type="sandwich",
|
|
211
|
+
target_tx=swap.tx_hash,
|
|
212
|
+
estimated_profit_usd=estimated_profit,
|
|
213
|
+
required_capital_usd=value_usd * 0.5, # Need capital to front-run
|
|
214
|
+
risk_level="high", # Sandwiches are risky
|
|
215
|
+
confidence=0.3, # Low confidence without pool analysis
|
|
216
|
+
details={
|
|
217
|
+
"dex": swap.dex,
|
|
218
|
+
"swap_value_usd": value_usd,
|
|
219
|
+
"target_slippage": estimated_slippage,
|
|
220
|
+
"gas_price_gwei": swap.gas_price / 10**9,
|
|
221
|
+
},
|
|
222
|
+
))
|
|
223
|
+
|
|
224
|
+
return opportunities
|
|
225
|
+
|
|
226
|
+
def detect_arbitrage_opportunities(
|
|
227
|
+
self,
|
|
228
|
+
pending_swaps: List[PendingSwap],
|
|
229
|
+
pool_prices: Dict[str, float] = None
|
|
230
|
+
) -> List[MEVOpportunity]:
|
|
231
|
+
"""Detect arbitrage opportunities from pending swaps.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
pending_swaps: List of detected pending swaps
|
|
235
|
+
pool_prices: Optional pool price data for comparison
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
List of potential arbitrage opportunities
|
|
239
|
+
"""
|
|
240
|
+
# Simplified detection - would need real pool data
|
|
241
|
+
opportunities = []
|
|
242
|
+
|
|
243
|
+
# Group swaps by apparent token pair
|
|
244
|
+
# In real implementation, would decode full path and check pool prices
|
|
245
|
+
|
|
246
|
+
for swap in pending_swaps:
|
|
247
|
+
if not swap.amount_in or swap.amount_in < 10**18:
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
# Mock arbitrage detection (placeholder)
|
|
251
|
+
# Real implementation would:
|
|
252
|
+
# 1. Decode swap path
|
|
253
|
+
# 2. Check prices on other DEXes
|
|
254
|
+
# 3. Calculate if price difference creates opportunity
|
|
255
|
+
|
|
256
|
+
if self.verbose:
|
|
257
|
+
print(f"Checking arb opportunity for {swap.tx_hash[:16]}...")
|
|
258
|
+
|
|
259
|
+
return opportunities
|
|
260
|
+
|
|
261
|
+
def detect_liquidation_opportunities(
|
|
262
|
+
self,
|
|
263
|
+
pending_txs: List[Any]
|
|
264
|
+
) -> List[MEVOpportunity]:
|
|
265
|
+
"""Detect pending liquidation opportunities.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
pending_txs: List of pending transactions
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
List of potential liquidation opportunities
|
|
272
|
+
"""
|
|
273
|
+
opportunities = []
|
|
274
|
+
|
|
275
|
+
# Look for lending protocol interactions that might indicate
|
|
276
|
+
# underwater positions or pending liquidations
|
|
277
|
+
|
|
278
|
+
# This would require:
|
|
279
|
+
# 1. Monitoring Aave/Compound/etc health factors
|
|
280
|
+
# 2. Detecting position updates that lower health
|
|
281
|
+
# 3. Calculating profitability of liquidation
|
|
282
|
+
|
|
283
|
+
# Placeholder - real implementation is complex
|
|
284
|
+
|
|
285
|
+
return opportunities
|
|
286
|
+
|
|
287
|
+
def detect_all_opportunities(
|
|
288
|
+
self,
|
|
289
|
+
pending_txs: List[Any],
|
|
290
|
+
eth_price: float = 3000.0
|
|
291
|
+
) -> Dict[str, List[MEVOpportunity]]:
|
|
292
|
+
"""Run all MEV detection algorithms.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
pending_txs: List of pending transactions
|
|
296
|
+
eth_price: Current ETH price
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
Dict mapping opportunity type to list of opportunities
|
|
300
|
+
"""
|
|
301
|
+
# First, identify swaps
|
|
302
|
+
swaps = self.detect_pending_swaps(pending_txs, eth_price)
|
|
303
|
+
|
|
304
|
+
results = {
|
|
305
|
+
"pending_swaps": len(swaps),
|
|
306
|
+
"sandwich": self.detect_sandwich_opportunities(swaps, eth_price),
|
|
307
|
+
"arbitrage": self.detect_arbitrage_opportunities(swaps),
|
|
308
|
+
"liquidation": self.detect_liquidation_opportunities(pending_txs),
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return results
|
|
312
|
+
|
|
313
|
+
def format_opportunities(
|
|
314
|
+
self,
|
|
315
|
+
opportunities: List[MEVOpportunity]
|
|
316
|
+
) -> str:
|
|
317
|
+
"""Format opportunities for display.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
opportunities: List of MEV opportunities
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
Formatted string
|
|
324
|
+
"""
|
|
325
|
+
if not opportunities:
|
|
326
|
+
return "No MEV opportunities detected."
|
|
327
|
+
|
|
328
|
+
lines = [
|
|
329
|
+
"",
|
|
330
|
+
"MEV OPPORTUNITIES DETECTED",
|
|
331
|
+
"=" * 80,
|
|
332
|
+
f"{'Type':<12} {'Est. Profit':<14} {'Capital Req':<14} {'Risk':<10} {'Confidence':<12}",
|
|
333
|
+
"-" * 80,
|
|
334
|
+
]
|
|
335
|
+
|
|
336
|
+
for opp in sorted(opportunities, key=lambda x: x.estimated_profit_usd, reverse=True):
|
|
337
|
+
lines.append(
|
|
338
|
+
f"{opp.opportunity_type:<12} "
|
|
339
|
+
f"${opp.estimated_profit_usd:>11,.0f} "
|
|
340
|
+
f"${opp.required_capital_usd:>11,.0f} "
|
|
341
|
+
f"{opp.risk_level:<10} "
|
|
342
|
+
f"{opp.confidence * 100:>10.0f}%"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
lines.append("-" * 80)
|
|
346
|
+
lines.append(f"Total: {len(opportunities)} potential opportunities")
|
|
347
|
+
lines.append("")
|
|
348
|
+
lines.append("⚠️ WARNING: MEV detection is for educational purposes only.")
|
|
349
|
+
lines.append(" Actual profitability requires real-time pool data and")
|
|
350
|
+
lines.append(" sophisticated execution infrastructure.")
|
|
351
|
+
|
|
352
|
+
return "\n".join(lines)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def main():
|
|
356
|
+
"""CLI entry point for testing."""
|
|
357
|
+
detector = MEVDetector(verbose=True)
|
|
358
|
+
|
|
359
|
+
# Create mock pending transactions
|
|
360
|
+
class MockTx:
|
|
361
|
+
def __init__(self, value, gas_price):
|
|
362
|
+
import random
|
|
363
|
+
self.hash = f"0x{''.join(random.choices('0123456789abcdef', k=64))}"
|
|
364
|
+
self.from_address = f"0x{''.join(random.choices('0123456789abcdef', k=40))}"
|
|
365
|
+
self.to_address = "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D"
|
|
366
|
+
self.value = value
|
|
367
|
+
self.gas_price = gas_price
|
|
368
|
+
self.input_data = "0x38ed1739" + "0" * 256
|
|
369
|
+
|
|
370
|
+
import random
|
|
371
|
+
mock_txs = [
|
|
372
|
+
MockTx(random.randint(1, 100) * 10**18, (30 + random.randint(0, 20)) * 10**9)
|
|
373
|
+
for _ in range(20)
|
|
374
|
+
]
|
|
375
|
+
|
|
376
|
+
# Detect
|
|
377
|
+
print("=== Scanning for Pending Swaps ===")
|
|
378
|
+
swaps = detector.detect_pending_swaps(mock_txs)
|
|
379
|
+
print(f"Found {len(swaps)} pending swaps")
|
|
380
|
+
|
|
381
|
+
print("\n=== Checking for MEV Opportunities ===")
|
|
382
|
+
opportunities = detector.detect_sandwich_opportunities(swaps)
|
|
383
|
+
print(detector.format_opportunities(opportunities))
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
if __name__ == "__main__":
|
|
387
|
+
main()
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Ethereum RPC Client
|
|
4
|
+
|
|
5
|
+
Connect to Ethereum nodes and fetch mempool data.
|
|
6
|
+
|
|
7
|
+
Author: Jeremy Longshore <jeremy@intentsolutions.io>
|
|
8
|
+
Version: 1.0.0
|
|
9
|
+
License: MIT
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Any, Dict, List, Optional
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import requests
|
|
19
|
+
except ImportError:
|
|
20
|
+
requests = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Default RPC endpoints (public, may be rate limited)
|
|
24
|
+
DEFAULT_RPC_URLS = {
|
|
25
|
+
"ethereum": "https://eth.llamarpc.com",
|
|
26
|
+
"polygon": "https://polygon-rpc.com",
|
|
27
|
+
"arbitrum": "https://arb1.arbitrum.io/rpc",
|
|
28
|
+
"optimism": "https://mainnet.optimism.io",
|
|
29
|
+
"base": "https://mainnet.base.org",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class PendingTransaction:
|
|
35
|
+
"""Represents a pending transaction in the mempool."""
|
|
36
|
+
hash: str
|
|
37
|
+
from_address: str
|
|
38
|
+
to_address: Optional[str]
|
|
39
|
+
value: int # in wei
|
|
40
|
+
gas: int
|
|
41
|
+
gas_price: int # in wei
|
|
42
|
+
max_fee_per_gas: Optional[int]
|
|
43
|
+
max_priority_fee_per_gas: Optional[int]
|
|
44
|
+
nonce: int
|
|
45
|
+
input_data: str
|
|
46
|
+
block_number: Optional[int]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class GasInfo:
|
|
51
|
+
"""Current gas price information."""
|
|
52
|
+
base_fee: int
|
|
53
|
+
priority_fee: int
|
|
54
|
+
gas_price: int # legacy
|
|
55
|
+
pending_count: int
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class RPCClient:
|
|
59
|
+
"""Ethereum JSON-RPC client for mempool access."""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
rpc_url: str = None,
|
|
64
|
+
chain: str = "ethereum",
|
|
65
|
+
verbose: bool = False
|
|
66
|
+
):
|
|
67
|
+
"""Initialize RPC client.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
rpc_url: Custom RPC URL or None for default
|
|
71
|
+
chain: Chain name for default URL lookup
|
|
72
|
+
verbose: Enable verbose output
|
|
73
|
+
"""
|
|
74
|
+
self.rpc_url = rpc_url or os.environ.get("ETH_RPC_URL") or DEFAULT_RPC_URLS.get(chain)
|
|
75
|
+
self.chain = chain
|
|
76
|
+
self.verbose = verbose
|
|
77
|
+
self._request_id = 0
|
|
78
|
+
|
|
79
|
+
def _rpc_call(self, method: str, params: List = None) -> Any:
|
|
80
|
+
"""Make JSON-RPC call."""
|
|
81
|
+
if not requests:
|
|
82
|
+
raise ImportError("requests library required: pip install requests")
|
|
83
|
+
|
|
84
|
+
self._request_id += 1
|
|
85
|
+
payload = {
|
|
86
|
+
"jsonrpc": "2.0",
|
|
87
|
+
"method": method,
|
|
88
|
+
"params": params or [],
|
|
89
|
+
"id": self._request_id,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if self.verbose:
|
|
93
|
+
print(f"RPC: {method}")
|
|
94
|
+
|
|
95
|
+
response = requests.post(
|
|
96
|
+
self.rpc_url,
|
|
97
|
+
json=payload,
|
|
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_pending_transactions(self, limit: int = 100, allow_mock: bool = False) -> List[PendingTransaction]:
|
|
110
|
+
"""Get pending transactions from mempool.
|
|
111
|
+
|
|
112
|
+
Note: Not all nodes support txpool_content.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
limit: Maximum transactions to return
|
|
116
|
+
allow_mock: If True, return mock data when RPC fails (for demo/testing)
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
List of pending transactions
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
RuntimeError: If no RPC method succeeds and allow_mock is False
|
|
123
|
+
"""
|
|
124
|
+
errors = []
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
# Try txpool_content (Geth nodes)
|
|
128
|
+
result = self._rpc_call("txpool_content")
|
|
129
|
+
if result:
|
|
130
|
+
return self._parse_txpool_content(result, limit)
|
|
131
|
+
except Exception as e:
|
|
132
|
+
errors.append(f"txpool_content: {e}")
|
|
133
|
+
if self.verbose:
|
|
134
|
+
print(f"txpool_content not available: {e}", file=sys.stderr)
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
# Try eth_pendingTransactions (some nodes)
|
|
138
|
+
result = self._rpc_call("eth_pendingTransactions")
|
|
139
|
+
if result:
|
|
140
|
+
return self._parse_pending_transactions(result[:limit])
|
|
141
|
+
except Exception as e:
|
|
142
|
+
errors.append(f"eth_pendingTransactions: {e}")
|
|
143
|
+
if self.verbose:
|
|
144
|
+
print(f"eth_pendingTransactions not available: {e}", file=sys.stderr)
|
|
145
|
+
|
|
146
|
+
# If allow_mock is True (demo mode), return mock data with warning
|
|
147
|
+
if allow_mock:
|
|
148
|
+
print("WARNING: Using mock data - could not fetch real mempool data", file=sys.stderr)
|
|
149
|
+
return self._get_mock_pending_transactions(limit)
|
|
150
|
+
|
|
151
|
+
# Otherwise raise an exception
|
|
152
|
+
raise RuntimeError(
|
|
153
|
+
f"Could not fetch pending transactions from RPC. "
|
|
154
|
+
f"Both 'txpool_content' and 'eth_pendingTransactions' failed.\n"
|
|
155
|
+
f"Errors: {'; '.join(errors)}\n"
|
|
156
|
+
f"Hint: Use --demo flag to use mock data for testing."
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def _parse_txpool_content(self, content: Dict, limit: int) -> List[PendingTransaction]:
|
|
160
|
+
"""Parse txpool_content response."""
|
|
161
|
+
transactions = []
|
|
162
|
+
|
|
163
|
+
for pool in ["pending", "queued"]:
|
|
164
|
+
pool_data = content.get(pool, {})
|
|
165
|
+
for address, nonces in pool_data.items():
|
|
166
|
+
for nonce, tx in nonces.items():
|
|
167
|
+
if len(transactions) >= limit:
|
|
168
|
+
break
|
|
169
|
+
transactions.append(self._tx_to_pending(tx))
|
|
170
|
+
|
|
171
|
+
return transactions[:limit]
|
|
172
|
+
|
|
173
|
+
def _parse_pending_transactions(self, txs: List[Dict]) -> List[PendingTransaction]:
|
|
174
|
+
"""Parse eth_pendingTransactions response."""
|
|
175
|
+
return [self._tx_to_pending(tx) for tx in txs]
|
|
176
|
+
|
|
177
|
+
def _tx_to_pending(self, tx: Dict) -> PendingTransaction:
|
|
178
|
+
"""Convert raw tx dict to PendingTransaction."""
|
|
179
|
+
return PendingTransaction(
|
|
180
|
+
hash=tx.get("hash", ""),
|
|
181
|
+
from_address=tx.get("from", ""),
|
|
182
|
+
to_address=tx.get("to"),
|
|
183
|
+
value=int(tx.get("value", "0x0"), 16),
|
|
184
|
+
gas=int(tx.get("gas", "0x0"), 16),
|
|
185
|
+
gas_price=int(tx.get("gasPrice", "0x0"), 16),
|
|
186
|
+
max_fee_per_gas=int(tx.get("maxFeePerGas", "0x0"), 16) if tx.get("maxFeePerGas") else None,
|
|
187
|
+
max_priority_fee_per_gas=int(tx.get("maxPriorityFeePerGas", "0x0"), 16) if tx.get("maxPriorityFeePerGas") else None,
|
|
188
|
+
nonce=int(tx.get("nonce", "0x0"), 16),
|
|
189
|
+
input_data=tx.get("input", "0x"),
|
|
190
|
+
block_number=int(tx.get("blockNumber", "0x0"), 16) if tx.get("blockNumber") else None,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def _get_mock_pending_transactions(self, limit: int) -> List[PendingTransaction]:
|
|
194
|
+
"""Generate mock pending transactions for demo."""
|
|
195
|
+
import random
|
|
196
|
+
|
|
197
|
+
mock_txs = []
|
|
198
|
+
base_gas_price = 30 * 10**9 # 30 gwei
|
|
199
|
+
|
|
200
|
+
# Common router addresses
|
|
201
|
+
routers = [
|
|
202
|
+
"0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D", # Uniswap V2
|
|
203
|
+
"0xE592427A0AEce92De3Edee1F18E0157C05861564", # Uniswap V3
|
|
204
|
+
"0xd9e1cE17f2641f24aE83637ab66a2cca9C378B9F", # SushiSwap
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
# Sample swap input data prefix
|
|
208
|
+
swap_input = "0x38ed1739" # swapExactTokensForTokens
|
|
209
|
+
|
|
210
|
+
for i in range(min(limit, 20)):
|
|
211
|
+
gas_price = base_gas_price + random.randint(-5, 20) * 10**9
|
|
212
|
+
mock_txs.append(PendingTransaction(
|
|
213
|
+
hash=f"0x{''.join(random.choices('0123456789abcdef', k=64))}",
|
|
214
|
+
from_address=f"0x{''.join(random.choices('0123456789abcdef', k=40))}",
|
|
215
|
+
to_address=random.choice(routers),
|
|
216
|
+
value=random.randint(0, 10) * 10**18,
|
|
217
|
+
gas=random.randint(100000, 500000),
|
|
218
|
+
gas_price=gas_price,
|
|
219
|
+
max_fee_per_gas=gas_price + 5 * 10**9,
|
|
220
|
+
max_priority_fee_per_gas=2 * 10**9,
|
|
221
|
+
nonce=random.randint(1, 1000),
|
|
222
|
+
input_data=swap_input + "0" * 128,
|
|
223
|
+
block_number=None,
|
|
224
|
+
))
|
|
225
|
+
|
|
226
|
+
return mock_txs
|
|
227
|
+
|
|
228
|
+
def get_gas_price(self) -> GasInfo:
|
|
229
|
+
"""Get current gas price information.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
GasInfo with current gas prices
|
|
233
|
+
"""
|
|
234
|
+
try:
|
|
235
|
+
# Get base fee from latest block
|
|
236
|
+
block = self._rpc_call("eth_getBlockByNumber", ["latest", False])
|
|
237
|
+
base_fee = int(block.get("baseFeePerGas", "0x0"), 16)
|
|
238
|
+
|
|
239
|
+
# Get legacy gas price
|
|
240
|
+
gas_price = int(self._rpc_call("eth_gasPrice"), 16)
|
|
241
|
+
|
|
242
|
+
# Estimate priority fee
|
|
243
|
+
priority_fee = max(gas_price - base_fee, 1 * 10**9)
|
|
244
|
+
|
|
245
|
+
# Get pending tx count (optional - not all nodes support txpool_status)
|
|
246
|
+
pending_count = 0
|
|
247
|
+
try:
|
|
248
|
+
txpool_status = self._rpc_call("txpool_status")
|
|
249
|
+
pending_count = int(txpool_status.get("pending", "0x0"), 16)
|
|
250
|
+
except Exception:
|
|
251
|
+
# txpool_status not supported by this node - use 0 as fallback
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
return GasInfo(
|
|
255
|
+
base_fee=base_fee,
|
|
256
|
+
priority_fee=priority_fee,
|
|
257
|
+
gas_price=gas_price,
|
|
258
|
+
pending_count=pending_count,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
except Exception as e:
|
|
262
|
+
if self.verbose:
|
|
263
|
+
print(f"Error getting gas price: {e}")
|
|
264
|
+
# Return reasonable defaults
|
|
265
|
+
return GasInfo(
|
|
266
|
+
base_fee=30 * 10**9,
|
|
267
|
+
priority_fee=2 * 10**9,
|
|
268
|
+
gas_price=32 * 10**9,
|
|
269
|
+
pending_count=0,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
def get_transaction(self, tx_hash: str) -> Optional[Dict]:
|
|
273
|
+
"""Get transaction by hash.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
tx_hash: Transaction hash
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
Transaction dict or None
|
|
280
|
+
"""
|
|
281
|
+
try:
|
|
282
|
+
return self._rpc_call("eth_getTransactionByHash", [tx_hash])
|
|
283
|
+
except Exception:
|
|
284
|
+
# Transaction not found or RPC error - return None
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
def get_block_number(self) -> int:
|
|
288
|
+
"""Get current block number."""
|
|
289
|
+
result = self._rpc_call("eth_blockNumber")
|
|
290
|
+
return int(result, 16)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def main():
|
|
294
|
+
"""CLI entry point for testing."""
|
|
295
|
+
client = RPCClient(verbose=True)
|
|
296
|
+
|
|
297
|
+
print("=== Current Gas Prices ===")
|
|
298
|
+
gas = client.get_gas_price()
|
|
299
|
+
print(f"Base Fee: {gas.base_fee / 10**9:.2f} gwei")
|
|
300
|
+
print(f"Priority Fee: {gas.priority_fee / 10**9:.2f} gwei")
|
|
301
|
+
print(f"Gas Price: {gas.gas_price / 10**9:.2f} gwei")
|
|
302
|
+
print(f"Pending Txs: {gas.pending_count}")
|
|
303
|
+
|
|
304
|
+
print("\n=== Pending Transactions ===")
|
|
305
|
+
pending = client.get_pending_transactions(limit=5)
|
|
306
|
+
for tx in pending:
|
|
307
|
+
print(f" {tx.hash[:16]}... | {tx.gas_price / 10**9:.1f} gwei | {tx.gas} gas")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
if __name__ == "__main__":
|
|
311
|
+
main()
|