wayfinder-paths 0.1.14__py3-none-any.whl → 0.1.16__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.
Files changed (58) hide show
  1. wayfinder_paths/adapters/balance_adapter/README.md +19 -20
  2. wayfinder_paths/adapters/balance_adapter/adapter.py +91 -22
  3. wayfinder_paths/adapters/balance_adapter/test_adapter.py +5 -11
  4. wayfinder_paths/adapters/brap_adapter/README.md +22 -19
  5. wayfinder_paths/adapters/brap_adapter/adapter.py +95 -45
  6. wayfinder_paths/adapters/brap_adapter/test_adapter.py +8 -24
  7. wayfinder_paths/adapters/hyperlend_adapter/adapter.py +40 -42
  8. wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +8 -15
  9. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +6 -6
  10. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +12 -12
  11. wayfinder_paths/adapters/ledger_adapter/test_adapter.py +6 -6
  12. wayfinder_paths/adapters/moonwell_adapter/README.md +29 -31
  13. wayfinder_paths/adapters/moonwell_adapter/adapter.py +326 -364
  14. wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +285 -189
  15. wayfinder_paths/adapters/pool_adapter/test_adapter.py +2 -2
  16. wayfinder_paths/adapters/token_adapter/test_adapter.py +4 -4
  17. wayfinder_paths/core/config.py +8 -47
  18. wayfinder_paths/core/constants/base.py +0 -1
  19. wayfinder_paths/core/constants/erc20_abi.py +13 -24
  20. wayfinder_paths/core/engine/StrategyJob.py +3 -1
  21. wayfinder_paths/core/services/test_local_evm_txn.py +145 -0
  22. wayfinder_paths/core/strategies/Strategy.py +22 -4
  23. wayfinder_paths/core/utils/erc20_service.py +100 -0
  24. wayfinder_paths/core/utils/evm_helpers.py +1 -8
  25. wayfinder_paths/core/utils/transaction.py +191 -0
  26. wayfinder_paths/core/utils/web3.py +66 -0
  27. wayfinder_paths/policies/erc20.py +1 -1
  28. wayfinder_paths/run_strategy.py +42 -6
  29. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +263 -220
  30. wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +132 -155
  31. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/README.md +0 -1
  32. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +123 -80
  33. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +0 -12
  34. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/README.md +6 -6
  35. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +2270 -1328
  36. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +282 -121
  37. wayfinder_paths/strategies/stablecoin_yield_strategy/README.md +0 -1
  38. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +107 -85
  39. wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +0 -8
  40. wayfinder_paths/templates/adapter/README.md +1 -1
  41. wayfinder_paths/templates/strategy/README.md +1 -5
  42. {wayfinder_paths-0.1.14.dist-info → wayfinder_paths-0.1.16.dist-info}/METADATA +3 -41
  43. {wayfinder_paths-0.1.14.dist-info → wayfinder_paths-0.1.16.dist-info}/RECORD +45 -54
  44. {wayfinder_paths-0.1.14.dist-info → wayfinder_paths-0.1.16.dist-info}/WHEEL +1 -1
  45. wayfinder_paths/abis/generic/erc20.json +0 -383
  46. wayfinder_paths/core/clients/sdk_example.py +0 -125
  47. wayfinder_paths/core/engine/__init__.py +0 -5
  48. wayfinder_paths/core/services/__init__.py +0 -0
  49. wayfinder_paths/core/services/base.py +0 -130
  50. wayfinder_paths/core/services/local_evm_txn.py +0 -334
  51. wayfinder_paths/core/services/local_token_txn.py +0 -242
  52. wayfinder_paths/core/services/web3_service.py +0 -43
  53. wayfinder_paths/core/wallets/README.md +0 -88
  54. wayfinder_paths/core/wallets/WalletManager.py +0 -56
  55. wayfinder_paths/core/wallets/__init__.py +0 -7
  56. wayfinder_paths/scripts/run_strategy.py +0 -152
  57. wayfinder_paths/strategies/config.py +0 -85
  58. {wayfinder_paths-0.1.14.dist-info → wayfinder_paths-0.1.16.dist-info}/LICENSE +0 -0
@@ -15,14 +15,18 @@ from eth_utils import to_checksum_address
15
15
 
16
16
  from wayfinder_paths.core.adapters.BaseAdapter import BaseAdapter
17
17
  from wayfinder_paths.core.clients.TokenClient import TokenClient
18
- from wayfinder_paths.core.constants.base import DEFAULT_TRANSACTION_TIMEOUT
19
18
  from wayfinder_paths.core.constants.moonwell_abi import (
20
19
  COMPTROLLER_ABI,
21
20
  MTOKEN_ABI,
22
21
  REWARD_DISTRIBUTOR_ABI,
23
22
  WETH_ABI,
24
23
  )
25
- from wayfinder_paths.core.services.base import Web3Service
24
+ from wayfinder_paths.core.utils.erc20_service import (
25
+ build_approve_transaction,
26
+ get_token_allowance,
27
+ )
28
+ from wayfinder_paths.core.utils.transaction import send_transaction
29
+ from wayfinder_paths.core.utils.web3 import web3_from_chain_id
26
30
 
27
31
  # Moonwell Base chain addresses
28
32
  MOONWELL_DEFAULTS = {
@@ -64,38 +68,6 @@ def _is_rate_limit_error(error: Exception | str) -> bool:
64
68
  return "429" in error_str or "Too Many Requests" in error_str
65
69
 
66
70
 
67
- async def _retry_with_backoff(
68
- coro_factory,
69
- max_retries: int = DEFAULT_MAX_RETRIES,
70
- base_delay: float = DEFAULT_BASE_DELAY,
71
- ):
72
- """Retry an async operation with exponential backoff on rate limit errors.
73
-
74
- Args:
75
- coro_factory: A callable that returns a new coroutine each time.
76
- max_retries: Maximum number of retry attempts.
77
- base_delay: Base delay in seconds (doubles each retry).
78
-
79
- Returns:
80
- The result of the coroutine if successful.
81
-
82
- Raises:
83
- The last exception if all retries fail.
84
- """
85
- last_error = None
86
- for attempt in range(max_retries):
87
- try:
88
- return await coro_factory()
89
- except Exception as exc:
90
- last_error = exc
91
- if _is_rate_limit_error(exc) and attempt < max_retries - 1:
92
- wait_time = base_delay * (2**attempt)
93
- await asyncio.sleep(wait_time)
94
- continue
95
- raise
96
- raise last_error
97
-
98
-
99
71
  def _timestamp_rate_to_apy(rate: float) -> float:
100
72
  """Convert a per-second rate to APY."""
101
73
  return (1 + rate) ** SECONDS_PER_YEAR - 1
@@ -109,20 +81,17 @@ class MoonwellAdapter(BaseAdapter):
109
81
  def __init__(
110
82
  self,
111
83
  config: dict[str, Any] | None = None,
112
- web3_service: Web3Service | None = None,
113
84
  token_client: TokenClient | None = None,
114
85
  simulation: bool = False,
86
+ strategy_wallet_signing_callback=None,
115
87
  ) -> None:
116
88
  super().__init__("moonwell_adapter", config)
117
89
  cfg = config or {}
118
90
  adapter_cfg = cfg.get("moonwell_adapter") or {}
119
91
 
120
- self.web3 = web3_service
121
92
  self.simulation = simulation
122
93
  self.token_client = token_client
123
- self.token_txn_service = (
124
- web3_service.token_transactions if web3_service else None
125
- )
94
+ self.strategy_wallet_signing_callback = strategy_wallet_signing_callback
126
95
 
127
96
  self.strategy_wallet = cfg.get("strategy_wallet") or {}
128
97
  self.chain_id = adapter_cfg.get("chain_id", BASE_CHAIN_ID)
@@ -197,7 +166,7 @@ class MoonwellAdapter(BaseAdapter):
197
166
  args=[amount],
198
167
  from_address=strategy,
199
168
  )
200
- return await self._execute(tx)
169
+ return await self._send_tx(tx)
201
170
 
202
171
  async def unlend(
203
172
  self,
@@ -221,7 +190,7 @@ class MoonwellAdapter(BaseAdapter):
221
190
  args=[amount],
222
191
  from_address=strategy,
223
192
  )
224
- return await self._execute(tx)
193
+ return await self._send_tx(tx)
225
194
 
226
195
  # ------------------------------------------------------------------ #
227
196
  # Public API - Borrowing Operations #
@@ -251,19 +220,18 @@ class MoonwellAdapter(BaseAdapter):
251
220
 
252
221
  # Get borrow balance before the transaction for verification
253
222
  borrow_before = 0
254
- if self.web3:
255
- try:
256
- web3 = self.web3.get_web3(self.chain_id)
223
+ try:
224
+ async with web3_from_chain_id(self.chain_id) as web3:
257
225
  mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
258
226
 
259
227
  borrow_before = await mtoken_contract.functions.borrowBalanceStored(
260
228
  strategy
261
- ).call()
229
+ ).call(block_identifier="pending")
262
230
 
263
231
  # Simulate borrow to check for errors before submitting
264
232
  try:
265
233
  borrow_return = await mtoken_contract.functions.borrow(amount).call(
266
- {"from": strategy}
234
+ {"from": strategy}, block_identifier="pending"
267
235
  )
268
236
  if borrow_return != 0:
269
237
  logger.warning(
@@ -273,9 +241,8 @@ class MoonwellAdapter(BaseAdapter):
273
241
  )
274
242
  except Exception as call_err:
275
243
  logger.debug(f"Borrow simulation failed: {call_err}")
276
-
277
- except Exception as e:
278
- logger.warning(f"Failed to get pre-borrow balance: {e}")
244
+ except Exception as e:
245
+ logger.warning(f"Failed to get pre-borrow balance: {e}")
279
246
 
280
247
  tx = await self._encode_call(
281
248
  target=mtoken,
@@ -284,19 +251,18 @@ class MoonwellAdapter(BaseAdapter):
284
251
  args=[amount],
285
252
  from_address=strategy,
286
253
  )
287
- result = await self._execute(tx)
254
+ result = await self._send_tx(tx)
288
255
 
289
256
  if not result[0]:
290
257
  return result
291
258
 
292
259
  # Verify the borrow actually succeeded by checking balance increased
293
- if self.web3:
294
- try:
295
- web3 = self.web3.get_web3(self.chain_id)
260
+ try:
261
+ async with web3_from_chain_id(self.chain_id) as web3:
296
262
  mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
297
263
  borrow_after = await mtoken_contract.functions.borrowBalanceStored(
298
264
  strategy
299
- ).call()
265
+ ).call(block_identifier="pending")
300
266
 
301
267
  # Borrow balance should have increased by approximately the amount
302
268
  # Allow for some interest accrual
@@ -317,11 +283,11 @@ class MoonwellAdapter(BaseAdapter):
317
283
  f"Borrow failed: balance did not increase as expected. "
318
284
  f"Before: {borrow_before}, After: {borrow_after}, Expected: +{amount}",
319
285
  )
320
- except Exception as e:
321
- from loguru import logger
286
+ except Exception as e:
287
+ from loguru import logger
322
288
 
323
- logger.warning(f"Could not verify borrow balance: {e}")
324
- # Continue with the original result if verification fails
289
+ logger.warning(f"Could not verify borrow balance: {e}")
290
+ # Continue with the original result if verification fails
325
291
 
326
292
  return result
327
293
 
@@ -370,7 +336,7 @@ class MoonwellAdapter(BaseAdapter):
370
336
  args=[repay_amount],
371
337
  from_address=strategy,
372
338
  )
373
- return await self._execute(tx)
339
+ return await self._send_tx(tx)
374
340
 
375
341
  # ------------------------------------------------------------------ #
376
342
  # Public API - Collateral Management #
@@ -396,21 +362,20 @@ class MoonwellAdapter(BaseAdapter):
396
362
  args=[[mtoken]],
397
363
  from_address=strategy,
398
364
  )
399
- result = await self._execute(tx)
365
+ result = await self._send_tx(tx)
400
366
 
401
367
  if not result[0]:
402
368
  return result
403
369
 
404
370
  # Verify the market was actually entered
405
- if self.web3:
406
- try:
407
- web3 = self.web3.get_web3(self.chain_id)
371
+ try:
372
+ async with web3_from_chain_id(self.chain_id) as web3:
408
373
  comptroller = web3.eth.contract(
409
374
  address=self.comptroller_address, abi=COMPTROLLER_ABI
410
375
  )
411
376
  is_member = await comptroller.functions.checkMembership(
412
377
  strategy, mtoken
413
- ).call()
378
+ ).call(block_identifier="pending")
414
379
 
415
380
  if not is_member:
416
381
  from loguru import logger
@@ -423,13 +388,38 @@ class MoonwellAdapter(BaseAdapter):
423
388
  False,
424
389
  f"enterMarkets succeeded but account is not a member of market {mtoken}",
425
390
  )
426
- except Exception as e:
427
- from loguru import logger
391
+ except Exception as e:
392
+ from loguru import logger
428
393
 
429
- logger.warning(f"Could not verify market membership: {e}")
394
+ logger.warning(f"Could not verify market membership: {e}")
430
395
 
431
396
  return result
432
397
 
398
+ async def is_market_entered(
399
+ self,
400
+ *,
401
+ mtoken: str,
402
+ account: str | None = None,
403
+ ) -> tuple[bool, bool | str]:
404
+ """Check whether an account has entered a given market (as collateral / borrowing market)."""
405
+ if self.simulation:
406
+ return True, True
407
+
408
+ try:
409
+ acct = self._checksum(account) if account else self._strategy_address()
410
+ mtoken = self._checksum(mtoken)
411
+
412
+ async with web3_from_chain_id(self.chain_id) as web3:
413
+ comptroller = web3.eth.contract(
414
+ address=self.comptroller_address, abi=COMPTROLLER_ABI
415
+ )
416
+ is_member = await comptroller.functions.checkMembership(
417
+ acct, mtoken
418
+ ).call()
419
+ return True, bool(is_member)
420
+ except Exception as exc:
421
+ return False, str(exc)
422
+
433
423
  async def remove_collateral(
434
424
  self,
435
425
  *,
@@ -446,7 +436,7 @@ class MoonwellAdapter(BaseAdapter):
446
436
  args=[mtoken],
447
437
  from_address=strategy,
448
438
  )
449
- return await self._execute(tx)
439
+ return await self._send_tx(tx)
450
440
 
451
441
  # ------------------------------------------------------------------ #
452
442
  # Public API - Rewards #
@@ -481,7 +471,7 @@ class MoonwellAdapter(BaseAdapter):
481
471
  args=[strategy],
482
472
  from_address=strategy,
483
473
  )
484
- result = await self._execute(tx)
474
+ result = await self._send_tx(tx)
485
475
  if not result[0]:
486
476
  return result
487
477
 
@@ -489,33 +479,30 @@ class MoonwellAdapter(BaseAdapter):
489
479
 
490
480
  async def _get_outstanding_rewards(self, account: str) -> dict[str, int]:
491
481
  """Get outstanding rewards for an account across all markets."""
492
- if not self.web3:
493
- return {}
494
-
495
482
  try:
496
- web3 = self.web3.get_web3(self.chain_id)
497
- contract = web3.eth.contract(
498
- address=self.reward_distributor_address, abi=REWARD_DISTRIBUTOR_ABI
499
- )
483
+ async with web3_from_chain_id(self.chain_id) as web3:
484
+ contract = web3.eth.contract(
485
+ address=self.reward_distributor_address, abi=REWARD_DISTRIBUTOR_ABI
486
+ )
500
487
 
501
- # Call getOutstandingRewardsForUser(user)
502
- all_rewards = await contract.functions.getOutstandingRewardsForUser(
503
- account
504
- ).call()
505
-
506
- rewards: dict[str, int] = {}
507
- for mtoken_data in all_rewards:
508
- # mtoken_data is (mToken, [(rewardToken, totalReward, supplySide, borrowSide)])
509
- if len(mtoken_data) >= 2:
510
- token_rewards = mtoken_data[1] if len(mtoken_data) > 1 else []
511
- for reward_info in token_rewards:
512
- if len(reward_info) >= 2:
513
- token_addr = reward_info[0]
514
- total_reward = reward_info[1]
515
- if total_reward > 0:
516
- key = f"{self.chain_name}_{token_addr}"
517
- rewards[key] = rewards.get(key, 0) + total_reward
518
- return rewards
488
+ # Call getOutstandingRewardsForUser(user)
489
+ all_rewards = await contract.functions.getOutstandingRewardsForUser(
490
+ account
491
+ ).call(block_identifier="pending")
492
+
493
+ rewards: dict[str, int] = {}
494
+ for mtoken_data in all_rewards:
495
+ # mtoken_data is (mToken, [(rewardToken, totalReward, supplySide, borrowSide)])
496
+ if len(mtoken_data) >= 2:
497
+ token_rewards = mtoken_data[1] if len(mtoken_data) > 1 else []
498
+ for reward_info in token_rewards:
499
+ if len(reward_info) >= 2:
500
+ token_addr = reward_info[0]
501
+ total_reward = reward_info[1]
502
+ if total_reward > 0:
503
+ key = f"{self.chain_name}_{token_addr}"
504
+ rewards[key] = rewards.get(key, 0) + total_reward
505
+ return rewards
519
506
  except Exception:
520
507
  return {}
521
508
 
@@ -559,45 +546,45 @@ class MoonwellAdapter(BaseAdapter):
559
546
  block_identifier: Block to query at. Can be:
560
547
  - int: specific block number (for pinning to tx block)
561
548
  - "safe": OP Stack safe block (data posted to L1)
562
- - None/"latest": current head (default)
549
+ - None/"pending": current head (default)
563
550
 
564
551
  Includes retry logic with exponential backoff for rate-limited RPCs.
565
552
  """
566
- if not self.web3:
567
- return False, "web3 service not configured"
568
-
569
553
  mtoken = self._checksum(mtoken)
570
554
  account = self._checksum(account) if account else self._strategy_address()
571
- block_id = block_identifier if block_identifier is not None else "latest"
555
+ block_id = block_identifier if block_identifier is not None else "pending"
572
556
 
573
557
  bal = exch = borrow = underlying = rewards = None
574
558
  last_error = ""
575
559
 
576
560
  for attempt in range(max_retries):
577
561
  try:
578
- web3 = self.web3.get_web3(self.chain_id)
579
- mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
580
- rewards_contract = web3.eth.contract(
581
- address=self.reward_distributor_address, abi=REWARD_DISTRIBUTOR_ABI
582
- )
562
+ async with web3_from_chain_id(self.chain_id) as web3:
563
+ mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
564
+ rewards_contract = web3.eth.contract(
565
+ address=self.reward_distributor_address,
566
+ abi=REWARD_DISTRIBUTOR_ABI,
567
+ )
583
568
 
584
- # Fetch data sequentially to avoid overwhelming rate-limited public RPCs
585
- # (parallel fetch would make 5 simultaneous calls per position)
586
- bal = await mtoken_contract.functions.balanceOf(account).call(
587
- block_identifier=block_id
588
- )
589
- exch = await mtoken_contract.functions.exchangeRateStored().call(
590
- block_identifier=block_id
591
- )
592
- borrow = await mtoken_contract.functions.borrowBalanceStored(
593
- account
594
- ).call(block_identifier=block_id)
595
- underlying = await mtoken_contract.functions.underlying().call(
596
- block_identifier=block_id
597
- )
598
- rewards = await rewards_contract.functions.getOutstandingRewardsForUser(
599
- mtoken, account
600
- ).call(block_identifier=block_id)
569
+ # Fetch data sequentially to avoid overwhelming rate-limited public RPCs
570
+ # (parallel fetch would make 5 simultaneous calls per position)
571
+ bal = await mtoken_contract.functions.balanceOf(account).call(
572
+ block_identifier=block_id
573
+ )
574
+ exch = await mtoken_contract.functions.exchangeRateStored().call(
575
+ block_identifier=block_id
576
+ )
577
+ borrow = await mtoken_contract.functions.borrowBalanceStored(
578
+ account
579
+ ).call(block_identifier=block_id)
580
+ underlying = await mtoken_contract.functions.underlying().call(
581
+ block_identifier=block_id
582
+ )
583
+ rewards = (
584
+ await rewards_contract.functions.getOutstandingRewardsForUser(
585
+ mtoken, account
586
+ ).call(block_identifier=block_id)
587
+ )
601
588
  break # Success, exit retry loop
602
589
  except Exception as exc:
603
590
  last_error = str(exc)
@@ -702,9 +689,6 @@ class MoonwellAdapter(BaseAdapter):
702
689
  Uses a 1-hour cache since collateral factors rarely change (governance controlled).
703
690
  Includes retry logic with exponential backoff for rate-limited RPCs.
704
691
  """
705
- if not self.web3:
706
- return False, "web3 service not configured"
707
-
708
692
  mtoken = self._checksum(mtoken)
709
693
 
710
694
  # Check cache first
@@ -717,25 +701,27 @@ class MoonwellAdapter(BaseAdapter):
717
701
  last_error = ""
718
702
  for attempt in range(max_retries):
719
703
  try:
720
- web3 = self.web3.get_web3(self.chain_id)
721
- contract = web3.eth.contract(
722
- address=self.comptroller_address, abi=COMPTROLLER_ABI
723
- )
704
+ async with web3_from_chain_id(self.chain_id) as web3:
705
+ contract = web3.eth.contract(
706
+ address=self.comptroller_address, abi=COMPTROLLER_ABI
707
+ )
724
708
 
725
- # markets() returns (isListed, collateralFactorMantissa)
726
- result = await contract.functions.markets(mtoken).call()
727
- is_listed, collateral_factor_mantissa = result
709
+ # markets() returns (isListed, collateralFactorMantissa)
710
+ result = await contract.functions.markets(mtoken).call(
711
+ block_identifier="pending"
712
+ )
713
+ is_listed, collateral_factor_mantissa = result
728
714
 
729
- if not is_listed:
730
- return False, f"Market {mtoken} is not listed"
715
+ if not is_listed:
716
+ return False, f"Market {mtoken} is not listed"
731
717
 
732
- # Convert from mantissa to decimal
733
- collateral_factor = collateral_factor_mantissa / MANTISSA
718
+ # Convert from mantissa to decimal
719
+ collateral_factor = collateral_factor_mantissa / MANTISSA
734
720
 
735
- # Cache the result
736
- self._cf_cache[mtoken] = (collateral_factor, now)
721
+ # Cache the result
722
+ self._cf_cache[mtoken] = (collateral_factor, now)
737
723
 
738
- return True, collateral_factor
724
+ return True, collateral_factor
739
725
  except Exception as exc:
740
726
  last_error = str(exc)
741
727
  if _is_rate_limit_error(exc) and attempt < max_retries - 1:
@@ -758,50 +744,60 @@ class MoonwellAdapter(BaseAdapter):
758
744
 
759
745
  Includes retry logic with exponential backoff for rate-limited RPCs.
760
746
  """
761
- if not self.web3:
762
- return False, "web3 service not configured"
763
-
764
747
  mtoken = self._checksum(mtoken)
765
748
 
766
749
  last_error = ""
767
750
  for attempt in range(max_retries):
768
751
  try:
769
- web3 = self.web3.get_web3(self.chain_id)
770
- mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
771
- reward_distributor = web3.eth.contract(
772
- address=self.reward_distributor_address, abi=REWARD_DISTRIBUTOR_ABI
773
- )
774
-
775
- # Get base rate (sequential to avoid rate limits)
776
- if apy_type == "supply":
777
- rate_per_timestamp = (
778
- await mtoken_contract.functions.supplyRatePerTimestamp().call()
779
- )
780
- mkt_config = await reward_distributor.functions.getAllMarketConfigs(
781
- mtoken
782
- ).call()
783
- total_value = await mtoken_contract.functions.totalSupply().call()
784
- else:
785
- rate_per_timestamp = (
786
- await mtoken_contract.functions.borrowRatePerTimestamp().call()
787
- )
788
- mkt_config = await reward_distributor.functions.getAllMarketConfigs(
789
- mtoken
790
- ).call()
791
- total_value = await mtoken_contract.functions.totalBorrows().call()
792
-
793
- # Convert rate per second to APY
794
- rate = rate_per_timestamp / MANTISSA
795
- apy = _timestamp_rate_to_apy(rate)
796
-
797
- # Add WELL rewards APY if requested and token_client available
798
- if include_rewards and self.token_client and total_value > 0:
799
- rewards_apr = await self._calculate_rewards_apr(
800
- mtoken, mkt_config, total_value, apy_type
752
+ async with web3_from_chain_id(self.chain_id) as web3:
753
+ mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
754
+ reward_distributor = web3.eth.contract(
755
+ address=self.reward_distributor_address,
756
+ abi=REWARD_DISTRIBUTOR_ABI,
801
757
  )
802
- apy += rewards_apr
803
758
 
804
- return True, apy
759
+ # Get base rate (sequential to avoid rate limits)
760
+ if apy_type == "supply":
761
+ rate_per_timestamp = await mtoken_contract.functions.supplyRatePerTimestamp().call(
762
+ block_identifier="pending"
763
+ )
764
+ mkt_config = (
765
+ await reward_distributor.functions.getAllMarketConfigs(
766
+ mtoken
767
+ ).call(block_identifier="pending")
768
+ )
769
+ total_value = (
770
+ await mtoken_contract.functions.totalSupply().call(
771
+ block_identifier="pending"
772
+ )
773
+ )
774
+ else:
775
+ rate_per_timestamp = await mtoken_contract.functions.borrowRatePerTimestamp().call(
776
+ block_identifier="pending"
777
+ )
778
+ mkt_config = (
779
+ await reward_distributor.functions.getAllMarketConfigs(
780
+ mtoken
781
+ ).call(block_identifier="pending")
782
+ )
783
+ total_value = (
784
+ await mtoken_contract.functions.totalBorrows().call(
785
+ block_identifier="pending"
786
+ )
787
+ )
788
+
789
+ # Convert rate per second to APY
790
+ rate = rate_per_timestamp / MANTISSA
791
+ apy = _timestamp_rate_to_apy(rate)
792
+
793
+ # Add WELL rewards APY if requested and token_client available
794
+ if include_rewards and self.token_client and total_value > 0:
795
+ rewards_apr = await self._calculate_rewards_apr(
796
+ mtoken, mkt_config, total_value, apy_type
797
+ )
798
+ apy += rewards_apr
799
+
800
+ return True, apy
805
801
  except Exception as exc:
806
802
  last_error = str(exc)
807
803
  if _is_rate_limit_error(exc) and attempt < max_retries - 1:
@@ -848,9 +844,11 @@ class MoonwellAdapter(BaseAdapter):
848
844
  return 0.0
849
845
 
850
846
  # Get underlying token for decimals
851
- web3 = self.web3.get_web3(self.chain_id)
852
- mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
853
- underlying_addr = await mtoken_contract.functions.underlying().call()
847
+ async with web3_from_chain_id(self.chain_id) as web3:
848
+ mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
849
+ underlying_addr = await mtoken_contract.functions.underlying().call(
850
+ block_identifier="pending"
851
+ )
854
852
 
855
853
  # Get prices
856
854
  well_key = f"{self.chain_name}_{self.well_token}"
@@ -906,33 +904,32 @@ class MoonwellAdapter(BaseAdapter):
906
904
 
907
905
  Includes retry logic with exponential backoff for rate-limited RPCs.
908
906
  """
909
- if not self.web3:
910
- return False, "web3 service not configured"
911
-
912
907
  account = self._checksum(account) if account else self._strategy_address()
913
908
 
914
909
  last_error = ""
915
910
  for attempt in range(max_retries):
916
911
  try:
917
- web3 = self.web3.get_web3(self.chain_id)
918
- contract = web3.eth.contract(
919
- address=self.comptroller_address, abi=COMPTROLLER_ABI
920
- )
912
+ async with web3_from_chain_id(self.chain_id) as web3:
913
+ contract = web3.eth.contract(
914
+ address=self.comptroller_address, abi=COMPTROLLER_ABI
915
+ )
921
916
 
922
- # getAccountLiquidity returns (error, liquidity, shortfall)
923
- (
924
- error,
925
- liquidity,
926
- shortfall,
927
- ) = await contract.functions.getAccountLiquidity(account).call()
917
+ # getAccountLiquidity returns (error, liquidity, shortfall)
918
+ (
919
+ error,
920
+ liquidity,
921
+ shortfall,
922
+ ) = await contract.functions.getAccountLiquidity(account).call(
923
+ block_identifier="pending"
924
+ )
928
925
 
929
- if error != 0:
930
- return False, f"Comptroller error: {error}"
926
+ if error != 0:
927
+ return False, f"Comptroller error: {error}"
931
928
 
932
- if shortfall > 0:
933
- return False, f"Account has shortfall: {shortfall}"
929
+ if shortfall > 0:
930
+ return False, f"Account has shortfall: {shortfall}"
934
931
 
935
- return True, liquidity
932
+ return True, liquidity
936
933
  except Exception as exc:
937
934
  last_error = str(exc)
938
935
  if _is_rate_limit_error(exc) and attempt < max_retries - 1:
@@ -950,93 +947,102 @@ class MoonwellAdapter(BaseAdapter):
950
947
  account: str | None = None,
951
948
  ) -> tuple[bool, dict[str, Any] | str]:
952
949
  """Calculate max mTokens withdrawable without liquidation using binary search."""
953
- if not self.web3:
954
- return False, "web3 service not configured"
955
-
956
950
  mtoken = self._checksum(mtoken)
957
951
  account = self._checksum(account) if account else self._strategy_address()
958
952
 
959
953
  try:
960
- web3 = self.web3.get_web3(self.chain_id)
961
- comptroller = web3.eth.contract(
962
- address=self.comptroller_address, abi=COMPTROLLER_ABI
963
- )
964
- mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
965
-
966
- # Get all needed data in parallel
967
- bal_raw, exch_raw, cash_raw, m_dec, u_addr = await asyncio.gather(
968
- mtoken_contract.functions.balanceOf(account).call(),
969
- mtoken_contract.functions.exchangeRateStored().call(),
970
- mtoken_contract.functions.getCash().call(),
971
- mtoken_contract.functions.decimals().call(),
972
- mtoken_contract.functions.underlying().call(),
973
- )
954
+ async with web3_from_chain_id(self.chain_id) as web3:
955
+ comptroller = web3.eth.contract(
956
+ address=self.comptroller_address, abi=COMPTROLLER_ABI
957
+ )
958
+ mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
959
+
960
+ # Get all needed data in parallel
961
+ bal_raw, exch_raw, cash_raw, m_dec, u_addr = await asyncio.gather(
962
+ mtoken_contract.functions.balanceOf(account).call(
963
+ block_identifier="pending"
964
+ ),
965
+ mtoken_contract.functions.exchangeRateStored().call(
966
+ block_identifier="pending"
967
+ ),
968
+ mtoken_contract.functions.getCash().call(
969
+ block_identifier="pending"
970
+ ),
971
+ mtoken_contract.functions.decimals().call(
972
+ block_identifier="pending"
973
+ ),
974
+ mtoken_contract.functions.underlying().call(
975
+ block_identifier="pending"
976
+ ),
977
+ )
978
+
979
+ if bal_raw == 0 or exch_raw == 0:
980
+ return True, {
981
+ "cTokens_raw": 0,
982
+ "cTokens": 0.0,
983
+ "underlying_raw": 0,
984
+ "underlying": 0.0,
985
+ "bounds_raw": {"collateral_cTokens": 0, "cash_cTokens": 0},
986
+ "exchangeRate_raw": int(exch_raw),
987
+ "mToken_decimals": int(m_dec),
988
+ "underlying_decimals": None,
989
+ }
990
+
991
+ # Get underlying decimals
992
+ u_dec = 18 # Default
993
+ if self.token_client:
994
+ try:
995
+ u_key = f"{self.chain_name}_{u_addr}"
996
+ u_data = await self.token_client.get_token_details(u_key)
997
+ if u_data:
998
+ u_dec = u_data.get("decimals", 18)
999
+ except Exception:
1000
+ pass
1001
+
1002
+ # Binary search: largest cTokens you can redeem without shortfall
1003
+ lo, hi = 0, int(bal_raw)
1004
+ while lo < hi:
1005
+ mid = (lo + hi + 1) // 2
1006
+ (
1007
+ err,
1008
+ _liq,
1009
+ short,
1010
+ ) = await comptroller.functions.getHypotheticalAccountLiquidity(
1011
+ account, mtoken, mid, 0
1012
+ ).call(block_identifier="pending")
1013
+ if err != 0:
1014
+ return False, f"Comptroller error {err}"
1015
+ if short == 0:
1016
+ lo = mid # Safe, try more
1017
+ else:
1018
+ hi = mid - 1
1019
+
1020
+ c_by_collateral = lo
1021
+
1022
+ # Pool cash bound (convert underlying cash -> cToken capacity)
1023
+ c_by_cash = (int(cash_raw) * MANTISSA) // int(exch_raw)
1024
+
1025
+ redeem_c_raw = min(c_by_collateral, int(c_by_cash))
1026
+
1027
+ # Final underlying you actually receive (mirror Solidity floor)
1028
+ under_raw = (redeem_c_raw * int(exch_raw)) // MANTISSA
974
1029
 
975
- if bal_raw == 0 or exch_raw == 0:
976
1030
  return True, {
977
- "cTokens_raw": 0,
978
- "cTokens": 0.0,
979
- "underlying_raw": 0,
980
- "underlying": 0.0,
981
- "bounds_raw": {"collateral_cTokens": 0, "cash_cTokens": 0},
1031
+ "cTokens_raw": int(redeem_c_raw),
1032
+ "cTokens": redeem_c_raw / (10 ** int(m_dec)),
1033
+ "underlying_raw": int(under_raw),
1034
+ "underlying": under_raw / (10 ** int(u_dec)),
1035
+ "bounds_raw": {
1036
+ "collateral_cTokens": int(c_by_collateral),
1037
+ "cash_cTokens": int(c_by_cash),
1038
+ },
982
1039
  "exchangeRate_raw": int(exch_raw),
983
1040
  "mToken_decimals": int(m_dec),
984
- "underlying_decimals": None,
1041
+ "underlying_decimals": int(u_dec),
1042
+ "conversion_factor": redeem_c_raw / under_raw
1043
+ if under_raw > 0
1044
+ else 0,
985
1045
  }
986
-
987
- # Get underlying decimals
988
- u_dec = 18 # Default
989
- if self.token_client:
990
- try:
991
- u_key = f"{self.chain_name}_{u_addr}"
992
- u_data = await self.token_client.get_token_details(u_key)
993
- if u_data:
994
- u_dec = u_data.get("decimals", 18)
995
- except Exception:
996
- pass
997
-
998
- # Binary search: largest cTokens you can redeem without shortfall
999
- lo, hi = 0, int(bal_raw)
1000
- while lo < hi:
1001
- mid = (lo + hi + 1) // 2
1002
- (
1003
- err,
1004
- _liq,
1005
- short,
1006
- ) = await comptroller.functions.getHypotheticalAccountLiquidity(
1007
- account, mtoken, mid, 0
1008
- ).call()
1009
- if err != 0:
1010
- return False, f"Comptroller error {err}"
1011
- if short == 0:
1012
- lo = mid # Safe, try more
1013
- else:
1014
- hi = mid - 1
1015
-
1016
- c_by_collateral = lo
1017
-
1018
- # Pool cash bound (convert underlying cash -> cToken capacity)
1019
- c_by_cash = (int(cash_raw) * MANTISSA) // int(exch_raw)
1020
-
1021
- redeem_c_raw = min(c_by_collateral, int(c_by_cash))
1022
-
1023
- # Final underlying you actually receive (mirror Solidity floor)
1024
- under_raw = (redeem_c_raw * int(exch_raw)) // MANTISSA
1025
-
1026
- return True, {
1027
- "cTokens_raw": int(redeem_c_raw),
1028
- "cTokens": redeem_c_raw / (10 ** int(m_dec)),
1029
- "underlying_raw": int(under_raw),
1030
- "underlying": under_raw / (10 ** int(u_dec)),
1031
- "bounds_raw": {
1032
- "collateral_cTokens": int(c_by_collateral),
1033
- "cash_cTokens": int(c_by_cash),
1034
- },
1035
- "exchangeRate_raw": int(exch_raw),
1036
- "mToken_decimals": int(m_dec),
1037
- "underlying_decimals": int(u_dec),
1038
- "conversion_factor": redeem_c_raw / under_raw if under_raw > 0 else 0,
1039
- }
1040
1046
  except Exception as exc:
1041
1047
  return False, str(exc)
1042
1048
 
@@ -1063,7 +1069,7 @@ class MoonwellAdapter(BaseAdapter):
1063
1069
  from_address=strategy,
1064
1070
  value=amount,
1065
1071
  )
1066
- return await self._execute(tx)
1072
+ return await self._send_tx(tx)
1067
1073
 
1068
1074
  # ------------------------------------------------------------------ #
1069
1075
  # Helpers #
@@ -1072,6 +1078,13 @@ class MoonwellAdapter(BaseAdapter):
1072
1078
  # Max uint256 for unlimited approvals
1073
1079
  MAX_UINT256 = 2**256 - 1
1074
1080
 
1081
+ async def _send_tx(self, tx: dict[str, Any]) -> tuple[bool, Any]:
1082
+ """Send transaction with simulation check."""
1083
+ if self.simulation:
1084
+ return True, {"simulation": tx}
1085
+ txn_hash = await send_transaction(tx, self.strategy_wallet_signing_callback)
1086
+ return True, txn_hash
1087
+
1075
1088
  async def _ensure_allowance(
1076
1089
  self,
1077
1090
  *,
@@ -1083,90 +1096,42 @@ class MoonwellAdapter(BaseAdapter):
1083
1096
  """Ensure token allowance is sufficient, approving if needed.
1084
1097
 
1085
1098
  Approves for max uint256 to avoid precision issues with exact amounts.
1099
+ In simulation mode, skips the allowance check and assumes approval needed.
1086
1100
  """
1087
- if not self.token_txn_service:
1088
- return False, "token_txn_service not configured"
1101
+ if self.simulation:
1102
+ return True, {} # Skip allowance check in simulation
1089
1103
 
1090
- chain = {"id": self.chain_id}
1091
- allowance = await self.token_txn_service.read_erc20_allowance(
1092
- chain, token_address, owner, spender
1104
+ allowance = await get_token_allowance(
1105
+ token_address, self.chain_id, owner, spender
1093
1106
  )
1094
- if allowance.get("allowance", 0) >= amount:
1107
+ if allowance >= amount:
1095
1108
  return True, {}
1096
1109
 
1097
1110
  # Approve for max uint256 to avoid precision/timing issues
1098
- build_success, approve_tx = self.token_txn_service.build_erc20_approve(
1111
+ approve_tx = await build_approve_transaction(
1112
+ from_address=owner,
1099
1113
  chain_id=self.chain_id,
1100
1114
  token_address=token_address,
1101
- from_address=owner,
1102
- spender=spender,
1115
+ spender_address=spender,
1103
1116
  amount=self.MAX_UINT256,
1104
1117
  )
1105
- if not build_success:
1106
- return False, approve_tx
1107
1118
 
1108
- result = await self._broadcast_transaction(approve_tx)
1119
+ result = await self._send_tx(approve_tx)
1109
1120
 
1110
- # Small delay after approval to ensure state is propagated
1121
+ # Small delay after approval to ensure state is propagated on providers/chains
1122
+ # where we don't wait for additional confirmations by default.
1111
1123
  if result[0]:
1112
- await asyncio.sleep(1.0)
1124
+ confirmations = 0
1125
+ if isinstance(result[1], dict):
1126
+ try:
1127
+ confirmations = int(result[1].get("confirmations") or 0)
1128
+ except (TypeError, ValueError):
1129
+ confirmations = 0
1130
+ if confirmations == 0:
1131
+ await asyncio.sleep(1.0)
1113
1132
 
1114
1133
  return result
1115
1134
 
1116
- async def _execute(
1117
- self, tx: dict[str, Any], max_retries: int = DEFAULT_MAX_RETRIES
1118
- ) -> tuple[bool, Any]:
1119
- """Execute a transaction (or return simulation data).
1120
-
1121
- Includes retry logic with exponential backoff for rate-limited RPCs.
1122
- """
1123
- if self.simulation:
1124
- return True, {"simulation": tx}
1125
- if not self.web3:
1126
- return False, "web3 service not configured"
1127
-
1128
- last_error = None
1129
- for attempt in range(max_retries):
1130
- try:
1131
- return await self.web3.broadcast_transaction(
1132
- tx, wait_for_receipt=True, timeout=DEFAULT_TRANSACTION_TIMEOUT
1133
- )
1134
- except Exception as exc:
1135
- last_error = exc
1136
- if _is_rate_limit_error(exc) and attempt < max_retries - 1:
1137
- wait_time = DEFAULT_BASE_DELAY * (2**attempt)
1138
- await asyncio.sleep(wait_time)
1139
- continue
1140
- return False, str(exc)
1141
-
1142
- return False, str(last_error) if last_error else "Max retries exceeded"
1143
-
1144
- async def _broadcast_transaction(
1145
- self, tx: dict[str, Any], max_retries: int = DEFAULT_MAX_RETRIES
1146
- ) -> tuple[bool, Any]:
1147
- """Broadcast a pre-built transaction.
1148
-
1149
- Includes retry logic with exponential backoff for rate-limited RPCs.
1150
- """
1151
- if not self.web3:
1152
- return False, "web3 service not configured"
1153
-
1154
- last_error = None
1155
- for attempt in range(max_retries):
1156
- try:
1157
- return await self.web3.evm_transactions.broadcast_transaction(
1158
- tx, wait_for_receipt=True, timeout=DEFAULT_TRANSACTION_TIMEOUT
1159
- )
1160
- except Exception as exc:
1161
- last_error = exc
1162
- if _is_rate_limit_error(exc) and attempt < max_retries - 1:
1163
- wait_time = DEFAULT_BASE_DELAY * (2**attempt)
1164
- await asyncio.sleep(wait_time)
1165
- continue
1166
- return False, str(exc)
1167
-
1168
- return False, str(last_error) if last_error else "Max retries exceeded"
1169
-
1170
1135
  async def _encode_call(
1171
1136
  self,
1172
1137
  *,
@@ -1178,28 +1143,25 @@ class MoonwellAdapter(BaseAdapter):
1178
1143
  value: int = 0,
1179
1144
  ) -> dict[str, Any]:
1180
1145
  """Encode a contract call without touching the network."""
1181
- if not self.web3:
1182
- raise ValueError("web3 service not configured")
1146
+ async with web3_from_chain_id(self.chain_id) as web3:
1147
+ contract = web3.eth.contract(address=target, abi=abi)
1183
1148
 
1184
- web3 = self.web3.get_web3(self.chain_id)
1185
- contract = web3.eth.contract(address=target, abi=abi)
1186
-
1187
- try:
1188
- tx_data = await getattr(contract.functions, fn_name)(
1189
- *args
1190
- ).build_transaction({"from": from_address})
1191
- data = tx_data["data"]
1192
- except ValueError as exc:
1193
- raise ValueError(f"Failed to encode {fn_name}: {exc}") from exc
1194
-
1195
- tx: dict[str, Any] = {
1196
- "chainId": int(self.chain_id),
1197
- "from": to_checksum_address(from_address),
1198
- "to": to_checksum_address(target),
1199
- "data": data,
1200
- "value": int(value),
1201
- }
1202
- return tx
1149
+ try:
1150
+ tx_data = await getattr(contract.functions, fn_name)(
1151
+ *args
1152
+ ).build_transaction({"from": from_address})
1153
+ data = tx_data["data"]
1154
+ except ValueError as exc:
1155
+ raise ValueError(f"Failed to encode {fn_name}: {exc}") from exc
1156
+
1157
+ tx: dict[str, Any] = {
1158
+ "chainId": int(self.chain_id),
1159
+ "from": to_checksum_address(from_address),
1160
+ "to": to_checksum_address(target),
1161
+ "data": data,
1162
+ "value": int(value),
1163
+ }
1164
+ return tx
1203
1165
 
1204
1166
  def _strategy_address(self) -> str:
1205
1167
  """Get the strategy wallet address."""