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/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())
|