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

Potentially problematic release.


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

Files changed (47) hide show
  1. wayfinder_paths/adapters/balance_adapter/README.md +19 -20
  2. wayfinder_paths/adapters/balance_adapter/adapter.py +66 -37
  3. wayfinder_paths/adapters/balance_adapter/test_adapter.py +2 -8
  4. wayfinder_paths/adapters/brap_adapter/README.md +22 -19
  5. wayfinder_paths/adapters/brap_adapter/adapter.py +33 -34
  6. wayfinder_paths/adapters/brap_adapter/test_adapter.py +2 -18
  7. wayfinder_paths/adapters/hyperlend_adapter/adapter.py +40 -56
  8. wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +1 -8
  9. wayfinder_paths/adapters/moonwell_adapter/README.md +29 -31
  10. wayfinder_paths/adapters/moonwell_adapter/adapter.py +301 -662
  11. wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +275 -179
  12. wayfinder_paths/core/config.py +8 -47
  13. wayfinder_paths/core/constants/base.py +0 -1
  14. wayfinder_paths/core/constants/erc20_abi.py +13 -13
  15. wayfinder_paths/core/strategies/Strategy.py +6 -2
  16. wayfinder_paths/core/utils/erc20_service.py +100 -0
  17. wayfinder_paths/core/utils/evm_helpers.py +1 -1
  18. wayfinder_paths/core/utils/transaction.py +191 -0
  19. wayfinder_paths/core/utils/web3.py +66 -0
  20. wayfinder_paths/run_strategy.py +37 -6
  21. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +200 -224
  22. wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +128 -151
  23. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/README.md +0 -1
  24. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +52 -78
  25. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +0 -12
  26. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/README.md +0 -1
  27. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +39 -64
  28. wayfinder_paths/strategies/stablecoin_yield_strategy/README.md +0 -1
  29. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +42 -85
  30. wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +0 -8
  31. wayfinder_paths/templates/strategy/README.md +1 -5
  32. {wayfinder_paths-0.1.15.dist-info → wayfinder_paths-0.1.16.dist-info}/METADATA +3 -41
  33. {wayfinder_paths-0.1.15.dist-info → wayfinder_paths-0.1.16.dist-info}/RECORD +35 -44
  34. {wayfinder_paths-0.1.15.dist-info → wayfinder_paths-0.1.16.dist-info}/WHEEL +1 -1
  35. wayfinder_paths/core/clients/sdk_example.py +0 -125
  36. wayfinder_paths/core/engine/__init__.py +0 -5
  37. wayfinder_paths/core/services/__init__.py +0 -0
  38. wayfinder_paths/core/services/base.py +0 -131
  39. wayfinder_paths/core/services/local_evm_txn.py +0 -350
  40. wayfinder_paths/core/services/local_token_txn.py +0 -238
  41. wayfinder_paths/core/services/web3_service.py +0 -43
  42. wayfinder_paths/core/wallets/README.md +0 -88
  43. wayfinder_paths/core/wallets/WalletManager.py +0 -56
  44. wayfinder_paths/core/wallets/__init__.py +0 -7
  45. wayfinder_paths/scripts/run_strategy.py +0 -152
  46. wayfinder_paths/strategies/config.py +0 -85
  47. {wayfinder_paths-0.1.15.dist-info → wayfinder_paths-0.1.16.dist-info}/LICENSE +0 -0
@@ -15,15 +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
- from wayfinder_paths.core.constants.erc20_abi import ERC20_ABI
20
18
  from wayfinder_paths.core.constants.moonwell_abi import (
21
19
  COMPTROLLER_ABI,
22
20
  MTOKEN_ABI,
23
21
  REWARD_DISTRIBUTOR_ABI,
24
22
  WETH_ABI,
25
23
  )
26
- 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
27
30
 
28
31
  # Moonwell Base chain addresses
29
32
  MOONWELL_DEFAULTS = {
@@ -58,11 +61,6 @@ CF_CACHE_TTL = 3600
58
61
  DEFAULT_MAX_RETRIES = 5
59
62
  DEFAULT_BASE_DELAY = 3.0 # seconds
60
63
 
61
- # Compound-style Failure(uint256,uint256,uint256) topic0
62
- FAILURE_EVENT_TOPIC0 = (
63
- "0x45b96fe442630264581b197e84bbada861235052c5a1aadfff9ea4e40a969aa0"
64
- )
65
-
66
64
 
67
65
  def _is_rate_limit_error(error: Exception | str) -> bool:
68
66
  """Check if an error is a rate limit (429) error."""
@@ -70,38 +68,6 @@ def _is_rate_limit_error(error: Exception | str) -> bool:
70
68
  return "429" in error_str or "Too Many Requests" in error_str
71
69
 
72
70
 
73
- async def _retry_with_backoff(
74
- coro_factory,
75
- max_retries: int = DEFAULT_MAX_RETRIES,
76
- base_delay: float = DEFAULT_BASE_DELAY,
77
- ):
78
- """Retry an async operation with exponential backoff on rate limit errors.
79
-
80
- Args:
81
- coro_factory: A callable that returns a new coroutine each time.
82
- max_retries: Maximum number of retry attempts.
83
- base_delay: Base delay in seconds (doubles each retry).
84
-
85
- Returns:
86
- The result of the coroutine if successful.
87
-
88
- Raises:
89
- The last exception if all retries fail.
90
- """
91
- last_error = None
92
- for attempt in range(max_retries):
93
- try:
94
- return await coro_factory()
95
- except Exception as exc:
96
- last_error = exc
97
- if _is_rate_limit_error(exc) and attempt < max_retries - 1:
98
- wait_time = base_delay * (2**attempt)
99
- await asyncio.sleep(wait_time)
100
- continue
101
- raise
102
- raise last_error
103
-
104
-
105
71
  def _timestamp_rate_to_apy(rate: float) -> float:
106
72
  """Convert a per-second rate to APY."""
107
73
  return (1 + rate) ** SECONDS_PER_YEAR - 1
@@ -115,20 +81,17 @@ class MoonwellAdapter(BaseAdapter):
115
81
  def __init__(
116
82
  self,
117
83
  config: dict[str, Any] | None = None,
118
- web3_service: Web3Service | None = None,
119
84
  token_client: TokenClient | None = None,
120
85
  simulation: bool = False,
86
+ strategy_wallet_signing_callback=None,
121
87
  ) -> None:
122
88
  super().__init__("moonwell_adapter", config)
123
89
  cfg = config or {}
124
90
  adapter_cfg = cfg.get("moonwell_adapter") or {}
125
91
 
126
- self.web3 = web3_service
127
92
  self.simulation = simulation
128
93
  self.token_client = token_client
129
- self.token_txn_service = (
130
- web3_service.token_transactions if web3_service else None
131
- )
94
+ self.strategy_wallet_signing_callback = strategy_wallet_signing_callback
132
95
 
133
96
  self.strategy_wallet = cfg.get("strategy_wallet") or {}
134
97
  self.chain_id = adapter_cfg.get("chain_id", BASE_CHAIN_ID)
@@ -169,79 +132,6 @@ class MoonwellAdapter(BaseAdapter):
169
132
  # Public API - Lending Operations #
170
133
  # ------------------------------------------------------------------ #
171
134
 
172
- def _tx_pinned_block(self, result: Any) -> int | None:
173
- if isinstance(result, dict):
174
- return (
175
- result.get("confirmed_block_number")
176
- or result.get("block_number")
177
- or (result.get("receipt", {}) or {}).get("blockNumber")
178
- )
179
- return None
180
-
181
- def _as_bytes(self, value: Any) -> bytes | None:
182
- if value is None:
183
- return None
184
- if isinstance(value, (bytes, bytearray)):
185
- return bytes(value)
186
- if hasattr(value, "hex"):
187
- try:
188
- hex_str = value.hex()
189
- if isinstance(hex_str, str):
190
- if hex_str.startswith("0x"):
191
- return bytes.fromhex(hex_str[2:])
192
- return bytes.fromhex(hex_str)
193
- except Exception:
194
- return None
195
- if isinstance(value, str):
196
- v = value
197
- if v.startswith("0x"):
198
- v = v[2:]
199
- try:
200
- return bytes.fromhex(v)
201
- except Exception:
202
- return None
203
- return None
204
-
205
- def _failure_event_details(
206
- self, result: Any, contract_address: str
207
- ) -> dict[str, int] | None:
208
- if not isinstance(result, dict):
209
- return None
210
-
211
- receipt = (
212
- result.get("receipt") if isinstance(result.get("receipt"), dict) else {}
213
- )
214
- logs = receipt.get("logs") if isinstance(receipt, dict) else None
215
- if not isinstance(logs, list):
216
- return None
217
-
218
- addr_l = str(contract_address or "").lower()
219
- for log in logs:
220
- if not isinstance(log, dict):
221
- continue
222
- if str(log.get("address") or "").lower() != addr_l:
223
- continue
224
- topics = log.get("topics") or []
225
- if not topics:
226
- continue
227
- topic0_bytes = self._as_bytes(topics[0])
228
- if not topic0_bytes:
229
- continue
230
- if topic0_bytes.hex() != FAILURE_EVENT_TOPIC0.lower().removeprefix("0x"):
231
- continue
232
-
233
- data_b = self._as_bytes(log.get("data"))
234
- if not data_b or len(data_b) < 96:
235
- return {"error": 0, "info": 0, "detail": 0}
236
-
237
- return {
238
- "error": int.from_bytes(data_b[0:32], "big"),
239
- "info": int.from_bytes(data_b[32:64], "big"),
240
- "detail": int.from_bytes(data_b[64:96], "big"),
241
- }
242
-
243
- return None
244
-
245
135
  async def lend(
246
136
  self,
247
137
  *,
@@ -249,11 +139,7 @@ class MoonwellAdapter(BaseAdapter):
249
139
  underlying_token: str,
250
140
  amount: int,
251
141
  ) -> tuple[bool, Any]:
252
- """Supply tokens to Moonwell by minting mTokens.
253
-
254
- Note: mint() returns an error code and can emit Failure without reverting.
255
- We verify success by checking that the mToken balance increased.
256
- """
142
+ """Supply tokens to Moonwell by minting mTokens."""
257
143
  strategy = self._strategy_address()
258
144
  amount = int(amount)
259
145
  if amount <= 0:
@@ -262,17 +148,6 @@ class MoonwellAdapter(BaseAdapter):
262
148
  mtoken = self._checksum(mtoken)
263
149
  underlying_token = self._checksum(underlying_token)
264
150
 
265
- mtoken_bal_before = None
266
- if self.web3 and not self.simulation:
267
- try:
268
- web3 = self.web3.get_web3(self.chain_id)
269
- mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
270
- mtoken_bal_before = await mtoken_contract.functions.balanceOf(
271
- strategy
272
- ).call()
273
- except Exception:
274
- mtoken_bal_before = None
275
-
276
151
  # Approve mToken to spend underlying tokens
277
152
  approved = await self._ensure_allowance(
278
153
  token_address=underlying_token,
@@ -291,41 +166,7 @@ class MoonwellAdapter(BaseAdapter):
291
166
  args=[amount],
292
167
  from_address=strategy,
293
168
  )
294
- result = await self._execute(tx)
295
-
296
- if not result[0] or not self.web3 or self.simulation:
297
- return result
298
-
299
- if isinstance(result[1], dict):
300
- failure = self._failure_event_details(result[1], mtoken)
301
- if failure is not None:
302
- return (
303
- False,
304
- f"Mint failed (Failure event): error={failure['error']} info={failure['info']} detail={failure['detail']}",
305
- )
306
-
307
- if mtoken_bal_before is None:
308
- return result
309
-
310
- try:
311
- pinned_block = self._tx_pinned_block(result[1])
312
- block_id = pinned_block if pinned_block is not None else "latest"
313
-
314
- web3 = self.web3.get_web3(self.chain_id)
315
- mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
316
- mtoken_bal_after = await mtoken_contract.functions.balanceOf(strategy).call(
317
- block_identifier=block_id
318
- )
319
- if int(mtoken_bal_after) <= int(mtoken_bal_before):
320
- return (
321
- False,
322
- f"Mint verification failed: mToken balance did not increase (before={mtoken_bal_before}, after={mtoken_bal_after})",
323
- )
324
- except Exception:
325
- # If verification fails due to RPC/ABI issues, keep original result.
326
- return result
327
-
328
- return result
169
+ return await self._send_tx(tx)
329
170
 
330
171
  async def unlend(
331
172
  self,
@@ -333,13 +174,7 @@ class MoonwellAdapter(BaseAdapter):
333
174
  mtoken: str,
334
175
  amount: int,
335
176
  ) -> tuple[bool, Any]:
336
- """Withdraw tokens from Moonwell by redeeming mTokens.
337
-
338
- Note: redeem() returns an error code and can emit Failure without reverting.
339
- We verify success by checking that either:
340
- - underlying wallet balance increased, or
341
- - mToken wallet balance decreased.
342
- """
177
+ """Withdraw tokens from Moonwell by redeeming mTokens."""
343
178
  strategy = self._strategy_address()
344
179
  amount = int(amount)
345
180
  if amount <= 0:
@@ -347,38 +182,6 @@ class MoonwellAdapter(BaseAdapter):
347
182
 
348
183
  mtoken = self._checksum(mtoken)
349
184
 
350
- mtoken_bal_before = None
351
- underlying_addr = None
352
- underlying_bal_before = None
353
-
354
- if self.web3 and not self.simulation:
355
- try:
356
- web3 = self.web3.get_web3(self.chain_id)
357
- mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
358
-
359
- # Snapshot balances for verification.
360
- mtoken_bal_before = await mtoken_contract.functions.balanceOf(
361
- strategy
362
- ).call()
363
- try:
364
- underlying_addr = (
365
- await mtoken_contract.functions.underlying().call()
366
- )
367
- except Exception:
368
- underlying_addr = None
369
- if underlying_addr:
370
- underlying_contract = web3.eth.contract(
371
- address=to_checksum_address(underlying_addr),
372
- abi=ERC20_ABI,
373
- )
374
- underlying_bal_before = (
375
- await underlying_contract.functions.balanceOf(strategy).call()
376
- )
377
- except Exception:
378
- mtoken_bal_before = None
379
- underlying_addr = None
380
- underlying_bal_before = None
381
-
382
185
  # Redeem mTokens for underlying
383
186
  tx = await self._encode_call(
384
187
  target=mtoken,
@@ -387,60 +190,7 @@ class MoonwellAdapter(BaseAdapter):
387
190
  args=[amount],
388
191
  from_address=strategy,
389
192
  )
390
- result = await self._execute(tx)
391
-
392
- if not result[0] or not self.web3 or self.simulation:
393
- return result
394
-
395
- if isinstance(result[1], dict):
396
- failure = self._failure_event_details(result[1], mtoken)
397
- if failure is not None:
398
- return (
399
- False,
400
- f"Redeem failed (Failure event): error={failure['error']} info={failure['info']} detail={failure['detail']}",
401
- )
402
-
403
- if mtoken_bal_before is None:
404
- return result
405
-
406
- try:
407
- pinned_block = self._tx_pinned_block(result[1])
408
- block_id = pinned_block if pinned_block is not None else "latest"
409
-
410
- web3 = self.web3.get_web3(self.chain_id)
411
- mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
412
- mtoken_bal_after = await mtoken_contract.functions.balanceOf(strategy).call(
413
- block_identifier=block_id
414
- )
415
-
416
- underlying_bal_after = None
417
- if underlying_addr:
418
- underlying_contract = web3.eth.contract(
419
- address=to_checksum_address(underlying_addr),
420
- abi=ERC20_ABI,
421
- )
422
- underlying_bal_after = await underlying_contract.functions.balanceOf(
423
- strategy
424
- ).call(block_identifier=block_id)
425
-
426
- mtoken_decreased = int(mtoken_bal_after) < int(mtoken_bal_before)
427
- underlying_increased = (
428
- underlying_bal_before is not None
429
- and underlying_bal_after is not None
430
- and int(underlying_bal_after) > int(underlying_bal_before)
431
- )
432
-
433
- if not mtoken_decreased and not underlying_increased:
434
- return (
435
- False,
436
- "Redeem verification failed: no observed balance change "
437
- f"(mtoken before={mtoken_bal_before}, after={mtoken_bal_after}; "
438
- f"underlying before={underlying_bal_before}, after={underlying_bal_after})",
439
- )
440
- except Exception:
441
- return result
442
-
443
- return result
193
+ return await self._send_tx(tx)
444
194
 
445
195
  # ------------------------------------------------------------------ #
446
196
  # Public API - Borrowing Operations #
@@ -470,19 +220,18 @@ class MoonwellAdapter(BaseAdapter):
470
220
 
471
221
  # Get borrow balance before the transaction for verification
472
222
  borrow_before = 0
473
- if self.web3:
474
- try:
475
- web3 = self.web3.get_web3(self.chain_id)
223
+ try:
224
+ async with web3_from_chain_id(self.chain_id) as web3:
476
225
  mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
477
226
 
478
227
  borrow_before = await mtoken_contract.functions.borrowBalanceStored(
479
228
  strategy
480
- ).call()
229
+ ).call(block_identifier="pending")
481
230
 
482
231
  # Simulate borrow to check for errors before submitting
483
232
  try:
484
233
  borrow_return = await mtoken_contract.functions.borrow(amount).call(
485
- {"from": strategy}
234
+ {"from": strategy}, block_identifier="pending"
486
235
  )
487
236
  if borrow_return != 0:
488
237
  logger.warning(
@@ -492,9 +241,8 @@ class MoonwellAdapter(BaseAdapter):
492
241
  )
493
242
  except Exception as call_err:
494
243
  logger.debug(f"Borrow simulation failed: {call_err}")
495
-
496
- except Exception as e:
497
- 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}")
498
246
 
499
247
  tx = await self._encode_call(
500
248
  target=mtoken,
@@ -503,28 +251,18 @@ class MoonwellAdapter(BaseAdapter):
503
251
  args=[amount],
504
252
  from_address=strategy,
505
253
  )
506
- result = await self._execute(tx)
254
+ result = await self._send_tx(tx)
507
255
 
508
256
  if not result[0]:
509
257
  return result
510
258
 
511
259
  # Verify the borrow actually succeeded by checking balance increased
512
- if self.web3:
513
- try:
514
- pinned_block = None
515
- if isinstance(result[1], dict):
516
- pinned_block = (
517
- result[1].get("confirmed_block_number")
518
- or result[1].get("block_number")
519
- or (result[1].get("receipt", {}) or {}).get("blockNumber")
520
- )
521
- block_id = pinned_block if pinned_block is not None else "latest"
522
-
523
- web3 = self.web3.get_web3(self.chain_id)
260
+ try:
261
+ async with web3_from_chain_id(self.chain_id) as web3:
524
262
  mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
525
263
  borrow_after = await mtoken_contract.functions.borrowBalanceStored(
526
264
  strategy
527
- ).call(block_identifier=block_id)
265
+ ).call(block_identifier="pending")
528
266
 
529
267
  # Borrow balance should have increased by approximately the amount
530
268
  # Allow for some interest accrual
@@ -545,11 +283,11 @@ class MoonwellAdapter(BaseAdapter):
545
283
  f"Borrow failed: balance did not increase as expected. "
546
284
  f"Before: {borrow_before}, After: {borrow_after}, Expected: +{amount}",
547
285
  )
548
- except Exception as e:
549
- from loguru import logger
286
+ except Exception as e:
287
+ from loguru import logger
550
288
 
551
- logger.warning(f"Could not verify borrow balance: {e}")
552
- # 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
553
291
 
554
292
  return result
555
293
 
@@ -577,17 +315,6 @@ class MoonwellAdapter(BaseAdapter):
577
315
  mtoken = self._checksum(mtoken)
578
316
  underlying_token = self._checksum(underlying_token)
579
317
 
580
- borrow_before = None
581
- if self.web3 and not self.simulation:
582
- try:
583
- web3 = self.web3.get_web3(self.chain_id)
584
- mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
585
- borrow_before = await mtoken_contract.functions.borrowBalanceStored(
586
- strategy
587
- ).call()
588
- except Exception:
589
- borrow_before = None
590
-
591
318
  # Approve mToken to spend underlying tokens for repayment
592
319
  # When repay_full=True, approve the amount we have, Moonwell will use only what's needed
593
320
  approved = await self._ensure_allowance(
@@ -609,49 +336,7 @@ class MoonwellAdapter(BaseAdapter):
609
336
  args=[repay_amount],
610
337
  from_address=strategy,
611
338
  )
612
- result = await self._execute(tx)
613
-
614
- if not result[0] or not self.web3 or self.simulation:
615
- return result
616
-
617
- if isinstance(result[1], dict):
618
- failure = self._failure_event_details(result[1], mtoken)
619
- if failure is not None:
620
- return (
621
- False,
622
- f"Repay failed (Failure event): error={failure['error']} info={failure['info']} detail={failure['detail']}",
623
- )
624
-
625
- if borrow_before is None or int(borrow_before) <= 0:
626
- return result
627
-
628
- try:
629
- pinned_block = self._tx_pinned_block(result[1])
630
- block_id = pinned_block if pinned_block is not None else "latest"
631
-
632
- web3 = self.web3.get_web3(self.chain_id)
633
- mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
634
- borrow_after = await mtoken_contract.functions.borrowBalanceStored(
635
- strategy
636
- ).call(block_identifier=block_id)
637
-
638
- if repay_full:
639
- # Full repayment should clear the borrow balance (allow 1 wei dust).
640
- if int(borrow_after) > 1:
641
- return (
642
- False,
643
- f"Repay verification failed: repay_full did not clear debt (before={borrow_before}, after={borrow_after})",
644
- )
645
- else:
646
- if int(borrow_after) >= int(borrow_before):
647
- return (
648
- False,
649
- f"Repay verification failed: borrow balance did not decrease (before={borrow_before}, after={borrow_after})",
650
- )
651
- except Exception:
652
- return result
653
-
654
- return result
339
+ return await self._send_tx(tx)
655
340
 
656
341
  # ------------------------------------------------------------------ #
657
342
  # Public API - Collateral Management #
@@ -677,30 +362,20 @@ class MoonwellAdapter(BaseAdapter):
677
362
  args=[[mtoken]],
678
363
  from_address=strategy,
679
364
  )
680
- result = await self._execute(tx)
365
+ result = await self._send_tx(tx)
681
366
 
682
367
  if not result[0]:
683
368
  return result
684
369
 
685
370
  # Verify the market was actually entered
686
- if self.web3:
687
- try:
688
- pinned_block = None
689
- if isinstance(result[1], dict):
690
- pinned_block = (
691
- result[1].get("confirmed_block_number")
692
- or result[1].get("block_number")
693
- or (result[1].get("receipt", {}) or {}).get("blockNumber")
694
- )
695
- block_id = pinned_block if pinned_block is not None else "latest"
696
-
697
- web3 = self.web3.get_web3(self.chain_id)
371
+ try:
372
+ async with web3_from_chain_id(self.chain_id) as web3:
698
373
  comptroller = web3.eth.contract(
699
374
  address=self.comptroller_address, abi=COMPTROLLER_ABI
700
375
  )
701
376
  is_member = await comptroller.functions.checkMembership(
702
377
  strategy, mtoken
703
- ).call(block_identifier=block_id)
378
+ ).call(block_identifier="pending")
704
379
 
705
380
  if not is_member:
706
381
  from loguru import logger
@@ -713,10 +388,10 @@ class MoonwellAdapter(BaseAdapter):
713
388
  False,
714
389
  f"enterMarkets succeeded but account is not a member of market {mtoken}",
715
390
  )
716
- except Exception as e:
717
- from loguru import logger
391
+ except Exception as e:
392
+ from loguru import logger
718
393
 
719
- logger.warning(f"Could not verify market membership: {e}")
394
+ logger.warning(f"Could not verify market membership: {e}")
720
395
 
721
396
  return result
722
397
 
@@ -729,19 +404,19 @@ class MoonwellAdapter(BaseAdapter):
729
404
  """Check whether an account has entered a given market (as collateral / borrowing market)."""
730
405
  if self.simulation:
731
406
  return True, True
732
- if not self.web3:
733
- return False, "web3 service not configured"
734
407
 
735
408
  try:
736
409
  acct = self._checksum(account) if account else self._strategy_address()
737
410
  mtoken = self._checksum(mtoken)
738
411
 
739
- web3 = self.web3.get_web3(self.chain_id)
740
- comptroller = web3.eth.contract(
741
- address=self.comptroller_address, abi=COMPTROLLER_ABI
742
- )
743
- is_member = await comptroller.functions.checkMembership(acct, mtoken).call()
744
- return True, bool(is_member)
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)
745
420
  except Exception as exc:
746
421
  return False, str(exc)
747
422
 
@@ -761,7 +436,7 @@ class MoonwellAdapter(BaseAdapter):
761
436
  args=[mtoken],
762
437
  from_address=strategy,
763
438
  )
764
- return await self._execute(tx)
439
+ return await self._send_tx(tx)
765
440
 
766
441
  # ------------------------------------------------------------------ #
767
442
  # Public API - Rewards #
@@ -796,7 +471,7 @@ class MoonwellAdapter(BaseAdapter):
796
471
  args=[strategy],
797
472
  from_address=strategy,
798
473
  )
799
- result = await self._execute(tx)
474
+ result = await self._send_tx(tx)
800
475
  if not result[0]:
801
476
  return result
802
477
 
@@ -804,33 +479,30 @@ class MoonwellAdapter(BaseAdapter):
804
479
 
805
480
  async def _get_outstanding_rewards(self, account: str) -> dict[str, int]:
806
481
  """Get outstanding rewards for an account across all markets."""
807
- if not self.web3:
808
- return {}
809
-
810
482
  try:
811
- web3 = self.web3.get_web3(self.chain_id)
812
- contract = web3.eth.contract(
813
- address=self.reward_distributor_address, abi=REWARD_DISTRIBUTOR_ABI
814
- )
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
+ )
815
487
 
816
- # Call getOutstandingRewardsForUser(user)
817
- all_rewards = await contract.functions.getOutstandingRewardsForUser(
818
- account
819
- ).call()
820
-
821
- rewards: dict[str, int] = {}
822
- for mtoken_data in all_rewards:
823
- # mtoken_data is (mToken, [(rewardToken, totalReward, supplySide, borrowSide)])
824
- if len(mtoken_data) >= 2:
825
- token_rewards = mtoken_data[1] if len(mtoken_data) > 1 else []
826
- for reward_info in token_rewards:
827
- if len(reward_info) >= 2:
828
- token_addr = reward_info[0]
829
- total_reward = reward_info[1]
830
- if total_reward > 0:
831
- key = f"{self.chain_name}_{token_addr}"
832
- rewards[key] = rewards.get(key, 0) + total_reward
833
- 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
834
506
  except Exception:
835
507
  return {}
836
508
 
@@ -874,45 +546,45 @@ class MoonwellAdapter(BaseAdapter):
874
546
  block_identifier: Block to query at. Can be:
875
547
  - int: specific block number (for pinning to tx block)
876
548
  - "safe": OP Stack safe block (data posted to L1)
877
- - None/"latest": current head (default)
549
+ - None/"pending": current head (default)
878
550
 
879
551
  Includes retry logic with exponential backoff for rate-limited RPCs.
880
552
  """
881
- if not self.web3:
882
- return False, "web3 service not configured"
883
-
884
553
  mtoken = self._checksum(mtoken)
885
554
  account = self._checksum(account) if account else self._strategy_address()
886
- 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"
887
556
 
888
557
  bal = exch = borrow = underlying = rewards = None
889
558
  last_error = ""
890
559
 
891
560
  for attempt in range(max_retries):
892
561
  try:
893
- web3 = self.web3.get_web3(self.chain_id)
894
- mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
895
- rewards_contract = web3.eth.contract(
896
- address=self.reward_distributor_address, abi=REWARD_DISTRIBUTOR_ABI
897
- )
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
+ )
898
568
 
899
- # Fetch data sequentially to avoid overwhelming rate-limited public RPCs
900
- # (parallel fetch would make 5 simultaneous calls per position)
901
- bal = await mtoken_contract.functions.balanceOf(account).call(
902
- block_identifier=block_id
903
- )
904
- exch = await mtoken_contract.functions.exchangeRateStored().call(
905
- block_identifier=block_id
906
- )
907
- borrow = await mtoken_contract.functions.borrowBalanceStored(
908
- account
909
- ).call(block_identifier=block_id)
910
- underlying = await mtoken_contract.functions.underlying().call(
911
- block_identifier=block_id
912
- )
913
- rewards = await rewards_contract.functions.getOutstandingRewardsForUser(
914
- mtoken, account
915
- ).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
+ )
916
588
  break # Success, exit retry loop
917
589
  except Exception as exc:
918
590
  last_error = str(exc)
@@ -1017,9 +689,6 @@ class MoonwellAdapter(BaseAdapter):
1017
689
  Uses a 1-hour cache since collateral factors rarely change (governance controlled).
1018
690
  Includes retry logic with exponential backoff for rate-limited RPCs.
1019
691
  """
1020
- if not self.web3:
1021
- return False, "web3 service not configured"
1022
-
1023
692
  mtoken = self._checksum(mtoken)
1024
693
 
1025
694
  # Check cache first
@@ -1032,25 +701,27 @@ class MoonwellAdapter(BaseAdapter):
1032
701
  last_error = ""
1033
702
  for attempt in range(max_retries):
1034
703
  try:
1035
- web3 = self.web3.get_web3(self.chain_id)
1036
- contract = web3.eth.contract(
1037
- address=self.comptroller_address, abi=COMPTROLLER_ABI
1038
- )
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
+ )
1039
708
 
1040
- # markets() returns (isListed, collateralFactorMantissa)
1041
- result = await contract.functions.markets(mtoken).call()
1042
- 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
1043
714
 
1044
- if not is_listed:
1045
- return False, f"Market {mtoken} is not listed"
715
+ if not is_listed:
716
+ return False, f"Market {mtoken} is not listed"
1046
717
 
1047
- # Convert from mantissa to decimal
1048
- collateral_factor = collateral_factor_mantissa / MANTISSA
718
+ # Convert from mantissa to decimal
719
+ collateral_factor = collateral_factor_mantissa / MANTISSA
1049
720
 
1050
- # Cache the result
1051
- self._cf_cache[mtoken] = (collateral_factor, now)
721
+ # Cache the result
722
+ self._cf_cache[mtoken] = (collateral_factor, now)
1052
723
 
1053
- return True, collateral_factor
724
+ return True, collateral_factor
1054
725
  except Exception as exc:
1055
726
  last_error = str(exc)
1056
727
  if _is_rate_limit_error(exc) and attempt < max_retries - 1:
@@ -1073,50 +744,60 @@ class MoonwellAdapter(BaseAdapter):
1073
744
 
1074
745
  Includes retry logic with exponential backoff for rate-limited RPCs.
1075
746
  """
1076
- if not self.web3:
1077
- return False, "web3 service not configured"
1078
-
1079
747
  mtoken = self._checksum(mtoken)
1080
748
 
1081
749
  last_error = ""
1082
750
  for attempt in range(max_retries):
1083
751
  try:
1084
- web3 = self.web3.get_web3(self.chain_id)
1085
- mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
1086
- reward_distributor = web3.eth.contract(
1087
- address=self.reward_distributor_address, abi=REWARD_DISTRIBUTOR_ABI
1088
- )
1089
-
1090
- # Get base rate (sequential to avoid rate limits)
1091
- if apy_type == "supply":
1092
- rate_per_timestamp = (
1093
- await mtoken_contract.functions.supplyRatePerTimestamp().call()
1094
- )
1095
- mkt_config = await reward_distributor.functions.getAllMarketConfigs(
1096
- mtoken
1097
- ).call()
1098
- total_value = await mtoken_contract.functions.totalSupply().call()
1099
- else:
1100
- rate_per_timestamp = (
1101
- await mtoken_contract.functions.borrowRatePerTimestamp().call()
1102
- )
1103
- mkt_config = await reward_distributor.functions.getAllMarketConfigs(
1104
- mtoken
1105
- ).call()
1106
- total_value = await mtoken_contract.functions.totalBorrows().call()
1107
-
1108
- # Convert rate per second to APY
1109
- rate = rate_per_timestamp / MANTISSA
1110
- apy = _timestamp_rate_to_apy(rate)
1111
-
1112
- # Add WELL rewards APY if requested and token_client available
1113
- if include_rewards and self.token_client and total_value > 0:
1114
- rewards_apr = await self._calculate_rewards_apr(
1115
- 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,
1116
757
  )
1117
- apy += rewards_apr
1118
758
 
1119
- 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
1120
801
  except Exception as exc:
1121
802
  last_error = str(exc)
1122
803
  if _is_rate_limit_error(exc) and attempt < max_retries - 1:
@@ -1163,9 +844,11 @@ class MoonwellAdapter(BaseAdapter):
1163
844
  return 0.0
1164
845
 
1165
846
  # Get underlying token for decimals
1166
- web3 = self.web3.get_web3(self.chain_id)
1167
- mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
1168
- 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
+ )
1169
852
 
1170
853
  # Get prices
1171
854
  well_key = f"{self.chain_name}_{self.well_token}"
@@ -1221,33 +904,32 @@ class MoonwellAdapter(BaseAdapter):
1221
904
 
1222
905
  Includes retry logic with exponential backoff for rate-limited RPCs.
1223
906
  """
1224
- if not self.web3:
1225
- return False, "web3 service not configured"
1226
-
1227
907
  account = self._checksum(account) if account else self._strategy_address()
1228
908
 
1229
909
  last_error = ""
1230
910
  for attempt in range(max_retries):
1231
911
  try:
1232
- web3 = self.web3.get_web3(self.chain_id)
1233
- contract = web3.eth.contract(
1234
- address=self.comptroller_address, abi=COMPTROLLER_ABI
1235
- )
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
+ )
1236
916
 
1237
- # getAccountLiquidity returns (error, liquidity, shortfall)
1238
- (
1239
- error,
1240
- liquidity,
1241
- shortfall,
1242
- ) = 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
+ )
1243
925
 
1244
- if error != 0:
1245
- return False, f"Comptroller error: {error}"
926
+ if error != 0:
927
+ return False, f"Comptroller error: {error}"
1246
928
 
1247
- if shortfall > 0:
1248
- return False, f"Account has shortfall: {shortfall}"
929
+ if shortfall > 0:
930
+ return False, f"Account has shortfall: {shortfall}"
1249
931
 
1250
- return True, liquidity
932
+ return True, liquidity
1251
933
  except Exception as exc:
1252
934
  last_error = str(exc)
1253
935
  if _is_rate_limit_error(exc) and attempt < max_retries - 1:
@@ -1265,93 +947,102 @@ class MoonwellAdapter(BaseAdapter):
1265
947
  account: str | None = None,
1266
948
  ) -> tuple[bool, dict[str, Any] | str]:
1267
949
  """Calculate max mTokens withdrawable without liquidation using binary search."""
1268
- if not self.web3:
1269
- return False, "web3 service not configured"
1270
-
1271
950
  mtoken = self._checksum(mtoken)
1272
951
  account = self._checksum(account) if account else self._strategy_address()
1273
952
 
1274
953
  try:
1275
- web3 = self.web3.get_web3(self.chain_id)
1276
- comptroller = web3.eth.contract(
1277
- address=self.comptroller_address, abi=COMPTROLLER_ABI
1278
- )
1279
- mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
1280
-
1281
- # Get all needed data in parallel
1282
- bal_raw, exch_raw, cash_raw, m_dec, u_addr = await asyncio.gather(
1283
- mtoken_contract.functions.balanceOf(account).call(),
1284
- mtoken_contract.functions.exchangeRateStored().call(),
1285
- mtoken_contract.functions.getCash().call(),
1286
- mtoken_contract.functions.decimals().call(),
1287
- mtoken_contract.functions.underlying().call(),
1288
- )
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
1289
1029
 
1290
- if bal_raw == 0 or exch_raw == 0:
1291
1030
  return True, {
1292
- "cTokens_raw": 0,
1293
- "cTokens": 0.0,
1294
- "underlying_raw": 0,
1295
- "underlying": 0.0,
1296
- "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
+ },
1297
1039
  "exchangeRate_raw": int(exch_raw),
1298
1040
  "mToken_decimals": int(m_dec),
1299
- "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,
1300
1045
  }
1301
-
1302
- # Get underlying decimals
1303
- u_dec = 18 # Default
1304
- if self.token_client:
1305
- try:
1306
- u_key = f"{self.chain_name}_{u_addr}"
1307
- u_data = await self.token_client.get_token_details(u_key)
1308
- if u_data:
1309
- u_dec = u_data.get("decimals", 18)
1310
- except Exception:
1311
- pass
1312
-
1313
- # Binary search: largest cTokens you can redeem without shortfall
1314
- lo, hi = 0, int(bal_raw)
1315
- while lo < hi:
1316
- mid = (lo + hi + 1) // 2
1317
- (
1318
- err,
1319
- _liq,
1320
- short,
1321
- ) = await comptroller.functions.getHypotheticalAccountLiquidity(
1322
- account, mtoken, mid, 0
1323
- ).call()
1324
- if err != 0:
1325
- return False, f"Comptroller error {err}"
1326
- if short == 0:
1327
- lo = mid # Safe, try more
1328
- else:
1329
- hi = mid - 1
1330
-
1331
- c_by_collateral = lo
1332
-
1333
- # Pool cash bound (convert underlying cash -> cToken capacity)
1334
- c_by_cash = (int(cash_raw) * MANTISSA) // int(exch_raw)
1335
-
1336
- redeem_c_raw = min(c_by_collateral, int(c_by_cash))
1337
-
1338
- # Final underlying you actually receive (mirror Solidity floor)
1339
- under_raw = (redeem_c_raw * int(exch_raw)) // MANTISSA
1340
-
1341
- return True, {
1342
- "cTokens_raw": int(redeem_c_raw),
1343
- "cTokens": redeem_c_raw / (10 ** int(m_dec)),
1344
- "underlying_raw": int(under_raw),
1345
- "underlying": under_raw / (10 ** int(u_dec)),
1346
- "bounds_raw": {
1347
- "collateral_cTokens": int(c_by_collateral),
1348
- "cash_cTokens": int(c_by_cash),
1349
- },
1350
- "exchangeRate_raw": int(exch_raw),
1351
- "mToken_decimals": int(m_dec),
1352
- "underlying_decimals": int(u_dec),
1353
- "conversion_factor": redeem_c_raw / under_raw if under_raw > 0 else 0,
1354
- }
1355
1046
  except Exception as exc:
1356
1047
  return False, str(exc)
1357
1048
 
@@ -1378,7 +1069,7 @@ class MoonwellAdapter(BaseAdapter):
1378
1069
  from_address=strategy,
1379
1070
  value=amount,
1380
1071
  )
1381
- return await self._execute(tx)
1072
+ return await self._send_tx(tx)
1382
1073
 
1383
1074
  # ------------------------------------------------------------------ #
1384
1075
  # Helpers #
@@ -1387,6 +1078,13 @@ class MoonwellAdapter(BaseAdapter):
1387
1078
  # Max uint256 for unlimited approvals
1388
1079
  MAX_UINT256 = 2**256 - 1
1389
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
+
1390
1088
  async def _ensure_allowance(
1391
1089
  self,
1392
1090
  *,
@@ -1398,29 +1096,27 @@ class MoonwellAdapter(BaseAdapter):
1398
1096
  """Ensure token allowance is sufficient, approving if needed.
1399
1097
 
1400
1098
  Approves for max uint256 to avoid precision issues with exact amounts.
1099
+ In simulation mode, skips the allowance check and assumes approval needed.
1401
1100
  """
1402
- if not self.token_txn_service:
1403
- return False, "token_txn_service not configured"
1101
+ if self.simulation:
1102
+ return True, {} # Skip allowance check in simulation
1404
1103
 
1405
- chain = {"id": self.chain_id}
1406
- allowance = await self.token_txn_service.read_erc20_allowance(
1407
- chain, token_address, owner, spender
1104
+ allowance = await get_token_allowance(
1105
+ token_address, self.chain_id, owner, spender
1408
1106
  )
1409
- if allowance.get("allowance", 0) >= amount:
1107
+ if allowance >= amount:
1410
1108
  return True, {}
1411
1109
 
1412
1110
  # Approve for max uint256 to avoid precision/timing issues
1413
- build_success, approve_tx = self.token_txn_service.build_erc20_approve(
1111
+ approve_tx = await build_approve_transaction(
1112
+ from_address=owner,
1414
1113
  chain_id=self.chain_id,
1415
1114
  token_address=token_address,
1416
- from_address=owner,
1417
- spender=spender,
1115
+ spender_address=spender,
1418
1116
  amount=self.MAX_UINT256,
1419
1117
  )
1420
- if not build_success:
1421
- return False, approve_tx
1422
1118
 
1423
- result = await self._broadcast_transaction(approve_tx)
1119
+ result = await self._send_tx(approve_tx)
1424
1120
 
1425
1121
  # Small delay after approval to ensure state is propagated on providers/chains
1426
1122
  # where we don't wait for additional confirmations by default.
@@ -1436,60 +1132,6 @@ class MoonwellAdapter(BaseAdapter):
1436
1132
 
1437
1133
  return result
1438
1134
 
1439
- async def _execute(
1440
- self, tx: dict[str, Any], max_retries: int = DEFAULT_MAX_RETRIES
1441
- ) -> tuple[bool, Any]:
1442
- """Execute a transaction (or return simulation data).
1443
-
1444
- Includes retry logic with exponential backoff for rate-limited RPCs.
1445
- """
1446
- if self.simulation:
1447
- return True, {"simulation": tx}
1448
- if not self.web3:
1449
- return False, "web3 service not configured"
1450
-
1451
- last_error = None
1452
- for attempt in range(max_retries):
1453
- try:
1454
- return await self.web3.broadcast_transaction(
1455
- tx, wait_for_receipt=True, timeout=DEFAULT_TRANSACTION_TIMEOUT
1456
- )
1457
- except Exception as exc:
1458
- last_error = exc
1459
- if _is_rate_limit_error(exc) and attempt < max_retries - 1:
1460
- wait_time = DEFAULT_BASE_DELAY * (2**attempt)
1461
- await asyncio.sleep(wait_time)
1462
- continue
1463
- return False, str(exc)
1464
-
1465
- return False, str(last_error) if last_error else "Max retries exceeded"
1466
-
1467
- async def _broadcast_transaction(
1468
- self, tx: dict[str, Any], max_retries: int = DEFAULT_MAX_RETRIES
1469
- ) -> tuple[bool, Any]:
1470
- """Broadcast a pre-built transaction.
1471
-
1472
- Includes retry logic with exponential backoff for rate-limited RPCs.
1473
- """
1474
- if not self.web3:
1475
- return False, "web3 service not configured"
1476
-
1477
- last_error = None
1478
- for attempt in range(max_retries):
1479
- try:
1480
- return await self.web3.evm_transactions.broadcast_transaction(
1481
- tx, wait_for_receipt=True, timeout=DEFAULT_TRANSACTION_TIMEOUT
1482
- )
1483
- except Exception as exc:
1484
- last_error = exc
1485
- if _is_rate_limit_error(exc) and attempt < max_retries - 1:
1486
- wait_time = DEFAULT_BASE_DELAY * (2**attempt)
1487
- await asyncio.sleep(wait_time)
1488
- continue
1489
- return False, str(exc)
1490
-
1491
- return False, str(last_error) if last_error else "Max retries exceeded"
1492
-
1493
1135
  async def _encode_call(
1494
1136
  self,
1495
1137
  *,
@@ -1501,28 +1143,25 @@ class MoonwellAdapter(BaseAdapter):
1501
1143
  value: int = 0,
1502
1144
  ) -> dict[str, Any]:
1503
1145
  """Encode a contract call without touching the network."""
1504
- if not self.web3:
1505
- raise ValueError("web3 service not configured")
1506
-
1507
- web3 = self.web3.get_web3(self.chain_id)
1508
- contract = web3.eth.contract(address=target, abi=abi)
1146
+ async with web3_from_chain_id(self.chain_id) as web3:
1147
+ contract = web3.eth.contract(address=target, abi=abi)
1509
1148
 
1510
- try:
1511
- tx_data = await getattr(contract.functions, fn_name)(
1512
- *args
1513
- ).build_transaction({"from": from_address})
1514
- data = tx_data["data"]
1515
- except ValueError as exc:
1516
- raise ValueError(f"Failed to encode {fn_name}: {exc}") from exc
1517
-
1518
- tx: dict[str, Any] = {
1519
- "chainId": int(self.chain_id),
1520
- "from": to_checksum_address(from_address),
1521
- "to": to_checksum_address(target),
1522
- "data": data,
1523
- "value": int(value),
1524
- }
1525
- 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
1526
1165
 
1527
1166
  def _strategy_address(self) -> str:
1528
1167
  """Get the strategy wallet address."""