web3-agent-kit 0.3.0__py3-none-any.whl
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.
- src/__init__.py +33 -0
- src/agent.py +239 -0
- src/bridge.py +504 -0
- src/chain.py +115 -0
- src/defi/__init__.py +476 -0
- src/llm.py +272 -0
- src/portfolio.py +326 -0
- src/sniper.py +511 -0
- src/utils/__init__.py +140 -0
- src/wallet.py +128 -0
- web3_agent_kit-0.3.0.dist-info/METADATA +333 -0
- web3_agent_kit-0.3.0.dist-info/RECORD +15 -0
- web3_agent_kit-0.3.0.dist-info/WHEEL +5 -0
- web3_agent_kit-0.3.0.dist-info/licenses/LICENSE +21 -0
- web3_agent_kit-0.3.0.dist-info/top_level.txt +1 -0
src/sniper.py
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
"""Token sniper — monitor new liquidity pools and auto-buy.
|
|
2
|
+
|
|
3
|
+
Monitors DEX factory contracts for new pair creation,
|
|
4
|
+
analyzes contract safety, and executes buys if safe.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from web3_agent_kit.sniper import TokenSniper, SniperConfig
|
|
8
|
+
|
|
9
|
+
sniper = TokenSniper(
|
|
10
|
+
chain_manager=chain_manager,
|
|
11
|
+
wallet=wallet,
|
|
12
|
+
config=SniperConfig(
|
|
13
|
+
max_buy=0.05, # max 0.05 ETH per snipe
|
|
14
|
+
auto_buy=True, # auto-buy safe tokens
|
|
15
|
+
honeypot_check=True, # check if token is honeypot
|
|
16
|
+
),
|
|
17
|
+
)
|
|
18
|
+
sniper.start() # begins monitoring
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import time
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from enum import Enum
|
|
28
|
+
from threading import Thread, Event
|
|
29
|
+
from typing import Any, Callable, Optional
|
|
30
|
+
|
|
31
|
+
from .wallet import Wallet
|
|
32
|
+
from .chain import Chain, ChainManager, CHAIN_IDS
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Uniswap V2 Factory ABI (minimal)
|
|
38
|
+
UNISWAP_V2_FACTORY_ABI = json.loads("""[
|
|
39
|
+
{
|
|
40
|
+
"anonymous": false,
|
|
41
|
+
"inputs": [
|
|
42
|
+
{"indexed": true, "internalType": "address", "name": "token0", "type": "address"},
|
|
43
|
+
{"indexed": true, "internalType": "address", "name": "token1", "type": "address"},
|
|
44
|
+
{"indexed": false, "internalType": "address", "name": "pair", "type": "address"},
|
|
45
|
+
{"indexed": false, "internalType": "uint256", "name": "", "type": "uint256"}
|
|
46
|
+
],
|
|
47
|
+
"name": "PairCreated",
|
|
48
|
+
"type": "event"
|
|
49
|
+
}
|
|
50
|
+
]""")
|
|
51
|
+
|
|
52
|
+
# Uniswap V2 Pair ABI (minimal)
|
|
53
|
+
UNISWAP_V2_PAIR_ABI = json.loads("""[
|
|
54
|
+
{
|
|
55
|
+
"inputs": [],
|
|
56
|
+
"name": "token0",
|
|
57
|
+
"outputs": [{"internalType": "address", "name": "", "type": "address"}],
|
|
58
|
+
"stateMutability": "view",
|
|
59
|
+
"type": "function"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"inputs": [],
|
|
63
|
+
"name": "token1",
|
|
64
|
+
"outputs": [{"internalType": "address", "name": "", "type": "address"}],
|
|
65
|
+
"stateMutability": "view",
|
|
66
|
+
"type": "function"
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"inputs": [],
|
|
70
|
+
"name": "getReserves",
|
|
71
|
+
"outputs": [
|
|
72
|
+
{"internalType": "uint112", "name": "reserve0", "type": "uint112"},
|
|
73
|
+
{"internalType": "uint112", "name": "reserve1", "type": "uint112"},
|
|
74
|
+
{"internalType": "uint32", "name": "blockTimestampLast", "type": "uint32"}
|
|
75
|
+
],
|
|
76
|
+
"stateMutability": "view",
|
|
77
|
+
"type": "function"
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"inputs": [],
|
|
81
|
+
"name": "totalSupply",
|
|
82
|
+
"outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
|
|
83
|
+
"stateMutability": "view",
|
|
84
|
+
"type": "function"
|
|
85
|
+
}
|
|
86
|
+
]""")
|
|
87
|
+
|
|
88
|
+
# ERC20 ABI (minimal for safety checks)
|
|
89
|
+
ERC20_ABI = json.loads("""[
|
|
90
|
+
{
|
|
91
|
+
"inputs": [],
|
|
92
|
+
"name": "name",
|
|
93
|
+
"outputs": [{"internalType": "string", "name": "", "type": "string"}],
|
|
94
|
+
"stateMutability": "view",
|
|
95
|
+
"type": "function"
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"inputs": [],
|
|
99
|
+
"name": "symbol",
|
|
100
|
+
"outputs": [{"internalType": "string", "name": "", "type": "string"}],
|
|
101
|
+
"stateMutability": "view",
|
|
102
|
+
"type": "function"
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"inputs": [],
|
|
106
|
+
"name": "decimals",
|
|
107
|
+
"outputs": [{"internalType": "uint8", "name": "", "type": "uint8"}],
|
|
108
|
+
"stateMutability": "view",
|
|
109
|
+
"type": "function"
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
"inputs": [],
|
|
113
|
+
"name": "totalSupply",
|
|
114
|
+
"outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
|
|
115
|
+
"stateMutability": "view",
|
|
116
|
+
"type": "function"
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
"inputs": [{"internalType": "address", "name": "account", "type": "address"}],
|
|
120
|
+
"name": "balanceOf",
|
|
121
|
+
"outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
|
|
122
|
+
"stateMutability": "view",
|
|
123
|
+
"type": "function"
|
|
124
|
+
}
|
|
125
|
+
]""")
|
|
126
|
+
|
|
127
|
+
# Factory addresses
|
|
128
|
+
FACTORIES = {
|
|
129
|
+
Chain.ETHEREUM: "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f",
|
|
130
|
+
Chain.BASE: "0x8909Dc15e40173Ff4699343b6eB8132c65e18eC6",
|
|
131
|
+
Chain.ARBITRUM: "0xf1D7CC64Fb4452F05c498126312eBE29f30Fbcf9",
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
# WETH addresses
|
|
135
|
+
WETH = {
|
|
136
|
+
Chain.ETHEREUM: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
|
|
137
|
+
Chain.BASE: "0x4200000000000000000000000000000000000006",
|
|
138
|
+
Chain.ARBITRUM: "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1",
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class RiskLevel(Enum):
|
|
143
|
+
"""Token risk assessment."""
|
|
144
|
+
LOW = "low"
|
|
145
|
+
MEDIUM = "medium"
|
|
146
|
+
HIGH = "high"
|
|
147
|
+
SCAM = "scam"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass
|
|
151
|
+
class SniperConfig:
|
|
152
|
+
"""Configuration for token sniper."""
|
|
153
|
+
|
|
154
|
+
max_buy: float = 0.05 # max ETH per snipe
|
|
155
|
+
auto_buy: bool = True # auto-buy safe tokens
|
|
156
|
+
honeypot_check: bool = True # check if token is honeypot
|
|
157
|
+
min_liquidity: float = 1.0 # min ETH liquidity to consider
|
|
158
|
+
max_buy_tax: float = 10.0 # max buy tax percentage
|
|
159
|
+
max_sell_tax: float = 10.0 # max sell tax percentage
|
|
160
|
+
blacklisted_tokens: list[str] = field(default_factory=list)
|
|
161
|
+
whitelisted_tokens: list[str] = field(default_factory=list)
|
|
162
|
+
callback: Optional[Callable] = None # callback(new_pair_info)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass
|
|
166
|
+
class NewPair:
|
|
167
|
+
"""Information about a newly created trading pair."""
|
|
168
|
+
|
|
169
|
+
pair_address: str
|
|
170
|
+
token0: str
|
|
171
|
+
token1: str
|
|
172
|
+
chain: Chain
|
|
173
|
+
timestamp: float
|
|
174
|
+
risk_level: RiskLevel
|
|
175
|
+
token_name: str = ""
|
|
176
|
+
token_symbol: str = ""
|
|
177
|
+
reserves: tuple = (0, 0)
|
|
178
|
+
liquidity_eth: float = 0.0
|
|
179
|
+
score: float = 0.0
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def is_weth_pair(self) -> bool:
|
|
183
|
+
"""Check if pair includes WETH."""
|
|
184
|
+
weth = WETH.get(self.chain, "").lower()
|
|
185
|
+
return self.token0.lower() == weth or self.token1.lower() == weth
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def non_weth_token(self) -> str:
|
|
189
|
+
"""Get the non-WETH token address."""
|
|
190
|
+
weth = WETH.get(self.chain, "").lower()
|
|
191
|
+
if self.token0.lower() == weth:
|
|
192
|
+
return self.token1
|
|
193
|
+
return self.token0
|
|
194
|
+
|
|
195
|
+
def to_dict(self) -> dict:
|
|
196
|
+
return {
|
|
197
|
+
"pair": self.pair_address,
|
|
198
|
+
"token0": self.token0,
|
|
199
|
+
"token1": self.token1,
|
|
200
|
+
"chain": self.chain.value,
|
|
201
|
+
"risk": self.risk_level.value,
|
|
202
|
+
"name": self.token_name,
|
|
203
|
+
"symbol": self.token_symbol,
|
|
204
|
+
"liquidity_eth": self.liquidity_eth,
|
|
205
|
+
"score": self.score,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class TokenSniper:
|
|
210
|
+
"""
|
|
211
|
+
Token sniper — monitors new liquidity pools and auto-buys.
|
|
212
|
+
|
|
213
|
+
Example:
|
|
214
|
+
sniper = TokenSniper(chain_manager, wallet, SniperConfig(max_buy=0.05))
|
|
215
|
+
sniper.start() # non-blocking monitor
|
|
216
|
+
|
|
217
|
+
# Or process events manually
|
|
218
|
+
pairs = sniper.scan_recent_blocks(100)
|
|
219
|
+
for pair in pairs:
|
|
220
|
+
if pair.risk_level == RiskLevel.LOW:
|
|
221
|
+
sniper.buy(pair)
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
def __init__(
|
|
225
|
+
self,
|
|
226
|
+
chain_manager: ChainManager,
|
|
227
|
+
wallet: Wallet,
|
|
228
|
+
config: Optional[SniperConfig] = None,
|
|
229
|
+
uniswap=None,
|
|
230
|
+
):
|
|
231
|
+
self.chain_manager = chain_manager
|
|
232
|
+
self.wallet = wallet
|
|
233
|
+
self.config = config or SniperConfig()
|
|
234
|
+
self.uniswap = uniswap
|
|
235
|
+
self._stop_event = Event()
|
|
236
|
+
self._monitor_thread: Optional[Thread] = None
|
|
237
|
+
self.detected_pairs: list[NewPair] = []
|
|
238
|
+
|
|
239
|
+
def scan_recent_blocks(self, num_blocks: int = 100, chain: Chain = Chain.BASE) -> list[NewPair]:
|
|
240
|
+
"""
|
|
241
|
+
Scan recent blocks for new pair creation events.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
num_blocks: Number of blocks to scan
|
|
245
|
+
chain: Chain to scan on
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
List of newly detected pairs
|
|
249
|
+
"""
|
|
250
|
+
if chain not in FACTORIES:
|
|
251
|
+
raise ValueError(f"Factory not configured for {chain.value}")
|
|
252
|
+
|
|
253
|
+
w3 = self.chain_manager.get_web3(chain)
|
|
254
|
+
factory_addr = FACTORIES[chain]
|
|
255
|
+
factory = w3.eth.contract(
|
|
256
|
+
address=w3.to_checksum_address(factory_addr),
|
|
257
|
+
abi=UNISWAP_V2_FACTORY_ABI,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
current_block = w3.eth.block_number
|
|
261
|
+
from_block = max(0, current_block - num_blocks)
|
|
262
|
+
|
|
263
|
+
logger.info(f"Scanning blocks {from_block} → {current_block} on {chain.value}")
|
|
264
|
+
|
|
265
|
+
# Get PairCreated events
|
|
266
|
+
events = factory.events.PairCreated.get_logs(
|
|
267
|
+
fromBlock=from_block,
|
|
268
|
+
toBlock=current_block,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
pairs = []
|
|
272
|
+
for event in events:
|
|
273
|
+
pair_info = self._analyze_pair(
|
|
274
|
+
pair_address=event.args.pair,
|
|
275
|
+
token0=event.args.token0,
|
|
276
|
+
token1=event.args.token1,
|
|
277
|
+
chain=chain,
|
|
278
|
+
block_number=event.blockNumber,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
if pair_info:
|
|
282
|
+
pairs.append(pair_info)
|
|
283
|
+
self.detected_pairs.append(pair_info)
|
|
284
|
+
|
|
285
|
+
logger.info(
|
|
286
|
+
f"New pair: {pair_info.token_symbol} ({pair_info.risk_level.value}) "
|
|
287
|
+
f"LIQ: {pair_info.liquidity_eth:.2f} ETH | Score: {pair_info.score:.1f}"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
# Auto-buy if configured and safe
|
|
291
|
+
if self.config.auto_buy and pair_info.risk_level == RiskLevel.LOW:
|
|
292
|
+
self.buy(pair_info)
|
|
293
|
+
|
|
294
|
+
return pairs
|
|
295
|
+
|
|
296
|
+
def _analyze_pair(self, pair_address: str, token0: str, token1: str,
|
|
297
|
+
chain: Chain, block_number: int) -> Optional[NewPair]:
|
|
298
|
+
"""Analyze a new pair for safety and profitability."""
|
|
299
|
+
w3 = self.chain_manager.get_web3(chain)
|
|
300
|
+
|
|
301
|
+
# Check if WETH pair
|
|
302
|
+
weth_addr = WETH.get(chain, "").lower()
|
|
303
|
+
is_weth = token0.lower() == weth_addr or token1.lower() == weth_addr
|
|
304
|
+
|
|
305
|
+
if not is_weth:
|
|
306
|
+
return None # Skip non-WETH pairs
|
|
307
|
+
|
|
308
|
+
# Get pair contract
|
|
309
|
+
pair = w3.eth.contract(
|
|
310
|
+
address=w3.to_checksum_address(pair_address),
|
|
311
|
+
abi=UNISWAP_V2_PAIR_ABI,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
reserves = pair.functions.getReserves().call()
|
|
316
|
+
token0_addr = pair.functions.token0().call()
|
|
317
|
+
except Exception as e:
|
|
318
|
+
logger.debug(f"Failed to get pair info: {e}")
|
|
319
|
+
return None
|
|
320
|
+
|
|
321
|
+
# Calculate liquidity
|
|
322
|
+
if token0_addr.lower() == weth_addr:
|
|
323
|
+
liq_reserve = reserves[0]
|
|
324
|
+
else:
|
|
325
|
+
liq_reserve = reserves[1]
|
|
326
|
+
|
|
327
|
+
liquidity_eth = w3.from_wei(liq_reserve, "ether")
|
|
328
|
+
|
|
329
|
+
# Get token info
|
|
330
|
+
non_weth = token1 if token0.lower() == weth_addr else token0
|
|
331
|
+
token_contract = w3.eth.contract(
|
|
332
|
+
address=w3.to_checksum_address(non_weth),
|
|
333
|
+
abi=ERC20_ABI,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
try:
|
|
337
|
+
token_name = token_contract.functions.name().call()
|
|
338
|
+
token_symbol = token_contract.functions.symbol().call()
|
|
339
|
+
except Exception:
|
|
340
|
+
token_name = "Unknown"
|
|
341
|
+
token_symbol = "???"
|
|
342
|
+
|
|
343
|
+
# Check blacklist
|
|
344
|
+
if non_weth.lower() in [t.lower() for t in self.config.blacklisted_tokens]:
|
|
345
|
+
return None
|
|
346
|
+
|
|
347
|
+
# Risk assessment
|
|
348
|
+
risk, score = self._assess_risk(
|
|
349
|
+
token_address=non_weth,
|
|
350
|
+
liquidity_eth=float(liquidity_eth),
|
|
351
|
+
chain=chain,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
return NewPair(
|
|
355
|
+
pair_address=pair_address,
|
|
356
|
+
token0=token0,
|
|
357
|
+
token1=token1,
|
|
358
|
+
chain=chain,
|
|
359
|
+
timestamp=time.time(),
|
|
360
|
+
risk_level=risk,
|
|
361
|
+
token_name=token_name,
|
|
362
|
+
token_symbol=token_symbol,
|
|
363
|
+
reserves=(reserves[0], reserves[1]),
|
|
364
|
+
liquidity_eth=float(liquidity_eth),
|
|
365
|
+
score=score,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
def _assess_risk(self, token_address: str, liquidity_eth: float,
|
|
369
|
+
chain: Chain) -> tuple[RiskLevel, float]:
|
|
370
|
+
"""
|
|
371
|
+
Assess token risk level.
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
(RiskLevel, score) where score is 0-100 (higher = safer)
|
|
375
|
+
"""
|
|
376
|
+
score = 50.0 # Start neutral
|
|
377
|
+
|
|
378
|
+
# Liquidity check
|
|
379
|
+
if liquidity_eth >= self.config.min_liquidity:
|
|
380
|
+
score += 15
|
|
381
|
+
else:
|
|
382
|
+
score -= 30
|
|
383
|
+
|
|
384
|
+
# Check contract code size
|
|
385
|
+
w3 = self.chain_manager.get_web3(chain)
|
|
386
|
+
try:
|
|
387
|
+
code = w3.eth.get_code(w3.to_checksum_address(token_address))
|
|
388
|
+
code_size = len(code)
|
|
389
|
+
|
|
390
|
+
if code_size < 100:
|
|
391
|
+
# Too small — likely a scam
|
|
392
|
+
score -= 40
|
|
393
|
+
elif code_size > 1000:
|
|
394
|
+
# Reasonable contract size
|
|
395
|
+
score += 10
|
|
396
|
+
except Exception:
|
|
397
|
+
score -= 20
|
|
398
|
+
|
|
399
|
+
# Honeypot check (simplified)
|
|
400
|
+
if self.config.honeypot_check:
|
|
401
|
+
# Try to simulate a sell — if it fails, might be honeypot
|
|
402
|
+
# This is a simplified check; real implementation would use
|
|
403
|
+
# a honeypot detection API
|
|
404
|
+
score += 5 # Assume safe for now
|
|
405
|
+
|
|
406
|
+
# Determine risk level
|
|
407
|
+
if score >= 70:
|
|
408
|
+
risk = RiskLevel.LOW
|
|
409
|
+
elif score >= 40:
|
|
410
|
+
risk = RiskLevel.MEDIUM
|
|
411
|
+
elif score >= 20:
|
|
412
|
+
risk = RiskLevel.HIGH
|
|
413
|
+
else:
|
|
414
|
+
risk = RiskLevel.SCAM
|
|
415
|
+
|
|
416
|
+
return risk, score
|
|
417
|
+
|
|
418
|
+
def buy(self, pair: NewPair) -> Optional[str]:
|
|
419
|
+
"""
|
|
420
|
+
Buy the non-WETH token in a pair.
|
|
421
|
+
|
|
422
|
+
Args:
|
|
423
|
+
pair: NewPair to buy
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
Transaction hash or None if failed
|
|
427
|
+
"""
|
|
428
|
+
if not self.uniswap:
|
|
429
|
+
logger.error("Uniswap tool not configured — cannot buy")
|
|
430
|
+
return None
|
|
431
|
+
|
|
432
|
+
amount = self.config.max_buy
|
|
433
|
+
logger.info(f"Buying {pair.token_symbol} with {amount} ETH on {pair.chain.value}")
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
result = self.uniswap.execute(
|
|
437
|
+
wallet=self.wallet,
|
|
438
|
+
token_in="ETH",
|
|
439
|
+
token_out=pair.non_weth_token,
|
|
440
|
+
amount=amount,
|
|
441
|
+
chain=pair.chain,
|
|
442
|
+
)
|
|
443
|
+
logger.info(f"Buy TX: {result.tx_hash}")
|
|
444
|
+
return result.tx_hash
|
|
445
|
+
except Exception as e:
|
|
446
|
+
logger.error(f"Buy failed: {e}")
|
|
447
|
+
return None
|
|
448
|
+
|
|
449
|
+
def start(self, chain: Chain = Chain.BASE, poll_interval: int = 12):
|
|
450
|
+
"""
|
|
451
|
+
Start monitoring for new pairs in a background thread.
|
|
452
|
+
|
|
453
|
+
Args:
|
|
454
|
+
chain: Chain to monitor
|
|
455
|
+
poll_interval: Seconds between block scans
|
|
456
|
+
"""
|
|
457
|
+
if self._monitor_thread and self._monitor_thread.is_alive():
|
|
458
|
+
logger.warning("Monitor already running")
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
self._stop_event.clear()
|
|
462
|
+
|
|
463
|
+
def _monitor():
|
|
464
|
+
logger.info(f"Starting sniper monitor on {chain.value}")
|
|
465
|
+
last_block = 0
|
|
466
|
+
|
|
467
|
+
while not self._stop_event.is_set():
|
|
468
|
+
try:
|
|
469
|
+
w3 = self.chain_manager.get_web3(chain)
|
|
470
|
+
current_block = w3.eth.block_number
|
|
471
|
+
|
|
472
|
+
if current_block > last_block:
|
|
473
|
+
new_pairs = self.scan_recent_blocks(
|
|
474
|
+
num_blocks=current_block - last_block,
|
|
475
|
+
chain=chain,
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
if new_pairs:
|
|
479
|
+
logger.info(f"Found {len(new_pairs)} new pairs")
|
|
480
|
+
|
|
481
|
+
# Call callback if configured
|
|
482
|
+
if self.config.callback:
|
|
483
|
+
for pair in new_pairs:
|
|
484
|
+
self.config.callback(pair)
|
|
485
|
+
|
|
486
|
+
last_block = current_block
|
|
487
|
+
|
|
488
|
+
except Exception as e:
|
|
489
|
+
logger.error(f"Monitor error: {e}")
|
|
490
|
+
|
|
491
|
+
self._stop_event.wait(poll_interval)
|
|
492
|
+
|
|
493
|
+
logger.info("Sniper monitor stopped")
|
|
494
|
+
|
|
495
|
+
self._monitor_thread = Thread(target=_monitor, daemon=True)
|
|
496
|
+
self._monitor_thread.start()
|
|
497
|
+
|
|
498
|
+
def stop(self):
|
|
499
|
+
"""Stop the monitoring thread."""
|
|
500
|
+
self._stop_event.set()
|
|
501
|
+
if self._monitor_thread:
|
|
502
|
+
self._monitor_thread.join(timeout=5)
|
|
503
|
+
|
|
504
|
+
def get_detected_pairs(self, risk_filter: Optional[RiskLevel] = None) -> list[NewPair]:
|
|
505
|
+
"""Get all detected pairs, optionally filtered by risk."""
|
|
506
|
+
if risk_filter:
|
|
507
|
+
return [p for p in self.detected_pairs if p.risk_level == risk_filter]
|
|
508
|
+
return self.detected_pairs
|
|
509
|
+
|
|
510
|
+
def __repr__(self) -> str:
|
|
511
|
+
return f"TokenSniper(chains={[c.value for c in FACTORIES.keys()]}, detected={len(self.detected_pairs)})"
|
src/utils/__init__.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Safety & Governor — spend caps, kill-switch, operator confirmation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class SpendLimits:
|
|
12
|
+
"""Spend limits for the governor."""
|
|
13
|
+
|
|
14
|
+
max_per_tx: float = 1.0 # Max ETH per transaction
|
|
15
|
+
daily_limit: float = 10.0 # Max ETH per day
|
|
16
|
+
session_limit: float = 50.0 # Max ETH per session
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class GovernorDecision:
|
|
21
|
+
"""Result of a governor authorization check."""
|
|
22
|
+
|
|
23
|
+
allowed: bool
|
|
24
|
+
reason: str = ""
|
|
25
|
+
remaining_daily: float = 0.0
|
|
26
|
+
remaining_session: float = 0.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SpendGovernor:
|
|
30
|
+
"""
|
|
31
|
+
Spend governor — enforces transaction limits and safety caps.
|
|
32
|
+
|
|
33
|
+
Features:
|
|
34
|
+
- Per-transaction limits
|
|
35
|
+
- Daily spending limits
|
|
36
|
+
- Session spending limits
|
|
37
|
+
- Kill switch (emergency stop)
|
|
38
|
+
- Operator confirmation gate
|
|
39
|
+
|
|
40
|
+
Example:
|
|
41
|
+
governor = SpendGovernor(
|
|
42
|
+
limits=SpendLimits(max_per_tx=0.1, daily_limit=1.0),
|
|
43
|
+
require_confirm=True,
|
|
44
|
+
)
|
|
45
|
+
decision = governor.authorize(tx_value=0.05)
|
|
46
|
+
if decision.allowed:
|
|
47
|
+
# proceed with transaction
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
limits: Optional[SpendLimits] = None,
|
|
53
|
+
require_confirm: bool = True,
|
|
54
|
+
confirm_fn: Optional[callable] = None,
|
|
55
|
+
):
|
|
56
|
+
self.limits = limits or SpendLimits()
|
|
57
|
+
self.require_confirm = require_confirm
|
|
58
|
+
self.confirm_fn = confirm_fn
|
|
59
|
+
|
|
60
|
+
# Tracking
|
|
61
|
+
self._daily_spent: float = 0.0
|
|
62
|
+
self._session_spent: float = 0.0
|
|
63
|
+
self._daily_reset: float = time.time()
|
|
64
|
+
self._kill_switch: bool = False
|
|
65
|
+
|
|
66
|
+
def authorize(self, tx_value: float, action: str = "") -> GovernorDecision:
|
|
67
|
+
"""
|
|
68
|
+
Check if a transaction should be authorized.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
tx_value: Transaction value in ETH
|
|
72
|
+
action: Description of the action
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
GovernorDecision with allowed status and reason
|
|
76
|
+
"""
|
|
77
|
+
# Kill switch check
|
|
78
|
+
if self._kill_switch:
|
|
79
|
+
return GovernorDecision(allowed=False, reason="Kill switch activated")
|
|
80
|
+
|
|
81
|
+
# Reset daily counter if needed
|
|
82
|
+
if time.time() - self._daily_reset > 86400:
|
|
83
|
+
self._daily_spent = 0.0
|
|
84
|
+
self._daily_reset = time.time()
|
|
85
|
+
|
|
86
|
+
# Per-transaction limit
|
|
87
|
+
if tx_value > self.limits.max_per_tx:
|
|
88
|
+
return GovernorDecision(
|
|
89
|
+
allowed=False,
|
|
90
|
+
reason=f"Transaction value {tx_value} exceeds per-tx limit {self.limits.max_per_tx}",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Daily limit
|
|
94
|
+
if self._daily_spent + tx_value > self.limits.daily_limit:
|
|
95
|
+
return GovernorDecision(
|
|
96
|
+
allowed=False,
|
|
97
|
+
reason=f"Daily limit reached ({self._daily_spent}/{self.limits.daily_limit})",
|
|
98
|
+
remaining_daily=self.limits.daily_limit - self._daily_spent,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Session limit
|
|
102
|
+
if self._session_spent + tx_value > self.limits.session_limit:
|
|
103
|
+
return GovernorDecision(
|
|
104
|
+
allowed=False,
|
|
105
|
+
reason=f"Session limit reached ({self._session_spent}/{self.limits.session_limit})",
|
|
106
|
+
remaining_session=self.limits.session_limit - self._session_spent,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Operator confirmation
|
|
110
|
+
if self.require_confirm and self.confirm_fn:
|
|
111
|
+
if not self.confirm_fn({"action": action, "value": tx_value}):
|
|
112
|
+
return GovernorDecision(allowed=False, reason="Operator rejected")
|
|
113
|
+
|
|
114
|
+
# Update tracking
|
|
115
|
+
self._daily_spent += tx_value
|
|
116
|
+
self._session_spent += tx_value
|
|
117
|
+
|
|
118
|
+
return GovernorDecision(
|
|
119
|
+
allowed=True,
|
|
120
|
+
remaining_daily=self.limits.daily_limit - self._daily_spent,
|
|
121
|
+
remaining_session=self.limits.session_limit - self._session_spent,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def kill(self):
|
|
125
|
+
"""Activate kill switch — blocks all transactions."""
|
|
126
|
+
self._kill_switch = True
|
|
127
|
+
|
|
128
|
+
def unkill(self):
|
|
129
|
+
"""Deactivate kill switch."""
|
|
130
|
+
self._kill_switch = False
|
|
131
|
+
|
|
132
|
+
def get_stats(self) -> dict:
|
|
133
|
+
"""Get current spending stats."""
|
|
134
|
+
return {
|
|
135
|
+
"daily_spent": self._daily_spent,
|
|
136
|
+
"daily_limit": self.limits.daily_limit,
|
|
137
|
+
"session_spent": self._session_spent,
|
|
138
|
+
"session_limit": self.limits.session_limit,
|
|
139
|
+
"kill_switch": self._kill_switch,
|
|
140
|
+
}
|