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/bridge.py ADDED
@@ -0,0 +1,504 @@
1
+ """Bridge agent — cross-chain token transfers via bridge aggregators.
2
+
3
+ Supports Li.Fi, Socket, and direct bridge contracts.
4
+
5
+ Usage:
6
+ from web3_agent_kit.bridge import BridgeAgent
7
+
8
+ bridge = BridgeAgent(chain_manager, wallet)
9
+ result = bridge.transfer(
10
+ token="ETH",
11
+ amount=0.1,
12
+ from_chain=Chain.ETHEREUM,
13
+ to_chain=Chain.BASE,
14
+ )
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import logging
21
+ import time
22
+ from dataclasses import dataclass
23
+ from typing import Optional
24
+
25
+ import requests
26
+
27
+ from .wallet import Wallet
28
+ from .chain import Chain, ChainManager, CHAIN_IDS
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ # WETH addresses
34
+ WETH = {
35
+ Chain.ETHEREUM: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
36
+ Chain.BASE: "0x4200000000000000000000000000000000000006",
37
+ Chain.ARBITRUM: "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1",
38
+ Chain.OPTIMISM: "0x4200000000000000000000000000000000000006",
39
+ Chain.POLYGON: "0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270",
40
+ }
41
+
42
+ # Native token address
43
+ NATIVE = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"
44
+
45
+ # Chain IDs for Li.Fi
46
+ LIFI_CHAIN_IDS = {
47
+ Chain.ETHEREUM: "eth",
48
+ Chain.BASE: "base",
49
+ Chain.ARBITRUM: "arb",
50
+ Chain.OPTIMISM: "opt",
51
+ Chain.POLYGON: "pol",
52
+ Chain.AVALANCHE: "ava",
53
+ Chain.BSC: "bsc",
54
+ }
55
+
56
+ # Chain IDs for Socket
57
+ SOCKET_CHAIN_IDS = {
58
+ Chain.ETHEREUM: 1,
59
+ Chain.BASE: 8453,
60
+ Chain.ARBITRUM: 42161,
61
+ Chain.OPTIMISM: 10,
62
+ Chain.POLYGON: 137,
63
+ Chain.AVALANCHE: 43114,
64
+ Chain.BSC: 56,
65
+ }
66
+
67
+
68
+ @dataclass
69
+ class BridgeRoute:
70
+ """A bridge route with quote details."""
71
+
72
+ bridge_name: str
73
+ from_chain: Chain
74
+ to_chain: Chain
75
+ token_in: str
76
+ token_out: str
77
+ amount_in: float
78
+ amount_out: float
79
+ gas_estimate: float
80
+ time_estimate: int # seconds
81
+ fee_usd: float
82
+ steps: list[dict]
83
+
84
+ def to_dict(self) -> dict:
85
+ return {
86
+ "bridge": self.bridge_name,
87
+ "from": self.from_chain.value,
88
+ "to": self.to_chain.value,
89
+ "amount_in": self.amount_in,
90
+ "amount_out": self.amount_out,
91
+ "gas_estimate": self.gas_estimate,
92
+ "time_minutes": self.time_estimate // 60,
93
+ "fee_usd": self.fee_usd,
94
+ }
95
+
96
+
97
+ @dataclass
98
+ class BridgeResult:
99
+ """Result of a bridge transfer."""
100
+
101
+ tx_hash: str
102
+ from_chain: Chain
103
+ to_chain: Chain
104
+ token: str
105
+ amount: float
106
+ bridge_name: str
107
+ estimated_arrival: int # seconds
108
+
109
+ def to_dict(self) -> dict:
110
+ return {
111
+ "tx_hash": self.tx_hash,
112
+ "from": self.from_chain.value,
113
+ "to": self.to_chain.value,
114
+ "token": self.token,
115
+ "amount": self.amount,
116
+ "bridge": self.bridge_name,
117
+ "eta_minutes": self.estimated_arrival // 60,
118
+ }
119
+
120
+
121
+ class BridgeAgent:
122
+ """
123
+ Cross-chain bridge agent — find best routes and execute transfers.
124
+
125
+ Supports:
126
+ - Li.Fi (aggregator)
127
+ - Socket (aggregator)
128
+ - Direct bridge contracts
129
+
130
+ Example:
131
+ bridge = BridgeAgent(chain_manager, wallet)
132
+
133
+ # Get best route
134
+ routes = bridge.get_routes("ETH", 0.1, Chain.ETHEREUM, Chain.BASE)
135
+ best = routes[0]
136
+ print(f"Best route: {best.bridge_name} — {best.amount_out:.6f} ETH")
137
+
138
+ # Execute transfer
139
+ result = bridge.transfer("ETH", 0.1, Chain.ETHEREUM, Chain.BASE)
140
+ print(f"TX: {result.tx_hash}")
141
+ """
142
+
143
+ def __init__(
144
+ self,
145
+ chain_manager: ChainManager,
146
+ wallet: Wallet,
147
+ lifi_api_key: Optional[str] = None,
148
+ ):
149
+ self.chain_manager = chain_manager
150
+ self.wallet = wallet
151
+ self.lifi_api_key = lifi_api_key
152
+ self.session = requests.Session()
153
+ self.session.headers.update({
154
+ "User-Agent": "web3-agent-kit/0.2.0",
155
+ })
156
+
157
+ def get_routes(
158
+ self,
159
+ token: str,
160
+ amount: float,
161
+ from_chain: Chain,
162
+ to_chain: Chain,
163
+ ) -> list[BridgeRoute]:
164
+ """
165
+ Get available bridge routes with quotes.
166
+
167
+ Args:
168
+ token: Token symbol or address ("ETH", "USDC", etc.)
169
+ amount: Amount to bridge
170
+ from_chain: Source chain
171
+ to_chain: Destination chain
172
+
173
+ Returns:
174
+ List of BridgeRoute sorted by amount_out (best first)
175
+ """
176
+ routes = []
177
+
178
+ # Try Li.Fi
179
+ try:
180
+ lifi_routes = self._get_lifi_routes(token, amount, from_chain, to_chain)
181
+ routes.extend(lifi_routes)
182
+ except Exception as e:
183
+ logger.warning(f"Li.Fi failed: {e}")
184
+
185
+ # Try Socket
186
+ try:
187
+ socket_routes = self._get_socket_routes(token, amount, from_chain, to_chain)
188
+ routes.extend(socket_routes)
189
+ except Exception as e:
190
+ logger.warning(f"Socket failed: {e}")
191
+
192
+ # Sort by amount_out (best first)
193
+ routes.sort(key=lambda r: r.amount_out, reverse=True)
194
+
195
+ return routes
196
+
197
+ def _get_lifi_routes(
198
+ self, token: str, amount: float, from_chain: Chain, to_chain: Chain
199
+ ) -> list[BridgeRoute]:
200
+ """Get routes from Li.Fi API."""
201
+ from_chain_id = LIFI_CHAIN_IDS.get(from_chain)
202
+ to_chain_id = LIFI_CHAIN_IDS.get(to_chain)
203
+
204
+ if not from_chain_id or not to_chain_id:
205
+ return []
206
+
207
+ # Resolve token address
208
+ token_addr = self._resolve_token(token, from_chain)
209
+ to_token_addr = self._resolve_token(token, to_chain)
210
+
211
+ # Get decimals
212
+ decimals = self._get_decimals(token_addr, from_chain)
213
+ amount_wei = str(int(amount * (10 ** decimals)))
214
+
215
+ url = "https://li.quest/v1/quote"
216
+ params = {
217
+ "fromChain": from_chain_id,
218
+ "toChain": to_chain_id,
219
+ "fromToken": token_addr,
220
+ "toToken": to_token_addr,
221
+ "fromAmount": amount_wei,
222
+ "fromAddress": self.wallet.address,
223
+ }
224
+
225
+ if self.lifi_api_key:
226
+ self.session.headers["x-lifi-api-key"] = self.lifi_api_key
227
+
228
+ resp = self.session.get(url, params=params, timeout=15)
229
+ resp.raise_for_status()
230
+ data = resp.json()
231
+
232
+ routes = []
233
+ if "routes" in data:
234
+ for route in data["routes"][:3]: # Top 3 routes
235
+ to_amount = int(route.get("toAmount", "0"))
236
+ to_decimals = self._get_decimals(to_token_addr, to_chain)
237
+ amount_out = to_amount / (10 ** to_decimals)
238
+
239
+ routes.append(BridgeRoute(
240
+ bridge_name=route.get("tags", ["unknown"])[0] if route.get("tags") else "Li.Fi",
241
+ from_chain=from_chain,
242
+ to_chain=to_chain,
243
+ token_in=token_addr,
244
+ token_out=to_token_addr,
245
+ amount_in=amount,
246
+ amount_out=amount_out,
247
+ gas_estimate=float(route.get("gasCostUSD", "0")),
248
+ time_estimate=int(route.get("duration", 300)),
249
+ fee_usd=float(route.get("gasCostUSD", "0")),
250
+ steps=route.get("steps", []),
251
+ ))
252
+
253
+ return routes
254
+
255
+ def _get_socket_routes(
256
+ self, token: str, amount: float, from_chain: Chain, to_chain: Chain
257
+ ) -> list[BridgeRoute]:
258
+ """Get routes from Socket API."""
259
+ from_chain_id = SOCKET_CHAIN_IDS.get(from_chain)
260
+ to_chain_id = SOCKET_CHAIN_IDS.get(to_chain)
261
+
262
+ if not from_chain_id or not to_chain_id:
263
+ return []
264
+
265
+ token_addr = self._resolve_token(token, from_chain)
266
+ to_token_addr = self._resolve_token(token, to_chain)
267
+
268
+ decimals = self._get_decimals(token_addr, from_chain)
269
+ amount_wei = str(int(amount * (10 ** decimals)))
270
+
271
+ url = "https://api.socket.tech/v2/quote"
272
+ params = {
273
+ "fromChainId": from_chain_id,
274
+ "toChainId": to_chain_id,
275
+ "fromTokenAddress": token_addr,
276
+ "toTokenAddress": to_token_addr,
277
+ "fromAmount": amount_wei,
278
+ "userAddress": self.wallet.address,
279
+ "sort": "output",
280
+ "singleTxOnly": "true",
281
+ }
282
+ headers = {
283
+ "API-KEY": "demo", # Free tier
284
+ }
285
+
286
+ resp = self.session.get(url, params=params, headers=headers, timeout=15)
287
+ resp.raise_for_status()
288
+ data = resp.json()
289
+
290
+ routes = []
291
+ result = data.get("result", {})
292
+ routes_data = result.get("routes", [])
293
+
294
+ for route in routes_data[:3]:
295
+ to_amount = int(route.get("toAmount", "0"))
296
+ to_decimals = self._get_decimals(to_token_addr, to_chain)
297
+ amount_out = to_amount / (10 ** to_decimals)
298
+
299
+ bridge_name = route.get("bridgeName", "Socket")
300
+ gas_usd = float(route.get("gasFees", {}).get("gasAmountUSD", "0"))
301
+
302
+ routes.append(BridgeRoute(
303
+ bridge_name=bridge_name,
304
+ from_chain=from_chain,
305
+ to_chain=to_chain,
306
+ token_in=token_addr,
307
+ token_out=to_token_addr,
308
+ amount_in=amount,
309
+ amount_out=amount_out,
310
+ gas_estimate=gas_usd,
311
+ time_estimate=int(route.get("serviceTime", 300)),
312
+ fee_usd=gas_usd,
313
+ steps=[],
314
+ ))
315
+
316
+ return routes
317
+
318
+ def transfer(
319
+ self,
320
+ token: str,
321
+ amount: float,
322
+ from_chain: Chain,
323
+ to_chain: Chain,
324
+ route: Optional[BridgeRoute] = None,
325
+ ) -> BridgeResult:
326
+ """
327
+ Execute a cross-chain transfer.
328
+
329
+ Args:
330
+ token: Token symbol or address
331
+ amount: Amount to bridge
332
+ from_chain: Source chain
333
+ to_chain: Destination chain
334
+ route: Specific route to use (optional — uses best route if None)
335
+
336
+ Returns:
337
+ BridgeResult with transaction hash
338
+ """
339
+ if route is None:
340
+ routes = self.get_routes(token, amount, from_chain, to_chain)
341
+ if not routes:
342
+ raise ValueError("No bridge routes found")
343
+ route = routes[0]
344
+ logger.info(f"Using best route: {route.bridge_name} ({route.amount_out:.6f} {token})")
345
+
346
+ # Execute based on bridge type
347
+ if route.bridge_name in ("lifuel", "Li.Fi", "li.fi"):
348
+ return self._execute_lifi(route)
349
+ else:
350
+ return self._execute_socket(route)
351
+
352
+ def _execute_lifi(self, route: BridgeRoute) -> BridgeResult:
353
+ """Execute a Li.Fi bridge transfer."""
354
+ # Get transaction data from Li.Fi
355
+ from_chain_id = LIFI_CHAIN_IDS.get(route.from_chain)
356
+ to_chain_id = LIFI_CHAIN_IDS.get(route.to_chain)
357
+
358
+ decimals = self._get_decimals(route.token_in, route.from_chain)
359
+ amount_wei = str(int(route.amount_in * (10 ** decimals)))
360
+
361
+ url = "https://li.quest/v1/quote"
362
+ params = {
363
+ "fromChain": from_chain_id,
364
+ "toChain": to_chain_id,
365
+ "fromToken": route.token_in,
366
+ "toToken": route.token_out,
367
+ "fromAmount": amount_wei,
368
+ "fromAddress": self.wallet.address,
369
+ }
370
+
371
+ if self.lifi_api_key:
372
+ self.session.headers["x-lifi-api-key"] = self.lifi_api_key
373
+
374
+ resp = self.session.get(url, params=params, timeout=15)
375
+ resp.raise_for_status()
376
+ data = resp.json()
377
+
378
+ # Get the transaction request
379
+ tx_request = data.get("transactionRequest")
380
+ if not tx_request:
381
+ raise ValueError("No transaction data from Li.Fi")
382
+
383
+ # Build and send transaction
384
+ w3 = self.chain_manager.get_web3(route.from_chain)
385
+
386
+ tx = {
387
+ "from": w3.to_checksum_address(self.wallet.address),
388
+ "to": w3.to_checksum_address(tx_request["to"]),
389
+ "data": tx_request["data"],
390
+ "value": int(tx_request.get("value", "0")),
391
+ "gas": int(tx_request.get("gasLimit", "300000")),
392
+ "gasPrice": w3.eth.gas_price,
393
+ "nonce": w3.eth.get_transaction_count(self.wallet.address),
394
+ "chainId": CHAIN_IDS.get(route.from_chain, 1),
395
+ }
396
+
397
+ signed = self.wallet.sign_transaction(tx, route.from_chain)
398
+ tx_hash = w3.eth.send_raw_transaction(signed)
399
+ receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120)
400
+
401
+ return BridgeResult(
402
+ tx_hash=tx_hash.hex(),
403
+ from_chain=route.from_chain,
404
+ to_chain=route.to_chain,
405
+ token=route.token_in,
406
+ amount=route.amount_in,
407
+ bridge_name=route.bridge_name,
408
+ estimated_arrival=route.time_estimate,
409
+ )
410
+
411
+ def _execute_socket(self, route: BridgeRoute) -> BridgeResult:
412
+ """Execute a Socket bridge transfer."""
413
+ from_chain_id = SOCKET_CHAIN_IDS.get(route.from_chain)
414
+ to_chain_id = SOCKET_CHAIN_IDS.get(route.to_chain)
415
+
416
+ decimals = self._get_decimals(route.token_in, route.from_chain)
417
+ amount_wei = str(int(route.amount_in * (10 ** decimals)))
418
+
419
+ # Get transaction data
420
+ url = "https://api.socket.tech/v2/build-tx"
421
+ params = {
422
+ "fromChainId": from_chain_id,
423
+ "toChainId": to_chain_id,
424
+ "fromTokenAddress": route.token_in,
425
+ "toTokenAddress": route.token_out,
426
+ "fromAmount": amount_wei,
427
+ "userAddress": self.wallet.address,
428
+ "route": json.dumps(route.steps) if route.steps else "",
429
+ }
430
+ headers = {"API-KEY": "demo"}
431
+
432
+ resp = self.session.get(url, params=params, headers=headers, timeout=15)
433
+ resp.raise_for_status()
434
+ data = resp.json()
435
+
436
+ tx_data = data.get("result", {}).get("txData", {})
437
+ if not tx_data:
438
+ raise ValueError("No transaction data from Socket")
439
+
440
+ w3 = self.chain_manager.get_web3(route.from_chain)
441
+
442
+ tx = {
443
+ "from": w3.to_checksum_address(self.wallet.address),
444
+ "to": w3.to_checksum_address(tx_data["to"]),
445
+ "data": tx_data["data"],
446
+ "value": int(tx_data.get("value", "0")),
447
+ "gas": int(tx_data.get("gasLimit", "300000")),
448
+ "gasPrice": w3.eth.gas_price,
449
+ "nonce": w3.eth.get_transaction_count(self.wallet.address),
450
+ "chainId": CHAIN_IDS.get(route.from_chain, 1),
451
+ }
452
+
453
+ signed = self.wallet.sign_transaction(tx, route.from_chain)
454
+ tx_hash = w3.eth.send_raw_transaction(signed)
455
+ receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120)
456
+
457
+ return BridgeResult(
458
+ tx_hash=tx_hash.hex(),
459
+ from_chain=route.from_chain,
460
+ to_chain=route.to_chain,
461
+ token=route.token_in,
462
+ amount=route.amount_in,
463
+ bridge_name=route.bridge_name,
464
+ estimated_arrival=route.time_estimate,
465
+ )
466
+
467
+ def _resolve_token(self, token: str, chain: Chain) -> str:
468
+ """Resolve token symbol to address."""
469
+ token = token.upper()
470
+ if token in ("ETH", "NATIVE", "MATIC"):
471
+ return NATIVE
472
+ if chain in WETH and token == "WETH":
473
+ return WETH[chain]
474
+ # Check known tokens
475
+ from .portfolio import KNOWN_TOKENS
476
+ chain_tokens = KNOWN_TOKENS.get(chain, {})
477
+ if token in chain_tokens:
478
+ return chain_tokens[token]
479
+ # Assume it's an address
480
+ return token
481
+
482
+ def _get_decimals(self, token_address: str, chain: Chain) -> int:
483
+ """Get token decimals."""
484
+ if token_address == NATIVE:
485
+ return 18
486
+
487
+ w3 = self.chain_manager.get_web3(chain)
488
+ try:
489
+ token = w3.eth.contract(
490
+ address=w3.to_checksum_address(token_address),
491
+ abi=[{
492
+ "inputs": [],
493
+ "name": "decimals",
494
+ "outputs": [{"internalType": "uint8", "name": "", "type": "uint8"}],
495
+ "stateMutability": "view",
496
+ "type": "function"
497
+ }],
498
+ )
499
+ return token.functions.decimals().call()
500
+ except Exception:
501
+ return 18 # Default to 18
502
+
503
+ def __repr__(self) -> str:
504
+ return f"BridgeAgent(wallet={self.wallet.address[:10]}...)"
src/chain.py ADDED
@@ -0,0 +1,115 @@
1
+ """Multi-chain support — chain definitions and RPC management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+ from typing import Optional
8
+
9
+
10
+ class Chain(Enum):
11
+ """Supported blockchain networks."""
12
+
13
+ ETHEREUM = "ethereum"
14
+ BASE = "base"
15
+ ARBITRUM = "arbitrum"
16
+ OPTIMISM = "optimism"
17
+ POLYGON = "polygon"
18
+ AVALANCHE = "avalanche"
19
+ BSC = "bsc"
20
+ SOLANA = "solana"
21
+
22
+
23
+ # Default RPC endpoints
24
+ DEFAULT_RPCS = {
25
+ Chain.ETHEREUM: "https://eth.llamarpc.com",
26
+ Chain.BASE: "https://mainnet.base.org",
27
+ Chain.ARBITRUM: "https://arb1.arbitrum.io/rpc",
28
+ Chain.OPTIMISM: "https://mainnet.optimism.io",
29
+ Chain.POLYGON: "https://polygon-rpc.com",
30
+ Chain.AVALANCHE: "https://api.avax.network/ext/bc/C/rpc",
31
+ Chain.BSC: "https://bsc-dataseed1.binance.org",
32
+ Chain.SOLANA: "https://api.mainnet-beta.solana.com",
33
+ }
34
+
35
+ # Chain IDs for EVM chains
36
+ CHAIN_IDS = {
37
+ Chain.ETHEREUM: 1,
38
+ Chain.BASE: 8453,
39
+ Chain.ARBITRUM: 42161,
40
+ Chain.OPTIMISM: 10,
41
+ Chain.POLYGON: 137,
42
+ Chain.AVALANCHE: 43114,
43
+ Chain.BSC: 56,
44
+ }
45
+
46
+
47
+ @dataclass
48
+ class ChainConfig:
49
+ """Configuration for a blockchain connection."""
50
+
51
+ chain: Chain
52
+ rpc_url: Optional[str] = None
53
+ chain_id: Optional[int] = None
54
+ explorer_url: Optional[str] = None
55
+
56
+ def __post_init__(self):
57
+ if self.rpc_url is None:
58
+ self.rpc_url = DEFAULT_RPCS.get(self.chain)
59
+ if self.chain_id is None:
60
+ self.chain_id = CHAIN_IDS.get(self.chain)
61
+
62
+ @property
63
+ def is_evm(self) -> bool:
64
+ """Check if chain is EVM-compatible."""
65
+ return self.chain != Chain.SOLANA
66
+
67
+ @property
68
+ def explorer(self) -> str:
69
+ """Get block explorer URL."""
70
+ explorers = {
71
+ Chain.ETHEREUM: "https://etherscan.io",
72
+ Chain.BASE: "https://basescan.org",
73
+ Chain.ARBITRUM: "https://arbiscan.io",
74
+ Chain.OPTIMISM: "https://optimistic.etherscan.io",
75
+ Chain.POLYGON: "https://polygonscan.com",
76
+ Chain.AVALANCHE: "https://snowtrace.io",
77
+ Chain.BSC: "https://bscscan.com",
78
+ Chain.SOLANA: "https://solscan.io",
79
+ }
80
+ return self.explorer_url or explorers.get(self.chain, "")
81
+
82
+
83
+ class ChainManager:
84
+ """Manage connections to multiple blockchain networks."""
85
+
86
+ def __init__(self, chains: list[Chain], rpcs: Optional[dict[Chain, str]] = None):
87
+ self.configs = {}
88
+ for chain in chains:
89
+ rpc = (rpcs or {}).get(chain)
90
+ self.configs[chain] = ChainConfig(chain=chain, rpc_url=rpc)
91
+
92
+ def get_config(self, chain: Chain) -> ChainConfig:
93
+ """Get configuration for a chain."""
94
+ if chain not in self.configs:
95
+ raise ValueError(f"Chain {chain.value} not configured")
96
+ return self.configs[chain]
97
+
98
+ def get_web3(self, chain: Chain):
99
+ """Get Web3 instance for a chain."""
100
+ config = self.get_config(chain)
101
+ if not config.is_evm:
102
+ raise ValueError(f"Chain {chain.value} is not EVM — use get_solana() instead")
103
+
104
+ from web3 import Web3
105
+ return Web3(Web3.HTTPProvider(config.rpc_url))
106
+
107
+ def get_solana(self):
108
+ """Get Solana client."""
109
+ from solana.rpc.api import Client
110
+ config = self.get_config(Chain.SOLANA)
111
+ return Client(config.rpc_url)
112
+
113
+ def list_chains(self) -> list[Chain]:
114
+ """List configured chains."""
115
+ return list(self.configs.keys())