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/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
+ }