wayfinder-paths 0.1.1__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.

Potentially problematic release.


This version of wayfinder-paths might be problematic. Click here for more details.

Files changed (115) hide show
  1. wayfinder_paths/CONFIG_GUIDE.md +394 -0
  2. wayfinder_paths/__init__.py +21 -0
  3. wayfinder_paths/config.example.json +20 -0
  4. wayfinder_paths/conftest.py +31 -0
  5. wayfinder_paths/core/__init__.py +13 -0
  6. wayfinder_paths/core/adapters/BaseAdapter.py +48 -0
  7. wayfinder_paths/core/adapters/__init__.py +5 -0
  8. wayfinder_paths/core/adapters/base.py +5 -0
  9. wayfinder_paths/core/clients/AuthClient.py +83 -0
  10. wayfinder_paths/core/clients/BRAPClient.py +90 -0
  11. wayfinder_paths/core/clients/ClientManager.py +231 -0
  12. wayfinder_paths/core/clients/HyperlendClient.py +151 -0
  13. wayfinder_paths/core/clients/LedgerClient.py +222 -0
  14. wayfinder_paths/core/clients/PoolClient.py +96 -0
  15. wayfinder_paths/core/clients/SimulationClient.py +180 -0
  16. wayfinder_paths/core/clients/TokenClient.py +73 -0
  17. wayfinder_paths/core/clients/TransactionClient.py +47 -0
  18. wayfinder_paths/core/clients/WalletClient.py +90 -0
  19. wayfinder_paths/core/clients/WayfinderClient.py +258 -0
  20. wayfinder_paths/core/clients/__init__.py +48 -0
  21. wayfinder_paths/core/clients/protocols.py +295 -0
  22. wayfinder_paths/core/clients/sdk_example.py +115 -0
  23. wayfinder_paths/core/config.py +369 -0
  24. wayfinder_paths/core/constants/__init__.py +26 -0
  25. wayfinder_paths/core/constants/base.py +25 -0
  26. wayfinder_paths/core/constants/erc20_abi.py +118 -0
  27. wayfinder_paths/core/constants/hyperlend_abi.py +152 -0
  28. wayfinder_paths/core/engine/VaultJob.py +182 -0
  29. wayfinder_paths/core/engine/__init__.py +5 -0
  30. wayfinder_paths/core/engine/manifest.py +97 -0
  31. wayfinder_paths/core/services/__init__.py +0 -0
  32. wayfinder_paths/core/services/base.py +177 -0
  33. wayfinder_paths/core/services/local_evm_txn.py +429 -0
  34. wayfinder_paths/core/services/local_token_txn.py +231 -0
  35. wayfinder_paths/core/services/web3_service.py +45 -0
  36. wayfinder_paths/core/settings.py +61 -0
  37. wayfinder_paths/core/strategies/Strategy.py +183 -0
  38. wayfinder_paths/core/strategies/__init__.py +5 -0
  39. wayfinder_paths/core/strategies/base.py +7 -0
  40. wayfinder_paths/core/utils/__init__.py +1 -0
  41. wayfinder_paths/core/utils/evm_helpers.py +165 -0
  42. wayfinder_paths/core/utils/wallets.py +77 -0
  43. wayfinder_paths/core/wallets/README.md +91 -0
  44. wayfinder_paths/core/wallets/WalletManager.py +56 -0
  45. wayfinder_paths/core/wallets/__init__.py +7 -0
  46. wayfinder_paths/run_strategy.py +409 -0
  47. wayfinder_paths/scripts/__init__.py +0 -0
  48. wayfinder_paths/scripts/create_strategy.py +181 -0
  49. wayfinder_paths/scripts/make_wallets.py +160 -0
  50. wayfinder_paths/scripts/validate_manifests.py +213 -0
  51. wayfinder_paths/tests/__init__.py +0 -0
  52. wayfinder_paths/tests/test_smoke_manifest.py +48 -0
  53. wayfinder_paths/tests/test_test_coverage.py +212 -0
  54. wayfinder_paths/tests/test_utils.py +64 -0
  55. wayfinder_paths/vaults/__init__.py +0 -0
  56. wayfinder_paths/vaults/adapters/__init__.py +0 -0
  57. wayfinder_paths/vaults/adapters/balance_adapter/README.md +104 -0
  58. wayfinder_paths/vaults/adapters/balance_adapter/adapter.py +257 -0
  59. wayfinder_paths/vaults/adapters/balance_adapter/examples.json +6 -0
  60. wayfinder_paths/vaults/adapters/balance_adapter/manifest.yaml +8 -0
  61. wayfinder_paths/vaults/adapters/balance_adapter/test_adapter.py +83 -0
  62. wayfinder_paths/vaults/adapters/brap_adapter/README.md +249 -0
  63. wayfinder_paths/vaults/adapters/brap_adapter/__init__.py +7 -0
  64. wayfinder_paths/vaults/adapters/brap_adapter/adapter.py +717 -0
  65. wayfinder_paths/vaults/adapters/brap_adapter/examples.json +175 -0
  66. wayfinder_paths/vaults/adapters/brap_adapter/manifest.yaml +11 -0
  67. wayfinder_paths/vaults/adapters/brap_adapter/test_adapter.py +288 -0
  68. wayfinder_paths/vaults/adapters/hyperlend_adapter/__init__.py +7 -0
  69. wayfinder_paths/vaults/adapters/hyperlend_adapter/adapter.py +298 -0
  70. wayfinder_paths/vaults/adapters/hyperlend_adapter/manifest.yaml +10 -0
  71. wayfinder_paths/vaults/adapters/hyperlend_adapter/test_adapter.py +267 -0
  72. wayfinder_paths/vaults/adapters/ledger_adapter/README.md +158 -0
  73. wayfinder_paths/vaults/adapters/ledger_adapter/__init__.py +7 -0
  74. wayfinder_paths/vaults/adapters/ledger_adapter/adapter.py +286 -0
  75. wayfinder_paths/vaults/adapters/ledger_adapter/examples.json +131 -0
  76. wayfinder_paths/vaults/adapters/ledger_adapter/manifest.yaml +11 -0
  77. wayfinder_paths/vaults/adapters/ledger_adapter/test_adapter.py +202 -0
  78. wayfinder_paths/vaults/adapters/pool_adapter/README.md +218 -0
  79. wayfinder_paths/vaults/adapters/pool_adapter/__init__.py +7 -0
  80. wayfinder_paths/vaults/adapters/pool_adapter/adapter.py +289 -0
  81. wayfinder_paths/vaults/adapters/pool_adapter/examples.json +143 -0
  82. wayfinder_paths/vaults/adapters/pool_adapter/manifest.yaml +10 -0
  83. wayfinder_paths/vaults/adapters/pool_adapter/test_adapter.py +222 -0
  84. wayfinder_paths/vaults/adapters/token_adapter/README.md +101 -0
  85. wayfinder_paths/vaults/adapters/token_adapter/__init__.py +3 -0
  86. wayfinder_paths/vaults/adapters/token_adapter/adapter.py +92 -0
  87. wayfinder_paths/vaults/adapters/token_adapter/examples.json +26 -0
  88. wayfinder_paths/vaults/adapters/token_adapter/manifest.yaml +6 -0
  89. wayfinder_paths/vaults/adapters/token_adapter/test_adapter.py +135 -0
  90. wayfinder_paths/vaults/strategies/__init__.py +0 -0
  91. wayfinder_paths/vaults/strategies/config.py +85 -0
  92. wayfinder_paths/vaults/strategies/hyperlend_stable_yield_strategy/README.md +99 -0
  93. wayfinder_paths/vaults/strategies/hyperlend_stable_yield_strategy/examples.json +16 -0
  94. wayfinder_paths/vaults/strategies/hyperlend_stable_yield_strategy/manifest.yaml +7 -0
  95. wayfinder_paths/vaults/strategies/hyperlend_stable_yield_strategy/strategy.py +2328 -0
  96. wayfinder_paths/vaults/strategies/hyperlend_stable_yield_strategy/test_strategy.py +319 -0
  97. wayfinder_paths/vaults/strategies/stablecoin_yield_strategy/README.md +95 -0
  98. wayfinder_paths/vaults/strategies/stablecoin_yield_strategy/examples.json +17 -0
  99. wayfinder_paths/vaults/strategies/stablecoin_yield_strategy/manifest.yaml +17 -0
  100. wayfinder_paths/vaults/strategies/stablecoin_yield_strategy/strategy.py +1684 -0
  101. wayfinder_paths/vaults/strategies/stablecoin_yield_strategy/test_strategy.py +350 -0
  102. wayfinder_paths/vaults/templates/adapter/README.md +105 -0
  103. wayfinder_paths/vaults/templates/adapter/adapter.py +26 -0
  104. wayfinder_paths/vaults/templates/adapter/examples.json +8 -0
  105. wayfinder_paths/vaults/templates/adapter/manifest.yaml +6 -0
  106. wayfinder_paths/vaults/templates/adapter/test_adapter.py +49 -0
  107. wayfinder_paths/vaults/templates/strategy/README.md +152 -0
  108. wayfinder_paths/vaults/templates/strategy/examples.json +11 -0
  109. wayfinder_paths/vaults/templates/strategy/manifest.yaml +8 -0
  110. wayfinder_paths/vaults/templates/strategy/strategy.py +57 -0
  111. wayfinder_paths/vaults/templates/strategy/test_strategy.py +197 -0
  112. wayfinder_paths-0.1.1.dist-info/LICENSE +21 -0
  113. wayfinder_paths-0.1.1.dist-info/METADATA +727 -0
  114. wayfinder_paths-0.1.1.dist-info/RECORD +115 -0
  115. wayfinder_paths-0.1.1.dist-info/WHEEL +4 -0
@@ -0,0 +1,717 @@
1
+ from typing import Any
2
+
3
+ from eth_utils import to_checksum_address
4
+
5
+ from wayfinder_paths.core.adapters.BaseAdapter import BaseAdapter
6
+ from wayfinder_paths.core.clients.BRAPClient import BRAPClient
7
+ from wayfinder_paths.core.clients.SimulationClient import SimulationClient
8
+ from wayfinder_paths.core.clients.TokenClient import TokenClient
9
+ from wayfinder_paths.core.constants import DEFAULT_SLIPPAGE
10
+ from wayfinder_paths.core.services.base import Web3Service
11
+ from wayfinder_paths.core.settings import settings
12
+ from wayfinder_paths.vaults.adapters.ledger_adapter.adapter import LedgerAdapter
13
+ from wayfinder_paths.vaults.adapters.token_adapter.adapter import TokenAdapter
14
+
15
+ _NEEDS_CLEAR_APPROVAL = {
16
+ (1, "0xdac17f958d2ee523a2206206994597c13d831ec7"),
17
+ (137, "0xc2132d05d31c914a87c6611c10748aeb04b58e8f"),
18
+ (56, "0x55d398326f99059ff775485246999027b3197955"),
19
+ }
20
+
21
+
22
+ class BRAPAdapter(BaseAdapter):
23
+ """
24
+ BRAP (Bridge/Router/Adapter Protocol) adapter for cross-chain swaps and quotes.
25
+
26
+ Provides high-level operations for:
27
+ - Getting swap quotes across chains
28
+ - Executing cross-chain transactions
29
+ - Route optimization and fee calculation
30
+ - Bridge operations
31
+ """
32
+
33
+ adapter_type: str = "BRAP"
34
+
35
+ def __init__(
36
+ self,
37
+ config: dict[str, Any] | None = None,
38
+ *,
39
+ web3_service: Web3Service,
40
+ simulation: bool = False,
41
+ ):
42
+ super().__init__("brap_adapter", config)
43
+ self.brap_client = BRAPClient()
44
+ self.token_client = TokenClient()
45
+ self.token_adapter = TokenAdapter()
46
+ self.ledger_adapter = LedgerAdapter()
47
+ self.web3_service = web3_service
48
+ self.wallet_provider = web3_service.evm_transactions
49
+ self.token_transactions = web3_service.token_transactions
50
+ self.simulation = simulation
51
+ self.simulation_client = SimulationClient() if simulation else None
52
+
53
+ async def get_swap_quote(
54
+ self,
55
+ from_token_address: str,
56
+ to_token_address: str,
57
+ from_chain_id: int,
58
+ to_chain_id: int,
59
+ from_address: str,
60
+ to_address: str,
61
+ amount: str,
62
+ slippage: float | None = None,
63
+ wayfinder_fee: float | None = None,
64
+ ) -> tuple[bool, Any]:
65
+ """
66
+ Get a quote for a cross-chain swap operation.
67
+
68
+ Args:
69
+ from_token_address: Source token contract address
70
+ to_token_address: Destination token contract address
71
+ from_chain_id: Source chain ID
72
+ to_chain_id: Destination chain ID
73
+ from_address: Source wallet address
74
+ to_address: Destination wallet address
75
+ amount: Amount to swap (in smallest units)
76
+ slippage: Maximum slippage tolerance (optional)
77
+ wayfinder_fee: Wayfinder fee (optional)
78
+
79
+ Returns:
80
+ Tuple of (success, data) where data is quote information or error message
81
+ """
82
+ try:
83
+ data = await self.brap_client.get_quote(
84
+ from_token_address=from_token_address,
85
+ to_token_address=to_token_address,
86
+ from_chain_id=from_chain_id,
87
+ to_chain_id=to_chain_id,
88
+ from_address=from_address,
89
+ to_address=to_address,
90
+ amount1=amount,
91
+ slippage=slippage,
92
+ wayfinder_fee=wayfinder_fee,
93
+ )
94
+ return (True, data)
95
+ except Exception as e:
96
+ self.logger.error(f"Error getting swap quote: {e}")
97
+ return (False, str(e))
98
+
99
+ async def get_best_quote(
100
+ self,
101
+ from_token_address: str,
102
+ to_token_address: str,
103
+ from_chain_id: int,
104
+ to_chain_id: int,
105
+ from_address: str,
106
+ to_address: str,
107
+ amount: str,
108
+ slippage: float | None = None,
109
+ wayfinder_fee: float | None = None,
110
+ ) -> tuple[bool, Any]:
111
+ """
112
+ Get the best available quote for a swap operation.
113
+
114
+ Args:
115
+ from_token_address: Source token contract address
116
+ to_token_address: Destination token contract address
117
+ from_chain_id: Source chain ID
118
+ to_chain_id: Destination chain ID
119
+ from_address: Source wallet address
120
+ to_address: Destination wallet address
121
+ amount: Amount to swap (in smallest units)
122
+ slippage: Maximum slippage tolerance (optional)
123
+ wayfinder_fee: Wayfinder fee (optional)
124
+
125
+ Returns:
126
+ Tuple of (success, data) where data is best quote or error message
127
+ """
128
+ try:
129
+ data = await self.brap_client.get_quote(
130
+ from_token_address=from_token_address,
131
+ to_token_address=to_token_address,
132
+ from_chain_id=from_chain_id,
133
+ to_chain_id=to_chain_id,
134
+ from_address=from_address,
135
+ to_address=to_address,
136
+ amount1=amount,
137
+ slippage=slippage,
138
+ wayfinder_fee=wayfinder_fee,
139
+ )
140
+
141
+ # Extract best quote from response
142
+ quotes = data.get("quotes", {})
143
+ best_quote = quotes.get("best_quote")
144
+
145
+ if not best_quote:
146
+ return (False, "No quotes available")
147
+
148
+ return (True, best_quote)
149
+ except Exception as e:
150
+ self.logger.error(f"Error getting best quote: {e}")
151
+ return (False, str(e))
152
+
153
+ async def calculate_swap_fees(
154
+ self,
155
+ from_token_address: str,
156
+ to_token_address: str,
157
+ from_chain_id: int,
158
+ to_chain_id: int,
159
+ amount: str,
160
+ slippage: float | None = None,
161
+ ) -> tuple[bool, Any]:
162
+ """
163
+ Calculate fees for a swap operation.
164
+
165
+ Args:
166
+ from_token_address: Source token contract address
167
+ to_token_address: Destination token contract address
168
+ from_chain_id: Source chain ID
169
+ to_chain_id: Destination chain ID
170
+ amount: Amount to swap (in smallest units)
171
+ slippage: Maximum slippage tolerance (optional)
172
+
173
+ Returns:
174
+ Tuple of (success, data) where data is fee breakdown or error message
175
+ """
176
+ try:
177
+ # Get quote to extract fee information
178
+ success, quote_data = await self.get_swap_quote(
179
+ from_token_address=from_token_address,
180
+ to_token_address=to_token_address,
181
+ from_chain_id=from_chain_id,
182
+ to_chain_id=to_chain_id,
183
+ from_address="0x0000000000000000000000000000000000000000", # Dummy address
184
+ to_address="0x0000000000000000000000000000000000000000", # Dummy address
185
+ amount=amount,
186
+ slippage=slippage,
187
+ )
188
+
189
+ if not success:
190
+ return (False, quote_data)
191
+
192
+ quotes = quote_data.get("quotes", {})
193
+ best_quote = quotes.get("best_quote")
194
+
195
+ if not best_quote:
196
+ return (False, "No quote available for fee calculation")
197
+
198
+ # Extract fee information
199
+ fees = {
200
+ "input_amount": best_quote.get("input_amount", 0),
201
+ "output_amount": best_quote.get("output_amount", 0),
202
+ "gas_fee": best_quote.get("gas_fee", 0),
203
+ "bridge_fee": best_quote.get("bridge_fee", 0),
204
+ "protocol_fee": best_quote.get("protocol_fee", 0),
205
+ "total_fee": best_quote.get("total_fee", 0),
206
+ "slippage": best_quote.get("slippage", 0),
207
+ "price_impact": best_quote.get("price_impact", 0),
208
+ }
209
+
210
+ return (True, fees)
211
+ except Exception as e:
212
+ self.logger.error(f"Error calculating swap fees: {e}")
213
+ return (False, str(e))
214
+
215
+ async def compare_routes(
216
+ self,
217
+ from_token_address: str,
218
+ to_token_address: str,
219
+ from_chain_id: int,
220
+ to_chain_id: int,
221
+ amount: str,
222
+ slippage: float | None = None,
223
+ ) -> tuple[bool, Any]:
224
+ """
225
+ Compare multiple routes for a swap operation.
226
+
227
+ Args:
228
+ from_token_address: Source token contract address
229
+ to_token_address: Destination token contract address
230
+ from_chain_id: Source chain ID
231
+ to_chain_id: Destination chain ID
232
+ amount: Amount to swap (in smallest units)
233
+ slippage: Maximum slippage tolerance (optional)
234
+
235
+ Returns:
236
+ Tuple of (success, data) where data is route comparison or error message
237
+ """
238
+ try:
239
+ data = await self.brap_client.get_quote(
240
+ from_token_address=from_token_address,
241
+ to_token_address=to_token_address,
242
+ from_chain_id=from_chain_id,
243
+ to_chain_id=to_chain_id,
244
+ from_address="0x0000000000000000000000000000000000000000", # Dummy address
245
+ to_address="0x0000000000000000000000000000000000000000", # Dummy address
246
+ amount1=amount,
247
+ slippage=slippage,
248
+ )
249
+
250
+ quotes = data.get("quotes", {})
251
+ all_quotes = quotes.get("quotes", [])
252
+ best_quote = quotes.get("best_quote")
253
+
254
+ if not all_quotes:
255
+ return (False, "No routes available")
256
+
257
+ # Sort quotes by output amount (descending)
258
+ sorted_quotes = sorted(
259
+ all_quotes, key=lambda x: int(x.get("output_amount", 0)), reverse=True
260
+ )
261
+
262
+ comparison = {
263
+ "total_routes": len(all_quotes),
264
+ "best_route": best_quote,
265
+ "all_routes": sorted_quotes,
266
+ "route_analysis": {
267
+ "highest_output": sorted_quotes[0] if sorted_quotes else None,
268
+ "lowest_fees": min(
269
+ all_quotes, key=lambda x: int(x.get("total_fee", 0))
270
+ )
271
+ if all_quotes
272
+ else None,
273
+ "fastest": min(
274
+ all_quotes, key=lambda x: int(x.get("estimated_time", 0))
275
+ )
276
+ if all_quotes
277
+ else None,
278
+ },
279
+ }
280
+
281
+ return (True, comparison)
282
+ except Exception as e:
283
+ self.logger.error(f"Error comparing routes: {e}")
284
+ return (False, str(e))
285
+
286
+ async def swap_from_token_ids(
287
+ self,
288
+ from_token_id: str,
289
+ to_token_id: str,
290
+ from_address: str,
291
+ amount: str,
292
+ slippage: float = DEFAULT_SLIPPAGE,
293
+ strategy_name: str | None = None,
294
+ ) -> tuple[bool, Any]:
295
+ """
296
+ Execute a swap by looking up token metadata via token IDs.
297
+ """
298
+ from_token = await self.token_client.get_token_details(from_token_id)
299
+ if not from_token:
300
+ return (False, f"From token not found: {from_token_id}")
301
+ to_token = await self.token_client.get_token_details(to_token_id)
302
+ if not to_token:
303
+ return (False, f"To token not found: {to_token_id}")
304
+
305
+ success, best_quote = await self.get_best_quote(
306
+ from_token_address=from_token.get("address"),
307
+ to_token_address=to_token.get("address"),
308
+ from_chain_id=(from_token.get("chain") or {}).get("id"),
309
+ to_chain_id=(to_token.get("chain") or {}).get("id"),
310
+ from_address=from_address,
311
+ to_address=from_address,
312
+ amount=amount,
313
+ slippage=slippage,
314
+ )
315
+ if not success:
316
+ return (False, best_quote)
317
+
318
+ return await self.swap_from_quote(
319
+ from_token=from_token,
320
+ to_token=to_token,
321
+ from_address=from_address,
322
+ quote=best_quote,
323
+ strategy_name=strategy_name,
324
+ )
325
+
326
+ async def swap_from_quote(
327
+ self,
328
+ from_token: dict[str, Any],
329
+ to_token: dict[str, Any],
330
+ from_address: str,
331
+ quote: dict[str, Any],
332
+ strategy_name: str | None = None,
333
+ ) -> tuple[bool, Any]:
334
+ """
335
+ Execute a swap using a previously retrieved BRAP quote.
336
+ """
337
+ chain = from_token.get("chain") or {}
338
+ chain_id = self._chain_id(chain)
339
+
340
+ transaction = dict(quote.get("calldata") or {})
341
+ if not transaction:
342
+ return (False, "Quote missing calldata")
343
+ transaction["chainId"] = chain_id
344
+ transaction["from"] = to_checksum_address(from_address)
345
+
346
+ spender = transaction.get("to")
347
+ approve_amount = (
348
+ quote.get("input_amount")
349
+ or quote.get("inputAmount")
350
+ or transaction.get("value")
351
+ )
352
+ if from_token.get("address") and spender and approve_amount:
353
+ approve_success, approve_response = await self._handle_token_approval(
354
+ chain=chain,
355
+ token_address=from_token.get("address"),
356
+ owner_address=from_address,
357
+ spender_address=spender,
358
+ amount=int(approve_amount),
359
+ )
360
+ if not approve_success:
361
+ return (False, approve_response)
362
+
363
+ if self.simulation:
364
+ simulation = await self._simulate_swap(
365
+ from_token, to_token, from_address, chain_id, quote
366
+ )
367
+ return (True, {"quote": quote, "simulation": simulation})
368
+
369
+ broadcast_success, broadcast_response = await self._broadcast_transaction(
370
+ transaction
371
+ )
372
+ if not broadcast_success:
373
+ return (False, broadcast_response)
374
+
375
+ ledger_record = await self._record_swap_operation(
376
+ from_token=from_token,
377
+ to_token=to_token,
378
+ wallet_address=from_address,
379
+ quote=quote,
380
+ broadcast_response=broadcast_response,
381
+ strategy_name=strategy_name,
382
+ )
383
+ return (True, ledger_record)
384
+
385
+ async def get_bridge_quote(
386
+ self,
387
+ from_token_address: str,
388
+ to_token_address: str,
389
+ from_chain_id: int,
390
+ to_chain_id: int,
391
+ amount: str,
392
+ slippage: float | None = None,
393
+ ) -> tuple[bool, Any]:
394
+ """
395
+ Get a quote for a bridge operation (same as swap for BRAP).
396
+
397
+ Args:
398
+ from_token_address: Source token contract address
399
+ to_token_address: Destination token contract address
400
+ from_chain_id: Source chain ID
401
+ to_chain_id: Destination chain ID
402
+ amount: Amount to bridge (in smallest units)
403
+ slippage: Maximum slippage tolerance (optional)
404
+
405
+ Returns:
406
+ Tuple of (success, data) where data is bridge quote or error message
407
+ """
408
+ # For BRAP, bridge operations are the same as swap operations
409
+ return await self.get_swap_quote(
410
+ from_token_address=from_token_address,
411
+ to_token_address=to_token_address,
412
+ from_chain_id=from_chain_id,
413
+ to_chain_id=to_chain_id,
414
+ from_address="0x0000000000000000000000000000000000000000", # Dummy address
415
+ to_address="0x0000000000000000000000000000000000000000", # Dummy address
416
+ amount=amount,
417
+ slippage=slippage,
418
+ )
419
+
420
+ async def estimate_gas_cost(
421
+ self, from_chain_id: int, to_chain_id: int, operation_type: str = "swap"
422
+ ) -> tuple[bool, Any]:
423
+ """
424
+ Estimate gas costs for a cross-chain operation.
425
+
426
+ Args:
427
+ from_chain_id: Source chain ID
428
+ to_chain_id: Destination chain ID
429
+ operation_type: Type of operation ("swap", "bridge")
430
+
431
+ Returns:
432
+ Tuple of (success, data) where data is gas cost estimate or error message
433
+ """
434
+ try:
435
+ # This is a simplified estimation - in practice, you'd want to
436
+ # query actual gas prices from the chains
437
+ gas_estimates = {
438
+ "ethereum": {"swap": 150000, "bridge": 200000},
439
+ "base": {"swap": 100000, "bridge": 150000},
440
+ "arbitrum": {"swap": 80000, "bridge": 120000},
441
+ "polygon": {"swap": 60000, "bridge": 100000},
442
+ }
443
+
444
+ # Map chain IDs to names (simplified)
445
+ chain_names = {
446
+ 1: "ethereum",
447
+ 8453: "base",
448
+ 42161: "arbitrum",
449
+ 137: "polygon",
450
+ }
451
+
452
+ from_chain = chain_names.get(from_chain_id, "unknown")
453
+ to_chain = chain_names.get(to_chain_id, "unknown")
454
+
455
+ from_gas = gas_estimates.get(from_chain, {}).get(operation_type, 100000)
456
+ to_gas = gas_estimates.get(to_chain, {}).get(operation_type, 100000)
457
+
458
+ return (
459
+ True,
460
+ {
461
+ "from_chain": from_chain,
462
+ "to_chain": to_chain,
463
+ "from_gas_estimate": from_gas,
464
+ "to_gas_estimate": to_gas,
465
+ "total_operations": 2 if from_chain_id != to_chain_id else 1,
466
+ "operation_type": operation_type,
467
+ },
468
+ )
469
+ except Exception as e:
470
+ self.logger.error(f"Error estimating gas cost: {e}")
471
+ return (False, str(e))
472
+
473
+ async def validate_swap_parameters(
474
+ self,
475
+ from_token_address: str,
476
+ to_token_address: str,
477
+ from_chain_id: int,
478
+ to_chain_id: int,
479
+ amount: str,
480
+ ) -> tuple[bool, Any]:
481
+ """
482
+ Validate swap parameters before executing.
483
+
484
+ Args:
485
+ from_token_address: Source token contract address
486
+ to_token_address: Destination token contract address
487
+ from_chain_id: Source chain ID
488
+ to_chain_id: Destination chain ID
489
+ amount: Amount to swap (in smallest units)
490
+
491
+ Returns:
492
+ Tuple of (success, data) where data is validation result or error message
493
+ """
494
+ try:
495
+ validation_errors = []
496
+
497
+ # Basic validation
498
+ if not from_token_address or len(from_token_address) != 42:
499
+ validation_errors.append("Invalid from_token_address")
500
+
501
+ if not to_token_address or len(to_token_address) != 42:
502
+ validation_errors.append("Invalid to_token_address")
503
+
504
+ if from_chain_id <= 0 or to_chain_id <= 0:
505
+ validation_errors.append("Invalid chain IDs")
506
+
507
+ try:
508
+ amount_int = int(amount)
509
+ if amount_int <= 0:
510
+ validation_errors.append("Amount must be positive")
511
+ except (ValueError, TypeError):
512
+ validation_errors.append("Invalid amount format")
513
+
514
+ if validation_errors:
515
+ return (False, {"valid": False, "errors": validation_errors})
516
+
517
+ # Try to get a quote to validate the swap is possible
518
+ success, quote_data = await self.get_swap_quote(
519
+ from_token_address=from_token_address,
520
+ to_token_address=to_token_address,
521
+ from_chain_id=from_chain_id,
522
+ to_chain_id=to_chain_id,
523
+ from_address="0x0000000000000000000000000000000000000000",
524
+ to_address="0x0000000000000000000000000000000000000000",
525
+ amount=amount,
526
+ )
527
+
528
+ if not success:
529
+ validation_errors.append(f"Swap not possible: {quote_data}")
530
+ return (False, {"valid": False, "errors": validation_errors})
531
+
532
+ return (
533
+ True,
534
+ {
535
+ "valid": True,
536
+ "quote_available": True,
537
+ "estimated_output": quote_data.get("quotes", {})
538
+ .get("best_quote", {})
539
+ .get("output_amount", "0"),
540
+ },
541
+ )
542
+ except Exception as e:
543
+ self.logger.error(f"Error validating swap parameters: {e}")
544
+ return (False, str(e))
545
+
546
+ async def _handle_token_approval(
547
+ self,
548
+ *,
549
+ chain: dict[str, Any],
550
+ token_address: str,
551
+ owner_address: str,
552
+ spender_address: str,
553
+ amount: int,
554
+ ) -> tuple[bool, Any]:
555
+ chain_id = self._chain_id(chain)
556
+ token_checksum = to_checksum_address(token_address)
557
+ owner_checksum = to_checksum_address(owner_address)
558
+ spender_checksum = to_checksum_address(spender_address)
559
+
560
+ if (chain_id, token_checksum.lower()) in _NEEDS_CLEAR_APPROVAL:
561
+ allowance = await self.token_transactions.read_erc20_allowance(
562
+ {"id": chain_id},
563
+ token_checksum,
564
+ owner_checksum,
565
+ spender_checksum,
566
+ )
567
+ if allowance.get("allowance", 0) > 0:
568
+ clear_success, clear_tx = self.token_transactions.build_erc20_approve(
569
+ chain_id=chain_id,
570
+ token_address=token_checksum,
571
+ from_address=owner_checksum,
572
+ spender=spender_checksum,
573
+ amount=0,
574
+ )
575
+ if not clear_success:
576
+ return False, clear_tx
577
+ clear_result = await self._broadcast_transaction(clear_tx)
578
+ if not clear_result[0]:
579
+ return clear_result
580
+
581
+ build_success, approve_tx = self.token_transactions.build_erc20_approve(
582
+ chain_id=chain_id,
583
+ token_address=token_checksum,
584
+ from_address=owner_checksum,
585
+ spender=spender_checksum,
586
+ amount=int(amount),
587
+ )
588
+ if not build_success:
589
+ return False, approve_tx
590
+ return await self._broadcast_transaction(approve_tx)
591
+
592
+ async def _broadcast_transaction(
593
+ self, transaction: dict[str, Any]
594
+ ) -> tuple[bool, Any]:
595
+ if getattr(settings, "DRY_RUN", False):
596
+ return True, {"dry_run": True, "transaction": transaction}
597
+ return await self.wallet_provider.broadcast_transaction(
598
+ transaction,
599
+ wait_for_receipt=True,
600
+ timeout=120,
601
+ )
602
+
603
+ async def _record_swap_operation(
604
+ self,
605
+ from_token: dict[str, Any],
606
+ to_token: dict[str, Any],
607
+ wallet_address: str,
608
+ quote: dict[str, Any],
609
+ broadcast_response: dict[str, Any] | Any,
610
+ strategy_name: str | None = None,
611
+ ) -> Any:
612
+ from_amount_usd = quote.get("from_amount_usd")
613
+ if from_amount_usd is None:
614
+ from_amount_usd = await self._token_amount_usd(
615
+ from_token, quote.get("input_amount")
616
+ )
617
+
618
+ to_amount_usd = quote.get("to_amount_usd")
619
+ if to_amount_usd is None:
620
+ to_amount_usd = await self._token_amount_usd(
621
+ to_token, quote.get("output_amount")
622
+ )
623
+
624
+ response = broadcast_response if isinstance(broadcast_response, dict) else {}
625
+ operation_payload = {
626
+ "type": "SWAP",
627
+ "from_token_id": from_token.get("id"),
628
+ "to_token_id": to_token.get("id"),
629
+ "from_amount": quote.get("input_amount"),
630
+ "to_amount": quote.get("output_amount"),
631
+ "from_amount_usd": from_amount_usd or 0,
632
+ "to_amount_usd": to_amount_usd or 0,
633
+ "transaction_hash": response.get("transaction_hash"),
634
+ "transaction_status": response.get("transaction_status"),
635
+ "transaction_receipt": response.get("transaction_receipt"),
636
+ }
637
+
638
+ try:
639
+ success, ledger_response = await self.ledger_adapter.record_operation(
640
+ wallet_address=wallet_address,
641
+ operation_data=operation_payload,
642
+ usd_value=from_amount_usd or 0,
643
+ strategy_name=strategy_name,
644
+ )
645
+ if success:
646
+ return ledger_response
647
+ self.logger.warning(
648
+ "Ledger swap record failed", error=ledger_response, quote=quote
649
+ )
650
+ except Exception as exc: # noqa: BLE001
651
+ self.logger.warning(f"Ledger swap record raised: {exc}", quote=quote)
652
+
653
+ return operation_payload
654
+
655
+ async def _token_amount_usd(
656
+ self, token_info: dict[str, Any], raw_amount: Any
657
+ ) -> float | None:
658
+ if raw_amount is None:
659
+ return None
660
+ success, price_data = await self.token_adapter.get_token_price(
661
+ token_info.get("id")
662
+ )
663
+ if not success or not price_data:
664
+ return None
665
+ decimals = token_info.get("decimals") or 18
666
+ return (
667
+ price_data.get("current_price", 0.0)
668
+ * float(raw_amount)
669
+ / 10 ** int(decimals)
670
+ )
671
+
672
+ async def _simulate_swap(
673
+ self,
674
+ from_token: dict[str, Any],
675
+ to_token: dict[str, Any],
676
+ from_address: str,
677
+ chain_id: int,
678
+ quote: dict[str, Any],
679
+ ) -> Any:
680
+ client = await self._get_simulation_client()
681
+ initial_balances = {"native": "5000000000000000000"}
682
+ if from_token.get("address"):
683
+ initial_balances[from_token.get("address")] = "1000000000000000000000000"
684
+
685
+ slippage = quote.get("slippage") or quote.get("slippage_percent")
686
+ if isinstance(slippage, str):
687
+ try:
688
+ slippage = float(slippage)
689
+ except ValueError:
690
+ slippage = DEFAULT_SLIPPAGE
691
+ slippage = slippage or DEFAULT_SLIPPAGE
692
+
693
+ amount = quote.get("input_amount") or quote.get("inputAmount") or "0"
694
+ return await client.simulate_swap(
695
+ from_token_address=from_token.get("address"),
696
+ to_token_address=to_token.get("address"),
697
+ from_chain_id=chain_id,
698
+ to_chain_id=chain_id,
699
+ amount=str(amount),
700
+ from_address=from_address,
701
+ slippage=float(slippage),
702
+ initial_balances=initial_balances,
703
+ )
704
+
705
+ async def _get_simulation_client(self) -> SimulationClient:
706
+ if not self.simulation_client:
707
+ self.simulation_client = SimulationClient()
708
+ return self.simulation_client
709
+
710
+ def _chain_id(self, chain: Any) -> int:
711
+ if isinstance(chain, dict):
712
+ chain_id = chain.get("id") or chain.get("chain_id")
713
+ else:
714
+ chain_id = getattr(chain, "id", None)
715
+ if chain_id is None:
716
+ raise ValueError("Chain ID is required")
717
+ return int(chain_id)