wayfinder-paths 0.1.9__py3-none-any.whl → 0.1.11__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 (54) hide show
  1. wayfinder_paths/adapters/balance_adapter/README.md +1 -2
  2. wayfinder_paths/adapters/balance_adapter/adapter.py +4 -4
  3. wayfinder_paths/adapters/brap_adapter/adapter.py +139 -23
  4. wayfinder_paths/adapters/moonwell_adapter/README.md +174 -0
  5. wayfinder_paths/adapters/moonwell_adapter/__init__.py +7 -0
  6. wayfinder_paths/adapters/moonwell_adapter/adapter.py +1226 -0
  7. wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +635 -0
  8. wayfinder_paths/core/clients/AuthClient.py +3 -0
  9. wayfinder_paths/core/clients/WayfinderClient.py +2 -2
  10. wayfinder_paths/core/constants/__init__.py +0 -2
  11. wayfinder_paths/core/constants/base.py +6 -2
  12. wayfinder_paths/core/constants/moonwell_abi.py +411 -0
  13. wayfinder_paths/core/engine/StrategyJob.py +3 -0
  14. wayfinder_paths/core/services/local_evm_txn.py +182 -217
  15. wayfinder_paths/core/services/local_token_txn.py +46 -26
  16. wayfinder_paths/core/strategies/descriptors.py +1 -1
  17. wayfinder_paths/core/utils/evm_helpers.py +0 -27
  18. wayfinder_paths/run_strategy.py +34 -74
  19. wayfinder_paths/scripts/create_strategy.py +2 -27
  20. wayfinder_paths/scripts/run_strategy.py +37 -7
  21. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +1 -1
  22. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/README.md +0 -15
  23. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +1 -1
  24. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/README.md +108 -0
  25. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/examples.json +11 -0
  26. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +2975 -0
  27. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +886 -0
  28. wayfinder_paths/strategies/stablecoin_yield_strategy/README.md +0 -7
  29. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +1 -1
  30. wayfinder_paths/templates/adapter/README.md +5 -21
  31. wayfinder_paths/templates/adapter/adapter.py +1 -2
  32. wayfinder_paths/templates/adapter/test_adapter.py +1 -1
  33. wayfinder_paths/templates/strategy/README.md +4 -21
  34. wayfinder_paths/tests/test_smoke_manifest.py +17 -2
  35. {wayfinder_paths-0.1.9.dist-info → wayfinder_paths-0.1.11.dist-info}/METADATA +60 -187
  36. {wayfinder_paths-0.1.9.dist-info → wayfinder_paths-0.1.11.dist-info}/RECORD +38 -45
  37. wayfinder_paths/CONFIG_GUIDE.md +0 -390
  38. wayfinder_paths/adapters/balance_adapter/manifest.yaml +0 -8
  39. wayfinder_paths/adapters/brap_adapter/manifest.yaml +0 -11
  40. wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +0 -10
  41. wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +0 -8
  42. wayfinder_paths/adapters/ledger_adapter/manifest.yaml +0 -11
  43. wayfinder_paths/adapters/pool_adapter/manifest.yaml +0 -10
  44. wayfinder_paths/adapters/token_adapter/manifest.yaml +0 -6
  45. wayfinder_paths/config.example.json +0 -22
  46. wayfinder_paths/core/engine/manifest.py +0 -97
  47. wayfinder_paths/scripts/validate_manifests.py +0 -213
  48. wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +0 -23
  49. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/manifest.yaml +0 -7
  50. wayfinder_paths/strategies/stablecoin_yield_strategy/manifest.yaml +0 -17
  51. wayfinder_paths/templates/adapter/manifest.yaml +0 -6
  52. wayfinder_paths/templates/strategy/manifest.yaml +0 -8
  53. {wayfinder_paths-0.1.9.dist-info → wayfinder_paths-0.1.11.dist-info}/LICENSE +0 -0
  54. {wayfinder_paths-0.1.9.dist-info → wayfinder_paths-0.1.11.dist-info}/WHEEL +0 -0
@@ -0,0 +1,1226 @@
1
+ """
2
+ Moonwell Adapter for lending, borrowing, and collateral management on Moonwell protocol.
3
+
4
+ This adapter provides functionality for interacting with Moonwell on Base chain,
5
+ including supplying/withdrawing collateral, borrowing/repaying, and claiming rewards.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import time
12
+ from typing import Any, Literal
13
+
14
+ from eth_utils import to_checksum_address
15
+
16
+ from wayfinder_paths.core.adapters.BaseAdapter import BaseAdapter
17
+ from wayfinder_paths.core.clients.TokenClient import TokenClient
18
+ from wayfinder_paths.core.constants.base import DEFAULT_TRANSACTION_TIMEOUT
19
+ from wayfinder_paths.core.constants.moonwell_abi import (
20
+ COMPTROLLER_ABI,
21
+ MTOKEN_ABI,
22
+ REWARD_DISTRIBUTOR_ABI,
23
+ WETH_ABI,
24
+ )
25
+ from wayfinder_paths.core.services.base import Web3Service
26
+ from wayfinder_paths.core.settings import settings
27
+
28
+ # Moonwell Base chain addresses
29
+ MOONWELL_DEFAULTS = {
30
+ # mToken addresses
31
+ "m_usdc": "0xEdc817A28E8B93B03976FBd4a3dDBc9f7D176c22",
32
+ "m_weth": "0x628ff693426583D9a7FB391E54366292F509D457",
33
+ "m_wsteth": "0x627Fe393Bc6EdDA28e99AE648fD6fF362514304b",
34
+ # Underlying token addresses
35
+ "usdc": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
36
+ "weth": "0x4200000000000000000000000000000000000006",
37
+ "wsteth": "0xc1CBa3fCea344f92D9239c08C0568f6F2F0ee452",
38
+ # Protocol addresses
39
+ "reward_distributor": "0xe9005b078701e2a0948d2eac43010d35870ad9d2",
40
+ "comptroller": "0xfbb21d0380bee3312b33c4353c8936a0f13ef26c",
41
+ # WELL token address on Base
42
+ "well_token": "0xA88594D404727625A9437C3f886C7643872296AE",
43
+ }
44
+
45
+ # Base chain ID
46
+ BASE_CHAIN_ID = 8453
47
+
48
+ # Mantissa for collateral factor calculations (1e18)
49
+ MANTISSA = 10**18
50
+
51
+ # Seconds per year for APY calculations
52
+ SECONDS_PER_YEAR = 365 * 24 * 60 * 60
53
+
54
+ # Collateral factor cache TTL (1 hour - rarely changes, governance controlled)
55
+ CF_CACHE_TTL = 3600
56
+
57
+ # Default retry settings for rate-limited RPCs
58
+ DEFAULT_MAX_RETRIES = 5
59
+ DEFAULT_BASE_DELAY = 3.0 # seconds
60
+
61
+
62
+ def _is_rate_limit_error(error: Exception | str) -> bool:
63
+ """Check if an error is a rate limit (429) error."""
64
+ error_str = str(error)
65
+ return "429" in error_str or "Too Many Requests" in error_str
66
+
67
+
68
+ async def _retry_with_backoff(
69
+ coro_factory,
70
+ max_retries: int = DEFAULT_MAX_RETRIES,
71
+ base_delay: float = DEFAULT_BASE_DELAY,
72
+ ):
73
+ """Retry an async operation with exponential backoff on rate limit errors.
74
+
75
+ Args:
76
+ coro_factory: A callable that returns a new coroutine each time.
77
+ max_retries: Maximum number of retry attempts.
78
+ base_delay: Base delay in seconds (doubles each retry).
79
+
80
+ Returns:
81
+ The result of the coroutine if successful.
82
+
83
+ Raises:
84
+ The last exception if all retries fail.
85
+ """
86
+ last_error = None
87
+ for attempt in range(max_retries):
88
+ try:
89
+ return await coro_factory()
90
+ except Exception as exc:
91
+ last_error = exc
92
+ if _is_rate_limit_error(exc) and attempt < max_retries - 1:
93
+ wait_time = base_delay * (2**attempt)
94
+ await asyncio.sleep(wait_time)
95
+ continue
96
+ raise
97
+ raise last_error
98
+
99
+
100
+ def _timestamp_rate_to_apy(rate: float) -> float:
101
+ """Convert a per-second rate to APY."""
102
+ return (1 + rate) ** SECONDS_PER_YEAR - 1
103
+
104
+
105
+ class MoonwellAdapter(BaseAdapter):
106
+ """Moonwell adapter for lending/borrowing operations on Base chain."""
107
+
108
+ adapter_type = "MOONWELL"
109
+
110
+ def __init__(
111
+ self,
112
+ config: dict[str, Any] | None = None,
113
+ web3_service: Web3Service | None = None,
114
+ token_client: TokenClient | None = None,
115
+ simulation: bool = False,
116
+ ) -> None:
117
+ super().__init__("moonwell_adapter", config)
118
+ cfg = config or {}
119
+ adapter_cfg = cfg.get("moonwell_adapter") or {}
120
+
121
+ self.web3 = web3_service
122
+ self.simulation = simulation
123
+ self.token_client = token_client
124
+ self.token_txn_service = (
125
+ web3_service.token_transactions if web3_service else None
126
+ )
127
+
128
+ self.strategy_wallet = cfg.get("strategy_wallet") or {}
129
+ self.chain_id = adapter_cfg.get("chain_id", BASE_CHAIN_ID)
130
+ self.chain_name = "base"
131
+
132
+ # Protocol addresses (with config overrides)
133
+ self.comptroller_address = self._checksum(
134
+ adapter_cfg.get("comptroller") or MOONWELL_DEFAULTS["comptroller"]
135
+ )
136
+ self.reward_distributor_address = self._checksum(
137
+ adapter_cfg.get("reward_distributor")
138
+ or MOONWELL_DEFAULTS["reward_distributor"]
139
+ )
140
+ self.well_token = self._checksum(
141
+ adapter_cfg.get("well_token") or MOONWELL_DEFAULTS["well_token"]
142
+ )
143
+
144
+ # Token addresses
145
+ self.m_usdc = self._checksum(
146
+ adapter_cfg.get("m_usdc") or MOONWELL_DEFAULTS["m_usdc"]
147
+ )
148
+ self.m_weth = self._checksum(
149
+ adapter_cfg.get("m_weth") or MOONWELL_DEFAULTS["m_weth"]
150
+ )
151
+ self.m_wsteth = self._checksum(
152
+ adapter_cfg.get("m_wsteth") or MOONWELL_DEFAULTS["m_wsteth"]
153
+ )
154
+ self.usdc = self._checksum(adapter_cfg.get("usdc") or MOONWELL_DEFAULTS["usdc"])
155
+ self.weth = self._checksum(adapter_cfg.get("weth") or MOONWELL_DEFAULTS["weth"])
156
+ self.wsteth = self._checksum(
157
+ adapter_cfg.get("wsteth") or MOONWELL_DEFAULTS["wsteth"]
158
+ )
159
+
160
+ # Collateral factor cache: mtoken -> (value, timestamp)
161
+ self._cf_cache: dict[str, tuple[float, float]] = {}
162
+
163
+ # ------------------------------------------------------------------ #
164
+ # Public API - Lending Operations #
165
+ # ------------------------------------------------------------------ #
166
+
167
+ async def lend(
168
+ self,
169
+ *,
170
+ mtoken: str,
171
+ underlying_token: str,
172
+ amount: int,
173
+ ) -> tuple[bool, Any]:
174
+ """Supply tokens to Moonwell by minting mTokens."""
175
+ strategy = self._strategy_address()
176
+ amount = int(amount)
177
+ if amount <= 0:
178
+ return False, "amount must be positive"
179
+
180
+ mtoken = self._checksum(mtoken)
181
+ underlying_token = self._checksum(underlying_token)
182
+
183
+ # Approve mToken to spend underlying tokens
184
+ approved = await self._ensure_allowance(
185
+ token_address=underlying_token,
186
+ owner=strategy,
187
+ spender=mtoken,
188
+ amount=amount,
189
+ )
190
+ if not approved[0]:
191
+ return approved
192
+
193
+ # Mint mTokens (supply underlying)
194
+ tx = await self._encode_call(
195
+ target=mtoken,
196
+ abi=MTOKEN_ABI,
197
+ fn_name="mint",
198
+ args=[amount],
199
+ from_address=strategy,
200
+ )
201
+ return await self._execute(tx)
202
+
203
+ async def unlend(
204
+ self,
205
+ *,
206
+ mtoken: str,
207
+ amount: int,
208
+ ) -> tuple[bool, Any]:
209
+ """Withdraw tokens from Moonwell by redeeming mTokens."""
210
+ strategy = self._strategy_address()
211
+ amount = int(amount)
212
+ if amount <= 0:
213
+ return False, "amount must be positive"
214
+
215
+ mtoken = self._checksum(mtoken)
216
+
217
+ # Redeem mTokens for underlying
218
+ tx = await self._encode_call(
219
+ target=mtoken,
220
+ abi=MTOKEN_ABI,
221
+ fn_name="redeem",
222
+ args=[amount],
223
+ from_address=strategy,
224
+ )
225
+ return await self._execute(tx)
226
+
227
+ # ------------------------------------------------------------------ #
228
+ # Public API - Borrowing Operations #
229
+ # ------------------------------------------------------------------ #
230
+
231
+ async def borrow(
232
+ self,
233
+ *,
234
+ mtoken: str,
235
+ amount: int,
236
+ ) -> tuple[bool, Any]:
237
+ """Borrow tokens from Moonwell.
238
+
239
+ Note: Moonwell/Compound borrow() returns an error code, not a boolean.
240
+ Even if the transaction succeeds (status=1), the borrow may have failed
241
+ if the return value is non-zero. We verify success by checking that
242
+ the borrow balance actually increased.
243
+ """
244
+ from loguru import logger
245
+
246
+ strategy = self._strategy_address()
247
+ amount = int(amount)
248
+ if amount <= 0:
249
+ return False, "amount must be positive"
250
+
251
+ mtoken = self._checksum(mtoken)
252
+
253
+ # Get borrow balance before the transaction for verification
254
+ borrow_before = 0
255
+ if self.web3:
256
+ try:
257
+ web3 = self.web3.get_web3(self.chain_id)
258
+ mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
259
+
260
+ borrow_before = await mtoken_contract.functions.borrowBalanceStored(
261
+ strategy
262
+ ).call()
263
+
264
+ # Simulate borrow to check for errors before submitting
265
+ try:
266
+ borrow_return = await mtoken_contract.functions.borrow(amount).call(
267
+ {"from": strategy}
268
+ )
269
+ if borrow_return != 0:
270
+ logger.warning(
271
+ f"Borrow simulation returned error code {borrow_return}. "
272
+ "Codes: 3=COMPTROLLER_REJECTION, 9=INVALID_ACCOUNT_PAIR, "
273
+ "14=INSUFFICIENT_LIQUIDITY"
274
+ )
275
+ except Exception as call_err:
276
+ logger.debug(f"Borrow simulation failed: {call_err}")
277
+
278
+ except Exception as e:
279
+ logger.warning(f"Failed to get pre-borrow balance: {e}")
280
+
281
+ tx = await self._encode_call(
282
+ target=mtoken,
283
+ abi=MTOKEN_ABI,
284
+ fn_name="borrow",
285
+ args=[amount],
286
+ from_address=strategy,
287
+ )
288
+ result = await self._execute(tx)
289
+
290
+ if not result[0]:
291
+ return result
292
+
293
+ # Verify the borrow actually succeeded by checking balance increased
294
+ if self.web3:
295
+ try:
296
+ web3 = self.web3.get_web3(self.chain_id)
297
+ mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
298
+ borrow_after = await mtoken_contract.functions.borrowBalanceStored(
299
+ strategy
300
+ ).call()
301
+
302
+ # Borrow balance should have increased by approximately the amount
303
+ # Allow for some interest accrual
304
+ expected_increase = amount * 0.99 # Allow 1% tolerance for interest
305
+ actual_increase = borrow_after - borrow_before
306
+
307
+ if actual_increase < expected_increase:
308
+ from loguru import logger
309
+
310
+ logger.error(
311
+ f"Borrow verification failed: balance only increased by "
312
+ f"{actual_increase} (expected ~{amount}). "
313
+ f"Moonwell likely returned an error code. "
314
+ f"Before: {borrow_before}, After: {borrow_after}"
315
+ )
316
+ return (
317
+ False,
318
+ f"Borrow failed: balance did not increase as expected. "
319
+ f"Before: {borrow_before}, After: {borrow_after}, Expected: +{amount}",
320
+ )
321
+ except Exception as e:
322
+ from loguru import logger
323
+
324
+ logger.warning(f"Could not verify borrow balance: {e}")
325
+ # Continue with the original result if verification fails
326
+
327
+ return result
328
+
329
+ async def repay(
330
+ self,
331
+ *,
332
+ mtoken: str,
333
+ underlying_token: str,
334
+ amount: int,
335
+ repay_full: bool = False,
336
+ ) -> tuple[bool, Any]:
337
+ """Repay borrowed tokens to Moonwell.
338
+
339
+ Args:
340
+ mtoken: The mToken address
341
+ underlying_token: The underlying token address (e.g., WETH)
342
+ amount: Amount to repay (used for approval if repay_full=True)
343
+ repay_full: If True, uses type(uint256).max to repay exact debt
344
+ """
345
+ strategy = self._strategy_address()
346
+ amount = int(amount)
347
+ if amount <= 0:
348
+ return False, "amount must be positive"
349
+
350
+ mtoken = self._checksum(mtoken)
351
+ underlying_token = self._checksum(underlying_token)
352
+
353
+ # Approve mToken to spend underlying tokens for repayment
354
+ # When repay_full=True, approve the amount we have, Moonwell will use only what's needed
355
+ approved = await self._ensure_allowance(
356
+ token_address=underlying_token,
357
+ owner=strategy,
358
+ spender=mtoken,
359
+ amount=amount,
360
+ )
361
+ if not approved[0]:
362
+ return approved
363
+
364
+ # Use max uint256 for full repayment to avoid balance calculation issues
365
+ repay_amount = self.MAX_UINT256 if repay_full else amount
366
+
367
+ tx = await self._encode_call(
368
+ target=mtoken,
369
+ abi=MTOKEN_ABI,
370
+ fn_name="repayBorrow",
371
+ args=[repay_amount],
372
+ from_address=strategy,
373
+ )
374
+ return await self._execute(tx)
375
+
376
+ # ------------------------------------------------------------------ #
377
+ # Public API - Collateral Management #
378
+ # ------------------------------------------------------------------ #
379
+
380
+ async def set_collateral(
381
+ self,
382
+ *,
383
+ mtoken: str,
384
+ ) -> tuple[bool, Any]:
385
+ """Enable a market as collateral (enter market).
386
+
387
+ Note: enterMarkets returns an array of error codes. We verify success
388
+ by checking if the account has actually entered the market.
389
+ """
390
+ strategy = self._strategy_address()
391
+ mtoken = self._checksum(mtoken)
392
+
393
+ tx = await self._encode_call(
394
+ target=self.comptroller_address,
395
+ abi=COMPTROLLER_ABI,
396
+ fn_name="enterMarkets",
397
+ args=[[mtoken]],
398
+ from_address=strategy,
399
+ )
400
+ result = await self._execute(tx)
401
+
402
+ if not result[0]:
403
+ return result
404
+
405
+ # Verify the market was actually entered
406
+ if self.web3:
407
+ try:
408
+ web3 = self.web3.get_web3(self.chain_id)
409
+ comptroller = web3.eth.contract(
410
+ address=self.comptroller_address, abi=COMPTROLLER_ABI
411
+ )
412
+ is_member = await comptroller.functions.checkMembership(
413
+ strategy, mtoken
414
+ ).call()
415
+
416
+ if not is_member:
417
+ from loguru import logger
418
+
419
+ logger.error(
420
+ f"set_collateral verification failed: account {strategy} "
421
+ f"is not a member of market {mtoken} after enterMarkets call"
422
+ )
423
+ return (
424
+ False,
425
+ f"enterMarkets succeeded but account is not a member of market {mtoken}",
426
+ )
427
+ except Exception as e:
428
+ from loguru import logger
429
+
430
+ logger.warning(f"Could not verify market membership: {e}")
431
+
432
+ return result
433
+
434
+ async def remove_collateral(
435
+ self,
436
+ *,
437
+ mtoken: str,
438
+ ) -> tuple[bool, Any]:
439
+ """Disable a market as collateral (exit market)."""
440
+ strategy = self._strategy_address()
441
+ mtoken = self._checksum(mtoken)
442
+
443
+ tx = await self._encode_call(
444
+ target=self.comptroller_address,
445
+ abi=COMPTROLLER_ABI,
446
+ fn_name="exitMarket",
447
+ args=[mtoken],
448
+ from_address=strategy,
449
+ )
450
+ return await self._execute(tx)
451
+
452
+ # ------------------------------------------------------------------ #
453
+ # Public API - Rewards #
454
+ # ------------------------------------------------------------------ #
455
+
456
+ async def claim_rewards(
457
+ self,
458
+ *,
459
+ min_rewards_usd: float = 0.0,
460
+ ) -> tuple[bool, dict[str, int] | str]:
461
+ """Claim WELL rewards from Moonwell. Skips if below min_rewards_usd threshold."""
462
+ strategy = self._strategy_address()
463
+
464
+ # Get outstanding rewards first
465
+ rewards = await self._get_outstanding_rewards(strategy)
466
+
467
+ # Skip if no rewards to claim
468
+ if not rewards:
469
+ return True, {}
470
+
471
+ # Check minimum threshold if token_client available
472
+ if min_rewards_usd > 0 and self.token_client:
473
+ total_usd = await self._calculate_rewards_usd(rewards)
474
+ if total_usd < min_rewards_usd:
475
+ return True, {} # Skip claiming, below threshold
476
+
477
+ # Claim via comptroller (like reference implementation)
478
+ tx = await self._encode_call(
479
+ target=self.comptroller_address,
480
+ abi=COMPTROLLER_ABI,
481
+ fn_name="claimReward",
482
+ args=[strategy],
483
+ from_address=strategy,
484
+ )
485
+ result = await self._execute(tx)
486
+ if not result[0]:
487
+ return result
488
+
489
+ return True, rewards
490
+
491
+ async def _get_outstanding_rewards(self, account: str) -> dict[str, int]:
492
+ """Get outstanding rewards for an account across all markets."""
493
+ if not self.web3:
494
+ return {}
495
+
496
+ try:
497
+ web3 = self.web3.get_web3(self.chain_id)
498
+ contract = web3.eth.contract(
499
+ address=self.reward_distributor_address, abi=REWARD_DISTRIBUTOR_ABI
500
+ )
501
+
502
+ # Call getOutstandingRewardsForUser(user)
503
+ all_rewards = await contract.functions.getOutstandingRewardsForUser(
504
+ account
505
+ ).call()
506
+
507
+ rewards: dict[str, int] = {}
508
+ for mtoken_data in all_rewards:
509
+ # mtoken_data is (mToken, [(rewardToken, totalReward, supplySide, borrowSide)])
510
+ if len(mtoken_data) >= 2:
511
+ token_rewards = mtoken_data[1] if len(mtoken_data) > 1 else []
512
+ for reward_info in token_rewards:
513
+ if len(reward_info) >= 2:
514
+ token_addr = reward_info[0]
515
+ total_reward = reward_info[1]
516
+ if total_reward > 0:
517
+ key = f"{self.chain_name}_{token_addr}"
518
+ rewards[key] = rewards.get(key, 0) + total_reward
519
+ return rewards
520
+ except Exception:
521
+ return {}
522
+
523
+ async def _calculate_rewards_usd(self, rewards: dict[str, int]) -> float:
524
+ """Calculate total USD value of rewards."""
525
+ if not self.token_client:
526
+ return 0.0
527
+
528
+ total_usd = 0.0
529
+ for token_key, amount in rewards.items():
530
+ try:
531
+ token_data = await self.token_client.get_token_details(token_key)
532
+ if token_data:
533
+ price = token_data.get("price_usd") or token_data.get("price", 0)
534
+ decimals = token_data.get("decimals", 18)
535
+ total_usd += (amount / (10**decimals)) * price
536
+ except Exception:
537
+ pass
538
+ return total_usd
539
+
540
+ # ------------------------------------------------------------------ #
541
+ # Public API - Position & Market Data #
542
+ # ------------------------------------------------------------------ #
543
+
544
+ async def get_pos(
545
+ self,
546
+ *,
547
+ mtoken: str,
548
+ account: str | None = None,
549
+ include_usd: bool = False,
550
+ max_retries: int = 3,
551
+ block_identifier: int | str | None = None,
552
+ ) -> tuple[bool, dict[str, Any] | str]:
553
+ """Get position data (balances, rewards) for an account in a market.
554
+
555
+ Args:
556
+ mtoken: The mToken address
557
+ account: Account to query (defaults to strategy wallet)
558
+ include_usd: Whether to include USD values
559
+ max_retries: Number of retry attempts
560
+ block_identifier: Block to query at. Can be:
561
+ - int: specific block number (for pinning to tx block)
562
+ - "safe": OP Stack safe block (data posted to L1)
563
+ - None/"latest": current head (default)
564
+
565
+ Includes retry logic with exponential backoff for rate-limited RPCs.
566
+ """
567
+ if not self.web3:
568
+ return False, "web3 service not configured"
569
+
570
+ mtoken = self._checksum(mtoken)
571
+ account = self._checksum(account) if account else self._strategy_address()
572
+ block_id = block_identifier if block_identifier is not None else "latest"
573
+
574
+ bal = exch = borrow = underlying = rewards = None
575
+ last_error = ""
576
+
577
+ for attempt in range(max_retries):
578
+ try:
579
+ web3 = self.web3.get_web3(self.chain_id)
580
+ mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
581
+ rewards_contract = web3.eth.contract(
582
+ address=self.reward_distributor_address, abi=REWARD_DISTRIBUTOR_ABI
583
+ )
584
+
585
+ # Fetch data sequentially to avoid overwhelming rate-limited public RPCs
586
+ # (parallel fetch would make 5 simultaneous calls per position)
587
+ bal = await mtoken_contract.functions.balanceOf(account).call(
588
+ block_identifier=block_id
589
+ )
590
+ exch = await mtoken_contract.functions.exchangeRateStored().call(
591
+ block_identifier=block_id
592
+ )
593
+ borrow = await mtoken_contract.functions.borrowBalanceStored(
594
+ account
595
+ ).call(block_identifier=block_id)
596
+ underlying = await mtoken_contract.functions.underlying().call(
597
+ block_identifier=block_id
598
+ )
599
+ rewards = await rewards_contract.functions.getOutstandingRewardsForUser(
600
+ mtoken, account
601
+ ).call(block_identifier=block_id)
602
+ break # Success, exit retry loop
603
+ except Exception as exc:
604
+ last_error = str(exc)
605
+ if "429" in last_error or "Too Many Requests" in last_error:
606
+ if attempt < max_retries - 1:
607
+ wait_time = 2 ** (attempt + 1) # 2, 4, 8 seconds
608
+ await asyncio.sleep(wait_time)
609
+ continue
610
+ return False, last_error
611
+ else:
612
+ # All retries exhausted
613
+ return False, last_error
614
+
615
+ try:
616
+ # Process rewards
617
+ reward_balances = self._process_rewards(rewards)
618
+
619
+ # Build balances dict
620
+ mtoken_key = f"{self.chain_name}_{mtoken}"
621
+ underlying_key = f"{self.chain_name}_{underlying}"
622
+
623
+ balances: dict[str, int] = {mtoken_key: bal}
624
+ balances.update(reward_balances)
625
+
626
+ if borrow > 0:
627
+ balances[underlying_key] = -borrow
628
+
629
+ result: dict[str, Any] = {
630
+ "balances": balances,
631
+ "mtoken_balance": bal,
632
+ "underlying_balance": (bal * exch) // MANTISSA,
633
+ "borrow_balance": borrow,
634
+ "exchange_rate": exch,
635
+ "underlying_token": underlying,
636
+ }
637
+
638
+ # Calculate USD values if requested and token_client available
639
+ if include_usd and self.token_client:
640
+ usd_balances = await self._calculate_usd_balances(
641
+ balances, underlying_key, exch
642
+ )
643
+ result["usd_balances"] = usd_balances
644
+
645
+ return True, result
646
+ except Exception as exc:
647
+ return False, str(exc)
648
+
649
+ def _process_rewards(self, rewards: list) -> dict[str, int]:
650
+ """Process rewards tuple into dict mapping token keys to amounts."""
651
+ result: dict[str, int] = {}
652
+ for reward_info in rewards:
653
+ if len(reward_info) >= 2:
654
+ token_addr = reward_info[0]
655
+ total_reward = reward_info[1]
656
+ if total_reward > 0:
657
+ key = f"{self.chain_name}_{token_addr}"
658
+ result[key] = total_reward
659
+ return result
660
+
661
+ async def _calculate_usd_balances(
662
+ self, balances: dict[str, int], underlying_key: str, _exchange_rate: int
663
+ ) -> dict[str, float | None]:
664
+ """Calculate USD values for balances."""
665
+ if not self.token_client:
666
+ return {}
667
+
668
+ # Fetch token data for all tokens
669
+ tokens = set(balances.keys()) | {underlying_key}
670
+ token_data: dict[str, dict | None] = {}
671
+ for token_key in tokens:
672
+ try:
673
+ token_data[token_key] = await self.token_client.get_token_details(
674
+ token_key
675
+ )
676
+ except Exception:
677
+ token_data[token_key] = None
678
+
679
+ # Calculate USD values
680
+ usd_balances: dict[str, float | None] = {}
681
+ for token_key, bal in balances.items():
682
+ data = token_data.get(token_key)
683
+ if data:
684
+ price = data.get("price_usd") or data.get("price")
685
+ if price is not None:
686
+ decimals = data.get("decimals", 18)
687
+ usd_balances[token_key] = (bal / (10**decimals)) * price
688
+ else:
689
+ usd_balances[token_key] = None
690
+ else:
691
+ usd_balances[token_key] = None
692
+
693
+ return usd_balances
694
+
695
+ async def get_collateral_factor(
696
+ self,
697
+ *,
698
+ mtoken: str,
699
+ max_retries: int = DEFAULT_MAX_RETRIES,
700
+ ) -> tuple[bool, float | str]:
701
+ """Get the collateral factor for a market as decimal (e.g., 0.75 for 75%).
702
+
703
+ Uses a 1-hour cache since collateral factors rarely change (governance controlled).
704
+ Includes retry logic with exponential backoff for rate-limited RPCs.
705
+ """
706
+ if not self.web3:
707
+ return False, "web3 service not configured"
708
+
709
+ mtoken = self._checksum(mtoken)
710
+
711
+ # Check cache first
712
+ now = time.time()
713
+ if mtoken in self._cf_cache:
714
+ cached_value, cached_time = self._cf_cache[mtoken]
715
+ if now - cached_time < CF_CACHE_TTL:
716
+ return True, cached_value
717
+
718
+ last_error = ""
719
+ for attempt in range(max_retries):
720
+ try:
721
+ web3 = self.web3.get_web3(self.chain_id)
722
+ contract = web3.eth.contract(
723
+ address=self.comptroller_address, abi=COMPTROLLER_ABI
724
+ )
725
+
726
+ # markets() returns (isListed, collateralFactorMantissa)
727
+ result = await contract.functions.markets(mtoken).call()
728
+ is_listed, collateral_factor_mantissa = result
729
+
730
+ if not is_listed:
731
+ return False, f"Market {mtoken} is not listed"
732
+
733
+ # Convert from mantissa to decimal
734
+ collateral_factor = collateral_factor_mantissa / MANTISSA
735
+
736
+ # Cache the result
737
+ self._cf_cache[mtoken] = (collateral_factor, now)
738
+
739
+ return True, collateral_factor
740
+ except Exception as exc:
741
+ last_error = str(exc)
742
+ if _is_rate_limit_error(exc) and attempt < max_retries - 1:
743
+ wait_time = DEFAULT_BASE_DELAY * (2**attempt)
744
+ await asyncio.sleep(wait_time)
745
+ continue
746
+ return False, last_error
747
+
748
+ return False, last_error
749
+
750
+ async def get_apy(
751
+ self,
752
+ *,
753
+ mtoken: str,
754
+ apy_type: Literal["supply", "borrow"] = "supply",
755
+ include_rewards: bool = True,
756
+ max_retries: int = DEFAULT_MAX_RETRIES,
757
+ ) -> tuple[bool, float | str]:
758
+ """Get supply or borrow APY for a market, optionally including WELL rewards.
759
+
760
+ Includes retry logic with exponential backoff for rate-limited RPCs.
761
+ """
762
+ if not self.web3:
763
+ return False, "web3 service not configured"
764
+
765
+ mtoken = self._checksum(mtoken)
766
+
767
+ last_error = ""
768
+ for attempt in range(max_retries):
769
+ try:
770
+ web3 = self.web3.get_web3(self.chain_id)
771
+ mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
772
+ reward_distributor = web3.eth.contract(
773
+ address=self.reward_distributor_address, abi=REWARD_DISTRIBUTOR_ABI
774
+ )
775
+
776
+ # Get base rate (sequential to avoid rate limits)
777
+ if apy_type == "supply":
778
+ rate_per_timestamp = (
779
+ await mtoken_contract.functions.supplyRatePerTimestamp().call()
780
+ )
781
+ mkt_config = await reward_distributor.functions.getAllMarketConfigs(
782
+ mtoken
783
+ ).call()
784
+ total_value = await mtoken_contract.functions.totalSupply().call()
785
+ else:
786
+ rate_per_timestamp = (
787
+ await mtoken_contract.functions.borrowRatePerTimestamp().call()
788
+ )
789
+ mkt_config = await reward_distributor.functions.getAllMarketConfigs(
790
+ mtoken
791
+ ).call()
792
+ total_value = await mtoken_contract.functions.totalBorrows().call()
793
+
794
+ # Convert rate per second to APY
795
+ rate = rate_per_timestamp / MANTISSA
796
+ apy = _timestamp_rate_to_apy(rate)
797
+
798
+ # Add WELL rewards APY if requested and token_client available
799
+ if include_rewards and self.token_client and total_value > 0:
800
+ rewards_apr = await self._calculate_rewards_apr(
801
+ mtoken, mkt_config, total_value, apy_type
802
+ )
803
+ apy += rewards_apr
804
+
805
+ return True, apy
806
+ except Exception as exc:
807
+ last_error = str(exc)
808
+ if _is_rate_limit_error(exc) and attempt < max_retries - 1:
809
+ wait_time = DEFAULT_BASE_DELAY * (2**attempt)
810
+ await asyncio.sleep(wait_time)
811
+ continue
812
+ return False, last_error
813
+
814
+ return False, last_error
815
+
816
+ async def _calculate_rewards_apr(
817
+ self,
818
+ mtoken: str,
819
+ mkt_config: list,
820
+ total_value: int,
821
+ apy_type: str,
822
+ ) -> float:
823
+ """Calculate WELL rewards APR for a market."""
824
+ if not self.token_client:
825
+ return 0.0
826
+
827
+ try:
828
+ # Find WELL token config
829
+ well_config = None
830
+ for config in mkt_config:
831
+ if len(config) >= 6 and config[1].lower() == self.well_token.lower():
832
+ well_config = config
833
+ break
834
+
835
+ if not well_config:
836
+ return 0.0
837
+
838
+ # Get emission rate (supply or borrow)
839
+ # Config format: (mToken, rewardToken, owner, emissionCap, supplyEmissionsPerSec, borrowEmissionsPerSec, ...)
840
+ if apy_type == "supply":
841
+ well_rate = well_config[4] # supplyEmissionsPerSec
842
+ else:
843
+ well_rate = well_config[5] # borrowEmissionsPerSec
844
+ # Borrow rewards are shown as negative in some implementations
845
+ if well_rate < 0:
846
+ well_rate = -well_rate
847
+
848
+ if well_rate == 0:
849
+ return 0.0
850
+
851
+ # Get underlying token for decimals
852
+ web3 = self.web3.get_web3(self.chain_id)
853
+ mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
854
+ underlying_addr = await mtoken_contract.functions.underlying().call()
855
+
856
+ # Get prices
857
+ well_key = f"{self.chain_name}_{self.well_token}"
858
+ underlying_key = f"{self.chain_name}_{underlying_addr}"
859
+
860
+ well_data, underlying_data = await asyncio.gather(
861
+ self.token_client.get_token_details(well_key),
862
+ self.token_client.get_token_details(underlying_key),
863
+ )
864
+
865
+ well_price = (
866
+ well_data.get("price_usd") or well_data.get("price", 0)
867
+ if well_data
868
+ else 0
869
+ )
870
+ underlying_price = (
871
+ underlying_data.get("price_usd") or underlying_data.get("price", 0)
872
+ if underlying_data
873
+ else 0
874
+ )
875
+ underlying_decimals = (
876
+ underlying_data.get("decimals", 18) if underlying_data else 18
877
+ )
878
+
879
+ if not well_price or not underlying_price:
880
+ return 0.0
881
+
882
+ # Calculate total value in USD
883
+ total_value_usd = (
884
+ total_value / (10**underlying_decimals)
885
+ ) * underlying_price
886
+
887
+ if total_value_usd == 0:
888
+ return 0.0
889
+
890
+ # Calculate rewards APR
891
+ # rewards_apr = well_price * emissions_per_second * seconds_per_year / total_value_usd
892
+ rewards_apr = (
893
+ well_price * (well_rate / MANTISSA) * SECONDS_PER_YEAR / total_value_usd
894
+ )
895
+
896
+ return rewards_apr
897
+ except Exception:
898
+ return 0.0
899
+
900
+ async def get_borrowable_amount(
901
+ self,
902
+ *,
903
+ account: str | None = None,
904
+ max_retries: int = DEFAULT_MAX_RETRIES,
905
+ ) -> tuple[bool, int | str]:
906
+ """Get the maximum borrowable amount for an account (USD with 18 decimals).
907
+
908
+ Includes retry logic with exponential backoff for rate-limited RPCs.
909
+ """
910
+ if not self.web3:
911
+ return False, "web3 service not configured"
912
+
913
+ account = self._checksum(account) if account else self._strategy_address()
914
+
915
+ last_error = ""
916
+ for attempt in range(max_retries):
917
+ try:
918
+ web3 = self.web3.get_web3(self.chain_id)
919
+ contract = web3.eth.contract(
920
+ address=self.comptroller_address, abi=COMPTROLLER_ABI
921
+ )
922
+
923
+ # getAccountLiquidity returns (error, liquidity, shortfall)
924
+ (
925
+ error,
926
+ liquidity,
927
+ shortfall,
928
+ ) = await contract.functions.getAccountLiquidity(account).call()
929
+
930
+ if error != 0:
931
+ return False, f"Comptroller error: {error}"
932
+
933
+ if shortfall > 0:
934
+ return False, f"Account has shortfall: {shortfall}"
935
+
936
+ return True, liquidity
937
+ except Exception as exc:
938
+ last_error = str(exc)
939
+ if _is_rate_limit_error(exc) and attempt < max_retries - 1:
940
+ wait_time = DEFAULT_BASE_DELAY * (2**attempt)
941
+ await asyncio.sleep(wait_time)
942
+ continue
943
+ return False, last_error
944
+
945
+ return False, last_error
946
+
947
+ async def max_withdrawable_mtoken(
948
+ self,
949
+ *,
950
+ mtoken: str,
951
+ account: str | None = None,
952
+ ) -> tuple[bool, dict[str, Any] | str]:
953
+ """Calculate max mTokens withdrawable without liquidation using binary search."""
954
+ if not self.web3:
955
+ return False, "web3 service not configured"
956
+
957
+ mtoken = self._checksum(mtoken)
958
+ account = self._checksum(account) if account else self._strategy_address()
959
+
960
+ try:
961
+ web3 = self.web3.get_web3(self.chain_id)
962
+ comptroller = web3.eth.contract(
963
+ address=self.comptroller_address, abi=COMPTROLLER_ABI
964
+ )
965
+ mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
966
+
967
+ # Get all needed data in parallel
968
+ bal_raw, exch_raw, cash_raw, m_dec, u_addr = await asyncio.gather(
969
+ mtoken_contract.functions.balanceOf(account).call(),
970
+ mtoken_contract.functions.exchangeRateStored().call(),
971
+ mtoken_contract.functions.getCash().call(),
972
+ mtoken_contract.functions.decimals().call(),
973
+ mtoken_contract.functions.underlying().call(),
974
+ )
975
+
976
+ if bal_raw == 0 or exch_raw == 0:
977
+ return True, {
978
+ "cTokens_raw": 0,
979
+ "cTokens": 0.0,
980
+ "underlying_raw": 0,
981
+ "underlying": 0.0,
982
+ "bounds_raw": {"collateral_cTokens": 0, "cash_cTokens": 0},
983
+ "exchangeRate_raw": int(exch_raw),
984
+ "mToken_decimals": int(m_dec),
985
+ "underlying_decimals": None,
986
+ }
987
+
988
+ # Get underlying decimals
989
+ u_dec = 18 # Default
990
+ if self.token_client:
991
+ try:
992
+ u_key = f"{self.chain_name}_{u_addr}"
993
+ u_data = await self.token_client.get_token_details(u_key)
994
+ if u_data:
995
+ u_dec = u_data.get("decimals", 18)
996
+ except Exception:
997
+ pass
998
+
999
+ # Binary search: largest cTokens you can redeem without shortfall
1000
+ lo, hi = 0, int(bal_raw)
1001
+ while lo < hi:
1002
+ mid = (lo + hi + 1) // 2
1003
+ (
1004
+ err,
1005
+ _liq,
1006
+ short,
1007
+ ) = await comptroller.functions.getHypotheticalAccountLiquidity(
1008
+ account, mtoken, mid, 0
1009
+ ).call()
1010
+ if err != 0:
1011
+ return False, f"Comptroller error {err}"
1012
+ if short == 0:
1013
+ lo = mid # Safe, try more
1014
+ else:
1015
+ hi = mid - 1
1016
+
1017
+ c_by_collateral = lo
1018
+
1019
+ # Pool cash bound (convert underlying cash -> cToken capacity)
1020
+ c_by_cash = (int(cash_raw) * MANTISSA) // int(exch_raw)
1021
+
1022
+ redeem_c_raw = min(c_by_collateral, int(c_by_cash))
1023
+
1024
+ # Final underlying you actually receive (mirror Solidity floor)
1025
+ under_raw = (redeem_c_raw * int(exch_raw)) // MANTISSA
1026
+
1027
+ return True, {
1028
+ "cTokens_raw": int(redeem_c_raw),
1029
+ "cTokens": redeem_c_raw / (10 ** int(m_dec)),
1030
+ "underlying_raw": int(under_raw),
1031
+ "underlying": under_raw / (10 ** int(u_dec)),
1032
+ "bounds_raw": {
1033
+ "collateral_cTokens": int(c_by_collateral),
1034
+ "cash_cTokens": int(c_by_cash),
1035
+ },
1036
+ "exchangeRate_raw": int(exch_raw),
1037
+ "mToken_decimals": int(m_dec),
1038
+ "underlying_decimals": int(u_dec),
1039
+ "conversion_factor": redeem_c_raw / under_raw if under_raw > 0 else 0,
1040
+ }
1041
+ except Exception as exc:
1042
+ return False, str(exc)
1043
+
1044
+ # ------------------------------------------------------------------ #
1045
+ # Public API - ETH Wrapping #
1046
+ # ------------------------------------------------------------------ #
1047
+
1048
+ async def wrap_eth(
1049
+ self,
1050
+ *,
1051
+ amount: int,
1052
+ ) -> tuple[bool, Any]:
1053
+ """Wrap ETH to WETH."""
1054
+ strategy = self._strategy_address()
1055
+ amount = int(amount)
1056
+ if amount <= 0:
1057
+ return False, "amount must be positive"
1058
+
1059
+ tx = await self._encode_call(
1060
+ target=self.weth,
1061
+ abi=WETH_ABI,
1062
+ fn_name="deposit",
1063
+ args=[],
1064
+ from_address=strategy,
1065
+ value=amount,
1066
+ )
1067
+ return await self._execute(tx)
1068
+
1069
+ # ------------------------------------------------------------------ #
1070
+ # Helpers #
1071
+ # ------------------------------------------------------------------ #
1072
+
1073
+ # Max uint256 for unlimited approvals
1074
+ MAX_UINT256 = 2**256 - 1
1075
+
1076
+ async def _ensure_allowance(
1077
+ self,
1078
+ *,
1079
+ token_address: str,
1080
+ owner: str,
1081
+ spender: str,
1082
+ amount: int,
1083
+ ) -> tuple[bool, Any]:
1084
+ """Ensure token allowance is sufficient, approving if needed.
1085
+
1086
+ Approves for max uint256 to avoid precision issues with exact amounts.
1087
+ """
1088
+ if not self.token_txn_service:
1089
+ return False, "token_txn_service not configured"
1090
+
1091
+ chain = {"id": self.chain_id}
1092
+ allowance = await self.token_txn_service.read_erc20_allowance(
1093
+ chain, token_address, owner, spender
1094
+ )
1095
+ if allowance.get("allowance", 0) >= amount:
1096
+ return True, {}
1097
+
1098
+ # Approve for max uint256 to avoid precision/timing issues
1099
+ build_success, approve_tx = self.token_txn_service.build_erc20_approve(
1100
+ chain_id=self.chain_id,
1101
+ token_address=token_address,
1102
+ from_address=owner,
1103
+ spender=spender,
1104
+ amount=self.MAX_UINT256,
1105
+ )
1106
+ if not build_success:
1107
+ return False, approve_tx
1108
+
1109
+ result = await self._broadcast_transaction(approve_tx)
1110
+
1111
+ # Small delay after approval to ensure state is propagated
1112
+ if result[0]:
1113
+ await asyncio.sleep(1.0)
1114
+
1115
+ return result
1116
+
1117
+ async def _execute(
1118
+ self, tx: dict[str, Any], max_retries: int = DEFAULT_MAX_RETRIES
1119
+ ) -> tuple[bool, Any]:
1120
+ """Execute a transaction (or return simulation data).
1121
+
1122
+ Includes retry logic with exponential backoff for rate-limited RPCs.
1123
+ """
1124
+ if self.simulation:
1125
+ return True, {"simulation": tx}
1126
+ if not self.web3:
1127
+ return False, "web3 service not configured"
1128
+
1129
+ last_error = None
1130
+ for attempt in range(max_retries):
1131
+ try:
1132
+ return await self.web3.broadcast_transaction(
1133
+ tx, wait_for_receipt=True, timeout=DEFAULT_TRANSACTION_TIMEOUT
1134
+ )
1135
+ except Exception as exc:
1136
+ last_error = exc
1137
+ if _is_rate_limit_error(exc) and attempt < max_retries - 1:
1138
+ wait_time = DEFAULT_BASE_DELAY * (2**attempt)
1139
+ await asyncio.sleep(wait_time)
1140
+ continue
1141
+ return False, str(exc)
1142
+
1143
+ return False, str(last_error) if last_error else "Max retries exceeded"
1144
+
1145
+ async def _broadcast_transaction(
1146
+ self, tx: dict[str, Any], max_retries: int = DEFAULT_MAX_RETRIES
1147
+ ) -> tuple[bool, Any]:
1148
+ """Broadcast a pre-built transaction.
1149
+
1150
+ Includes retry logic with exponential backoff for rate-limited RPCs.
1151
+ """
1152
+ if getattr(settings, "DRY_RUN", False):
1153
+ return True, {"dry_run": True, "transaction": tx}
1154
+ if not self.web3:
1155
+ return False, "web3 service not configured"
1156
+
1157
+ last_error = None
1158
+ for attempt in range(max_retries):
1159
+ try:
1160
+ return await self.web3.evm_transactions.broadcast_transaction(
1161
+ tx, wait_for_receipt=True, timeout=DEFAULT_TRANSACTION_TIMEOUT
1162
+ )
1163
+ except Exception as exc:
1164
+ last_error = exc
1165
+ if _is_rate_limit_error(exc) and attempt < max_retries - 1:
1166
+ wait_time = DEFAULT_BASE_DELAY * (2**attempt)
1167
+ await asyncio.sleep(wait_time)
1168
+ continue
1169
+ return False, str(exc)
1170
+
1171
+ return False, str(last_error) if last_error else "Max retries exceeded"
1172
+
1173
+ async def _encode_call(
1174
+ self,
1175
+ *,
1176
+ target: str,
1177
+ abi: list[dict[str, Any]],
1178
+ fn_name: str,
1179
+ args: list[Any],
1180
+ from_address: str,
1181
+ value: int = 0,
1182
+ ) -> dict[str, Any]:
1183
+ """Encode a contract call without touching the network."""
1184
+ if not self.web3:
1185
+ raise ValueError("web3 service not configured")
1186
+
1187
+ web3 = self.web3.get_web3(self.chain_id)
1188
+ contract = web3.eth.contract(address=target, abi=abi)
1189
+
1190
+ try:
1191
+ tx_data = await getattr(contract.functions, fn_name)(
1192
+ *args
1193
+ ).build_transaction({"from": from_address})
1194
+ data = tx_data["data"]
1195
+ except ValueError as exc:
1196
+ raise ValueError(f"Failed to encode {fn_name}: {exc}") from exc
1197
+
1198
+ tx: dict[str, Any] = {
1199
+ "chainId": int(self.chain_id),
1200
+ "from": to_checksum_address(from_address),
1201
+ "to": to_checksum_address(target),
1202
+ "data": data,
1203
+ "value": int(value),
1204
+ }
1205
+ return tx
1206
+
1207
+ def _strategy_address(self) -> str:
1208
+ """Get the strategy wallet address."""
1209
+ addr = None
1210
+ if isinstance(self.strategy_wallet, dict):
1211
+ addr = self.strategy_wallet.get("address") or (
1212
+ (self.strategy_wallet.get("evm") or {}).get("address")
1213
+ )
1214
+ elif isinstance(self.strategy_wallet, str):
1215
+ addr = self.strategy_wallet
1216
+ if not addr:
1217
+ raise ValueError(
1218
+ "strategy_wallet address is required for Moonwell operations"
1219
+ )
1220
+ return to_checksum_address(addr)
1221
+
1222
+ def _checksum(self, address: str | None) -> str:
1223
+ """Convert address to checksum format."""
1224
+ if not address:
1225
+ raise ValueError("Missing required contract address in Moonwell config")
1226
+ return to_checksum_address(address)