wayfinder-paths 0.1.13__py3-none-any.whl → 0.1.15__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 (61) hide show
  1. wayfinder_paths/adapters/balance_adapter/README.md +13 -14
  2. wayfinder_paths/adapters/balance_adapter/adapter.py +73 -32
  3. wayfinder_paths/adapters/balance_adapter/test_adapter.py +123 -0
  4. wayfinder_paths/adapters/brap_adapter/README.md +11 -16
  5. wayfinder_paths/adapters/brap_adapter/adapter.py +144 -78
  6. wayfinder_paths/adapters/brap_adapter/examples.json +63 -52
  7. wayfinder_paths/adapters/brap_adapter/test_adapter.py +127 -65
  8. wayfinder_paths/adapters/hyperlend_adapter/adapter.py +30 -14
  9. wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +121 -67
  10. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +6 -6
  11. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +12 -12
  12. wayfinder_paths/adapters/ledger_adapter/test_adapter.py +6 -6
  13. wayfinder_paths/adapters/moonwell_adapter/adapter.py +332 -9
  14. wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +13 -13
  15. wayfinder_paths/adapters/pool_adapter/README.md +9 -10
  16. wayfinder_paths/adapters/pool_adapter/adapter.py +9 -10
  17. wayfinder_paths/adapters/pool_adapter/test_adapter.py +2 -2
  18. wayfinder_paths/adapters/token_adapter/README.md +2 -14
  19. wayfinder_paths/adapters/token_adapter/adapter.py +16 -10
  20. wayfinder_paths/adapters/token_adapter/examples.json +4 -8
  21. wayfinder_paths/adapters/token_adapter/test_adapter.py +9 -7
  22. wayfinder_paths/core/clients/BRAPClient.py +102 -61
  23. wayfinder_paths/core/clients/ClientManager.py +1 -68
  24. wayfinder_paths/core/clients/HyperlendClient.py +125 -64
  25. wayfinder_paths/core/clients/LedgerClient.py +1 -4
  26. wayfinder_paths/core/clients/PoolClient.py +122 -48
  27. wayfinder_paths/core/clients/TokenClient.py +91 -36
  28. wayfinder_paths/core/clients/WalletClient.py +26 -56
  29. wayfinder_paths/core/clients/WayfinderClient.py +28 -160
  30. wayfinder_paths/core/clients/__init__.py +0 -2
  31. wayfinder_paths/core/clients/protocols.py +35 -46
  32. wayfinder_paths/core/clients/sdk_example.py +37 -22
  33. wayfinder_paths/core/constants/erc20_abi.py +0 -11
  34. wayfinder_paths/core/engine/StrategyJob.py +10 -56
  35. wayfinder_paths/core/services/base.py +1 -0
  36. wayfinder_paths/core/services/local_evm_txn.py +25 -9
  37. wayfinder_paths/core/services/local_token_txn.py +2 -6
  38. wayfinder_paths/core/services/test_local_evm_txn.py +145 -0
  39. wayfinder_paths/core/strategies/Strategy.py +16 -4
  40. wayfinder_paths/core/utils/evm_helpers.py +2 -9
  41. wayfinder_paths/policies/erc20.py +1 -1
  42. wayfinder_paths/run_strategy.py +13 -19
  43. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +77 -11
  44. wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +6 -6
  45. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +107 -23
  46. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +54 -9
  47. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/README.md +6 -5
  48. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +2246 -1279
  49. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +276 -109
  50. wayfinder_paths/strategies/stablecoin_yield_strategy/README.md +1 -1
  51. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +153 -56
  52. wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +16 -12
  53. wayfinder_paths/templates/adapter/README.md +1 -1
  54. wayfinder_paths/templates/strategy/README.md +3 -3
  55. wayfinder_paths/templates/strategy/test_strategy.py +3 -2
  56. {wayfinder_paths-0.1.13.dist-info → wayfinder_paths-0.1.15.dist-info}/METADATA +14 -49
  57. {wayfinder_paths-0.1.13.dist-info → wayfinder_paths-0.1.15.dist-info}/RECORD +59 -60
  58. wayfinder_paths/abis/generic/erc20.json +0 -383
  59. wayfinder_paths/core/clients/AuthClient.py +0 -83
  60. {wayfinder_paths-0.1.13.dist-info → wayfinder_paths-0.1.15.dist-info}/LICENSE +0 -0
  61. {wayfinder_paths-0.1.13.dist-info → wayfinder_paths-0.1.15.dist-info}/WHEEL +0 -0
@@ -8,9 +8,11 @@ so the position remains safe under a stETH/ETH depeg.
8
8
 
9
9
  import asyncio
10
10
  import time
11
- from typing import Any
11
+ from dataclasses import dataclass
12
+ from typing import Any, Optional
12
13
 
13
14
  import httpx
15
+ from eth_utils import to_checksum_address
14
16
  from loguru import logger
15
17
 
16
18
  from wayfinder_paths.adapters.balance_adapter.adapter import BalanceAdapter
@@ -18,6 +20,7 @@ from wayfinder_paths.adapters.brap_adapter.adapter import BRAPAdapter
18
20
  from wayfinder_paths.adapters.ledger_adapter.adapter import LedgerAdapter
19
21
  from wayfinder_paths.adapters.moonwell_adapter.adapter import MoonwellAdapter
20
22
  from wayfinder_paths.adapters.token_adapter.adapter import TokenAdapter
23
+ from wayfinder_paths.core.constants.erc20_abi import ERC20_ABI
21
24
  from wayfinder_paths.core.services.base import Web3Service
22
25
  from wayfinder_paths.core.services.local_token_txn import LocalTokenTxnService
23
26
  from wayfinder_paths.core.services.web3_service import DefaultWeb3Service
@@ -87,17 +90,35 @@ class MoonwellWstethLoopStrategy(Strategy):
87
90
  # When wrapping ETH to WETH for swaps/repayment, avoid draining gas below this floor.
88
91
  # We can dip below MIN_GAS temporarily, but should not wipe the wallet.
89
92
  WRAP_GAS_RESERVE = 0.0014
90
- MIN_USDC_DEPOSIT = 20.0 # Minimum USDC deposit required as initial collateral
93
+ MIN_USDC_DEPOSIT = 10.0 # Minimum USDC deposit required as initial collateral
94
+ MIN_REWARD_CLAIM_USD = 0.30 # Only claim WELL rewards if >= this value
91
95
  MAX_DEPEG = 0.01 # Maximum allowed stETH depeg threshold (1%)
92
96
  MAX_HEALTH_FACTOR = 1.5
93
97
  MIN_HEALTH_FACTOR = 1.2
94
- # Continue levering up if HF is more than this amount above MIN_HEALTH_FACTOR
95
- HF_LEVER_UP_BUFFER = 0.05 # Lever up if HF > MIN + 0.05 (i.e., > 1.25)
98
+ # Operational target HF (keep some buffer above MIN_HEALTH_FACTOR).
99
+ TARGET_HEALTH_FACTOR = 1.25
100
+ # Lever up if HF is more than this amount above TARGET_HEALTH_FACTOR
101
+ HF_LEVER_UP_BUFFER = 0.05 # Lever up if HF > TARGET + buffer
102
+ # Deleverage if HF drops below (TARGET - buffer).
103
+ HF_DELEVERAGE_BUFFER = 0.05
104
+ # Deleverage if leverage multiplier exceeds target by this much.
105
+ LEVERAGE_DELEVERAGE_BUFFER = 0.05
106
+ # During leverage-delever, allow HF to dip temporarily during the withdraw tx
107
+ # (swap+repay follows immediately). Keep well above liquidation.
108
+ LEVERAGE_DELEVER_HF_FLOOR = 1.05
109
+ # How close we need to be to "delta neutral" before we stop trying.
110
+ DELTA_TOL_USD = 5.0
111
+ # Prevent the post-run guard from spinning forever.
112
+ POST_RUN_MAX_PASSES = 2
113
+ # Full-exit (withdraw) dust behavior: do fewer, larger actions so we can repay_full in one go.
114
+ FULL_EXIT_BUFFER_MULT = 1.05
115
+ FULL_EXIT_MIN_BATCH_USD = 10.0
96
116
  _MAX_LOOP_LIMIT = 30 # Prevents infinite loops
97
117
 
98
118
  # Parameters
99
119
  leverage_limit = 10 # Limit on leverage multiplier
100
120
  min_withdraw_usd = 2
121
+ sweep_min_usd = 0.20
101
122
  max_swap_retries = 3 # Maximum number of swap retry attempts
102
123
  swap_slippage_tolerance = 0.005 # Base slippage of 50 bps
103
124
  MAX_SLIPPAGE_TOLERANCE = 0.03 # 3% absolute maximum slippage to prevent MEV attacks
@@ -111,7 +132,7 @@ class MoonwellWstethLoopStrategy(Strategy):
111
132
  description="Leveraged wstETH carry: loops USDC → borrow WETH → swap wstETH → lend. "
112
133
  "Depeg-aware sizing with safety factor. ETH-neutral: WETH debt vs wstETH collateral.",
113
134
  summary="Leveraged wstETH carry on Base with depeg-aware sizing.",
114
- risk_description=f"Protocol risk is always present when engaging with DeFi strategies, this includes underlying DeFi protocols and Wayfinder itself. Additional risks include weth/wsteth depegging (this strategy tracks the peg and is robust up to {int(MAX_DEPEG * 100)}% depeg). The rate spread between weth borrow and wsteth lend may also turn negative. This will likely only be temporary and is very rare. If this persists manual withdraw may be needed.",
135
+ risk_description="Protocol risk is always present when engaging with DeFi strategies, this includes underlying DeFi protocols and Wayfinder itself. Additional risks include wstETH/ETH depeg events which could trigger liquidations, health factor deterioration requiring emergency deleveraging, smart contract risk on Moonwell, and swap slippage during position adjustments. Strategy monitors peg ratio and adjusts leverage ceiling accordingly.",
115
136
  gas_token_symbol="ETH",
116
137
  gas_token_id=ETH_TOKEN_ID,
117
138
  deposit_token_id=USDC_TOKEN_ID,
@@ -171,9 +192,8 @@ class MoonwellWstethLoopStrategy(Strategy):
171
192
  strategy_wallet: dict | None = None,
172
193
  simulation: bool = False,
173
194
  web3_service: Web3Service | None = None,
174
- api_key: str | None = None,
175
195
  ):
176
- super().__init__(api_key=api_key)
196
+ super().__init__()
177
197
  merged_config: dict[str, Any] = dict(config or {})
178
198
  if main_wallet is not None:
179
199
  merged_config["main_wallet"] = main_wallet
@@ -293,6 +313,1330 @@ class MoonwellWstethLoopStrategy(Strategy):
293
313
  wallet = self.config.get("main_wallet", {})
294
314
  return wallet.get("address", "")
295
315
 
316
+ def _gas_keep_wei(self) -> int:
317
+ """
318
+ Hard ETH reserve in the strategy wallet.
319
+ Never spend below this when wrapping/swapping ETH.
320
+ """
321
+ # Extra buffer for a couple txs (wrap + swap/repay).
322
+ tx_buffer = int(0.0003 * 10**18)
323
+ return max(
324
+ int(self.MIN_GAS * 10**18),
325
+ int(self.WRAP_GAS_RESERVE * 10**18) + tx_buffer,
326
+ )
327
+
328
+ @dataclass
329
+ class AccountingSnapshot:
330
+ # raw balances
331
+ wallet_eth: int
332
+ wallet_weth: int
333
+ wallet_wsteth: int
334
+ wallet_usdc: int
335
+
336
+ usdc_supplied: int # underlying raw
337
+ wsteth_supplied: int # underlying raw
338
+ weth_debt: int # borrow raw
339
+
340
+ # prices/decimals
341
+ eth_price: float
342
+ weth_price: float
343
+ wsteth_price: float
344
+ usdc_price: float
345
+
346
+ eth_dec: int
347
+ weth_dec: int
348
+ wsteth_dec: int
349
+ usdc_dec: int
350
+
351
+ # derived USD values
352
+ wallet_usd: float
353
+ supplies_usd: float
354
+ debt_usd: float
355
+ net_equity_usd: float
356
+
357
+ # borrow capacity + risk
358
+ capacity_usd: float
359
+ ltv: float
360
+ hf: float
361
+
362
+ # gas
363
+ gas_keep_wei: int
364
+ eth_usable_wei: int
365
+
366
+ # convenient totals dict for HF simulations (same key shape as existing helpers)
367
+ totals_usd: dict[str, float]
368
+
369
+ async def _accounting_snapshot(
370
+ self, collateral_factors: tuple[float, float] | None = None
371
+ ) -> tuple["MoonwellWstethLoopStrategy.AccountingSnapshot", tuple[float, float]]:
372
+ """
373
+ One snapshot for decisions.
374
+ Keep it deterministic and re-usable across rebalance/unwind paths.
375
+ """
376
+ if collateral_factors is None:
377
+ collateral_factors = await self._get_collateral_factors()
378
+ cf_u, cf_w = collateral_factors
379
+
380
+ # Prices + decimals
381
+ (
382
+ (eth_price, eth_dec),
383
+ (weth_price, weth_dec),
384
+ (wsteth_price, wsteth_dec),
385
+ (usdc_price, usdc_dec),
386
+ ) = await asyncio.gather(
387
+ self._get_token_data(ETH_TOKEN_ID),
388
+ self._get_token_data(WETH_TOKEN_ID),
389
+ self._get_token_data(WSTETH_TOKEN_ID),
390
+ self._get_token_data(USDC_TOKEN_ID),
391
+ )
392
+
393
+ addr = self._get_strategy_wallet_address()
394
+
395
+ # Wallet balances
396
+ wallet_eth, wallet_weth, wallet_wsteth, wallet_usdc = await asyncio.gather(
397
+ self._get_balance_raw(token_id=ETH_TOKEN_ID, wallet_address=addr),
398
+ self._get_balance_raw(token_id=WETH_TOKEN_ID, wallet_address=addr),
399
+ self._get_balance_raw(token_id=WSTETH_TOKEN_ID, wallet_address=addr),
400
+ self._get_balance_raw(token_id=USDC_TOKEN_ID, wallet_address=addr),
401
+ )
402
+
403
+ # Protocol positions (sequential to avoid RPC burst)
404
+ usdc_pos_ok, usdc_pos = await self.moonwell_adapter.get_pos(mtoken=M_USDC)
405
+ wsteth_pos_ok, wsteth_pos = await self.moonwell_adapter.get_pos(mtoken=M_WSTETH)
406
+ weth_pos_ok, weth_pos = await self.moonwell_adapter.get_pos(mtoken=M_WETH)
407
+
408
+ usdc_supplied = (
409
+ int((usdc_pos or {}).get("underlying_balance", 0) or 0)
410
+ if usdc_pos_ok
411
+ else 0
412
+ )
413
+ wsteth_supplied = (
414
+ int((wsteth_pos or {}).get("underlying_balance", 0) or 0)
415
+ if wsteth_pos_ok
416
+ else 0
417
+ )
418
+ weth_debt = (
419
+ int((weth_pos or {}).get("borrow_balance", 0) or 0) if weth_pos_ok else 0
420
+ )
421
+
422
+ # Gas reserve
423
+ gas_keep_wei = int(self._gas_keep_wei())
424
+ eth_usable_wei = max(0, int(wallet_eth) - int(gas_keep_wei))
425
+
426
+ # USD conversions
427
+ def _usd(raw: int, price: float, dec: int) -> float:
428
+ if raw <= 0 or not price or price <= 0:
429
+ return 0.0
430
+ return (raw / (10**dec)) * float(price)
431
+
432
+ wallet_usd = (
433
+ _usd(wallet_eth, eth_price, eth_dec)
434
+ + _usd(wallet_weth, weth_price, weth_dec)
435
+ + _usd(wallet_wsteth, wsteth_price, wsteth_dec)
436
+ + _usd(wallet_usdc, usdc_price, usdc_dec)
437
+ )
438
+ supplies_usd = _usd(usdc_supplied, usdc_price, usdc_dec) + _usd(
439
+ wsteth_supplied, wsteth_price, wsteth_dec
440
+ )
441
+ debt_usd = _usd(weth_debt, weth_price, weth_dec)
442
+
443
+ net_equity_usd = wallet_usd + supplies_usd - debt_usd
444
+
445
+ capacity_usd = cf_u * _usd(usdc_supplied, usdc_price, usdc_dec) + cf_w * _usd(
446
+ wsteth_supplied, wsteth_price, wsteth_dec
447
+ )
448
+ ltv = (
449
+ (debt_usd / capacity_usd)
450
+ if (capacity_usd > 0 and debt_usd > 0)
451
+ else (0.0 if debt_usd <= 0 else float("nan"))
452
+ )
453
+ hf = (capacity_usd / debt_usd) if debt_usd > 0 else float("inf")
454
+
455
+ totals_usd = {
456
+ f"Base_{M_USDC}": _usd(usdc_supplied, usdc_price, usdc_dec),
457
+ f"Base_{M_WSTETH}": _usd(wsteth_supplied, wsteth_price, wsteth_dec),
458
+ f"Base_{WETH}": -debt_usd,
459
+ }
460
+
461
+ snap = self.AccountingSnapshot(
462
+ wallet_eth=int(wallet_eth),
463
+ wallet_weth=int(wallet_weth),
464
+ wallet_wsteth=int(wallet_wsteth),
465
+ wallet_usdc=int(wallet_usdc),
466
+ usdc_supplied=usdc_supplied,
467
+ wsteth_supplied=wsteth_supplied,
468
+ weth_debt=weth_debt,
469
+ eth_price=float(eth_price or 0.0),
470
+ weth_price=float(weth_price or 0.0),
471
+ wsteth_price=float(wsteth_price or 0.0),
472
+ usdc_price=float(usdc_price or 0.0),
473
+ eth_dec=int(eth_dec or 18),
474
+ weth_dec=int(weth_dec or 18),
475
+ wsteth_dec=int(wsteth_dec or 18),
476
+ usdc_dec=int(usdc_dec or 6),
477
+ wallet_usd=float(wallet_usd),
478
+ supplies_usd=float(supplies_usd),
479
+ debt_usd=float(debt_usd),
480
+ net_equity_usd=float(net_equity_usd),
481
+ capacity_usd=float(capacity_usd),
482
+ ltv=float(ltv),
483
+ hf=float(hf),
484
+ gas_keep_wei=gas_keep_wei,
485
+ eth_usable_wei=eth_usable_wei,
486
+ totals_usd=totals_usd,
487
+ )
488
+ return snap, collateral_factors
489
+
490
+ async def _ensure_markets_for_state(
491
+ self, snap: "MoonwellWstethLoopStrategy.AccountingSnapshot"
492
+ ) -> tuple[bool, str]:
493
+ """Idempotently ensure Moonwell markets are entered based on current positions."""
494
+ errors: list[str] = []
495
+
496
+ if snap.usdc_supplied > 0:
497
+ ok, msg = await self.moonwell_adapter.set_collateral(mtoken=M_USDC)
498
+ if not ok:
499
+ errors.append(f"USDC: {msg}")
500
+
501
+ if snap.wsteth_supplied > 0:
502
+ ok, msg = await self.moonwell_adapter.set_collateral(mtoken=M_WSTETH)
503
+ if not ok:
504
+ errors.append(f"wstETH: {msg}")
505
+
506
+ if snap.weth_debt > 0:
507
+ ok, msg = await self.moonwell_adapter.set_collateral(mtoken=M_WETH)
508
+ if not ok:
509
+ errors.append(f"WETH: {msg}")
510
+
511
+ if errors:
512
+ return (False, "; ".join(errors))
513
+ return (True, "markets ensured")
514
+
515
+ def _debt_gap_report(
516
+ self, snap: "MoonwellWstethLoopStrategy.AccountingSnapshot"
517
+ ) -> dict[str, float]:
518
+ """
519
+ Mode-agnostic accounting: how much USDC needs to be raised to repay debt.
520
+ """
521
+ eth_usable_usd = (
522
+ (snap.eth_usable_wei / (10**snap.eth_dec)) * snap.eth_price
523
+ if snap.eth_price
524
+ else 0.0
525
+ )
526
+ repayable_wallet_usd = (
527
+ (snap.wallet_weth / (10**snap.weth_dec)) * snap.weth_price
528
+ + (snap.wallet_wsteth / (10**snap.wsteth_dec)) * snap.wsteth_price
529
+ + (snap.wallet_usdc / (10**snap.usdc_dec)) * snap.usdc_price
530
+ + eth_usable_usd
531
+ )
532
+
533
+ missing_to_repay_usd = max(0.0, snap.debt_usd - repayable_wallet_usd)
534
+
535
+ gas_keep_usd = (
536
+ (snap.gas_keep_wei / (10**snap.eth_dec)) * snap.eth_price
537
+ if snap.eth_price
538
+ else 0.0
539
+ )
540
+ expected_final_usdc_usd = max(0.0, snap.net_equity_usd - gas_keep_usd)
541
+
542
+ return {
543
+ "debt_usd": float(snap.debt_usd),
544
+ "repayable_wallet_usd": float(repayable_wallet_usd),
545
+ "missing_to_repay_usd": float(missing_to_repay_usd),
546
+ "net_equity_usd": float(snap.net_equity_usd),
547
+ "expected_final_usdc_usd_if_fully_unwound": float(expected_final_usdc_usd),
548
+ }
549
+
550
+ def _delta_mismatch_usd(
551
+ self, snap: "MoonwellWstethLoopStrategy.AccountingSnapshot"
552
+ ) -> float:
553
+ """
554
+ Positive => net SHORT (debt > wstETH collateral) in USD terms.
555
+ Negative => net LONG (wstETH collateral > debt).
556
+ """
557
+ wsteth_coll_usd = float(snap.totals_usd.get(f"Base_{M_WSTETH}", 0.0))
558
+ return float(snap.debt_usd) - wsteth_coll_usd
559
+
560
+ def _max_safe_withdraw_usd(
561
+ self,
562
+ *,
563
+ totals_usd: dict[str, float],
564
+ withdraw_key: str,
565
+ collateral_factors: tuple[float, float],
566
+ hf_floor: float,
567
+ precision_usd: float = 0.50,
568
+ ) -> float:
569
+ """
570
+ Maximum USD amount of a given collateral you can remove while keeping HF >= hf_floor,
571
+ considering only the withdrawal step (before the subsequent swap+repay improves HF).
572
+ """
573
+ current_val = float(totals_usd.get(withdraw_key, 0.0))
574
+ if current_val <= 0:
575
+ return 0.0
576
+
577
+ debt_usd = abs(float(totals_usd.get(f"Base_{WETH}", 0.0)))
578
+ if debt_usd <= 0:
579
+ return current_val # if no debt, you can withdraw all
580
+
581
+ cf_u, cf_w = collateral_factors
582
+ usdc_key = f"Base_{M_USDC}"
583
+ wsteth_key = f"Base_{M_WSTETH}"
584
+ usdc_coll = float(totals_usd.get(usdc_key, 0.0))
585
+ wsteth_coll = float(totals_usd.get(wsteth_key, 0.0))
586
+
587
+ def hf_after(withdraw_usd: float) -> float:
588
+ u = usdc_coll - withdraw_usd if withdraw_key == usdc_key else usdc_coll
589
+ w = (
590
+ wsteth_coll - withdraw_usd
591
+ if withdraw_key == wsteth_key
592
+ else wsteth_coll
593
+ )
594
+ u = max(0.0, u)
595
+ w = max(0.0, w)
596
+ cap = cf_u * u + cf_w * w
597
+ return cap / debt_usd if debt_usd > 0 else float("inf")
598
+
599
+ lo, hi = 0.0, current_val
600
+ for _ in range(30):
601
+ mid = 0.5 * (lo + hi)
602
+ if hf_after(mid) >= hf_floor:
603
+ lo = mid
604
+ else:
605
+ hi = mid
606
+ if (hi - lo) <= precision_usd:
607
+ break
608
+
609
+ return max(0.0, lo)
610
+
611
+ async def _post_run_guard(
612
+ self,
613
+ *,
614
+ mode: str = "operate",
615
+ prior_error: Exception | None = None,
616
+ ) -> tuple[bool, str]:
617
+ """
618
+ Always-run finalizer. Attempts to restore:
619
+ - HF >= MIN_HEALTH_FACTOR
620
+ - Not net-short ETH (wstETH collateral >= WETH debt, within tolerance)
621
+
622
+ mode:
623
+ - "operate": keep strategy running normally after guard
624
+ - "exit": conservative (don't buy/lend; only reconcile wallet collateral + delever)
625
+ """
626
+ logger.info("-" * 40)
627
+ logger.info(f"POST-RUN GUARD: mode={mode}")
628
+ if prior_error:
629
+ logger.warning(f" Prior error: {prior_error}")
630
+ logger.info("-" * 40)
631
+
632
+ try:
633
+ gas = await self._get_gas_balance()
634
+ if gas < int(self.MAINTENANCE_GAS * 10**18):
635
+ return (
636
+ False,
637
+ f"post-run guard: insufficient gas ({gas / 1e18:.6f} ETH)",
638
+ )
639
+
640
+ collateral_factors = await self._get_collateral_factors()
641
+
642
+ snap, _ = await self._accounting_snapshot(
643
+ collateral_factors=collateral_factors
644
+ )
645
+ ok, msg = await self._ensure_markets_for_state(snap)
646
+ if not ok:
647
+ return (False, f"post-run guard: failed ensuring markets: {msg}")
648
+
649
+ # 0) Post collateral already in the wallet: lend loose wstETH and ensure collateral.
650
+ if snap.wallet_wsteth > 0:
651
+ ok, msg = await self.moonwell_adapter.lend(
652
+ mtoken=M_WSTETH,
653
+ underlying_token=WSTETH,
654
+ amount=int(snap.wallet_wsteth),
655
+ )
656
+ if not ok:
657
+ return (
658
+ False,
659
+ f"post-run guard: failed lending wallet wstETH: {msg}",
660
+ )
661
+ ok, msg = await self.moonwell_adapter.set_collateral(mtoken=M_WSTETH)
662
+ if not ok:
663
+ return (
664
+ False,
665
+ f"post-run guard: failed ensuring wstETH collateral: {msg}",
666
+ )
667
+ snap, _ = await self._accounting_snapshot(
668
+ collateral_factors=collateral_factors
669
+ )
670
+
671
+ # 1) HF safety check: deleverage until HF >= MIN_HEALTH_FACTOR.
672
+ if snap.hf < float(self.MIN_HEALTH_FACTOR):
673
+ if snap.capacity_usd <= 0:
674
+ return (
675
+ False,
676
+ "post-run guard: HF low but capacity_usd<=0; cannot compute deleverage target",
677
+ )
678
+ try:
679
+ ok, msg = await self._settle_weth_debt_to_target_usd(
680
+ target_debt_usd=0.0,
681
+ target_hf=float(self.MIN_HEALTH_FACTOR),
682
+ collateral_factors=collateral_factors,
683
+ mode="exit",
684
+ max_batch_usd=2500.0,
685
+ max_steps=20,
686
+ )
687
+ except SwapOutcomeUnknownError as exc:
688
+ return (
689
+ False,
690
+ f"post-run guard: swap outcome unknown during deleverage: {exc}",
691
+ )
692
+ if not ok:
693
+ return (False, f"post-run guard: deleverage failed: {msg}")
694
+
695
+ snap, _ = await self._accounting_snapshot(
696
+ collateral_factors=collateral_factors
697
+ )
698
+
699
+ # 2) Delta guard: keep ETH delta roughly neutral.
700
+ # We treat delta mismatch as (debt_usd - wstETH_collateral_usd).
701
+ tol = max(float(self.DELTA_TOL_USD), float(self.min_withdraw_usd))
702
+
703
+ for _ in range(int(self.POST_RUN_MAX_PASSES)):
704
+ mismatch = self._delta_mismatch_usd(snap) # + => net short
705
+ short_usd = max(0.0, float(mismatch))
706
+ long_usd = max(0.0, float(-mismatch))
707
+
708
+ if snap.weth_debt <= 1:
709
+ return (True, f"post-run guard: no debt (hf={snap.hf:.3f})")
710
+
711
+ if abs(float(mismatch)) <= float(tol):
712
+ return (
713
+ True,
714
+ "post-run guard: delta ok "
715
+ f"(short=${short_usd:.2f}, long=${long_usd:.2f}, hf={snap.hf:.3f})",
716
+ )
717
+
718
+ # Net long: unwind excess wstETH into USDC collateral (operate mode only).
719
+ if mismatch < -tol:
720
+ if mode == "exit":
721
+ return (
722
+ True,
723
+ "post-run guard: delta ok for exit "
724
+ f"(short=${short_usd:.2f}, long=${long_usd:.2f}, hf={snap.hf:.3f})",
725
+ )
726
+
727
+ unwind_usd = max(0.0, float(long_usd) - float(tol))
728
+ try:
729
+ ok, msg = await self._reduce_wsteth_long_to_usdc_collateral(
730
+ collateral_factors=collateral_factors,
731
+ unwind_usd=float(unwind_usd),
732
+ hf_floor=float(self.MIN_HEALTH_FACTOR),
733
+ max_batch_usd=8000.0,
734
+ )
735
+ if not ok:
736
+ logger.warning(f"post-run guard: long unwind failed: {msg}")
737
+ else:
738
+ logger.info(f"post-run guard: long unwind: {msg}")
739
+ except SwapOutcomeUnknownError as exc:
740
+ return (
741
+ False,
742
+ f"post-run guard: swap outcome unknown during long unwind: {exc}",
743
+ )
744
+
745
+ snap, _ = await self._accounting_snapshot(
746
+ collateral_factors=collateral_factors
747
+ )
748
+ continue
749
+
750
+ # 2a) Try to fix delta without touching collateral: complete borrow→swap→lend if stuck.
751
+ if mode != "exit":
752
+ try:
753
+ ok, msg = await self._reconcile_wallet_into_position(
754
+ collateral_factors=collateral_factors,
755
+ max_batch_usd=8000.0,
756
+ )
757
+ if not ok:
758
+ logger.warning(f"post-run guard: reconcile failed: {msg}")
759
+ except SwapOutcomeUnknownError as exc:
760
+ logger.warning(
761
+ f"post-run guard: swap outcome unknown during reconcile: {exc}"
762
+ )
763
+
764
+ snap, _ = await self._accounting_snapshot(
765
+ collateral_factors=collateral_factors
766
+ )
767
+ mismatch = self._delta_mismatch_usd(snap)
768
+ short_usd = max(0.0, float(mismatch))
769
+ long_usd = max(0.0, float(-mismatch))
770
+
771
+ if abs(float(mismatch)) <= float(tol):
772
+ return (
773
+ True,
774
+ "post-run guard: delta restored by reconcile "
775
+ f"(short=${short_usd:.2f}, long=${long_usd:.2f}, hf={snap.hf:.3f})",
776
+ )
777
+
778
+ # 2b) Still net short: reduce debt down to what wstETH collateral can cover.
779
+ wsteth_coll_usd = float(snap.totals_usd.get(f"Base_{M_WSTETH}", 0.0))
780
+ target_debt_usd = max(0.0, wsteth_coll_usd - 1.0)
781
+ try:
782
+ ok, msg = await self._settle_weth_debt_to_target_usd(
783
+ target_debt_usd=float(target_debt_usd),
784
+ collateral_factors=collateral_factors,
785
+ mode="exit",
786
+ max_batch_usd=2500.0,
787
+ max_steps=20,
788
+ )
789
+ except SwapOutcomeUnknownError as exc:
790
+ return (
791
+ False,
792
+ f"post-run guard: swap outcome unknown during delta delever: {exc}",
793
+ )
794
+ if not ok:
795
+ return (False, f"post-run guard: could not delever to delta: {msg}")
796
+
797
+ snap, _ = await self._accounting_snapshot(
798
+ collateral_factors=collateral_factors
799
+ )
800
+
801
+ mismatch = self._delta_mismatch_usd(snap)
802
+ short_usd = max(0.0, float(mismatch))
803
+ long_usd = max(0.0, float(-mismatch))
804
+ if mismatch > tol:
805
+ return (
806
+ False,
807
+ "post-run guard: exceeded passes; "
808
+ f"remaining short=${short_usd:.2f}, long=${long_usd:.2f}, hf={snap.hf:.3f}",
809
+ )
810
+ return (
811
+ True,
812
+ "post-run guard: exceeded passes; "
813
+ f"remaining short=${short_usd:.2f}, long=${long_usd:.2f}, hf={snap.hf:.3f}",
814
+ )
815
+
816
+ except Exception as exc:
817
+ if prior_error is not None:
818
+ logger.warning(
819
+ f"post-run guard crashed after prior error {type(prior_error).__name__}: {prior_error}"
820
+ )
821
+ return (False, f"post-run guard crashed: {type(exc).__name__}: {exc}")
822
+
823
+ def _target_leverage(
824
+ self,
825
+ *,
826
+ collateral_factors: tuple[float, float],
827
+ target_hf: float | None = None,
828
+ ) -> float:
829
+ """Compute the target leverage multiplier implied by HF + collateral factors.
830
+
831
+ In this strategy, leverage is defined as (wstETH_supply_usd / usdc_supply_usd) + 1.
832
+ """
833
+ cf_u, cf_w = collateral_factors
834
+ hf = (
835
+ float(target_hf)
836
+ if target_hf is not None
837
+ else float(self.TARGET_HEALTH_FACTOR)
838
+ )
839
+
840
+ if cf_u <= 0 or hf <= 0:
841
+ return 0.0
842
+
843
+ denominator = float(hf) + 0.001 - float(cf_w)
844
+ if denominator <= 0:
845
+ return 0.0
846
+
847
+ return float(cf_u) / float(denominator) + 1.0
848
+
849
+ async def _delever_wsteth_to_target_leverage(
850
+ self,
851
+ *,
852
+ target_leverage: float,
853
+ collateral_factors: tuple[float, float],
854
+ max_over_leverage: float | None = None,
855
+ max_batch_usd: float = 4000.0,
856
+ max_steps: int = 10,
857
+ ) -> tuple[bool, str]:
858
+ """Reduce leverage by withdrawing wstETH collateral and repaying WETH debt."""
859
+ band = (
860
+ float(max_over_leverage)
861
+ if max_over_leverage is not None
862
+ else float(self.LEVERAGE_DELEVERAGE_BUFFER)
863
+ )
864
+ band = max(0.0, float(band))
865
+
866
+ if not target_leverage or float(target_leverage) <= 0:
867
+ return (True, "Target leverage unavailable; skipping leverage delever")
868
+
869
+ addr = self._get_strategy_wallet_address()
870
+
871
+ for _step in range(int(max_steps)):
872
+ snap, _ = await self._accounting_snapshot(
873
+ collateral_factors=collateral_factors
874
+ )
875
+
876
+ usdc_key = f"Base_{M_USDC}"
877
+ wsteth_key = f"Base_{M_WSTETH}"
878
+ usdc_lend_value = float(snap.totals_usd.get(usdc_key, 0.0))
879
+ wsteth_lend_value = float(snap.totals_usd.get(wsteth_key, 0.0))
880
+
881
+ if usdc_lend_value <= 0 or wsteth_lend_value <= 0:
882
+ return (True, "No leveraged wstETH position to delever")
883
+
884
+ current_leverage = wsteth_lend_value / usdc_lend_value + 1.0
885
+ if current_leverage <= float(target_leverage) + float(band):
886
+ return (
887
+ True,
888
+ f"Leverage within band. leverage={current_leverage:.2f}x "
889
+ f"<= target+buffer={(float(target_leverage) + float(band)):.2f}x",
890
+ )
891
+
892
+ if snap.weth_debt <= 0:
893
+ return (True, "No WETH debt; nothing to delever")
894
+
895
+ if not snap.wsteth_price or snap.wsteth_price <= 0:
896
+ return (False, "wstETH price unavailable; cannot delever safely")
897
+
898
+ target_wsteth_usd = max(
899
+ 0.0, (float(target_leverage) - 1.0) * float(usdc_lend_value)
900
+ )
901
+ remaining_usd = max(
902
+ 0.0, float(wsteth_lend_value) - float(target_wsteth_usd)
903
+ )
904
+ batch_usd = min(float(max_batch_usd), float(remaining_usd))
905
+
906
+ safe_withdraw_usd = self._max_safe_withdraw_usd(
907
+ totals_usd=snap.totals_usd,
908
+ withdraw_key=wsteth_key,
909
+ collateral_factors=collateral_factors,
910
+ hf_floor=float(self.LEVERAGE_DELEVER_HF_FLOOR),
911
+ )
912
+ withdraw_usd = min(float(batch_usd), float(safe_withdraw_usd))
913
+
914
+ if withdraw_usd <= max(1.0, float(self.min_withdraw_usd)):
915
+ return (
916
+ False,
917
+ "Unable to safely withdraw wstETH collateral to delever "
918
+ f"(safe_withdraw_usd=${safe_withdraw_usd:.2f}, remaining_usd=${remaining_usd:.2f}, hf={snap.hf:.3f})",
919
+ )
920
+
921
+ underlying_raw = (
922
+ int(withdraw_usd / snap.wsteth_price * 10**snap.wsteth_dec) + 1
923
+ )
924
+ if underlying_raw <= 0:
925
+ return (False, "Calculated delever withdrawal amount was 0")
926
+
927
+ mw_res = await self.moonwell_adapter.max_withdrawable_mtoken(
928
+ mtoken=M_WSTETH
929
+ )
930
+ if not mw_res[0]:
931
+ return (
932
+ False,
933
+ f"Failed to compute max withdrawable wstETH: {mw_res[1]}",
934
+ )
935
+ withdraw_info = mw_res[1]
936
+ if not isinstance(withdraw_info, dict):
937
+ return (False, f"Bad withdraw info for wstETH: {withdraw_info}")
938
+
939
+ mtoken_amt = self._mtoken_amount_for_underlying(
940
+ withdraw_info, underlying_raw
941
+ )
942
+ if mtoken_amt <= 0:
943
+ return (
944
+ False,
945
+ "Could not compute a withdrawable mToken amount for delever",
946
+ )
947
+
948
+ ok, unlend_res = await self.moonwell_adapter.unlend(
949
+ mtoken=M_WSTETH, amount=mtoken_amt
950
+ )
951
+ if not ok:
952
+ return (False, f"Failed to unlend wstETH for delever: {unlend_res}")
953
+
954
+ pinned_block = self._pinned_block(unlend_res)
955
+
956
+ wallet_wsteth = await self._get_balance_raw(
957
+ token_id=WSTETH_TOKEN_ID,
958
+ wallet_address=addr,
959
+ block_identifier=pinned_block,
960
+ )
961
+ amount_to_swap = min(int(wallet_wsteth), int(underlying_raw))
962
+ if amount_to_swap <= 0:
963
+ return (
964
+ False,
965
+ "Delever unlend succeeded but no wstETH observed in wallet "
966
+ f"(wallet_wsteth={wallet_wsteth}, pinned_block={pinned_block})",
967
+ )
968
+
969
+ repaid = await self._swap_to_weth_and_repay(
970
+ WSTETH_TOKEN_ID, amount_to_swap, snap.weth_debt
971
+ )
972
+ if repaid <= 0:
973
+ logger.warning(
974
+ "Leverage delever swap->repay failed; re-lending wstETH to restore position"
975
+ )
976
+ relend_bal = await self._get_balance_raw(
977
+ token_id=WSTETH_TOKEN_ID, wallet_address=addr
978
+ )
979
+ if relend_bal > 0:
980
+ await self.moonwell_adapter.lend(
981
+ mtoken=M_WSTETH, underlying_token=WSTETH, amount=relend_bal
982
+ )
983
+ await self.moonwell_adapter.set_collateral(mtoken=M_WSTETH)
984
+
985
+ return (
986
+ False,
987
+ "Failed swapping wstETH->WETH and repaying during leverage delever",
988
+ )
989
+
990
+ return (False, f"Exceeded max_steps={max_steps} while deleveraging leverage")
991
+
992
+ async def _reconcile_wallet_into_position(
993
+ self,
994
+ *,
995
+ collateral_factors: tuple[float, float],
996
+ max_batch_usd: float = 5000.0,
997
+ ) -> tuple[bool, str]:
998
+ """
999
+ Operate-mode reconciliation:
1000
+ - Always lend loose wallet wstETH (no swaps).
1001
+ - If there is WETH debt and wstETH collateral is short, use wallet WETH then wallet ETH
1002
+ (above gas reserve) to buy wstETH and lend it.
1003
+ """
1004
+ snap, _ = await self._accounting_snapshot(collateral_factors=collateral_factors)
1005
+ addr = self._get_strategy_wallet_address()
1006
+
1007
+ # 1) Lend loose wallet wstETH first
1008
+ if snap.wallet_wsteth > 0:
1009
+ usd_val = (
1010
+ (snap.wallet_wsteth / 10**snap.wsteth_dec) * snap.wsteth_price
1011
+ if snap.wsteth_price
1012
+ else 0.0
1013
+ )
1014
+ if usd_val >= float(self.min_withdraw_usd):
1015
+ ok, msg = await self.moonwell_adapter.lend(
1016
+ mtoken=M_WSTETH,
1017
+ underlying_token=WSTETH,
1018
+ amount=int(snap.wallet_wsteth),
1019
+ )
1020
+ if not ok:
1021
+ return (False, f"Failed to lend wallet wstETH: {msg}")
1022
+ # Ensure it's collateral (idempotent).
1023
+ await self.moonwell_adapter.set_collateral(mtoken=M_WSTETH)
1024
+
1025
+ # Refresh (cheap) for next decisions
1026
+ snap, _ = await self._accounting_snapshot(collateral_factors=collateral_factors)
1027
+
1028
+ if snap.weth_debt <= 0 or not snap.weth_price or snap.weth_price <= 0:
1029
+ logger.info(
1030
+ " Result: No WETH debt (or missing price); wallet reconciliation done"
1031
+ )
1032
+ return (True, "No WETH debt (or missing price); wallet reconciliation done")
1033
+
1034
+ # 2) How much wstETH collateral are we missing vs debt (delta-neutral intent)
1035
+ wsteth_coll_usd = snap.totals_usd.get(f"Base_{M_WSTETH}", 0.0)
1036
+ deficit_usd = max(0.0, float(snap.debt_usd) - float(wsteth_coll_usd))
1037
+
1038
+ if deficit_usd <= max(1.0, float(self.min_withdraw_usd)):
1039
+ logger.info(
1040
+ " Result: wstETH collateral roughly matches WETH debt; wallet reconciliation done"
1041
+ )
1042
+ return (
1043
+ True,
1044
+ "wstETH collateral roughly matches WETH debt; wallet reconciliation done",
1045
+ )
1046
+
1047
+ deficit_usd = min(deficit_usd, float(max_batch_usd))
1048
+ needed_weth_raw = (
1049
+ int(deficit_usd / snap.weth_price * 10**snap.weth_dec / (1 - 0.005)) + 1
1050
+ )
1051
+
1052
+ # 3) Use wallet WETH -> wstETH
1053
+ used_any = False
1054
+ if snap.wallet_weth > 0 and needed_weth_raw > 0:
1055
+ amt = min(int(snap.wallet_weth), int(needed_weth_raw))
1056
+ if amt > 0:
1057
+ wsteth_before = await self._get_balance_raw(
1058
+ token_id=WSTETH_TOKEN_ID, wallet_address=addr
1059
+ )
1060
+ swap_res = await self._swap_with_retries(
1061
+ from_token_id=WETH_TOKEN_ID,
1062
+ to_token_id=WSTETH_TOKEN_ID,
1063
+ amount=amt,
1064
+ preferred_providers=["aerodrome", "enso"],
1065
+ )
1066
+ if swap_res is None:
1067
+ return (
1068
+ False,
1069
+ "Failed swapping wallet WETH->wstETH during reconciliation",
1070
+ )
1071
+ pinned_block = self._pinned_block(swap_res)
1072
+ wsteth_after = await self._get_balance_raw(
1073
+ token_id=WSTETH_TOKEN_ID,
1074
+ wallet_address=addr,
1075
+ block_identifier=pinned_block,
1076
+ )
1077
+ got = max(0, int(wsteth_after) - int(wsteth_before))
1078
+ if got > 0:
1079
+ ok, msg = await self.moonwell_adapter.lend(
1080
+ mtoken=M_WSTETH, underlying_token=WSTETH, amount=got
1081
+ )
1082
+ if not ok:
1083
+ return (False, f"Failed lending swapped wstETH: {msg}")
1084
+ await self.moonwell_adapter.set_collateral(mtoken=M_WSTETH)
1085
+ used_any = True
1086
+
1087
+ needed_weth_raw = max(0, int(needed_weth_raw) - int(amt))
1088
+
1089
+ # 4) Use wallet ETH (above reserve) -> wstETH
1090
+ if needed_weth_raw > 0 and snap.eth_usable_wei > 0:
1091
+ eth_amt = min(int(snap.eth_usable_wei), int(needed_weth_raw))
1092
+ if eth_amt > 0:
1093
+ wsteth_before = await self._get_balance_raw(
1094
+ token_id=WSTETH_TOKEN_ID, wallet_address=addr
1095
+ )
1096
+ swap_res = await self._swap_with_retries(
1097
+ from_token_id=ETH_TOKEN_ID,
1098
+ to_token_id=WSTETH_TOKEN_ID,
1099
+ amount=eth_amt,
1100
+ preferred_providers=["aerodrome", "enso"],
1101
+ )
1102
+ if swap_res is None:
1103
+ return (
1104
+ False,
1105
+ "Failed swapping usable wallet ETH->wstETH during reconciliation",
1106
+ )
1107
+ pinned_block = self._pinned_block(swap_res)
1108
+ wsteth_after = await self._get_balance_raw(
1109
+ token_id=WSTETH_TOKEN_ID,
1110
+ wallet_address=addr,
1111
+ block_identifier=pinned_block,
1112
+ )
1113
+ got = max(0, int(wsteth_after) - int(wsteth_before))
1114
+ if got > 0:
1115
+ ok, msg = await self.moonwell_adapter.lend(
1116
+ mtoken=M_WSTETH, underlying_token=WSTETH, amount=got
1117
+ )
1118
+ if not ok:
1119
+ return (False, f"Failed lending swapped wstETH: {msg}")
1120
+ await self.moonwell_adapter.set_collateral(mtoken=M_WSTETH)
1121
+ used_any = True
1122
+
1123
+ return (
1124
+ True,
1125
+ "Wallet reconciliation completed"
1126
+ if used_any
1127
+ else "No usable wallet assets to reconcile further",
1128
+ )
1129
+
1130
+ async def _reduce_wsteth_long_to_usdc_collateral(
1131
+ self,
1132
+ *,
1133
+ collateral_factors: tuple[float, float],
1134
+ unwind_usd: float,
1135
+ hf_floor: float,
1136
+ max_batch_usd: float = 8000.0,
1137
+ ) -> tuple[bool, str]:
1138
+ """Reduce net long wstETH exposure by converting mwstETH collateral into mUSDC."""
1139
+ if unwind_usd <= 0:
1140
+ return (True, "No wstETH long to unwind")
1141
+
1142
+ addr = self._get_strategy_wallet_address()
1143
+ snap, _ = await self._accounting_snapshot(collateral_factors=collateral_factors)
1144
+
1145
+ if not snap.wsteth_price or snap.wsteth_price <= 0:
1146
+ return (False, "wstETH price unavailable; cannot unwind long safely")
1147
+
1148
+ safe_unlend_usd = self._max_safe_withdraw_usd(
1149
+ totals_usd=snap.totals_usd,
1150
+ withdraw_key=f"Base_{M_WSTETH}",
1151
+ collateral_factors=collateral_factors,
1152
+ hf_floor=float(hf_floor),
1153
+ )
1154
+ desired_usd = min(
1155
+ float(unwind_usd), float(max_batch_usd), float(safe_unlend_usd)
1156
+ )
1157
+
1158
+ min_usd = max(1.0, float(self.min_withdraw_usd))
1159
+ if desired_usd < min_usd:
1160
+ return (
1161
+ True,
1162
+ f"wstETH long unwind not needed/too small (desired=${desired_usd:.2f})",
1163
+ )
1164
+
1165
+ desired_underlying_raw = (
1166
+ int(desired_usd / snap.wsteth_price * 10**snap.wsteth_dec) + 1
1167
+ )
1168
+ if desired_underlying_raw <= 0:
1169
+ return (False, "Computed 0 wstETH to unlend")
1170
+
1171
+ mw_ok, mw_info = await self.moonwell_adapter.max_withdrawable_mtoken(
1172
+ mtoken=M_WSTETH
1173
+ )
1174
+ if not mw_ok or not isinstance(mw_info, dict):
1175
+ return (False, f"Failed to compute withdrawable mwstETH: {mw_info}")
1176
+
1177
+ mtoken_amt = self._mtoken_amount_for_underlying(mw_info, desired_underlying_raw)
1178
+ if mtoken_amt <= 0:
1179
+ return (False, "mwstETH withdrawable amount is 0 (cash/shortfall bound)")
1180
+
1181
+ ok, msg = await self.moonwell_adapter.unlend(mtoken=M_WSTETH, amount=mtoken_amt)
1182
+ if not ok:
1183
+ return (False, f"Failed to redeem mwstETH to unwind long: {msg}")
1184
+
1185
+ pinned_block = self._pinned_block(msg)
1186
+ wsteth_wallet_raw = await self._balance_after_tx(
1187
+ token_id=WSTETH_TOKEN_ID,
1188
+ wallet=addr,
1189
+ pinned_block=pinned_block,
1190
+ min_expected=1,
1191
+ attempts=5,
1192
+ )
1193
+ if wsteth_wallet_raw <= 0 and pinned_block is not None:
1194
+ wsteth_wallet_raw = await self._balance_after_tx(
1195
+ token_id=WSTETH_TOKEN_ID,
1196
+ wallet=addr,
1197
+ pinned_block=None,
1198
+ min_expected=1,
1199
+ attempts=5,
1200
+ )
1201
+
1202
+ amount_to_swap = min(int(wsteth_wallet_raw), int(desired_underlying_raw))
1203
+ if amount_to_swap <= 0:
1204
+ return (False, "No wstETH available in wallet after unlend")
1205
+
1206
+ swap_res = await self._swap_with_retries(
1207
+ from_token_id=WSTETH_TOKEN_ID,
1208
+ to_token_id=USDC_TOKEN_ID,
1209
+ amount=amount_to_swap,
1210
+ preferred_providers=["aerodrome", "enso"],
1211
+ )
1212
+ if swap_res is None:
1213
+ # Restore collateral: re-lend wstETH if the swap fails.
1214
+ restore_amt = await self._get_balance_raw(
1215
+ token_id=WSTETH_TOKEN_ID,
1216
+ wallet_address=addr,
1217
+ block_identifier=pinned_block,
1218
+ )
1219
+ if restore_amt > 0:
1220
+ await self.moonwell_adapter.lend(
1221
+ mtoken=M_WSTETH,
1222
+ underlying_token=WSTETH,
1223
+ amount=restore_amt,
1224
+ )
1225
+ await self.moonwell_adapter.set_collateral(mtoken=M_WSTETH)
1226
+ return (False, "Failed swapping wstETH->USDC while unwinding long")
1227
+
1228
+ # Lend resulting USDC back as collateral (idempotent).
1229
+ usdc_wallet_raw = await self._get_balance_raw(
1230
+ token_id=USDC_TOKEN_ID, wallet_address=addr
1231
+ )
1232
+ if usdc_wallet_raw > 0:
1233
+ lend_ok, lend_msg = await self.moonwell_adapter.lend(
1234
+ mtoken=M_USDC, underlying_token=USDC, amount=int(usdc_wallet_raw)
1235
+ )
1236
+ if not lend_ok:
1237
+ return (
1238
+ False,
1239
+ f"wstETH->USDC swap succeeded but lending USDC failed: {lend_msg}",
1240
+ )
1241
+ await self.moonwell_adapter.set_collateral(mtoken=M_USDC)
1242
+
1243
+ return (
1244
+ True,
1245
+ f"Reduced long wstETH by ≈${desired_usd:.2f} via mwstETH->wstETH->USDC->mUSDC",
1246
+ )
1247
+
1248
+ async def _settle_weth_debt_to_target_usd(
1249
+ self,
1250
+ *,
1251
+ target_debt_usd: float,
1252
+ target_hf: float | None = None,
1253
+ collateral_factors: tuple[float, float],
1254
+ mode: str, # "operate" or "exit"
1255
+ max_batch_usd: float = 4000.0,
1256
+ max_steps: int = 20,
1257
+ ) -> tuple[bool, str]:
1258
+ """
1259
+ Reduce debt until the specified target is met.
1260
+ Uses wallet assets first; if insufficient, redeems collateral in HF-safe batches.
1261
+
1262
+ mode="operate": be conservative about collateral withdrawals (HF floor ~ MIN_HEALTH_FACTOR)
1263
+ mode="exit": allow lower HF floor during the withdraw step (still > 1.05), since repay comes right after.
1264
+ """
1265
+ logger.info(
1266
+ f"SETTLE DEBT: target_debt=${target_debt_usd:.2f}, target_hf={target_hf}, mode={mode}"
1267
+ )
1268
+ addr = self._get_strategy_wallet_address()
1269
+ effective_target_hf = float(target_hf) if target_hf is not None else None
1270
+ if effective_target_hf is not None and effective_target_hf <= 0:
1271
+ effective_target_hf = None
1272
+
1273
+ is_full_exit = (
1274
+ mode == "exit"
1275
+ and float(target_debt_usd) <= 0.0
1276
+ and effective_target_hf is None
1277
+ )
1278
+
1279
+ def _debt_target_usd(
1280
+ snap: "MoonwellWstethLoopStrategy.AccountingSnapshot",
1281
+ ) -> float:
1282
+ if is_full_exit:
1283
+ return 0.0
1284
+ if effective_target_hf is not None:
1285
+ if snap.capacity_usd <= 0:
1286
+ return 0.0
1287
+ return float(snap.capacity_usd) / float(effective_target_hf)
1288
+ return float(target_debt_usd)
1289
+
1290
+ for step in range(max_steps):
1291
+ snap, _ = await self._accounting_snapshot(
1292
+ collateral_factors=collateral_factors
1293
+ )
1294
+
1295
+ if is_full_exit:
1296
+ # Treat 1 wei dust as cleared (repay_full allows <=1 wei remaining).
1297
+ if snap.weth_debt <= 1:
1298
+ return (
1299
+ True,
1300
+ f"Debt settled. debt=${snap.debt_usd:.6f} (weth_debt={snap.weth_debt})",
1301
+ )
1302
+ elif effective_target_hf is not None:
1303
+ if snap.hf >= effective_target_hf:
1304
+ return (
1305
+ True,
1306
+ f"HF target reached. hf={snap.hf:.3f} >= target={effective_target_hf:.3f}",
1307
+ )
1308
+ elif snap.debt_usd <= target_debt_usd + 1.0:
1309
+ return (
1310
+ True,
1311
+ f"Debt settled to target. debt=${snap.debt_usd:.2f} <= target=${target_debt_usd:.2f}",
1312
+ )
1313
+
1314
+ if not snap.weth_price or snap.weth_price <= 0:
1315
+ return (False, "WETH price unavailable; cannot settle debt safely")
1316
+
1317
+ debt_target_usd = _debt_target_usd(snap)
1318
+ remaining_usd = max(0.0, float(snap.debt_usd) - float(debt_target_usd))
1319
+ if is_full_exit:
1320
+ # For full exit, aim to source a small WETH buffer so repayBorrow(MAX_UINT256)
1321
+ # can fully clear the debt even with minor interest/rounding drift.
1322
+ remaining_usd = snap.debt_usd
1323
+ batch_usd = min(
1324
+ float(max_batch_usd),
1325
+ max(
1326
+ float(remaining_usd) * float(self.FULL_EXIT_BUFFER_MULT),
1327
+ float(self.FULL_EXIT_MIN_BATCH_USD),
1328
+ ),
1329
+ )
1330
+ else:
1331
+ batch_usd = min(float(max_batch_usd), float(remaining_usd))
1332
+ batch_weth_raw = int(batch_usd / snap.weth_price * 10**snap.weth_dec) + 1
1333
+
1334
+ progressed = False
1335
+
1336
+ # 1) Wallet WETH -> repay
1337
+ if snap.wallet_weth > 0:
1338
+ repay_amt = min(int(snap.wallet_weth), int(batch_weth_raw))
1339
+ if repay_amt > 0:
1340
+ repaid = await self._repay_weth(repay_amt, snap.weth_debt)
1341
+ if repaid > 0:
1342
+ progressed = True
1343
+ continue
1344
+
1345
+ # 2) Wallet ETH (above reserve) -> wrap -> repay
1346
+ if snap.eth_usable_wei > 0:
1347
+ wrap_amt = min(int(snap.eth_usable_wei), int(batch_weth_raw))
1348
+ if wrap_amt > 0:
1349
+ wrap_ok, wrap_msg = await self.moonwell_adapter.wrap_eth(
1350
+ amount=wrap_amt
1351
+ )
1352
+ if wrap_ok:
1353
+ pinned_block = self._pinned_block(wrap_msg)
1354
+ weth_now = await self._get_balance_raw(
1355
+ token_id=WETH_TOKEN_ID,
1356
+ wallet_address=addr,
1357
+ block_identifier=pinned_block,
1358
+ )
1359
+ if weth_now > 0:
1360
+ repaid = await self._repay_weth(weth_now, snap.weth_debt)
1361
+ if repaid > 0:
1362
+ progressed = True
1363
+ continue
1364
+ else:
1365
+ logger.warning(
1366
+ f"wrap_eth failed during debt settle: {wrap_msg}"
1367
+ )
1368
+
1369
+ # 3) Wallet wstETH -> swap -> repay (skip dust)
1370
+ wallet_wsteth_usd = (
1371
+ (snap.wallet_wsteth / (10**snap.wsteth_dec)) * float(snap.wsteth_price)
1372
+ if snap.wsteth_price
1373
+ else 0.0
1374
+ )
1375
+ if (
1376
+ snap.wallet_wsteth > 0
1377
+ and snap.wsteth_price
1378
+ and snap.wsteth_price > 0
1379
+ and wallet_wsteth_usd >= 1.0
1380
+ ):
1381
+ needed_wsteth_usd = batch_usd * 1.02
1382
+ needed_wsteth_raw = (
1383
+ int(needed_wsteth_usd / snap.wsteth_price * 10**snap.wsteth_dec) + 1
1384
+ )
1385
+ swap_amt = min(int(snap.wallet_wsteth), int(needed_wsteth_raw))
1386
+ if swap_amt > 0:
1387
+ repaid = await self._swap_to_weth_and_repay(
1388
+ WSTETH_TOKEN_ID, swap_amt, snap.weth_debt
1389
+ )
1390
+ if repaid > 0:
1391
+ progressed = True
1392
+ continue
1393
+
1394
+ # 4) Wallet USDC -> swap -> repay (skip dust)
1395
+ wallet_usdc_usd = (
1396
+ (snap.wallet_usdc / (10**snap.usdc_dec)) * float(snap.usdc_price)
1397
+ if snap.usdc_price
1398
+ else 0.0
1399
+ )
1400
+ if (
1401
+ snap.wallet_usdc > 0
1402
+ and snap.usdc_price
1403
+ and snap.usdc_price > 0
1404
+ and wallet_usdc_usd >= 1.0
1405
+ ):
1406
+ needed_usdc_usd = batch_usd * 1.02
1407
+ needed_usdc_raw = (
1408
+ int(needed_usdc_usd / snap.usdc_price * 10**snap.usdc_dec) + 1
1409
+ )
1410
+ swap_amt = min(int(snap.wallet_usdc), int(needed_usdc_raw))
1411
+ if swap_amt > 0:
1412
+ repaid = await self._swap_to_weth_and_repay(
1413
+ USDC_TOKEN_ID, swap_amt, snap.weth_debt
1414
+ )
1415
+ if repaid > 0:
1416
+ progressed = True
1417
+ continue
1418
+
1419
+ # 5) Need more: redeem collateral in HF-safe batches, then swap -> repay.
1420
+ if mode == "operate":
1421
+ hf_floor = float(self.MIN_HEALTH_FACTOR)
1422
+ elif is_full_exit:
1423
+ # Full unwind can allow HF to dip temporarily during the unlend tx
1424
+ # (swap+repay follows immediately). This reduces the number of small
1425
+ # redeem+swap+repay cycles when HF is just above MIN_HEALTH_FACTOR.
1426
+ hf_floor = float(self.LEVERAGE_DELEVER_HF_FLOOR)
1427
+ else:
1428
+ hf_floor = max(
1429
+ self.LEVERAGE_DELEVER_HF_FLOOR,
1430
+ min(
1431
+ float(self.MIN_HEALTH_FACTOR),
1432
+ float(snap.hf) - 0.02
1433
+ if snap.hf != float("inf")
1434
+ else float(self.MIN_HEALTH_FACTOR),
1435
+ ),
1436
+ )
1437
+
1438
+ # Choose the collateral source that can actually be withdrawn (HF-safe + cash bound)
1439
+ # in the largest size. This avoids getting stuck redeeming ever-smaller amounts when
1440
+ # a market (often wstETH) is cash-limited.
1441
+ snap, _ = await self._accounting_snapshot(
1442
+ collateral_factors=collateral_factors
1443
+ )
1444
+
1445
+ if is_full_exit:
1446
+ if snap.weth_debt <= 1:
1447
+ logger.info(" Result: Debt fully settled")
1448
+ return (True, "Debt settled")
1449
+ elif effective_target_hf is not None:
1450
+ if snap.hf >= effective_target_hf:
1451
+ logger.info(f" Result: HF target reached (HF={snap.hf:.3f})")
1452
+ return (True, "HF target reached")
1453
+ elif snap.debt_usd <= target_debt_usd + 1.0:
1454
+ logger.info(
1455
+ f" Result: Debt settled to target (debt=${snap.debt_usd:.2f})"
1456
+ )
1457
+ return (True, "Debt settled to target")
1458
+
1459
+ debt_target_usd = _debt_target_usd(snap)
1460
+ remaining_usd = max(0.0, float(snap.debt_usd) - float(debt_target_usd))
1461
+ if is_full_exit:
1462
+ remaining_usd = snap.debt_usd
1463
+ batch_usd = min(
1464
+ float(max_batch_usd),
1465
+ max(
1466
+ float(remaining_usd) * float(self.FULL_EXIT_BUFFER_MULT),
1467
+ float(self.FULL_EXIT_MIN_BATCH_USD),
1468
+ ),
1469
+ )
1470
+ else:
1471
+ batch_usd = min(float(max_batch_usd), float(remaining_usd))
1472
+
1473
+ # Withdraw a small extra buffer so the subsequent swap->repay can tolerate slippage,
1474
+ # while still respecting the HF-safe withdrawal bound.
1475
+ slip = float(self.swap_slippage_tolerance)
1476
+ slip = max(0.0, min(slip, float(self.MAX_SLIPPAGE_TOLERANCE)))
1477
+ buffer_factor = 1.0 / (1.0 - slip) if slip < 0.999 else 1.0
1478
+
1479
+ # For full exit, allow smaller redemptions than operate-mode, but skip sub-$1 dust
1480
+ # to avoid spinning on tiny swaps.
1481
+ min_redeem_usd = (
1482
+ 1.0 if is_full_exit else max(1.0, float(self.min_withdraw_usd))
1483
+ )
1484
+
1485
+ candidates: list[dict[str, Any]] = []
1486
+ for withdraw_mtoken, withdraw_token_id, withdraw_key in [
1487
+ (M_WSTETH, WSTETH_TOKEN_ID, f"Base_{M_WSTETH}"),
1488
+ (M_USDC, USDC_TOKEN_ID, f"Base_{M_USDC}"),
1489
+ ]:
1490
+ safe_withdraw_usd = self._max_safe_withdraw_usd(
1491
+ totals_usd=snap.totals_usd,
1492
+ withdraw_key=withdraw_key,
1493
+ collateral_factors=collateral_factors,
1494
+ hf_floor=hf_floor,
1495
+ )
1496
+
1497
+ desired_withdraw_usd = min(
1498
+ float(batch_usd) * buffer_factor, float(safe_withdraw_usd)
1499
+ )
1500
+ if desired_withdraw_usd <= float(min_redeem_usd):
1501
+ continue
1502
+
1503
+ if withdraw_token_id == WSTETH_TOKEN_ID:
1504
+ price = float(snap.wsteth_price)
1505
+ dec = int(snap.wsteth_dec)
1506
+ else:
1507
+ price = float(snap.usdc_price)
1508
+ dec = int(snap.usdc_dec)
1509
+
1510
+ if not price or price <= 0:
1511
+ continue
1512
+
1513
+ desired_underlying_raw = int(desired_withdraw_usd / price * 10**dec) + 1
1514
+ if desired_underlying_raw <= 0:
1515
+ continue
1516
+
1517
+ mw_ok, mw_info = await self.moonwell_adapter.max_withdrawable_mtoken(
1518
+ mtoken=withdraw_mtoken
1519
+ )
1520
+ if not mw_ok or not isinstance(mw_info, dict):
1521
+ continue
1522
+
1523
+ max_underlying_raw = int(mw_info.get("underlying_raw", 0) or 0)
1524
+ if max_underlying_raw <= 0:
1525
+ continue
1526
+
1527
+ expected_underlying_raw = min(
1528
+ int(desired_underlying_raw), max_underlying_raw
1529
+ )
1530
+ expected_usd = (expected_underlying_raw / (10**dec)) * price
1531
+ if expected_usd <= float(min_redeem_usd):
1532
+ continue
1533
+
1534
+ mtoken_amt = self._mtoken_amount_for_underlying(
1535
+ mw_info, int(desired_underlying_raw)
1536
+ )
1537
+ if mtoken_amt <= 0:
1538
+ continue
1539
+
1540
+ candidates.append(
1541
+ {
1542
+ "expected_usd": float(expected_usd),
1543
+ "safe_withdraw_usd": float(safe_withdraw_usd),
1544
+ "cash_bound_usd": float(
1545
+ (max_underlying_raw / (10**dec)) * price
1546
+ ),
1547
+ "withdraw_mtoken": withdraw_mtoken,
1548
+ "withdraw_token_id": withdraw_token_id,
1549
+ "underlying_raw": int(desired_underlying_raw),
1550
+ "mtoken_amt": int(mtoken_amt),
1551
+ }
1552
+ )
1553
+
1554
+ if not candidates:
1555
+ logger.warning(
1556
+ "Debt settle: no HF-safe withdrawable collateral found "
1557
+ f"(batch=${float(batch_usd):.2f}, hf_floor={float(hf_floor):.2f})"
1558
+ )
1559
+
1560
+ candidates.sort(key=lambda c: float(c["expected_usd"]), reverse=True)
1561
+
1562
+ for chosen in candidates:
1563
+ withdraw_mtoken = str(chosen["withdraw_mtoken"])
1564
+ withdraw_token_id = str(chosen["withdraw_token_id"])
1565
+ underlying_raw = int(chosen["underlying_raw"])
1566
+ mtoken_amt = int(chosen["mtoken_amt"])
1567
+
1568
+ logger.info(
1569
+ f"Debt settle: redeem {withdraw_token_id} "
1570
+ f"(expected≈${float(chosen['expected_usd']):.2f}, "
1571
+ f"safe=${float(chosen['safe_withdraw_usd']):.2f}, "
1572
+ f"cash≤${float(chosen['cash_bound_usd']):.2f}, "
1573
+ f"batch=${float(batch_usd):.2f}, hf_floor={float(hf_floor):.2f})"
1574
+ )
1575
+
1576
+ ok, msg = await self.moonwell_adapter.unlend(
1577
+ mtoken=withdraw_mtoken, amount=mtoken_amt
1578
+ )
1579
+ if not ok:
1580
+ logger.warning(f"unlend failed for {withdraw_mtoken}: {msg}")
1581
+ continue
1582
+
1583
+ pinned_block = self._pinned_block(msg)
1584
+ wallet_underlying = await self._balance_after_tx(
1585
+ token_id=withdraw_token_id,
1586
+ wallet=addr,
1587
+ pinned_block=pinned_block,
1588
+ min_expected=1,
1589
+ attempts=5,
1590
+ )
1591
+ if wallet_underlying <= 0 and pinned_block is not None:
1592
+ wallet_underlying = await self._balance_after_tx(
1593
+ token_id=withdraw_token_id,
1594
+ wallet=addr,
1595
+ pinned_block=None,
1596
+ min_expected=1,
1597
+ attempts=5,
1598
+ )
1599
+
1600
+ amount_to_swap = min(int(wallet_underlying), int(underlying_raw))
1601
+ if amount_to_swap <= 0:
1602
+ continue
1603
+
1604
+ repaid = await self._swap_to_weth_and_repay(
1605
+ withdraw_token_id, amount_to_swap, snap.weth_debt
1606
+ )
1607
+ if repaid > 0:
1608
+ progressed = True
1609
+ break
1610
+
1611
+ # Swap failed: restore collateral to avoid leaving risk worsened.
1612
+ logger.warning(
1613
+ f"swap->repay failed after unlend ({withdraw_token_id}); re-lending to restore"
1614
+ )
1615
+ relend_bal = await self._get_balance_raw(
1616
+ token_id=withdraw_token_id,
1617
+ wallet_address=addr,
1618
+ )
1619
+ if relend_bal > 0:
1620
+ underlying_addr = WSTETH if withdraw_mtoken == M_WSTETH else USDC
1621
+ await self.moonwell_adapter.lend(
1622
+ mtoken=withdraw_mtoken,
1623
+ underlying_token=underlying_addr,
1624
+ amount=relend_bal,
1625
+ )
1626
+ if withdraw_mtoken == M_WSTETH:
1627
+ await self.moonwell_adapter.set_collateral(mtoken=M_WSTETH)
1628
+
1629
+ if progressed:
1630
+ continue
1631
+
1632
+ gap = self._debt_gap_report(snap)
1633
+ return (
1634
+ False,
1635
+ f"Could not progress debt settlement (step={step + 1}/{max_steps}). Gap report: {gap}",
1636
+ )
1637
+
1638
+ return (False, f"Exceeded max_steps={max_steps} while settling debt")
1639
+
296
1640
  async def setup(self):
297
1641
  """Initialize token info and validate configuration."""
298
1642
  if self.token_adapter is None:
@@ -369,6 +1713,24 @@ class MoonwellWstethLoopStrategy(Strategy):
369
1713
  if base_slippage is None:
370
1714
  base_slippage = self.swap_slippage_tolerance
371
1715
 
1716
+ # Get token info for logging
1717
+ from_decimals = (
1718
+ 18 if from_token_id in (ETH_TOKEN_ID, WETH_TOKEN_ID, WSTETH_TOKEN_ID) else 6
1719
+ )
1720
+ from_symbol = (
1721
+ from_token_id.split("-")[0].upper()
1722
+ if "-" in from_token_id
1723
+ else from_token_id[-6:].upper()
1724
+ )
1725
+ to_symbol = (
1726
+ to_token_id.split("-")[0].upper()
1727
+ if "-" in to_token_id
1728
+ else to_token_id[-6:].upper()
1729
+ )
1730
+ logger.info(
1731
+ f"SWAP: {amount / 10**from_decimals:.6f} {from_symbol} → {to_symbol}"
1732
+ )
1733
+
372
1734
  last_error: Exception | None = None
373
1735
  strategy_address = self._get_strategy_wallet_address()
374
1736
 
@@ -378,16 +1740,26 @@ class MoonwellWstethLoopStrategy(Strategy):
378
1740
  token_id=from_token_id,
379
1741
  wallet_address=strategy_address,
380
1742
  )
1743
+ logger.debug(
1744
+ f" Balance check: {from_symbol} wallet={wallet_balance / 10**from_decimals:.6f}"
1745
+ )
381
1746
  if from_token_id == ETH_TOKEN_ID:
382
- reserve = int(self.WRAP_GAS_RESERVE * 10**18)
1747
+ reserve = int(self._gas_keep_wei())
383
1748
  wallet_balance = max(0, wallet_balance - reserve)
1749
+ logger.debug(
1750
+ f" After gas reserve: {wallet_balance / 10**from_decimals:.6f}"
1751
+ )
1752
+ if int(amount) > wallet_balance:
1753
+ logger.info(
1754
+ f" Adjusting amount from {amount / 10**from_decimals:.6f} to {wallet_balance / 10**from_decimals:.6f} (wallet limit)"
1755
+ )
384
1756
  amount = min(int(amount), wallet_balance)
385
1757
  except Exception as exc:
386
1758
  logger.warning(f"Failed to check swap balance for {from_token_id}: {exc}")
387
1759
 
388
1760
  if amount <= 0:
389
1761
  logger.warning(
390
- f"Swap skipped: no available balance for {from_token_id} (post-reserve)"
1762
+ f"Swap skipped: no available balance for {from_symbol} (post-reserve)"
391
1763
  )
392
1764
  return None
393
1765
 
@@ -412,6 +1784,22 @@ class MoonwellWstethLoopStrategy(Strategy):
412
1784
  for i in range(max_retries):
413
1785
  # Cap slippage at MAX_SLIPPAGE_TOLERANCE to prevent MEV attacks
414
1786
  slippage = min(base_slippage * (i + 1), self.MAX_SLIPPAGE_TOLERANCE)
1787
+
1788
+ # On the final retry, try a different provider ordering to avoid getting stuck
1789
+ # on a single provider/route that may be intermittently broken.
1790
+ attempt_providers = preferred_providers
1791
+ if i == max_retries - 1:
1792
+ if preferred_providers:
1793
+ # Rotate preference order (e.g., [a, b] -> [b, a]) to encourage a different route.
1794
+ attempt_providers = (
1795
+ preferred_providers[1:] + preferred_providers[:1]
1796
+ if len(preferred_providers) > 1
1797
+ else None
1798
+ )
1799
+ else:
1800
+ # If the caller didn't specify providers, try a last-resort preference order.
1801
+ attempt_providers = ["enso", "aerodrome", "lifi"]
1802
+
415
1803
  try:
416
1804
  success, result = await self.brap_adapter.swap_from_token_ids(
417
1805
  from_token_id=from_token_id,
@@ -419,7 +1807,7 @@ class MoonwellWstethLoopStrategy(Strategy):
419
1807
  from_address=strategy_address,
420
1808
  amount=str(amount),
421
1809
  slippage=slippage,
422
- preferred_providers=preferred_providers,
1810
+ preferred_providers=attempt_providers,
423
1811
  )
424
1812
  if success and result:
425
1813
  logger.info(
@@ -437,7 +1825,8 @@ class MoonwellWstethLoopStrategy(Strategy):
437
1825
 
438
1826
  last_error = Exception(str(result))
439
1827
  logger.warning(
440
- f"Swap attempt {i + 1}/{max_retries} returned unsuccessful: {result}"
1828
+ f"Swap attempt {i + 1}/{max_retries} (providers={attempt_providers}) "
1829
+ f"returned unsuccessful: {result}"
441
1830
  )
442
1831
  except SwapOutcomeUnknownError:
443
1832
  raise
@@ -446,8 +1835,8 @@ class MoonwellWstethLoopStrategy(Strategy):
446
1835
  raise SwapOutcomeUnknownError(str(e)) from e
447
1836
  last_error = e
448
1837
  logger.warning(
449
- f"Swap attempt {i + 1}/{max_retries} failed with slippage "
450
- f"{slippage * 100:.1f}%: {e}"
1838
+ f"Swap attempt {i + 1}/{max_retries} (providers={attempt_providers}) "
1839
+ f"failed with slippage {slippage * 100:.1f}%: {e}"
451
1840
  )
452
1841
  if i < max_retries - 1:
453
1842
  # Exponential backoff: 1s, 2s, 4s
@@ -533,15 +1922,59 @@ class MoonwellWstethLoopStrategy(Strategy):
533
1922
  )
534
1923
  return 0
535
1924
 
536
- try:
537
- ok, bal = await self.balance_adapter.get_balance(
538
- token_id=token_id,
539
- wallet_address=wallet_address,
540
- )
541
- return int(bal) if ok else 0
542
- except Exception as exc:
543
- logger.warning(f"On-chain balance read failed for {token_id}: {exc}")
544
- return 0
1925
+ block_id = block_identifier if block_identifier is not None else "latest"
1926
+ max_retries = 3
1927
+ last_error: Exception | None = None
1928
+
1929
+ for attempt in range(max_retries):
1930
+ w3 = None
1931
+ try:
1932
+ w3 = self.web3_service.get_web3(BASE_CHAIN_ID)
1933
+
1934
+ if token_id == ETH_TOKEN_ID:
1935
+ bal = await w3.eth.get_balance(
1936
+ to_checksum_address(wallet_address),
1937
+ block_identifier=block_id,
1938
+ )
1939
+ return int(bal)
1940
+
1941
+ contract = w3.eth.contract(
1942
+ address=to_checksum_address(str(token_address)),
1943
+ abi=ERC20_ABI,
1944
+ )
1945
+ bal = await contract.functions.balanceOf(
1946
+ to_checksum_address(wallet_address)
1947
+ ).call(block_identifier=block_id)
1948
+ return int(bal)
1949
+ except Exception as exc:
1950
+ last_error = exc if isinstance(exc, Exception) else Exception(str(exc))
1951
+ err = str(exc)
1952
+ if ("429" in err or "Too Many Requests" in err) and attempt < (
1953
+ max_retries - 1
1954
+ ):
1955
+ # Backoff: 1s, 2s
1956
+ await asyncio.sleep(2**attempt)
1957
+ continue
1958
+ logger.warning(
1959
+ f"On-chain balance read failed for {token_id} at block {block_id}: {exc}"
1960
+ )
1961
+ return 0
1962
+ finally:
1963
+ if w3 is not None:
1964
+ try:
1965
+ close_web3 = getattr(
1966
+ self.web3_service.evm_transactions, "_close_web3", None
1967
+ )
1968
+ if close_web3 is not None:
1969
+ await close_web3(w3)
1970
+ except Exception:
1971
+ pass
1972
+
1973
+ logger.warning(
1974
+ f"On-chain balance read failed after {max_retries} attempts for {token_id} "
1975
+ f"at block {block_id}: {last_error}"
1976
+ )
1977
+ return 0
545
1978
 
546
1979
  def _normalize_usd_value(self, raw: Any) -> float:
547
1980
  """Normalize a USD value that may be 18-decimal scaled (Compound/Moonwell style).
@@ -586,593 +2019,149 @@ class MoonwellWstethLoopStrategy(Strategy):
586
2019
  ) // exchange_rate_raw
587
2020
  else:
588
2021
  try:
589
- cf = float(conversion_factor)
590
- except (TypeError, ValueError):
591
- cf = 0.0
592
- ctokens_needed = (
593
- int(cf * int(underlying_raw)) + 1 if cf > 0 else max_ctokens
594
- )
595
-
596
- return min(int(ctokens_needed), max_ctokens)
597
-
598
- async def _get_gas_balance(self) -> int:
599
- """Get ETH balance in strategy wallet (raw wei)."""
600
- return await self._get_balance_raw(
601
- token_id=ETH_TOKEN_ID, wallet_address=self._get_strategy_wallet_address()
602
- )
603
-
604
- async def _get_usdc_balance(self) -> int:
605
- """Get USDC balance in strategy wallet (raw wei)."""
606
- return await self._get_balance_raw(
607
- token_id=USDC_TOKEN_ID, wallet_address=self._get_strategy_wallet_address()
608
- )
609
-
610
- async def _validate_gas_balance(self) -> tuple[bool, str]:
611
- """Validate gas balance meets minimum requirements."""
612
- gas_balance = await self._get_gas_balance()
613
- main_gas = await self._get_balance_raw(
614
- token_id=ETH_TOKEN_ID, wallet_address=self._get_main_wallet_address()
615
- )
616
- total_gas = gas_balance + main_gas
617
-
618
- if total_gas < int(self.MIN_GAS * 10**18):
619
- return (
620
- False,
621
- f"Need at least {self.MIN_GAS} Base ETH for gas. You have: {total_gas / 10**18:.6f}",
622
- )
623
- return (True, "Gas balance validated")
624
-
625
- async def _validate_usdc_deposit(
626
- self, usdc_amount: float
627
- ) -> tuple[bool, str, float]:
628
- """Validate USDC deposit amount."""
629
- actual_balance = await self._get_balance_raw(
630
- token_id=USDC_TOKEN_ID, wallet_address=self._get_main_wallet_address()
631
- )
632
-
633
- token_info = await self._get_token_info(USDC_TOKEN_ID)
634
- decimals = token_info.get("decimals", 6)
635
- available_usdc = actual_balance / (10**decimals)
636
-
637
- usdc_amount = min(usdc_amount, available_usdc)
638
-
639
- if usdc_amount < self.MIN_USDC_DEPOSIT:
640
- return (
641
- False,
642
- f"Minimum deposit is {self.MIN_USDC_DEPOSIT} USDC. Available: {available_usdc:.2f}",
643
- usdc_amount,
644
- )
645
- return (True, "USDC deposit amount validated", usdc_amount)
646
-
647
- async def _check_quote_profitability(self) -> tuple[bool, str]:
648
- """Check if the quote APY is profitable."""
649
- quote = await self.quote()
650
- if quote.get("apy", 0) < 0:
651
- return (
652
- False,
653
- "APYs and ratios are not profitable at the moment, aborting deposit",
654
- )
655
- return (True, "Quote is profitable")
656
-
657
- async def _transfer_usdc_to_vault(self, usdc_amount: float) -> tuple[bool, str]:
658
- """Transfer USDC from main wallet to vault wallet."""
659
- (
660
- success,
661
- msg,
662
- ) = await self.balance_adapter.move_from_main_wallet_to_strategy_wallet(
663
- USDC_TOKEN_ID, usdc_amount
664
- )
665
- if not success:
666
- return (False, f"Depositing USDC into vault wallet failed: {msg}")
667
- return (True, "USDC transferred to vault")
668
-
669
- async def _transfer_gas_to_vault(self) -> tuple[bool, str]:
670
- """Transfer gas from main wallet to vault if needed."""
671
- vault_gas = await self._get_gas_balance()
672
- if vault_gas < int(self.MIN_GAS * 10**18):
673
- needed_gas = self.MIN_GAS - vault_gas / 10**18
674
- (
675
- success,
676
- msg,
677
- ) = await self.balance_adapter.move_from_main_wallet_to_strategy_wallet(
678
- ETH_TOKEN_ID, needed_gas
679
- )
680
- if not success:
681
- return (False, f"Depositing gas into strategy wallet failed: {msg}")
682
- return (True, "Gas transferred to strategy")
683
-
684
- async def _balance_weth_debt(self) -> tuple[bool, str]:
685
- """Balance WETH debt if it exceeds wstETH collateral for delta-neutrality."""
686
- # Get wstETH position (can be zero; missing collateral is a common recovery case)
687
- wsteth_underlying = 0
688
- wsteth_pos_result = await self.moonwell_adapter.get_pos(mtoken=M_WSTETH)
689
- if wsteth_pos_result[0] and isinstance(wsteth_pos_result[1], dict):
690
- wsteth_pos = wsteth_pos_result[1]
691
- wsteth_underlying = int(wsteth_pos.get("underlying_balance", 0) or 0)
692
- else:
693
- # Treat as 0 collateral and proceed conservatively (we still may have WETH debt).
694
- logger.warning(
695
- f"Failed to fetch wstETH position; treating as 0 for debt balancing: {wsteth_pos_result[1]}"
696
- )
697
-
698
- # Get WETH debt value
699
- weth_pos_result = await self.moonwell_adapter.get_pos(mtoken=M_WETH)
700
- if not weth_pos_result[0]:
701
- return (True, "No WETH debt to balance")
702
-
703
- weth_pos = weth_pos_result[1]
704
- weth_debt = weth_pos.get("borrow_balance", 0)
705
-
706
- if weth_debt == 0:
707
- return (True, "No WETH debt to balance")
708
-
709
- # Get prices and decimals
710
- weth_price, weth_decimals = await self._get_token_data(WETH_TOKEN_ID)
711
- if not weth_price or weth_price <= 0:
712
- return (False, "WETH price unavailable; cannot balance debt safely")
713
-
714
- wsteth_price, wsteth_decimals = await self._get_token_data(WSTETH_TOKEN_ID)
715
- if wsteth_underlying > 0 and (not wsteth_price or wsteth_price <= 0):
716
- return (False, "wstETH price unavailable; cannot balance debt safely")
717
- # If wstETH collateral is zero, we don't need wstETH price to proceed.
718
- wsteth_price = float(wsteth_price or 0.0)
719
-
720
- wsteth_value = (wsteth_underlying / 10**wsteth_decimals) * wsteth_price
721
- weth_debt_value = (weth_debt / 10**weth_decimals) * weth_price
722
-
723
- # Check if we're imbalanced (debt > collateral)
724
- excess_debt_value = weth_debt_value - wsteth_value
725
- if excess_debt_value <= 0:
726
- return (True, "WETH debt is balanced with wstETH collateral")
727
-
728
- logger.warning(
729
- f"WETH debt exceeds wstETH collateral by ${excess_debt_value:.2f}. Rebalancing..."
730
- )
731
-
732
- excess_debt_wei = int(excess_debt_value / weth_price * 10**weth_decimals)
733
- repaid = 0
734
-
735
- # Step 1: Try using wallet WETH
736
- wallet_weth = await self._get_balance_raw(
737
- token_id=WETH_TOKEN_ID, wallet_address=self._get_strategy_wallet_address()
738
- )
739
- if wallet_weth > 0:
740
- repay_amt = min(wallet_weth, excess_debt_wei - repaid)
741
- success, _ = await self.moonwell_adapter.repay(
742
- mtoken=M_WETH,
743
- underlying_token=WETH,
744
- amount=repay_amt,
745
- )
746
- if success:
747
- repaid += repay_amt
748
- logger.info(f"Repaid {repay_amt / 10**18:.6f} WETH from wallet")
749
-
750
- if repaid >= excess_debt_wei:
751
- return (
752
- True,
753
- f"Balanced debt by repaying {repaid / 10**18:.6f} WETH from wallet",
754
- )
755
-
756
- # Step 2: Try wrapping wallet ETH → WETH and repaying (within gas reserve)
757
- wallet_eth = await self._get_balance_raw(
758
- token_id=ETH_TOKEN_ID, wallet_address=self._get_strategy_wallet_address()
759
- )
760
- gas_reserve = int(self.WRAP_GAS_RESERVE * 10**18)
761
- usable_eth = max(0, wallet_eth - gas_reserve)
762
- if usable_eth > 0:
763
- wrap_amt = min(usable_eth, excess_debt_wei - repaid)
764
- try:
765
- wrap_success, wrap_msg = await self.moonwell_adapter.wrap_eth(
766
- amount=wrap_amt
767
- )
768
- if not wrap_success:
769
- logger.warning(f"Failed to wrap ETH for repayment: {wrap_msg}")
770
- else:
771
- weth_after = await self._get_balance_raw(
772
- token_id=WETH_TOKEN_ID,
773
- wallet_address=self._get_strategy_wallet_address(),
774
- )
775
- repay_amt = min(weth_after, excess_debt_wei - repaid)
776
- if repay_amt > 0:
777
- repay_success, _ = await self.moonwell_adapter.repay(
778
- mtoken=M_WETH,
779
- underlying_token=WETH,
780
- amount=repay_amt,
781
- )
782
- if repay_success:
783
- repaid += repay_amt
784
- logger.info(
785
- f"Wrapped and repaid {repay_amt / 10**18:.6f} ETH (as WETH)"
786
- )
787
-
788
- # Try topping up gas back to MIN_GAS if we dipped below it (non-critical).
789
- topup_success, topup_msg = await self._transfer_gas_to_vault()
790
- if not topup_success:
791
- logger.warning(f"Gas top-up failed (non-critical): {topup_msg}")
792
- except Exception as e:
793
- logger.warning(f"Failed to wrap ETH and repay: {e}")
794
-
795
- if repaid >= excess_debt_wei:
796
- return (True, f"Balanced debt by repaying {repaid / 10**18:.6f} WETH")
797
-
798
- # Step 3: Try swapping wallet USDC to WETH and repaying
799
- remaining_to_repay = excess_debt_wei - repaid
800
- remaining_value = (remaining_to_repay / 10**weth_decimals) * weth_price
801
-
802
- wallet_usdc = await self._get_balance_raw(
803
- token_id=USDC_TOKEN_ID, wallet_address=self._get_strategy_wallet_address()
804
- )
805
- if wallet_usdc > 0 and remaining_to_repay > 0:
806
- usdc_price, usdc_decimals = await self._get_token_data(USDC_TOKEN_ID)
807
- wallet_usdc_value = (wallet_usdc / 10**usdc_decimals) * usdc_price
808
-
809
- if wallet_usdc_value >= self.min_withdraw_usd:
810
- # Swap enough USDC to cover remaining debt (with 2% buffer)
811
- needed_usdc_value = min(remaining_value * 1.02, wallet_usdc_value)
812
- needed_usdc = int(needed_usdc_value / usdc_price * 10**usdc_decimals)
813
- amount_to_swap = min(needed_usdc, wallet_usdc)
814
- try:
815
- swap_result = await self._swap_with_retries(
816
- from_token_id=USDC_TOKEN_ID,
817
- to_token_id=WETH_TOKEN_ID,
818
- amount=amount_to_swap,
819
- )
820
- if swap_result:
821
- # Use actual post-swap WETH balance to avoid relying on quoted to_amount.
822
- weth_after = await self._get_balance_raw(
823
- token_id=WETH_TOKEN_ID,
824
- wallet_address=self._get_strategy_wallet_address(),
825
- )
826
- repay_amt = min(weth_after, excess_debt_wei - repaid)
827
- if repay_amt > 0:
828
- success, _ = await self.moonwell_adapter.repay(
829
- mtoken=M_WETH,
830
- underlying_token=WETH,
831
- amount=repay_amt,
832
- )
833
- if success:
834
- repaid += repay_amt
835
- logger.info(
836
- f"Swapped wallet USDC and repaid {repay_amt / 10**18:.6f} WETH"
837
- )
838
- except SwapOutcomeUnknownError as exc:
839
- return (
840
- False,
841
- f"Swap outcome unknown while swapping wallet USDC for repayment: {exc}",
842
- )
843
- except Exception as e:
844
- logger.warning(f"Failed to swap wallet USDC for repayment: {e}")
845
-
846
- if repaid >= excess_debt_wei:
847
- return (True, f"Balanced debt by repaying {repaid / 10**18:.6f} WETH")
848
-
849
- # Step 4: Unlend USDC collateral, swap to WETH, and repay
850
- remaining_to_repay = excess_debt_wei - repaid
851
- remaining_value = (remaining_to_repay / 10**weth_decimals) * weth_price
852
-
853
- usdc_withdraw_result = await self.moonwell_adapter.max_withdrawable_mtoken(
854
- mtoken=M_USDC
855
- )
856
- if usdc_withdraw_result[0]:
857
- withdraw_info = usdc_withdraw_result[1]
858
- underlying_raw = withdraw_info.get("underlying_raw", 0)
859
-
860
- usdc_price, usdc_decimals = await self._get_token_data(USDC_TOKEN_ID)
861
- usdc_value = (underlying_raw / 10**usdc_decimals) * usdc_price
862
-
863
- if usdc_value > self.min_withdraw_usd:
864
- # Calculate how much USDC to unlock
865
- needed_usdc_value = min(remaining_value * 1.02, usdc_value) # 2% buffer
866
- needed_usdc = int(needed_usdc_value / usdc_price * 10**usdc_decimals)
867
- mtoken_amt = self._mtoken_amount_for_underlying(
868
- withdraw_info, needed_usdc
869
- )
870
-
871
- try:
872
- success, _ = await self.moonwell_adapter.unlend(
873
- mtoken=M_USDC, amount=mtoken_amt
874
- )
875
- if success:
876
- # Swap only what we actually have in-wallet (avoid balance-based reverts).
877
- wallet_usdc_after = await self._get_balance_raw(
878
- token_id=USDC_TOKEN_ID,
879
- wallet_address=self._get_strategy_wallet_address(),
880
- )
881
- amount_to_swap = min(wallet_usdc_after, needed_usdc)
882
- if amount_to_swap <= 0:
883
- raise Exception("No USDC available to swap after unlending")
884
- # Swap USDC to WETH
885
- swap_result = await self._swap_with_retries(
886
- from_token_id=USDC_TOKEN_ID,
887
- to_token_id=WETH_TOKEN_ID,
888
- amount=amount_to_swap,
889
- )
890
- if swap_result:
891
- weth_after = await self._get_balance_raw(
892
- token_id=WETH_TOKEN_ID,
893
- wallet_address=self._get_strategy_wallet_address(),
894
- )
895
- repay_amt = min(weth_after, excess_debt_wei - repaid)
896
- if repay_amt > 0:
897
- repay_success, _ = await self.moonwell_adapter.repay(
898
- mtoken=M_WETH,
899
- underlying_token=WETH,
900
- amount=repay_amt,
901
- )
902
- if repay_success:
903
- repaid += repay_amt
904
- logger.info(
905
- f"Unlent USDC, swapped and repaid {repay_amt / 10**18:.6f} WETH"
906
- )
907
- except SwapOutcomeUnknownError as exc:
908
- return (
909
- False,
910
- f"Swap outcome unknown while unlocking USDC for repayment: {exc}",
911
- )
912
- except Exception as e:
913
- logger.warning(f"Failed to unlock USDC and swap for repayment: {e}")
914
-
915
- if repaid >= excess_debt_wei * 0.95: # Allow 5% tolerance
916
- return (True, f"Balanced debt by repaying {repaid / 10**18:.6f} WETH")
917
-
918
- return (
919
- False,
920
- f"Could only repay {repaid / 10**18:.6f} of {excess_debt_wei / 10**18:.6f} excess WETH debt",
921
- )
922
-
923
- async def _complete_unpaired_weth_borrow(self) -> tuple[bool, str]:
924
- """If we have WETH debt but insufficient wstETH collateral, try to complete the loop.
925
-
926
- This is the common "failed swap" recovery state:
927
- - Debt exists on Moonwell (borrowed WETH),
928
- - wstETH collateral is missing/low,
929
- - The borrowed value is still sitting in the wallet as WETH and/or native ETH.
930
-
931
- We prefer swapping wallet WETH → wstETH and lending it (to restore the intended position)
932
- before considering debt repayment.
933
- """
934
- # Read positions
935
- wsteth_pos_result, weth_pos_result = await asyncio.gather(
936
- self.moonwell_adapter.get_pos(mtoken=M_WSTETH),
937
- self.moonwell_adapter.get_pos(mtoken=M_WETH),
938
- )
939
- if not weth_pos_result[0]:
940
- return (True, "No WETH debt to complete")
941
-
942
- weth_debt = int((weth_pos_result[1] or {}).get("borrow_balance", 0) or 0)
943
- if weth_debt <= 0:
944
- return (True, "No WETH debt to complete")
945
-
946
- wsteth_underlying = 0
947
- if wsteth_pos_result[0]:
948
- wsteth_underlying = int(
949
- (wsteth_pos_result[1] or {}).get("underlying_balance", 0) or 0
950
- )
951
-
952
- # Determine whether we're meaningfully unpaired.
953
- # Prefer price-based comparison, but allow a strict fallback for the common case:
954
- # wstETH collateral is literally 0 after a failed swap.
955
- wsteth_price, wsteth_decimals = await self._get_token_data(WSTETH_TOKEN_ID)
956
- weth_price, weth_decimals = await self._get_token_data(WETH_TOKEN_ID)
957
-
958
- deficit_usd: float | None = None
959
- if wsteth_price and wsteth_price > 0 and weth_price and weth_price > 0:
960
- wsteth_value = (wsteth_underlying / 10**wsteth_decimals) * wsteth_price
961
- weth_debt_value = (weth_debt / 10**weth_decimals) * weth_price
962
- deficit_usd = weth_debt_value - wsteth_value
963
-
964
- # Small deficits are just rounding; ignore.
965
- if deficit_usd <= max(1.0, float(self.min_withdraw_usd)):
966
- return (True, "wstETH collateral already roughly matches WETH debt")
967
- elif wsteth_underlying > 0:
968
- # If we already have some wstETH collateral but cannot price it, don't guess.
969
- return (True, "Price unavailable; skipping unpaired borrow completion")
970
-
971
- strategy_address = self._get_strategy_wallet_address()
972
-
973
- # Check for loose wstETH first - lend it before swapping anything
974
- wallet_wsteth = await self._get_balance_raw(
975
- token_id=WSTETH_TOKEN_ID, wallet_address=strategy_address
976
- )
977
- if wallet_wsteth > 0:
978
- logger.info(
979
- f"Found {wallet_wsteth / 10**18:.6f} loose wstETH in wallet, lending first"
980
- )
981
- lend_success, lend_msg = await self.moonwell_adapter.lend(
982
- mtoken=M_WSTETH,
983
- underlying_token=WSTETH,
984
- amount=int(wallet_wsteth),
985
- )
986
- if lend_success:
987
- # Recalculate deficit after lending
988
- wsteth_pos_result = await self.moonwell_adapter.get_pos(mtoken=M_WSTETH)
989
- if wsteth_pos_result[0]:
990
- wsteth_underlying = int(
991
- (wsteth_pos_result[1] or {}).get("underlying_balance", 0) or 0
992
- )
993
- if (
994
- wsteth_price
995
- and wsteth_price > 0
996
- and weth_price
997
- and weth_price > 0
998
- ):
999
- wsteth_value = (
1000
- wsteth_underlying / 10**wsteth_decimals
1001
- ) * wsteth_price
1002
- weth_debt_value = (weth_debt / 10**weth_decimals) * weth_price
1003
- deficit_usd = weth_debt_value - wsteth_value
1004
- if deficit_usd <= max(1.0, float(self.min_withdraw_usd)):
1005
- return (
1006
- True,
1007
- "Loose wstETH lent; collateral now matches debt",
1008
- )
1009
- else:
1010
- logger.warning(f"Failed to lend loose wstETH: {lend_msg}")
1011
-
1012
- wallet_weth = await self._get_balance_raw(
1013
- token_id=WETH_TOKEN_ID, wallet_address=strategy_address
1014
- )
1015
- wallet_eth = await self._get_balance_raw(
1016
- token_id=ETH_TOKEN_ID, wallet_address=strategy_address
1017
- )
1018
-
1019
- gas_reserve = int(self.WRAP_GAS_RESERVE * 10**18)
1020
- usable_eth = max(0, wallet_eth - gas_reserve)
1021
-
1022
- # Target WETH input needed.
1023
- # If we couldn't price, fall back to swapping up to the debt amount when collateral is 0.
1024
- if deficit_usd is None:
1025
- target_weth_in = int(weth_debt)
1026
- else:
1027
- # Add 0.5% buffer for slippage/fees, but never exceed debt.
1028
- target_weth_in = (
1029
- int(deficit_usd / weth_price * 10**weth_decimals / (1 - 0.005)) + 1
2022
+ cf = float(conversion_factor)
2023
+ except (TypeError, ValueError):
2024
+ cf = 0.0
2025
+ ctokens_needed = (
2026
+ int(cf * int(underlying_raw)) + 1 if cf > 0 else max_ctokens
1030
2027
  )
1031
- target_weth_in = min(int(target_weth_in), int(weth_debt))
1032
2028
 
1033
- available_weth_like = int(wallet_weth) + int(usable_eth)
1034
- if available_weth_like <= 0:
1035
- return (False, "No wallet WETH/ETH available to complete the loop")
2029
+ return min(int(ctokens_needed), max_ctokens)
1036
2030
 
1037
- amount_to_source = min(int(target_weth_in), int(available_weth_like))
1038
- if amount_to_source <= 0:
1039
- return (True, "No meaningful WETH amount available to swap to wstETH")
2031
+ def _pinned_block(self, tx_result: Any) -> int | None:
2032
+ """Extract a deterministic pinned block number from an adapter tx result (best-effort)."""
2033
+ if not isinstance(tx_result, dict):
2034
+ return None
1040
2035
 
1041
- wsteth_before = await self._get_balance_raw(
1042
- token_id=WSTETH_TOKEN_ID, wallet_address=strategy_address
2036
+ receipt = tx_result.get("receipt") or {}
2037
+ receipt_block = (
2038
+ receipt.get("blockNumber") if isinstance(receipt, dict) else None
1043
2039
  )
1044
2040
 
1045
- remaining = int(amount_to_source)
1046
-
1047
- # Swap wallet WETH first (ERC20 path).
1048
- # Prefer enso/aerodrome for WETH→wstETH - LiFi gets bad fills
1049
- weth_to_swap = min(int(wallet_weth), int(remaining))
1050
- if weth_to_swap > 0:
1051
- swap_res = await self._swap_with_retries(
1052
- from_token_id=WETH_TOKEN_ID,
1053
- to_token_id=WSTETH_TOKEN_ID,
1054
- amount=weth_to_swap,
1055
- preferred_providers=["aerodrome", "enso"],
1056
- )
1057
- if swap_res is None:
1058
- logger.warning(
1059
- "WETH→wstETH swap failed during unpaired borrow completion"
1060
- )
1061
- remaining -= int(weth_to_swap)
1062
-
1063
- # Then swap native ETH (borrowed WETH often arrives as ETH on Base in practice).
1064
- # Prefer enso/aerodrome for ETH→wstETH - LiFi gets bad fills
1065
- eth_to_swap = min(int(usable_eth), int(remaining))
1066
- if eth_to_swap > 0:
1067
- swap_res = await self._swap_with_retries(
1068
- from_token_id=ETH_TOKEN_ID,
1069
- to_token_id=WSTETH_TOKEN_ID,
1070
- amount=eth_to_swap,
1071
- preferred_providers=["aerodrome", "enso"],
1072
- )
1073
- if swap_res is None:
1074
- logger.warning(
1075
- "ETH→wstETH swap failed during unpaired borrow completion"
1076
- )
1077
-
1078
- wsteth_after = await self._get_balance_raw(
1079
- token_id=WSTETH_TOKEN_ID, wallet_address=strategy_address
2041
+ return (
2042
+ tx_result.get("confirmed_block_number")
2043
+ or tx_result.get("block_number")
2044
+ or receipt_block
1080
2045
  )
1081
- received = max(0, int(wsteth_after) - int(wsteth_before))
1082
- if received <= 0:
1083
- return (
1084
- False,
1085
- "Swap to wstETH produced no wstETH; will fall back to debt balancing",
1086
- )
1087
2046
 
1088
- success, msg = await self.moonwell_adapter.lend(
1089
- mtoken=M_WSTETH,
1090
- underlying_token=WSTETH,
1091
- amount=received,
1092
- )
1093
- if not success:
1094
- return (False, f"Lending wstETH failed: {msg}")
2047
+ async def _balance_after_tx(
2048
+ self,
2049
+ *,
2050
+ token_id: str,
2051
+ wallet: str,
2052
+ pinned_block: int | None,
2053
+ min_expected: int = 1,
2054
+ attempts: int = 5,
2055
+ ) -> int:
2056
+ """Read a balance at a pinned block, retrying briefly to avoid RPC indexing lag."""
2057
+ bal = 0
2058
+ for i in range(int(attempts)):
2059
+ bal = await self._get_balance_raw(
2060
+ token_id=token_id,
2061
+ wallet_address=wallet,
2062
+ block_identifier=pinned_block,
2063
+ )
2064
+ if bal >= int(min_expected):
2065
+ return int(bal)
2066
+ await asyncio.sleep(1 + i)
2067
+ return int(bal)
1095
2068
 
1096
- await self.moonwell_adapter.set_collateral(mtoken=M_WSTETH)
1097
- return (
1098
- True,
1099
- f"Completed unpaired borrow by lending {received / 10**18:.6f} wstETH",
2069
+ async def _get_gas_balance(self) -> int:
2070
+ """Get ETH balance in strategy wallet (raw wei)."""
2071
+ return await self._get_balance_raw(
2072
+ token_id=ETH_TOKEN_ID, wallet_address=self._get_strategy_wallet_address()
1100
2073
  )
1101
2074
 
1102
- async def _convert_excess_eth_to_usdc(self) -> tuple[bool, str]:
1103
- """Convert excess native ETH (above MIN_GAS) into USDC to be redeployed."""
1104
- strategy_address = self._get_strategy_wallet_address()
1105
- eth_bal = await self._get_balance_raw(
1106
- token_id=ETH_TOKEN_ID, wallet_address=strategy_address
2075
+ async def _get_usdc_balance(self) -> int:
2076
+ """Get USDC balance in strategy wallet (raw wei)."""
2077
+ return await self._get_balance_raw(
2078
+ token_id=USDC_TOKEN_ID, wallet_address=self._get_strategy_wallet_address()
1107
2079
  )
1108
2080
 
1109
- keep_wei = int(self.MIN_GAS * 10**18)
1110
- excess = int(eth_bal) - int(keep_wei)
1111
- # Avoid dust conversions; 0.001 ETH is already plenty of gas on Base.
1112
- min_excess = int(0.001 * 10**18)
1113
- if excess <= min_excess:
1114
- return (True, "No excess ETH to convert")
1115
-
1116
- # Wrap excess ETH to WETH (so swaps are ERC20-based and allowance-friendly).
1117
- weth_before = await self._get_balance_raw(
1118
- token_id=WETH_TOKEN_ID, wallet_address=strategy_address
2081
+ async def _validate_gas_balance(self) -> tuple[bool, str]:
2082
+ """Validate gas balance meets minimum requirements."""
2083
+ gas_balance = await self._get_gas_balance()
2084
+ main_gas = await self._get_balance_raw(
2085
+ token_id=ETH_TOKEN_ID, wallet_address=self._get_main_wallet_address()
1119
2086
  )
1120
- wrap_success, wrap_msg = await self.moonwell_adapter.wrap_eth(amount=excess)
1121
- if not wrap_success:
1122
- return (False, f"Wrap ETH→WETH failed: {wrap_msg}")
2087
+ total_gas = gas_balance + main_gas
1123
2088
 
1124
- weth_after = await self._get_balance_raw(
1125
- token_id=WETH_TOKEN_ID, wallet_address=strategy_address
1126
- )
1127
- wrapped = max(0, int(weth_after) - int(weth_before))
1128
- if wrapped <= 0:
1129
- return (False, "ETH→WETH wrap produced no WETH")
2089
+ min_gas_wei = int(self._gas_keep_wei())
2090
+ if total_gas < min_gas_wei:
2091
+ return (
2092
+ False,
2093
+ f"Need at least {min_gas_wei / 10**18:.4f} Base ETH for gas. "
2094
+ f"You have: {total_gas / 10**18:.6f}",
2095
+ )
2096
+ return (True, "Gas balance validated")
1130
2097
 
1131
- swap_result = await self._swap_with_retries(
1132
- from_token_id=WETH_TOKEN_ID,
1133
- to_token_id=USDC_TOKEN_ID,
1134
- amount=wrapped,
2098
+ async def _validate_usdc_deposit(
2099
+ self, usdc_amount: float
2100
+ ) -> tuple[bool, str, float]:
2101
+ """Validate USDC deposit amount."""
2102
+ actual_balance = await self._get_balance_raw(
2103
+ token_id=USDC_TOKEN_ID, wallet_address=self._get_main_wallet_address()
1135
2104
  )
1136
- if swap_result is None:
1137
- return (False, "WETH→USDC swap failed when converting excess ETH")
1138
2105
 
1139
- return (True, "Converted excess ETH to USDC")
2106
+ token_info = await self._get_token_info(USDC_TOKEN_ID)
2107
+ decimals = token_info.get("decimals", 6)
2108
+ available_usdc = actual_balance / (10**decimals)
1140
2109
 
1141
- async def _convert_spot_wsteth_to_usdc(self) -> tuple[bool, str]:
1142
- """Convert wallet (spot) wstETH into USDC so it can be redeployed.
2110
+ usdc_amount = min(usdc_amount, available_usdc)
1143
2111
 
1144
- This never touches native ETH, so it cannot drain gas.
1145
- """
1146
- strategy_address = self._get_strategy_wallet_address()
1147
- wsteth_raw = await self._get_balance_raw(
1148
- token_id=WSTETH_TOKEN_ID, wallet_address=strategy_address
1149
- )
1150
- if wsteth_raw <= 0:
1151
- return (True, "No wallet wstETH to convert")
2112
+ if usdc_amount < self.MIN_USDC_DEPOSIT:
2113
+ return (
2114
+ False,
2115
+ f"Minimum deposit is {self.MIN_USDC_DEPOSIT} USDC. Available: {available_usdc:.2f}",
2116
+ usdc_amount,
2117
+ )
2118
+ return (True, "USDC deposit amount validated", usdc_amount)
1152
2119
 
1153
- # Avoid dust conversions.
1154
- wsteth_price, wsteth_decimals = await self._get_token_data(WSTETH_TOKEN_ID)
1155
- if not wsteth_price or wsteth_price <= 0:
1156
- return (True, "wstETH price unavailable; skipping wallet wstETH conversion")
1157
- usd_value = (wsteth_raw / 10**wsteth_decimals) * wsteth_price
1158
- if usd_value < float(self.min_withdraw_usd):
1159
- return (True, "Wallet wstETH below threshold; skipping conversion")
2120
+ async def _check_quote_profitability(self) -> tuple[bool, str]:
2121
+ """Check if the quote APY is profitable."""
2122
+ quote = await self.quote()
2123
+ if quote.get("apy", 0) < 0:
2124
+ return (
2125
+ False,
2126
+ "APYs and ratios are not profitable at the moment, aborting deposit",
2127
+ )
2128
+ return (True, "Quote is profitable")
1160
2129
 
1161
- swap_result = await self._swap_with_retries(
1162
- from_token_id=WSTETH_TOKEN_ID,
1163
- to_token_id=USDC_TOKEN_ID,
1164
- amount=int(wsteth_raw),
2130
+ async def _transfer_usdc_to_vault(self, usdc_amount: float) -> tuple[bool, str]:
2131
+ """Transfer USDC from main wallet to vault wallet."""
2132
+ (
2133
+ success,
2134
+ msg,
2135
+ ) = await self.balance_adapter.move_from_main_wallet_to_strategy_wallet(
2136
+ USDC_TOKEN_ID, usdc_amount
1165
2137
  )
1166
- if swap_result is None:
1167
- return (False, "wstETH→USDC swap failed")
2138
+ if not success:
2139
+ return (False, f"Depositing USDC into vault wallet failed: {msg}")
2140
+ return (True, "USDC transferred to vault")
1168
2141
 
1169
- return (True, f"Converted wallet wstETH (~${usd_value:.2f}) to USDC")
2142
+ async def _transfer_gas_to_vault(self) -> tuple[bool, str]:
2143
+ """Transfer gas from main wallet to vault if needed."""
2144
+ vault_gas = await self._get_gas_balance()
2145
+ min_gas_wei = int(self._gas_keep_wei())
2146
+ if vault_gas < min_gas_wei:
2147
+ needed_gas = (min_gas_wei - vault_gas) / 10**18
2148
+ (
2149
+ success,
2150
+ msg,
2151
+ ) = await self.balance_adapter.move_from_main_wallet_to_strategy_wallet(
2152
+ ETH_TOKEN_ID, needed_gas
2153
+ )
2154
+ if not success:
2155
+ return (False, f"Depositing gas into strategy wallet failed: {msg}")
2156
+ return (True, "Gas transferred to strategy")
1170
2157
 
1171
2158
  async def _sweep_token_balances(
1172
2159
  self,
1173
2160
  target_token_id: str,
1174
2161
  exclude: set[str] | None = None,
1175
2162
  min_usd_value: float = 1.0,
2163
+ *,
2164
+ strict: bool = False,
1176
2165
  ) -> tuple[bool, str]:
1177
2166
  """Sweep miscellaneous tokens above min_usd_value to target token."""
1178
2167
  if exclude is None:
@@ -1215,7 +2204,16 @@ class MoonwellWstethLoopStrategy(Strategy):
1215
2204
  f"Swept {balance / 10**decimals:.6f} {token_id} "
1216
2205
  f"(${usd_value:.2f}) to {target_token_id}"
1217
2206
  )
2207
+ else:
2208
+ msg = f"Failed to sweep {token_id} to {target_token_id}"
2209
+ logger.warning(msg)
2210
+ if strict:
2211
+ return (False, msg)
2212
+ except SwapOutcomeUnknownError:
2213
+ raise
1218
2214
  except Exception as e:
2215
+ if strict:
2216
+ return (False, f"Failed to sweep {token_id}: {e}")
1219
2217
  logger.warning(f"Failed to sweep {token_id}: {e}")
1220
2218
 
1221
2219
  if swept_count == 0:
@@ -1223,6 +2221,83 @@ class MoonwellWstethLoopStrategy(Strategy):
1223
2221
 
1224
2222
  return (True, f"Swept {swept_count} tokens totaling ${total_swept_usd:.2f}")
1225
2223
 
2224
+ async def _claim_and_reinvest_rewards(self) -> tuple[bool, str]:
2225
+ """Claim WELL rewards, swap to USDC, and lend directly to mUSDC (no leverage).
2226
+
2227
+ This deposits rewards as unleveraged USDC collateral rather than running
2228
+ them through the leverage loop, preserving the strategy's debt ratio.
2229
+ """
2230
+ # Claim rewards if above threshold
2231
+ claimed_ok, claimed = await self.moonwell_adapter.claim_rewards(
2232
+ min_rewards_usd=self.MIN_REWARD_CLAIM_USD
2233
+ )
2234
+ if not claimed_ok:
2235
+ logger.warning(f"Failed to claim rewards: {claimed}")
2236
+ return (True, "Reward claim failed, skipping reinvestment")
2237
+
2238
+ # Check if we actually got rewards (claimed is dict of token -> amount)
2239
+ if not claimed or not isinstance(claimed, dict):
2240
+ return (True, "No rewards to reinvest")
2241
+
2242
+ # Check WELL balance in wallet
2243
+ well_balance = await self._get_balance_raw(
2244
+ token_id=WELL_TOKEN_ID,
2245
+ wallet_address=self._get_strategy_wallet_address(),
2246
+ )
2247
+ if well_balance <= 0:
2248
+ return (True, "No WELL balance to reinvest")
2249
+
2250
+ # Get WELL price and check value
2251
+ well_price, well_decimals = await self._get_token_data(WELL_TOKEN_ID)
2252
+ well_value_usd = (well_balance / 10**well_decimals) * well_price
2253
+
2254
+ if well_value_usd < self.MIN_REWARD_CLAIM_USD:
2255
+ logger.debug(
2256
+ f"WELL balance ${well_value_usd:.2f} below threshold, skipping swap"
2257
+ )
2258
+ return (True, f"WELL value ${well_value_usd:.2f} below threshold")
2259
+
2260
+ # Swap WELL → USDC
2261
+ logger.info(
2262
+ f"Swapping {well_balance / 10**well_decimals:.4f} WELL "
2263
+ f"(${well_value_usd:.2f}) to USDC"
2264
+ )
2265
+ try:
2266
+ swap_result = await self._swap_with_retries(
2267
+ from_token_id=WELL_TOKEN_ID,
2268
+ to_token_id=USDC_TOKEN_ID,
2269
+ amount=well_balance,
2270
+ )
2271
+ if not swap_result:
2272
+ logger.warning("Failed to swap WELL to USDC")
2273
+ return (True, "WELL swap failed, rewards left in wallet")
2274
+ except Exception as e:
2275
+ logger.warning(f"WELL swap error: {e}")
2276
+ return (True, f"WELL swap error: {e}")
2277
+
2278
+ # Get resulting USDC balance to lend
2279
+ usdc_balance = await self._get_balance_raw(
2280
+ token_id=USDC_TOKEN_ID,
2281
+ wallet_address=self._get_strategy_wallet_address(),
2282
+ )
2283
+ if usdc_balance <= 0:
2284
+ return (True, "No USDC from reward swap")
2285
+
2286
+ usdc_decimals = 6
2287
+ usdc_amount = usdc_balance / 10**usdc_decimals
2288
+
2289
+ # Lend USDC directly to mUSDC (no leverage loop)
2290
+ logger.info(f"Lending {usdc_amount:.2f} USDC from rewards to mUSDC")
2291
+ lend_ok, lend_msg = await self.moonwell_adapter.lend(
2292
+ mtoken=M_USDC,
2293
+ amount=usdc_balance,
2294
+ )
2295
+ if not lend_ok:
2296
+ logger.warning(f"Failed to lend reward USDC: {lend_msg}")
2297
+ return (True, f"Reward USDC lend failed: {lend_msg}")
2298
+
2299
+ return (True, f"Reinvested ${usdc_amount:.2f} USDC from WELL rewards")
2300
+
1226
2301
  async def deposit(
1227
2302
  self, main_token_amount: float = 0.0, gas_token_amount: float = 0.0
1228
2303
  ) -> StatusTuple:
@@ -1261,8 +2336,10 @@ class MoonwellWstethLoopStrategy(Strategy):
1261
2336
  if not success:
1262
2337
  return (False, message)
1263
2338
 
1264
- # Execute the leverage loop via update
1265
- return await self.update()
2339
+ return (
2340
+ True,
2341
+ f"Deposited {usdc_amount:.2f} USDC to strategy wallet. Call update() to deploy funds to Moonwell.",
2342
+ )
1266
2343
 
1267
2344
  async def _get_collateral_factors(self) -> tuple[float, float]:
1268
2345
  """Fetch both collateral factors (USDC and wstETH), using adapter cache.
@@ -1279,170 +2356,30 @@ class MoonwellWstethLoopStrategy(Strategy):
1279
2356
 
1280
2357
  async def _get_current_leverage(
1281
2358
  self,
1282
- positions: tuple[dict, dict] | None = None,
2359
+ snap: Optional["MoonwellWstethLoopStrategy.AccountingSnapshot"] = None,
2360
+ collateral_factors: tuple[float, float] | None = None,
1283
2361
  ) -> tuple[float, float, float]:
1284
2362
  """Returns (usdc_lend_value, wsteth_lend_value, current_leverage).
1285
2363
 
1286
2364
  Args:
1287
- positions: Optional (total_bals, total_usd_bals) from _aggregate_positions().
1288
- If provided, skips position fetches.
1289
- """
1290
- # Use provided positions or fetch them
1291
- if positions is not None:
1292
- _total_bals, totals_usd = positions
1293
- usdc_key = f"Base_{M_USDC}"
1294
- wsteth_key = f"Base_{M_WSTETH}"
1295
- usdc_lend_value = float(totals_usd.get(usdc_key, 0.0))
1296
- wsteth_lend_value = float(totals_usd.get(wsteth_key, 0.0))
1297
- else:
1298
- wsteth_result = await self.moonwell_adapter.get_pos(mtoken=M_WSTETH)
1299
- usdc_result = await self.moonwell_adapter.get_pos(mtoken=M_USDC)
1300
-
1301
- # Get prices/decimals
1302
- wsteth_price, wsteth_decimals = await self._get_token_data(WSTETH_TOKEN_ID)
1303
- usdc_price, usdc_decimals = await self._get_token_data(USDC_TOKEN_ID)
1304
-
1305
- # Calculate wstETH lend value (may not exist yet)
1306
- wsteth_lend_value = 0.0
1307
- if wsteth_result[0]:
1308
- wsteth_pos = wsteth_result[1]
1309
- wsteth_underlying = wsteth_pos.get("underlying_balance", 0)
1310
- wsteth_lend_value = (
1311
- wsteth_underlying / 10**wsteth_decimals
1312
- ) * wsteth_price
1313
-
1314
- # Calculate USDC lend value
1315
- usdc_lend_value = 0.0
1316
- if usdc_result[0]:
1317
- usdc_pos = usdc_result[1]
1318
- usdc_underlying = usdc_pos.get("underlying_balance", 0)
1319
- usdc_lend_value = (usdc_underlying / 10**usdc_decimals) * usdc_price
1320
-
1321
- initial_leverage = (
1322
- wsteth_lend_value / usdc_lend_value + 1 if usdc_lend_value else 0
1323
- )
1324
-
1325
- return (usdc_lend_value, wsteth_lend_value, initial_leverage)
1326
-
1327
- async def _aggregate_positions(self) -> tuple[dict, dict]:
1328
- """Aggregate positions from all Moonwell markets. Returns (total_bals, total_usd_bals).
1329
-
1330
- Note: Position fetches are done sequentially to avoid overwhelming public RPCs
1331
- with too many parallel requests (each get_pos makes 5 RPC calls).
1332
- """
1333
- mtoken_list = [M_USDC, M_WETH, M_WSTETH]
1334
- underlying_list = [USDC, WETH, WSTETH]
1335
-
1336
- # Sequential fetch for positions with delays to avoid rate limiting on public RPCs
1337
- # Each get_pos makes 5 sequential RPC calls; adding delays between positions
1338
- # helps the rate limiter recover (Base public RPC has aggressive limits)
1339
- positions = []
1340
- for mtoken in mtoken_list:
1341
- pos = await self.moonwell_adapter.get_pos(mtoken=mtoken)
1342
- positions.append(pos)
1343
- # 2s delay between positions for public RPC
1344
- await asyncio.sleep(2.0)
1345
-
1346
- # Token data can be fetched in parallel (uses cache, minimal RPC)
1347
- token_data = await asyncio.gather(
1348
- self._get_token_data(USDC_TOKEN_ID),
1349
- self._get_token_data(WETH_TOKEN_ID),
1350
- self._get_token_data(WSTETH_TOKEN_ID),
1351
- )
1352
-
1353
- total_bals: dict[str, float] = {}
1354
- total_usd_bals: dict[str, float] = {}
1355
-
1356
- for i, mtoken in enumerate(mtoken_list):
1357
- success, pos = positions[i]
1358
- if not success:
1359
- logger.warning(f"get_pos failed for {mtoken}: {pos}")
1360
- continue
1361
-
1362
- price, decimals = token_data[i]
1363
- underlying_addr = underlying_list[i]
1364
-
1365
- underlying_bal = pos.get("underlying_balance", 0)
1366
- borrow_bal = pos.get("borrow_balance", 0)
1367
-
1368
- key_mtoken = f"Base_{mtoken}"
1369
- key_underlying = f"Base_{underlying_addr}"
1370
-
1371
- # Store underlying as positive if lent
1372
- if underlying_bal > 0:
1373
- total_bals[key_mtoken] = underlying_bal
1374
- total_usd_bals[key_mtoken] = (underlying_bal / 10**decimals) * price
1375
-
1376
- # Store borrow as negative
1377
- if borrow_bal > 0:
1378
- total_bals[key_underlying] = -borrow_bal
1379
- total_usd_bals[key_underlying] = -(borrow_bal / 10**decimals) * price
1380
-
1381
- return total_bals, total_usd_bals
1382
-
1383
- async def compute_ltv(
1384
- self,
1385
- total_usd_bals: dict,
1386
- collateral_factors: tuple[float, float] | None = None,
1387
- ) -> float:
1388
- """Compute loan-to-value ratio.
1389
-
1390
- LTV = Debt / (cf_u * C_u + cf_s * C_s)
1391
-
1392
- Args:
1393
- total_usd_bals: USD balances from _aggregate_positions().
1394
- collateral_factors: Optional (cf_usdc, cf_wsteth) tuple.
1395
- If provided, skips collateral factor fetches.
2365
+ snap: Optional accounting snapshot. If provided, skips snapshot fetches.
2366
+ collateral_factors: Optional (cf_usdc, cf_wsteth) tuple; only used if `snap` is None.
1396
2367
  """
1397
- # Get debt (WETH borrow)
1398
- weth_key = f"Base_{WETH}"
1399
- debt_usd = abs(float(total_usd_bals.get(weth_key, 0.0)))
2368
+ if snap is None:
2369
+ snap, _ = await self._accounting_snapshot(
2370
+ collateral_factors=collateral_factors
2371
+ )
1400
2372
 
1401
- # Get collateral values
1402
2373
  usdc_key = f"Base_{M_USDC}"
1403
2374
  wsteth_key = f"Base_{M_WSTETH}"
1404
- usdc_collateral = float(total_usd_bals.get(usdc_key, 0.0))
1405
- wsteth_collateral = float(total_usd_bals.get(wsteth_key, 0.0))
1406
-
1407
- # Use provided collateral factors or fetch them
1408
- if collateral_factors is not None:
1409
- cf_u, cf_s = collateral_factors
1410
- else:
1411
- cf_u, cf_s = await self._get_collateral_factors()
1412
-
1413
- capacity = cf_u * usdc_collateral + cf_s * wsteth_collateral
2375
+ usdc_lend_value = float(snap.totals_usd.get(usdc_key, 0.0))
2376
+ wsteth_lend_value = float(snap.totals_usd.get(wsteth_key, 0.0))
1414
2377
 
1415
- if capacity <= 0:
1416
- return float("nan")
1417
-
1418
- return debt_usd / capacity
2378
+ initial_leverage = (
2379
+ wsteth_lend_value / usdc_lend_value + 1 if usdc_lend_value else 0.0
2380
+ )
1419
2381
 
1420
- async def _can_withdraw_token(
1421
- self,
1422
- total_usd_bals: dict[str, float],
1423
- withdraw_token_id: str,
1424
- withdraw_token_usd_val: float,
1425
- *,
1426
- collateral_factors: tuple[float, float] | None = None,
1427
- ) -> bool:
1428
- """Simulate withdrawing collateral and check resulting HF stays >= MIN_HEALTH_FACTOR."""
1429
- current_val = float(total_usd_bals.get(withdraw_token_id, 0.0))
1430
- if withdraw_token_usd_val <= 0:
1431
- return True
1432
- if withdraw_token_usd_val > current_val:
1433
- return False
1434
-
1435
- simulated_bals = dict(total_usd_bals)
1436
- simulated_bals[withdraw_token_id] = current_val - withdraw_token_usd_val
1437
-
1438
- new_ltv = await self.compute_ltv(simulated_bals, collateral_factors)
1439
- if new_ltv == 0:
1440
- return True
1441
- if not new_ltv or new_ltv != new_ltv:
1442
- return False
1443
-
1444
- new_hf = 1.0 / new_ltv
1445
- return new_hf >= self.MIN_HEALTH_FACTOR
2382
+ return (usdc_lend_value, wsteth_lend_value, initial_leverage)
1446
2383
 
1447
2384
  async def _get_steth_apy(self) -> float | None:
1448
2385
  """Fetch wstETH APY from Lido API."""
@@ -1495,7 +2432,7 @@ class MoonwellWstethLoopStrategy(Strategy):
1495
2432
  return {"apy": 0, "information": "Invalid collateral factor", "data": {}}
1496
2433
 
1497
2434
  # Calculate target borrow and leverage
1498
- denominator = self.MIN_HEALTH_FACTOR - cf_w
2435
+ denominator = self.TARGET_HEALTH_FACTOR - cf_w
1499
2436
  if denominator <= 0:
1500
2437
  return {"apy": 0, "information": "Invalid health factor params", "data": {}}
1501
2438
  target_borrow = cf_u / denominator
@@ -1539,54 +2476,41 @@ class MoonwellWstethLoopStrategy(Strategy):
1539
2476
  if not success:
1540
2477
  raise Exception(f"Borrow failed: {borrow_result}")
1541
2478
 
1542
- # Extract block number from transaction result for block-pinned reads
1543
- tx_block: int | None = None
1544
- if isinstance(borrow_result, dict):
1545
- tx_block = borrow_result.get("block_number") or (
1546
- borrow_result.get("receipt", {}).get("blockNumber")
1547
- )
2479
+ # Extract a deterministic pinned block number from the transaction result.
2480
+ # On Base we wait +2 blocks by default; `confirmed_block_number` is safe to pin reads to.
2481
+ pinned_block = self._pinned_block(borrow_result)
1548
2482
 
1549
2483
  logger.info(
1550
2484
  f"Borrowed {safe_borrow_amt / 10**18:.6f} WETH (may arrive as ETH) "
1551
- f"in block {tx_block}"
2485
+ f"(pinned block {pinned_block})"
1552
2486
  )
1553
2487
 
1554
2488
  # Use block-pinned reads to check balances at the transaction's block
1555
2489
  # This avoids stale reads from RPC indexing lag on L2s like Base
2490
+ eth_after = int(eth_before)
2491
+ weth_after = int(weth_before)
1556
2492
  eth_delta = 0
1557
2493
  weth_delta = 0
1558
- eth_after = 0
1559
- weth_after = 0
1560
2494
  for attempt in range(5):
1561
- if attempt > 0:
1562
- # Exponential backoff: 1, 2, 4, 8 seconds
1563
- await asyncio.sleep(2 ** (attempt - 1))
1564
-
1565
- # Read at the specific block where the borrow occurred
1566
2495
  eth_after, weth_after = await asyncio.gather(
1567
2496
  self._get_balance_raw(
1568
2497
  token_id=ETH_TOKEN_ID,
1569
2498
  wallet_address=strategy_address,
1570
- block_identifier=tx_block,
2499
+ block_identifier=pinned_block,
1571
2500
  ),
1572
2501
  self._get_balance_raw(
1573
2502
  token_id=WETH_TOKEN_ID,
1574
2503
  wallet_address=strategy_address,
1575
- block_identifier=tx_block,
2504
+ block_identifier=pinned_block,
1576
2505
  ),
1577
2506
  )
1578
-
1579
2507
  eth_delta = max(0, int(eth_after) - int(eth_before))
1580
2508
  weth_delta = max(0, int(weth_after) - int(weth_before))
1581
-
1582
2509
  if eth_delta > 0 or weth_delta > 0:
1583
2510
  break
1584
- logger.debug(
1585
- f"Balance check attempt {attempt + 1} at block {tx_block}: "
1586
- f"no delta detected yet, retrying..."
1587
- )
2511
+ await asyncio.sleep(1 + attempt)
1588
2512
 
1589
- gas_reserve = int(self.WRAP_GAS_RESERVE * 10**18)
2513
+ gas_reserve = int(self._gas_keep_wei())
1590
2514
  # Usable ETH is the minimum of what we received (eth_delta) and what's available after gas reserve
1591
2515
  usable_eth = min(eth_delta, max(0, int(eth_after) - gas_reserve))
1592
2516
 
@@ -1667,7 +2591,7 @@ class MoonwellWstethLoopStrategy(Strategy):
1667
2591
  eth_bal = await self._get_balance_raw(
1668
2592
  token_id=ETH_TOKEN_ID, wallet_address=strategy_address
1669
2593
  )
1670
- gas_reserve = int(self.WRAP_GAS_RESERVE * 10**18)
2594
+ gas_reserve = int(self._gas_keep_wei())
1671
2595
  available_for_wrap = max(0, eth_bal - gas_reserve)
1672
2596
  shortfall = safe_borrow_amt - weth_bal
1673
2597
  wrap_amt = min(shortfall, available_for_wrap)
@@ -1861,8 +2785,6 @@ class MoonwellWstethLoopStrategy(Strategy):
1861
2785
 
1862
2786
  async def partial_liquidate(self, usd_value: float) -> StatusTuple:
1863
2787
  """Create USDC liquidity in the strategy wallet by safely redeeming collateral."""
1864
- self._clear_price_cache()
1865
-
1866
2788
  if usd_value <= 0:
1867
2789
  raise ValueError(f"usd_value must be positive, got {usd_value}")
1868
2790
 
@@ -1882,108 +2804,93 @@ class MoonwellWstethLoopStrategy(Strategy):
1882
2804
  f"Partial liquidation not needed. Available: {available:.2f} USDC",
1883
2805
  )
1884
2806
 
1885
- missing = usd_value - current_usdc
2807
+ missing_usd = float(usd_value - current_usdc)
1886
2808
 
1887
- # (2) Fetch Moonwell positions and collateral factors
1888
- (_totals_token, total_usd_bals), collateral_factors = await asyncio.gather(
1889
- self._aggregate_positions(),
1890
- self._get_collateral_factors(),
1891
- )
2809
+ collateral_factors = await self._get_collateral_factors()
2810
+ snap, _ = await self._accounting_snapshot(collateral_factors=collateral_factors)
1892
2811
 
1893
2812
  key_wsteth = f"Base_{M_WSTETH}"
1894
- key_weth = f"Base_{WETH}"
1895
2813
  key_usdc = f"Base_{M_USDC}"
1896
2814
 
1897
- wsteth_usd = float(total_usd_bals.get(key_wsteth, 0.0))
1898
- weth_debt_usd = abs(float(total_usd_bals.get(key_weth, 0.0)))
2815
+ wsteth_usd = float(snap.totals_usd.get(key_wsteth, 0.0))
2816
+ weth_debt_usd = float(snap.debt_usd)
1899
2817
 
1900
- # (2a) If wstETH collateral exceeds WETH debt, redeem some wstETH and swap to USDC
1901
- if missing > 0 and wsteth_usd > weth_debt_usd:
1902
- unlend_usd = min(missing, wsteth_usd - weth_debt_usd)
1903
- if await self._can_withdraw_token(
1904
- total_usd_bals,
1905
- key_wsteth,
1906
- unlend_usd,
2818
+ # (2a) Prefer withdrawing wstETH first if we're meaningfully long (collateral > debt).
2819
+ if missing_usd > 0 and wsteth_usd > weth_debt_usd:
2820
+ max_delta_unwind = max(0.0, wsteth_usd - weth_debt_usd)
2821
+ desired_unlend_usd = min(float(missing_usd), float(max_delta_unwind))
2822
+
2823
+ safe_unlend_usd = self._max_safe_withdraw_usd(
2824
+ totals_usd=snap.totals_usd,
2825
+ withdraw_key=key_wsteth,
1907
2826
  collateral_factors=collateral_factors,
1908
- ):
1909
- wsteth_price = await self._get_token_price(WSTETH_TOKEN_ID)
1910
- if not wsteth_price or wsteth_price <= 0:
2827
+ hf_floor=float(self.MIN_HEALTH_FACTOR),
2828
+ )
2829
+ unlend_usd = min(float(desired_unlend_usd), float(safe_unlend_usd))
2830
+
2831
+ if unlend_usd >= float(self.min_withdraw_usd):
2832
+ if not snap.wsteth_price or snap.wsteth_price <= 0:
1911
2833
  return (False, "Invalid wstETH price")
1912
2834
 
1913
- wsteth_info = await self._get_token_info(WSTETH_TOKEN_ID)
1914
- wsteth_decimals = wsteth_info.get("decimals", 18)
2835
+ unlend_underlying_raw = (
2836
+ int(unlend_usd / snap.wsteth_price * 10**snap.wsteth_dec) + 1
2837
+ )
1915
2838
 
1916
- token_qty = unlend_usd / wsteth_price
1917
- unlend_underlying_raw = int(token_qty * (10**wsteth_decimals))
2839
+ mwsteth_res = await self.moonwell_adapter.max_withdrawable_mtoken(
2840
+ mtoken=M_WSTETH
2841
+ )
2842
+ if not mwsteth_res[0]:
2843
+ return (
2844
+ False,
2845
+ f"Failed to compute withdrawable mwstETH: {mwsteth_res[1]}",
2846
+ )
2847
+ withdraw_info = mwsteth_res[1]
2848
+ if not isinstance(withdraw_info, dict):
2849
+ return (False, f"Bad withdraw info for mwstETH: {withdraw_info}")
1918
2850
 
1919
- if unlend_underlying_raw > 0:
1920
- mwsteth_res = await self.moonwell_adapter.max_withdrawable_mtoken(
1921
- mtoken=M_WSTETH
2851
+ mtoken_amt = self._mtoken_amount_for_underlying(
2852
+ withdraw_info, unlend_underlying_raw
2853
+ )
2854
+ if mtoken_amt > 0:
2855
+ success, msg = await self.moonwell_adapter.unlend(
2856
+ mtoken=M_WSTETH, amount=mtoken_amt
1922
2857
  )
1923
- if mwsteth_res[0]:
1924
- withdraw_info = mwsteth_res[1]
1925
- max_ctokens = int(withdraw_info.get("cTokens_raw", 0))
1926
- exchange_rate_raw = int(
1927
- withdraw_info.get("exchangeRate_raw", 0)
1928
- )
1929
- conversion_factor = float(
1930
- withdraw_info.get("conversion_factor", 0) or 0
2858
+ if not success:
2859
+ return (
2860
+ False,
2861
+ f"Failed to redeem mwstETH for partial liquidation: {msg}",
1931
2862
  )
1932
2863
 
1933
- if max_ctokens > 0:
1934
- if exchange_rate_raw > 0:
1935
- # underlying = cTokens * exchangeRate / 1e18
1936
- ctokens_needed = (
1937
- unlend_underlying_raw * 10**18
1938
- + exchange_rate_raw
1939
- - 1
1940
- ) // exchange_rate_raw
1941
- elif conversion_factor > 0:
1942
- ctokens_needed = (
1943
- int(conversion_factor * unlend_underlying_raw) + 1
1944
- )
1945
- else:
1946
- ctokens_needed = max_ctokens
1947
-
1948
- ctokens_to_redeem = min(int(ctokens_needed), max_ctokens)
1949
- if ctokens_to_redeem > 0:
1950
- success, msg = await self.moonwell_adapter.unlend(
1951
- mtoken=M_WSTETH, amount=ctokens_to_redeem
1952
- )
1953
- if not success:
1954
- return (
1955
- False,
1956
- f"Failed to redeem mwstETH for partial liquidation: {msg}",
1957
- )
1958
-
1959
- # Swap withdrawn wstETH → USDC
1960
- wsteth_wallet_raw = await self._get_balance_raw(
1961
- token_id=WSTETH_TOKEN_ID,
1962
- wallet_address=strategy_address,
2864
+ pinned_block = self._pinned_block(msg)
2865
+ wsteth_wallet_raw = await self._balance_after_tx(
2866
+ token_id=WSTETH_TOKEN_ID,
2867
+ wallet=strategy_address,
2868
+ pinned_block=pinned_block,
2869
+ min_expected=1,
2870
+ attempts=5,
2871
+ )
2872
+ amount_to_swap = min(
2873
+ int(wsteth_wallet_raw), int(unlend_underlying_raw)
2874
+ )
2875
+ if amount_to_swap > 0:
2876
+ swap_res = await self._swap_with_retries(
2877
+ from_token_id=WSTETH_TOKEN_ID,
2878
+ to_token_id=USDC_TOKEN_ID,
2879
+ amount=amount_to_swap,
2880
+ )
2881
+ if swap_res is None:
2882
+ restore_amt = min(
2883
+ int(wsteth_wallet_raw), int(amount_to_swap)
2884
+ )
2885
+ if restore_amt > 0:
2886
+ await self.moonwell_adapter.lend(
2887
+ mtoken=M_WSTETH,
2888
+ underlying_token=WSTETH,
2889
+ amount=restore_amt,
1963
2890
  )
1964
- amount_to_swap = min(
1965
- wsteth_wallet_raw, unlend_underlying_raw
2891
+ await self.moonwell_adapter.set_collateral(
2892
+ mtoken=M_WSTETH
1966
2893
  )
1967
- if amount_to_swap > 0:
1968
- swap_res = await self._swap_with_retries(
1969
- from_token_id=WSTETH_TOKEN_ID,
1970
- to_token_id=USDC_TOKEN_ID,
1971
- amount=amount_to_swap,
1972
- )
1973
- if swap_res is None:
1974
- # Restore collateral if swap fails
1975
- restore_amt = min(
1976
- amount_to_swap, wsteth_wallet_raw
1977
- )
1978
- if restore_amt > 0:
1979
- await self.moonwell_adapter.lend(
1980
- mtoken=M_WSTETH,
1981
- underlying_token=WSTETH,
1982
- amount=restore_amt,
1983
- )
1984
- await self.moonwell_adapter.set_collateral(
1985
- mtoken=M_WSTETH
1986
- )
1987
2894
 
1988
2895
  # (3) Re-check wallet USDC balance
1989
2896
  usdc_raw = await self._get_balance_raw(
@@ -1993,63 +2900,62 @@ class MoonwellWstethLoopStrategy(Strategy):
1993
2900
 
1994
2901
  # (4) If still short, redeem USDC collateral directly
1995
2902
  if current_usdc < usd_value:
1996
- # Refresh Moonwell balances after any prior redemptions/swaps.
1997
- (_totals_token, total_usd_bals) = await self._aggregate_positions()
1998
-
1999
- missing_usdc = usd_value - current_usdc
2000
- available_usdc = float(total_usd_bals.get(key_usdc, 0.0))
2001
-
2002
- if missing_usdc > 0 and available_usdc > 0:
2003
- unlend_usdc = min(missing_usdc, available_usdc)
2004
- if await self._can_withdraw_token(
2005
- total_usd_bals,
2006
- key_usdc,
2007
- unlend_usdc,
2903
+ snap, _ = await self._accounting_snapshot(
2904
+ collateral_factors=collateral_factors
2905
+ )
2906
+
2907
+ missing_usdc = float(usd_value - current_usdc)
2908
+ available_usdc_usd = float(snap.totals_usd.get(key_usdc, 0.0))
2909
+
2910
+ if missing_usdc > 0 and available_usdc_usd > 0:
2911
+ safe_unlend_usd = self._max_safe_withdraw_usd(
2912
+ totals_usd=snap.totals_usd,
2913
+ withdraw_key=key_usdc,
2008
2914
  collateral_factors=collateral_factors,
2009
- ):
2010
- unlend_underlying_raw = int(unlend_usdc * (10**usdc_decimals))
2011
- if unlend_underlying_raw > 0:
2012
- musdc_res = await self.moonwell_adapter.max_withdrawable_mtoken(
2013
- mtoken=M_USDC
2014
- )
2015
- if not musdc_res[0]:
2016
- return (
2017
- False,
2018
- f"Failed to compute withdrawable mUSDC: {musdc_res[1]}",
2915
+ hf_floor=float(self.MIN_HEALTH_FACTOR),
2916
+ )
2917
+ desired_unlend_usd = min(
2918
+ float(missing_usdc),
2919
+ float(available_usdc_usd),
2920
+ float(safe_unlend_usd),
2921
+ )
2922
+ if desired_unlend_usd >= float(self.min_withdraw_usd):
2923
+ if snap.usdc_price and snap.usdc_price > 0:
2924
+ unlend_underlying_raw = (
2925
+ int(
2926
+ desired_unlend_usd / snap.usdc_price * 10**snap.usdc_dec
2019
2927
  )
2020
- withdraw_info = musdc_res[1]
2021
- max_ctokens = int(withdraw_info.get("cTokens_raw", 0))
2022
- exchange_rate_raw = int(
2023
- withdraw_info.get("exchangeRate_raw", 0)
2928
+ + 1
2024
2929
  )
2025
- conversion_factor = float(
2026
- withdraw_info.get("conversion_factor", 0) or 0
2930
+ else:
2931
+ unlend_underlying_raw = int(
2932
+ desired_unlend_usd * (10**usdc_decimals)
2027
2933
  )
2028
2934
 
2029
- if max_ctokens > 0:
2030
- if exchange_rate_raw > 0:
2031
- ctokens_needed = (
2032
- unlend_underlying_raw * 10**18
2033
- + exchange_rate_raw
2034
- - 1
2035
- ) // exchange_rate_raw
2036
- elif conversion_factor > 0:
2037
- ctokens_needed = (
2038
- int(conversion_factor * unlend_underlying_raw) + 1
2039
- )
2040
- else:
2041
- ctokens_needed = max_ctokens
2935
+ musdc_res = await self.moonwell_adapter.max_withdrawable_mtoken(
2936
+ mtoken=M_USDC
2937
+ )
2938
+ if not musdc_res[0]:
2939
+ return (
2940
+ False,
2941
+ f"Failed to compute withdrawable mUSDC: {musdc_res[1]}",
2942
+ )
2943
+ withdraw_info = musdc_res[1]
2944
+ if not isinstance(withdraw_info, dict):
2945
+ return (False, f"Bad withdraw info for mUSDC: {withdraw_info}")
2042
2946
 
2043
- ctokens_to_redeem = min(int(ctokens_needed), max_ctokens)
2044
- if ctokens_to_redeem > 0:
2045
- success, msg = await self.moonwell_adapter.unlend(
2046
- mtoken=M_USDC, amount=ctokens_to_redeem
2047
- )
2048
- if not success:
2049
- return (
2050
- False,
2051
- f"Failed to redeem mUSDC for partial liquidation: {msg}",
2052
- )
2947
+ mtoken_amt = self._mtoken_amount_for_underlying(
2948
+ withdraw_info, unlend_underlying_raw
2949
+ )
2950
+ if mtoken_amt > 0:
2951
+ success, msg = await self.moonwell_adapter.unlend(
2952
+ mtoken=M_USDC, amount=mtoken_amt
2953
+ )
2954
+ if not success:
2955
+ return (
2956
+ False,
2957
+ f"Failed to redeem mUSDC for partial liquidation: {msg}",
2958
+ )
2053
2959
 
2054
2960
  # (5) Final available USDC (capped to target)
2055
2961
  usdc_raw = await self._get_balance_raw(
@@ -2185,10 +3091,10 @@ class MoonwellWstethLoopStrategy(Strategy):
2185
3091
  max_safe_f = self._max_safe_F(cf_w)
2186
3092
 
2187
3093
  # Guard against division by zero/negative denominator
2188
- denominator = self.MIN_HEALTH_FACTOR + 0.001 - cf_w
3094
+ denominator = self.TARGET_HEALTH_FACTOR + 0.001 - cf_w
2189
3095
  if denominator <= 0:
2190
3096
  logger.warning(
2191
- f"Cannot calculate target borrow: cf_w ({cf_w:.3f}) >= MIN_HF ({self.MIN_HEALTH_FACTOR})"
3097
+ f"Cannot calculate target borrow: cf_w ({cf_w:.3f}) >= TARGET_HF ({self.TARGET_HEALTH_FACTOR})"
2192
3098
  )
2193
3099
  return (False, initial_leverage, -1)
2194
3100
 
@@ -2286,9 +3192,44 @@ class MoonwellWstethLoopStrategy(Strategy):
2286
3192
 
2287
3193
  return (True, leverage_tracker[-1], len(leverage_tracker) - 1)
2288
3194
 
2289
- async def update(self) -> StatusTuple:
2290
- """Rebalance positions. Runs deposit loop only if HF > MAX_HEALTH_FACTOR."""
2291
- self._clear_price_cache()
3195
+ async def update(self) -> StatusTuple:
3196
+ """Rebalance positions, then run a post-run safety guard."""
3197
+ logger.info("")
3198
+ logger.info("*" * 60)
3199
+ logger.info("* MOONWELL STRATEGY UPDATE CALLED")
3200
+ logger.info("*" * 60)
3201
+ self._clear_price_cache()
3202
+
3203
+ status: StatusTuple = (False, "Unknown")
3204
+ err: Exception | None = None
3205
+
3206
+ try:
3207
+ status = await self._update_impl()
3208
+ except Exception as exc:
3209
+ err = exc
3210
+ if isinstance(exc, SwapOutcomeUnknownError):
3211
+ status = (False, f"Swap outcome unknown: {exc}")
3212
+ else:
3213
+ status = (False, f"Update failed: {exc}")
3214
+
3215
+ guard_ok, guard_msg = await self._post_run_guard(
3216
+ mode="operate", prior_error=err
3217
+ )
3218
+ if not guard_ok:
3219
+ return (
3220
+ False,
3221
+ f"{status[1]} | finalizer FAILED: {guard_msg}",
3222
+ )
3223
+ return (
3224
+ status[0],
3225
+ f"{status[1]} | finalizer: {guard_msg}",
3226
+ )
3227
+
3228
+ async def _update_impl(self) -> StatusTuple:
3229
+ """Update implementation (called by update() wrapper)."""
3230
+ logger.info("=" * 60)
3231
+ logger.info("UPDATE START")
3232
+ logger.info("=" * 60)
2292
3233
 
2293
3234
  # Best-effort top-up if we dipped below MIN_GAS (non-critical).
2294
3235
  topup_success, topup_msg = await self._transfer_gas_to_vault()
@@ -2296,105 +3237,198 @@ class MoonwellWstethLoopStrategy(Strategy):
2296
3237
  logger.warning(f"Gas top-up failed (non-critical): {topup_msg}")
2297
3238
 
2298
3239
  gas_amt = await self._get_gas_balance()
3240
+ logger.info(
3241
+ f"Gas balance: {gas_amt / 10**18:.6f} ETH (min: {self.MAINTENANCE_GAS} ETH)"
3242
+ )
2299
3243
  if gas_amt < int(self.MAINTENANCE_GAS * 10**18):
3244
+ logger.error(
3245
+ f"Insufficient gas: {gas_amt / 10**18:.6f} < {self.MAINTENANCE_GAS}"
3246
+ )
2300
3247
  return (
2301
3248
  False,
2302
3249
  f"Less than {self.MAINTENANCE_GAS} ETH in strategy wallet. Please transfer more gas.",
2303
3250
  )
2304
3251
 
2305
- # Recovery: if a previous loop borrowed WETH but failed to swap/lend, complete it first.
2306
- try:
2307
- completed, msg = await self._complete_unpaired_weth_borrow()
2308
- if not completed:
2309
- logger.warning(
2310
- f"Unpaired borrow completion failed (will continue): {msg}"
2311
- )
2312
- except Exception as exc:
2313
- return (False, f"Failed while completing unpaired borrow: {exc}")
3252
+ # Pre-fetch collateral factors once (saves RPC + makes decisions consistent)
3253
+ collateral_factors = await self._get_collateral_factors()
2314
3254
 
2315
- # Balance WETH debt first (critical for delta-neutrality)
2316
- balance_success, balance_msg = await self._balance_weth_debt()
2317
- if not balance_success:
2318
- return (
2319
- False,
2320
- f"Failed to balance WETH debt: {balance_msg}. Consider calling withdraw to unwind safely.",
2321
- )
3255
+ snap, _ = await self._accounting_snapshot(collateral_factors=collateral_factors)
3256
+
3257
+ # Log current state
3258
+ logger.info("-" * 40)
3259
+ logger.info("CURRENT STATE:")
3260
+ logger.info(f" Health Factor: {snap.hf:.3f}")
3261
+ logger.info(
3262
+ f" Wallet: ETH={snap.wallet_eth / 10**18:.4f}, WETH={snap.wallet_weth / 10**18:.4f}, USDC={snap.wallet_usdc / 10**6:.2f}"
3263
+ )
3264
+ logger.info(
3265
+ f" Supplied: USDC=${snap.usdc_supplied / 10**6 * snap.usdc_price:.2f}, wstETH=${snap.wsteth_supplied / 10**18 * snap.wsteth_price:.2f}"
3266
+ )
3267
+ logger.info(f" Debt: WETH=${snap.weth_debt / 10**18 * snap.weth_price:.2f}")
3268
+ logger.info(f" Net equity: ${snap.net_equity_usd:.2f}")
3269
+ logger.info(f" Borrow capacity: ${snap.capacity_usd:.2f}")
3270
+ logger.info("-" * 40)
2322
3271
 
2323
- # If we are holding excess native ETH, convert it into USDC so it can be redeployed.
3272
+ ok, msg = await self._ensure_markets_for_state(snap)
3273
+ if not ok:
3274
+ return (False, f"Failed ensuring markets: {msg}")
3275
+
3276
+ # 1) Reconcile wallet leftovers into the intended position
3277
+ logger.info("STEP 1: Reconciling wallet leftovers into position...")
2324
3278
  try:
2325
- converted, msg = await self._convert_excess_eth_to_usdc()
2326
- if not converted:
2327
- logger.warning(f"Excess ETH conversion failed (non-critical): {msg}")
3279
+ ok, msg = await self._reconcile_wallet_into_position(
3280
+ collateral_factors=collateral_factors,
3281
+ max_batch_usd=8000.0,
3282
+ )
3283
+ if not ok:
3284
+ return (False, msg)
2328
3285
  except SwapOutcomeUnknownError as exc:
2329
- return (False, f"Swap outcome unknown while converting excess ETH: {exc}")
3286
+ return (False, f"Swap outcome unknown during wallet reconciliation: {exc}")
2330
3287
  except Exception as exc:
2331
- logger.warning(f"Excess ETH conversion raised (non-critical): {exc}")
3288
+ return (False, f"Failed during wallet reconciliation: {exc}")
2332
3289
 
2333
- # Fetch positions and collateral factors in parallel (single fetch for update)
2334
- positions_task = self._aggregate_positions()
2335
- cf_task = self._get_collateral_factors()
2336
- (totals_token, totals_usd), collateral_factors = await asyncio.gather(
2337
- positions_task, cf_task
2338
- )
3290
+ # 2) Refresh snapshot and keep HF near TARGET_HEALTH_FACTOR (deleverage if too low)
3291
+ snap, _ = await self._accounting_snapshot(collateral_factors=collateral_factors)
3292
+ logger.info(f"STEP 2: Check HF thresholds (current HF={snap.hf:.3f})")
2339
3293
 
2340
- # Compute health factor using pre-fetched data
2341
- ltv = await self.compute_ltv(totals_usd, collateral_factors=collateral_factors)
2342
- hf = (1 / ltv) if ltv and ltv > 0 and not (ltv != ltv) else float("inf")
3294
+ emergency_hf_floor = float(self.LEVERAGE_DELEVER_HF_FLOOR)
3295
+ if snap.hf < emergency_hf_floor:
3296
+ logger.warning(
3297
+ f"EMERGENCY: HF {snap.hf:.3f} < floor {emergency_hf_floor:.2f} - depositing USDC to raise HF"
3298
+ )
3299
+ # Emergency: raise collateral without touching debt/collateral withdrawals.
3300
+ # Sweep whatever wallet assets we can into USDC, then lend it as collateral.
3301
+ try:
3302
+ ok, sweep_msg = await self._sweep_token_balances(
3303
+ target_token_id=USDC_TOKEN_ID,
3304
+ exclude={ETH_TOKEN_ID},
3305
+ min_usd_value=float(self.sweep_min_usd),
3306
+ )
3307
+ except SwapOutcomeUnknownError as exc:
3308
+ return (False, f"Swap outcome unknown during emergency sweep: {exc}")
2343
3309
 
2344
- # Check if we need to deleverage
2345
- if hf < self.MIN_HEALTH_FACTOR:
2346
- cf_u, cf_w = collateral_factors
3310
+ if not ok:
3311
+ return (False, f"Emergency sweep failed: {sweep_msg}")
2347
3312
 
2348
- usdc_key = f"Base_{M_USDC}"
2349
- weth_key = f"Base_{WETH}"
3313
+ addr = self._get_strategy_wallet_address()
3314
+ usdc_bal = await self._get_balance_raw(
3315
+ token_id=USDC_TOKEN_ID, wallet_address=addr
3316
+ )
3317
+ if usdc_bal <= 0:
3318
+ return (
3319
+ False,
3320
+ f"HF={snap.hf:.3f} below emergency floor ({emergency_hf_floor:.2f}); "
3321
+ "no USDC available to deposit after sweep",
3322
+ )
2350
3323
 
2351
- c_u = totals_usd.get(usdc_key, 0)
2352
- debt = abs(totals_usd.get(weth_key, 0))
3324
+ lend_ok, lend_res = await self.moonwell_adapter.lend(
3325
+ mtoken=M_USDC, underlying_token=USDC, amount=int(usdc_bal)
3326
+ )
3327
+ if not lend_ok:
3328
+ return (False, f"Emergency USDC deposit failed: {lend_res}")
2353
3329
 
2354
- repay_usd = debt - (cf_u * c_u) / (self.MIN_HEALTH_FACTOR + 1e-4 - cf_w)
2355
- weth_price = await self._get_token_price(WETH_TOKEN_ID)
3330
+ # Ensure USDC market is entered as collateral (idempotent).
3331
+ await self.moonwell_adapter.set_collateral(mtoken=M_USDC)
2356
3332
 
2357
- repay_amt = int(repay_usd / weth_price * 10**18) + 1
2358
- success, msg = await self._repay_debt_loop(target_repaid=repay_amt)
3333
+ return (
3334
+ True,
3335
+ f"HF={snap.hf:.3f} below emergency floor ({emergency_hf_floor:.2f}); "
3336
+ f"swept+deposited {usdc_bal / 10**snap.usdc_dec:.2f} USDC to improve HF "
3337
+ f"({sweep_msg})",
3338
+ )
2359
3339
 
2360
- if not success:
3340
+ target_hf = float(self.TARGET_HEALTH_FACTOR)
3341
+ deleverage_threshold = max(
3342
+ float(self.MIN_HEALTH_FACTOR),
3343
+ float(target_hf) - float(self.HF_DELEVERAGE_BUFFER),
3344
+ )
3345
+ logger.info(
3346
+ f" Target HF={target_hf:.2f}, Deleverage threshold={deleverage_threshold:.2f}"
3347
+ )
3348
+ if snap.hf < deleverage_threshold:
3349
+ logger.info(
3350
+ f"DELEVERAGE: HF {snap.hf:.3f} < threshold {deleverage_threshold:.2f} - reducing debt"
3351
+ )
3352
+ if snap.capacity_usd <= 0:
2361
3353
  return (
2362
3354
  False,
2363
- f"Health factor is {hf:.2f} which is dangerous. Deleveraging failed: {msg}",
3355
+ "No borrow capacity found; cannot compute deleverage target",
3356
+ )
3357
+ try:
3358
+ ok, msg = await self._settle_weth_debt_to_target_usd(
3359
+ target_debt_usd=0.0,
3360
+ target_hf=float(target_hf),
3361
+ collateral_factors=collateral_factors,
3362
+ mode="operate",
3363
+ max_batch_usd=3000.0,
2364
3364
  )
2365
- return (success, msg)
3365
+ except SwapOutcomeUnknownError as exc:
3366
+ return (False, f"Swap outcome unknown during deleverage: {exc}")
3367
+ if not ok:
3368
+ return (False, f"Deleverage failed: {msg}")
3369
+ snap, _ = await self._accounting_snapshot(
3370
+ collateral_factors=collateral_factors
3371
+ )
2366
3372
 
2367
- # Claim rewards if above threshold
2368
- await self.moonwell_adapter.claim_rewards(min_rewards_usd=self.MIN_USDC_DEPOSIT)
3373
+ # If leverage drifted above target (by > LEVERAGE_DELEVERAGE_BUFFER), reduce it by
3374
+ # withdrawing wstETH collateral and applying it to the WETH borrow.
3375
+ target_leverage = self._target_leverage(
3376
+ collateral_factors=collateral_factors,
3377
+ target_hf=float(target_hf),
3378
+ )
3379
+ usdc_key = f"Base_{M_USDC}"
3380
+ wsteth_key = f"Base_{M_WSTETH}"
3381
+ usdc_lend_value = float(snap.totals_usd.get(usdc_key, 0.0))
3382
+ wsteth_lend_value = float(snap.totals_usd.get(wsteth_key, 0.0))
3383
+ current_leverage = (
3384
+ wsteth_lend_value / usdc_lend_value + 1.0 if usdc_lend_value > 0 else 0.0
3385
+ )
3386
+ logger.info(
3387
+ f"STEP 3: Check leverage (current={current_leverage:.2f}x, target={target_leverage:.2f}x)"
3388
+ )
3389
+ if target_leverage > 0 and current_leverage > target_leverage + float(
3390
+ self.LEVERAGE_DELEVERAGE_BUFFER
3391
+ ):
3392
+ logger.info(
3393
+ f"DELEVER: Leverage {current_leverage:.2f}x > target+buffer {target_leverage + self.LEVERAGE_DELEVERAGE_BUFFER:.2f}x"
3394
+ )
3395
+ try:
3396
+ ok, msg = await self._delever_wsteth_to_target_leverage(
3397
+ target_leverage=float(target_leverage),
3398
+ collateral_factors=collateral_factors,
3399
+ max_over_leverage=float(self.LEVERAGE_DELEVERAGE_BUFFER),
3400
+ max_batch_usd=3000.0,
3401
+ max_steps=10,
3402
+ )
3403
+ except SwapOutcomeUnknownError as exc:
3404
+ return (False, f"Swap outcome unknown during leverage delever: {exc}")
3405
+ if not ok:
3406
+ return (False, msg)
3407
+
3408
+ snap, _ = await self._accounting_snapshot(
3409
+ collateral_factors=collateral_factors
3410
+ )
3411
+
3412
+ totals_usd = snap.totals_usd
3413
+ hf = snap.hf
3414
+
3415
+ # Claim WELL rewards, swap to USDC, and lend directly (no leverage)
3416
+ logger.info("STEP 4: Claim and reinvest WELL rewards...")
3417
+ reward_ok, reward_msg = await self._claim_and_reinvest_rewards()
3418
+ logger.info(f" Rewards: {reward_msg}")
2369
3419
 
2370
3420
  # Check profitability
2371
3421
  success, msg = await self._check_quote_profitability()
2372
3422
  if not success:
2373
3423
  return (False, msg)
2374
3424
 
2375
- # If we have idle wallet wstETH (spot long), convert it to USDC so it can be redeployed
2376
- # via the leverage loop. This does not affect native ETH gas.
2377
- try:
2378
- converted, conv_msg = await self._convert_spot_wsteth_to_usdc()
2379
- if not converted:
2380
- logger.warning(
2381
- f"Wallet wstETH conversion failed (non-critical): {conv_msg}"
2382
- )
2383
- except SwapOutcomeUnknownError as exc:
2384
- return (
2385
- False,
2386
- f"Swap outcome unknown while converting wallet wstETH: {exc}",
2387
- )
2388
- except Exception as exc:
2389
- logger.warning(f"Wallet wstETH conversion raised (non-critical): {exc}")
2390
-
2391
3425
  # Get USDC balance in wallet
2392
3426
  usdc_balance_wei = await self._get_usdc_balance()
2393
3427
  token_info = await self._get_token_info(USDC_TOKEN_ID)
2394
3428
  decimals = token_info.get("decimals", 6)
2395
3429
  usdc_balance = usdc_balance_wei / 10**decimals
2396
3430
 
2397
- # Get lend values from already-fetched aggregate positions
3431
+ # Get lend values from the snapshot
2398
3432
  usdc_key = f"Base_{M_USDC}"
2399
3433
  wsteth_key = f"Base_{M_WSTETH}"
2400
3434
  usdc_lend_value = totals_usd.get(usdc_key, 0)
@@ -2403,8 +3437,17 @@ class MoonwellWstethLoopStrategy(Strategy):
2403
3437
  wsteth_lend_value / usdc_lend_value + 1 if usdc_lend_value else 0
2404
3438
  )
2405
3439
 
3440
+ logger.info("STEP 5: Check for USDC to deploy...")
3441
+ logger.info(
3442
+ f" Wallet USDC: {usdc_balance:.2f}, Min deposit: {self.MIN_USDC_DEPOSIT}"
3443
+ )
3444
+ logger.info(f" Current leverage: {initial_leverage:.2f}x, HF: {hf:.3f}")
3445
+
2406
3446
  # If we have meaningful USDC in-wallet, redeploy it regardless of current HF.
2407
3447
  if usdc_balance >= self.MIN_USDC_DEPOSIT:
3448
+ logger.info(
3449
+ f"REDEPLOY: Deploying {usdc_balance:.2f} USDC into leverage loop"
3450
+ )
2408
3451
  success, final_leverage, n_loops = await self._execute_deposit_loop(
2409
3452
  usdc_balance
2410
3453
  )
@@ -2418,13 +3461,19 @@ class MoonwellWstethLoopStrategy(Strategy):
2418
3461
  f"Redeployed {usdc_balance:.2f} USDC to {final_leverage:.2f}x with {n_loops} loops",
2419
3462
  )
2420
3463
 
2421
- # Lever-up when HF is significantly above target (MIN_HEALTH_FACTOR).
2422
- # Only skip if HF is close enough to target (within HF_LEVER_UP_BUFFER).
2423
- lever_up_threshold = self.MIN_HEALTH_FACTOR + self.HF_LEVER_UP_BUFFER
3464
+ # Lever-up when HF is significantly above target (TARGET_HEALTH_FACTOR).
3465
+ lever_up_threshold = float(target_hf) + float(self.HF_LEVER_UP_BUFFER)
3466
+ logger.info(
3467
+ f"STEP 6: Check lever-up (HF={hf:.3f}, threshold={lever_up_threshold:.2f})"
3468
+ )
2424
3469
  if hf <= lever_up_threshold:
3470
+ logger.info(
3471
+ f"NO ACTION: HF {hf:.3f} <= lever-up threshold {lever_up_threshold:.2f}"
3472
+ )
3473
+ logger.info("=" * 60)
2425
3474
  return (
2426
3475
  True,
2427
- f"HF={hf:.3f} <= target+buffer({lever_up_threshold:.2f}); no action needed.",
3476
+ f"HF={hf:.3f} <= lever-up threshold({lever_up_threshold:.2f}); no action needed.",
2428
3477
  )
2429
3478
 
2430
3479
  # Use 95% threshold to handle rounding/slippage from deposit
@@ -2481,107 +3530,38 @@ class MoonwellWstethLoopStrategy(Strategy):
2481
3530
  f"Executed redeposit loop to {final_leverage:.2f}x with {n_loops} loops",
2482
3531
  )
2483
3532
 
2484
- async def _repay_debt_loop(
2485
- self, target_repaid: int | None = None
2486
- ) -> tuple[bool, str]:
2487
- """Iteratively repay debt."""
2488
- total_repaid = 0
2489
-
2490
- if target_repaid is not None and target_repaid < 0:
2491
- return (False, "Target repay was negative")
2492
-
2493
- for _ in range(self._MAX_LOOP_LIMIT * 2):
2494
- # Get current debt
2495
- pos_result = await self.moonwell_adapter.get_pos(mtoken=M_WETH)
2496
- if not pos_result[0]:
2497
- break
2498
-
2499
- current_debt = pos_result[1].get("borrow_balance", 0)
2500
- if current_debt < 1:
2501
- break
2502
-
2503
- # Attempt repayment
2504
- try:
2505
- repaid = await self._safe_repay(current_debt)
2506
- except SwapOutcomeUnknownError as exc:
2507
- return (False, f"Swap outcome unknown during debt repayment: {exc}")
2508
- if repaid == 0:
2509
- break
2510
-
2511
- total_repaid += repaid
2512
-
2513
- if target_repaid is not None and total_repaid >= target_repaid:
2514
- return (True, f"Repaid {total_repaid} > {target_repaid} target")
3533
+ async def _repay_weth(self, amount: int, remaining_debt: int) -> int:
3534
+ """Repay WETH debt. Returns amount actually repaid."""
3535
+ if amount <= 0 or remaining_debt <= 0:
3536
+ return 0
2515
3537
 
2516
- # Check remaining debt
2517
- pos_result = await self.moonwell_adapter.get_pos(mtoken=M_WETH)
2518
- if not pos_result[0]:
2519
- return (False, "Failed to check remaining debt after repayment")
3538
+ amount = int(amount)
3539
+ remaining_debt = int(remaining_debt)
2520
3540
 
2521
- remaining_debt = pos_result[1].get("borrow_balance", 0)
3541
+ # Only use repay_full when we have a small buffer above the observed debt.
3542
+ # This avoids cases where debt accrues between snapshot and execution and leaves dust.
3543
+ full_repay_buffer_wei = max(
3544
+ 10_000, remaining_debt // 10_000
3545
+ ) # 0.01% or 10k wei
3546
+ can_repay_full = amount >= (remaining_debt + full_repay_buffer_wei)
2522
3547
 
2523
- if remaining_debt > 0:
2524
- return (
2525
- False,
2526
- f"Could not repay all debt. Remaining: {remaining_debt / 10**18:.6f} WETH",
3548
+ if can_repay_full:
3549
+ # Approve the full amount we have available so repayBorrow(MAX_UINT256)
3550
+ # can clear debt even if it drifted slightly.
3551
+ success, _ = await self.moonwell_adapter.repay(
3552
+ mtoken=M_WETH,
3553
+ underlying_token=WETH,
3554
+ amount=amount,
3555
+ repay_full=True,
2527
3556
  )
3557
+ return remaining_debt if success else 0
2528
3558
 
2529
- return (True, "Debt repayment completed")
2530
-
2531
- async def _emergency_eth_repayment(self, debt: int) -> tuple[bool, str]:
2532
- """Emergency fallback to repay debt using available ETH."""
2533
- gas_balance = await self._get_gas_balance()
2534
- # Reserve for gas: base reserve + buffer for wrap + repay tx gas
2535
- tx_gas_buffer = int(0.001 * 10**18) # ~0.001 ETH for wrap + repay txs
2536
- gas_buffer = int(self.WRAP_GAS_RESERVE * 10**18) + tx_gas_buffer
2537
-
2538
- logger.debug(
2539
- f"Emergency repay check: gas_balance={gas_balance / 10**18:.6f}, "
2540
- f"gas_buffer={gas_buffer / 10**18:.6f}, debt={debt / 10**18:.6f}"
2541
- )
2542
-
2543
- if gas_balance > gas_buffer:
2544
- available_eth = gas_balance - gas_buffer
2545
- repay_amt = min(available_eth, debt)
2546
-
2547
- try:
2548
- wrap_success, wrap_msg = await self.moonwell_adapter.wrap_eth(
2549
- amount=repay_amt
2550
- )
2551
- if not wrap_success:
2552
- logger.warning(f"Emergency wrap failed: {wrap_msg}")
2553
- return (False, f"Wrap failed: {wrap_msg}")
2554
-
2555
- # Only use repay_full=True when we have enough to cover full debt
2556
- can_repay_full = repay_amt >= debt
2557
- success, _ = await self.moonwell_adapter.repay(
2558
- mtoken=M_WETH,
2559
- underlying_token=WETH,
2560
- amount=repay_amt,
2561
- repay_full=can_repay_full,
2562
- )
2563
- if success:
2564
- logger.info(f"Emergency repayment: {repay_amt / 10**18:.6f} WETH")
2565
- return (True, f"Emergency repaid {repay_amt}")
2566
- else:
2567
- logger.warning("Emergency repayment transaction failed")
2568
- return (False, "Repay transaction failed")
2569
- except Exception as e:
2570
- logger.warning(f"Emergency ETH repayment failed: {e}")
2571
- return (False, str(e))
2572
-
2573
- return (False, "Insufficient ETH for emergency repayment")
2574
-
2575
- async def _repay_weth(self, amount: int, remaining_debt: int) -> int:
2576
- """Repay WETH debt. Returns amount actually repaid."""
2577
- if amount <= 0:
2578
- return 0
2579
3559
  repay_amt = min(amount, remaining_debt)
2580
3560
  success, _ = await self.moonwell_adapter.repay(
2581
3561
  mtoken=M_WETH,
2582
3562
  underlying_token=WETH,
2583
3563
  amount=repay_amt,
2584
- repay_full=(repay_amt >= remaining_debt),
3564
+ repay_full=False,
2585
3565
  )
2586
3566
  return repay_amt if success else 0
2587
3567
 
@@ -2590,26 +3570,31 @@ class MoonwellWstethLoopStrategy(Strategy):
2590
3570
  ) -> int:
2591
3571
  """Swap token to WETH and repay. Returns amount repaid."""
2592
3572
  swap_result = await self._swap_with_retries(
2593
- from_token_id=token_id, to_token_id=WETH_TOKEN_ID, amount=amount
3573
+ from_token_id=token_id,
3574
+ to_token_id=WETH_TOKEN_ID,
3575
+ amount=amount,
3576
+ preferred_providers=["aerodrome", "enso"],
2594
3577
  )
2595
3578
  if not swap_result:
2596
3579
  return 0
2597
3580
 
3581
+ pinned_block = self._pinned_block(swap_result)
3582
+
2598
3583
  # Use swap quote amount as minimum expected, retry balance read until we see it
2599
- expected_weth = int(swap_result.get("to_amount") or 0)
3584
+ expected_weth = (
3585
+ int(swap_result.get("to_amount") or 0)
3586
+ if isinstance(swap_result, dict)
3587
+ else 0
3588
+ )
2600
3589
  addr = self._get_strategy_wallet_address()
2601
- weth_bal = 0
2602
-
2603
- for attempt in range(5):
2604
- weth_bal = await self._get_balance_raw(
2605
- token_id=WETH_TOKEN_ID, wallet_address=addr
2606
- )
2607
- if weth_bal >= expected_weth * 0.95 or weth_bal > 0:
2608
- break
2609
- logger.debug(
2610
- f"WETH balance read {weth_bal}, expected ~{expected_weth}, retrying..."
2611
- )
2612
- await asyncio.sleep(1 + attempt)
3590
+ min_expected = max(1, int(expected_weth * 0.95)) if expected_weth > 0 else 1
3591
+ weth_bal = await self._balance_after_tx(
3592
+ token_id=WETH_TOKEN_ID,
3593
+ wallet=addr,
3594
+ pinned_block=pinned_block,
3595
+ min_expected=min_expected,
3596
+ attempts=5,
3597
+ )
2613
3598
 
2614
3599
  if weth_bal <= 0:
2615
3600
  logger.warning(
@@ -2619,211 +3604,181 @@ class MoonwellWstethLoopStrategy(Strategy):
2619
3604
 
2620
3605
  return await self._repay_weth(weth_bal, remaining_debt)
2621
3606
 
2622
- async def _safe_repay(self, debt_to_repay: int) -> int:
2623
- """Attempt repayment using all available assets. Returns total amount repaid."""
2624
- if debt_to_repay < 1:
2625
- return 0
2626
-
2627
- repaid = 0
2628
- addr = self._get_strategy_wallet_address()
3607
+ async def withdraw(self, amount: float | None = None) -> StatusTuple:
3608
+ """Withdraw funds; on failure, run a post-run safety guard."""
3609
+ logger.info("")
3610
+ logger.info("*" * 60)
3611
+ logger.info("* MOONWELL STRATEGY WITHDRAW CALLED")
3612
+ logger.info(
3613
+ f"* Amount requested: {amount if amount else 'ALL (full withdrawal)'}"
3614
+ )
3615
+ logger.info("*" * 60)
3616
+ self._clear_price_cache()
2629
3617
 
2630
- # 1. Use wallet WETH directly
2631
- weth_bal = await self._get_balance_raw(
2632
- token_id=WETH_TOKEN_ID, wallet_address=addr
2633
- )
2634
- if weth_bal > 0:
2635
- repaid += await self._repay_weth(weth_bal, debt_to_repay - repaid)
2636
- if repaid >= debt_to_repay:
2637
- return repaid
2638
-
2639
- # 2. Wrap ETH (above gas reserve) and repay
2640
- eth_bal = await self._get_balance_raw(
2641
- token_id=ETH_TOKEN_ID, wallet_address=addr
2642
- )
2643
- gas_reserve = int((self.WRAP_GAS_RESERVE + 0.0005) * 10**18)
2644
- usable_eth = max(0, eth_bal - gas_reserve)
2645
- if usable_eth > 0:
2646
- wrap_amt = min(usable_eth, debt_to_repay - repaid)
2647
- wrap_ok, _ = await self.moonwell_adapter.wrap_eth(amount=wrap_amt)
2648
- if wrap_ok:
2649
- weth_bal = await self._get_balance_raw(
2650
- token_id=WETH_TOKEN_ID, wallet_address=addr
2651
- )
2652
- repaid += await self._repay_weth(weth_bal, debt_to_repay - repaid)
2653
- if repaid >= debt_to_repay:
2654
- return repaid
3618
+ status: StatusTuple = (False, "Unknown")
3619
+ err: Exception | None = None
2655
3620
 
2656
- # 3. Swap wallet assets (wstETH, USDC) to WETH and repay
2657
- weth_price, weth_dec = await self._get_token_data(WETH_TOKEN_ID)
2658
- if not weth_price or weth_price <= 0:
2659
- return repaid
3621
+ try:
3622
+ status = await self._withdraw_impl(amount)
3623
+ except Exception as exc:
3624
+ err = exc
3625
+ if isinstance(exc, SwapOutcomeUnknownError):
3626
+ status = (False, f"Swap outcome unknown: {exc}")
3627
+ else:
3628
+ status = (False, f"Withdraw failed: {exc}")
2660
3629
 
2661
- for token_id in [WSTETH_TOKEN_ID, USDC_TOKEN_ID]:
2662
- remaining = debt_to_repay - repaid
2663
- if remaining <= 0:
2664
- return repaid
3630
+ # Only run the guard if withdraw failed; a successful withdraw should not touch positions.
3631
+ if status[0]:
3632
+ return status
2665
3633
 
2666
- bal = await self._get_balance_raw(token_id=token_id, wallet_address=addr)
2667
- if bal <= 0:
2668
- continue
3634
+ guard_ok, guard_msg = await self._post_run_guard(mode="exit", prior_error=err)
3635
+ suffix = (
3636
+ f"finalizer: {guard_msg}" if guard_ok else f"finalizer FAILED: {guard_msg}"
3637
+ )
3638
+ return (False, f"{status[1]} | {suffix}")
2669
3639
 
2670
- price, dec = await self._get_token_data(token_id)
2671
- if not price or price <= 0:
2672
- continue
3640
+ async def _withdraw_impl(self, amount: float | None = None) -> StatusTuple:
3641
+ """Withdraw implementation (called by withdraw() wrapper).
2673
3642
 
2674
- bal_usd = (bal / 10**dec) * price
2675
- if bal_usd < self.min_withdraw_usd:
2676
- continue
3643
+ Logic:
3644
+ 1. Liquidate any Moonwell positions to USDC (if any exist)
3645
+ 2. Transfer any USDC > 0 to main wallet
3646
+ """
3647
+ # NOTE: amount is currently unused; withdraw() is all-or-nothing in this strategy.
3648
+ logger.info("=" * 60)
3649
+ logger.info("WITHDRAW START - Full position unwind")
3650
+ logger.info("=" * 60)
2677
3651
 
2678
- # Swap only what's needed (with 2% slippage buffer)
2679
- needed_usd = (remaining / 10**weth_dec) * weth_price * 1.02
2680
- needed_raw = int(needed_usd / price * 10**dec) + 1
2681
- swap_amt = min(bal, needed_raw)
3652
+ collateral_factors = await self._get_collateral_factors()
2682
3653
 
2683
- logger.info(
2684
- f"Swapping {swap_amt / 10**dec:.6f} {token_id} to WETH for repayment"
2685
- )
2686
- repaid += await self._swap_to_weth_and_repay(
2687
- token_id, swap_amt, debt_to_repay - repaid
2688
- )
3654
+ # Get initial state for logging
3655
+ snap, _ = await self._accounting_snapshot(collateral_factors=collateral_factors)
3656
+ logger.info("INITIAL STATE:")
3657
+ logger.info(f" USDC supplied: ${snap.usdc_supplied / 10**6:.2f}")
3658
+ logger.info(
3659
+ f" wstETH supplied: {snap.wsteth_supplied / 10**18:.6f} (${snap.wsteth_supplied / 10**18 * snap.wsteth_price:.2f})"
3660
+ )
3661
+ logger.info(
3662
+ f" WETH debt: {snap.weth_debt / 10**18:.6f} (${snap.weth_debt / 10**18 * snap.weth_price:.2f})"
3663
+ )
3664
+ logger.info(f" Wallet USDC: {snap.wallet_usdc / 10**6:.2f}")
3665
+ logger.info(f" Health Factor: {snap.hf:.3f}")
3666
+ logger.info("-" * 40)
2689
3667
 
2690
- # 4. Unlend collateral, swap to WETH, and repay
2691
- for mtoken, token_id in [(M_WSTETH, WSTETH_TOKEN_ID), (M_USDC, USDC_TOKEN_ID)]:
2692
- remaining = debt_to_repay - repaid
2693
- if remaining <= 0:
2694
- return repaid
3668
+ # Best-effort: convert reward/odd-lot tokens to WETH to improve repayment.
3669
+ logger.info("STEP 1: Sweeping wallet tokens to WETH for debt repayment...")
3670
+ await self._sweep_token_balances(target_token_id=WETH_TOKEN_ID)
2695
3671
 
2696
- withdraw_result = await self.moonwell_adapter.max_withdrawable_mtoken(
2697
- mtoken=mtoken
3672
+ # 1) Settle debt to zero (batchy + safe)
3673
+ logger.info("STEP 2: Settling WETH debt to zero...")
3674
+ logger.info(" Source: Withdraw wstETH collateral → swap to WETH → repay")
3675
+ try:
3676
+ ok, msg = await self._settle_weth_debt_to_target_usd(
3677
+ target_debt_usd=0.0,
3678
+ collateral_factors=collateral_factors,
3679
+ mode="exit",
3680
+ max_batch_usd=4000.0,
3681
+ max_steps=30,
2698
3682
  )
2699
- if not withdraw_result[0]:
2700
- continue
2701
-
2702
- withdraw_info = withdraw_result[1]
2703
- underlying_raw = withdraw_info.get("underlying_raw", 0)
2704
- if underlying_raw < 1:
2705
- continue
2706
-
2707
- price, dec = await self._get_token_data(token_id)
2708
- if not price or price <= 0:
2709
- continue
2710
-
2711
- avail_raw = int(underlying_raw * COLLATERAL_SAFETY_FACTOR)
2712
- avail_usd = (avail_raw / 10**dec) * price
2713
- if avail_usd <= self.min_withdraw_usd:
2714
- continue
2715
-
2716
- # Calculate needed amount with buffer
2717
- remaining_usd = (remaining / 10**weth_dec) * weth_price
2718
- target_usd = max(remaining_usd * 1.02, float(self.min_withdraw_usd))
2719
- needed_raw = int(target_usd / price * 10**dec) + 1
2720
- unlend_raw = min(avail_raw, needed_raw)
3683
+ except SwapOutcomeUnknownError as exc:
3684
+ return (False, f"Swap outcome unknown while unwinding debt: {exc}")
2721
3685
 
2722
- mtoken_amt = self._mtoken_amount_for_underlying(withdraw_info, unlend_raw)
2723
- if mtoken_amt <= 0:
2724
- continue
3686
+ if not ok:
3687
+ logger.error(f"Failed to settle debt: {msg}")
3688
+ return (False, f"Failed to unwind debt: {msg}")
3689
+ logger.info(" Debt settled successfully")
2725
3690
 
2726
- success, _ = await self.moonwell_adapter.unlend(
2727
- mtoken=mtoken, amount=mtoken_amt
2728
- )
2729
- if not success:
2730
- continue
3691
+ # 2) Unlend everything and convert to USDC
3692
+ logger.info("STEP 3: Unlending remaining positions and converting to USDC...")
3693
+ logger.info(" Source: Redeem mUSDC → USDC, Redeem mwstETH → swap to USDC")
3694
+ try:
3695
+ (
3696
+ ok,
3697
+ msg,
3698
+ ) = (
3699
+ await self._unlend_remaining_positions()
3700
+ ) # swaps wstETH->USDC internally
3701
+ except SwapOutcomeUnknownError as exc:
3702
+ return (False, f"Swap outcome unknown while unlending positions: {exc}")
3703
+ except Exception as exc: # noqa: BLE001
3704
+ return (False, f"Failed while unlending positions: {exc}")
2731
3705
 
2732
- # Swap what we unlended
2733
- bal = await self._get_balance_raw(token_id=token_id, wallet_address=addr)
2734
- swap_amt = min(bal, unlend_raw)
2735
- if swap_amt <= 0:
2736
- continue
3706
+ if not ok:
3707
+ logger.error(f"Failed to unlend positions: {msg}")
3708
+ return (False, msg)
3709
+ logger.info(" Positions unlent successfully")
2737
3710
 
2738
- logger.info(f"Swapping {swap_amt / 10**dec:.6f} unlent {token_id} to WETH")
2739
- amt_repaid = await self._swap_to_weth_and_repay(
2740
- token_id, swap_amt, remaining
3711
+ # 3) Sweep any wallet leftovers to USDC (keeping ETH)
3712
+ logger.info("STEP 4: Sweeping remaining wallet tokens to USDC...")
3713
+ try:
3714
+ ok, msg = await self._sweep_token_balances(
3715
+ target_token_id=USDC_TOKEN_ID,
3716
+ exclude={ETH_TOKEN_ID, WELL_TOKEN_ID},
3717
+ min_usd_value=float(self.sweep_min_usd),
3718
+ strict=True,
2741
3719
  )
2742
- if amt_repaid > 0:
2743
- repaid += amt_repaid
2744
- else:
2745
- # Swap failed - re-lend to restore position
2746
- logger.warning(f"Swap failed for {token_id}, re-lending")
2747
- underlying = WSTETH if mtoken == M_WSTETH else USDC
2748
- relend_bal = await self._get_balance_raw(
2749
- token_id=token_id, wallet_address=addr
2750
- )
2751
- if relend_bal > 0:
2752
- await self.moonwell_adapter.lend(
2753
- mtoken=mtoken, underlying_token=underlying, amount=relend_bal
2754
- )
2755
-
2756
- # Emergency fallback: use available ETH when nothing else worked
2757
- if repaid == 0 and debt_to_repay > 0:
2758
- success, _ = await self._emergency_eth_repayment(debt_to_repay)
2759
- if success:
2760
- pos_result = await self.moonwell_adapter.get_pos(mtoken=M_WETH)
2761
- if pos_result[0]:
2762
- new_debt = pos_result[1].get("borrow_balance", 0)
2763
- repaid = debt_to_repay - new_debt
2764
- logger.info(
2765
- f"Emergency repayment succeeded: {repaid / 10**18:.6f} WETH"
2766
- )
2767
-
2768
- return repaid
3720
+ except SwapOutcomeUnknownError as exc:
3721
+ return (False, f"Swap outcome unknown while sweeping wallet: {exc}")
2769
3722
 
2770
- async def withdraw(self, amount: float | None = None) -> StatusTuple:
2771
- """Withdraw funds. If amount is None, withdraws all.
3723
+ if not ok:
3724
+ logger.error(f"Failed to sweep wallet: {msg}")
3725
+ return (False, msg)
3726
+ logger.info(" Wallet swept successfully")
2772
3727
 
2773
- Logic:
2774
- 1. Liquidate any Moonwell positions to USDC (if any exist)
2775
- 2. Transfer any USDC > 0 to main wallet (regardless of step 1)
2776
- """
2777
- self._clear_price_cache()
3728
+ # Step 5: Report final balances in strategy wallet
3729
+ logger.info("STEP 5: Checking final balances in strategy wallet...")
3730
+ usdc_balance = await self._get_balance_raw(
3731
+ token_id=USDC_TOKEN_ID, wallet_address=self._get_strategy_wallet_address()
3732
+ )
3733
+ gas_balance = await self._get_balance_raw(
3734
+ token_id=ETH_TOKEN_ID, wallet_address=self._get_strategy_wallet_address()
3735
+ )
2778
3736
 
2779
- # Step 1: Liquidate Moonwell positions if any exist
2780
- totals_token, totals_usd = await self._aggregate_positions()
2781
- has_positions = len(totals_token) > 0
3737
+ token_info = await self._get_token_info(USDC_TOKEN_ID)
3738
+ decimals = token_info.get("decimals", 6)
3739
+ usdc_amount = usdc_balance / 10**decimals if usdc_balance > 0 else 0.0
3740
+ gas_amount = gas_balance / 10**18 if gas_balance > 0 else 0.0
2782
3741
 
2783
- if has_positions:
2784
- # Sweep misc tokens to WETH first (helps with repayment)
2785
- await self._sweep_token_balances(
2786
- target_token_id=WETH_TOKEN_ID,
2787
- exclude={USDC_TOKEN_ID, WSTETH_TOKEN_ID},
2788
- )
3742
+ logger.info(f" Strategy wallet USDC: {usdc_amount:.2f}")
3743
+ logger.info(f" Strategy wallet ETH: {gas_amount:.6f}")
2789
3744
 
2790
- # Execute debt repayment loop
2791
- success, message = await self._repay_debt_loop()
2792
- if not success:
2793
- return (False, message)
3745
+ return (
3746
+ True,
3747
+ f"Positions liquidated. Strategy wallet contains {usdc_amount:.2f} USDC and {gas_amount:.6f} ETH. Call exit() to transfer to main wallet.",
3748
+ )
2794
3749
 
2795
- # Unlend and convert remaining positions to USDC
2796
- await self._unlend_remaining_positions()
3750
+ async def exit(self, **kwargs) -> StatusTuple:
3751
+ """Transfer funds from strategy wallet to main wallet."""
3752
+ logger.info("")
3753
+ logger.info("*" * 60)
3754
+ logger.info("* MOONWELL STRATEGY EXIT CALLED")
3755
+ logger.info("*" * 60)
2797
3756
 
2798
- # Always sweep any remaining tokens to USDC (catches WETH, wstETH, WELL even without positions)
2799
- await self._sweep_token_balances(
2800
- target_token_id=USDC_TOKEN_ID,
2801
- exclude={ETH_TOKEN_ID}, # Keep gas token
2802
- )
3757
+ transferred_items = []
2803
3758
 
2804
- # Step 2: Transfer any USDC to main wallet
3759
+ # Transfer USDC to main wallet
2805
3760
  usdc_balance = await self._get_balance_raw(
2806
3761
  token_id=USDC_TOKEN_ID, wallet_address=self._get_strategy_wallet_address()
2807
3762
  )
3763
+ if usdc_balance > 0:
3764
+ token_info = await self._get_token_info(USDC_TOKEN_ID)
3765
+ decimals = token_info.get("decimals", 6)
3766
+ usdc_amount = usdc_balance / 10**decimals
3767
+ logger.info(f"Transferring {usdc_amount:.2f} USDC to main wallet")
2808
3768
 
2809
- if usdc_balance <= 0:
2810
- return (False, "No USDC to withdraw.")
2811
-
2812
- token_info = await self._get_token_info(USDC_TOKEN_ID)
2813
- decimals = token_info.get("decimals", 6)
2814
- usdc_amount = usdc_balance / 10**decimals
2815
-
2816
- (
2817
- success,
2818
- msg,
2819
- ) = await self.balance_adapter.move_from_strategy_wallet_to_main_wallet(
2820
- USDC_TOKEN_ID, usdc_amount
2821
- )
2822
- if not success:
2823
- return (False, f"USDC transfer failed: {msg}")
3769
+ (
3770
+ success,
3771
+ msg,
3772
+ ) = await self.balance_adapter.move_from_strategy_wallet_to_main_wallet(
3773
+ USDC_TOKEN_ID, usdc_amount
3774
+ )
3775
+ if not success:
3776
+ logger.error(f"USDC transfer failed: {msg}")
3777
+ return (False, f"USDC transfer failed: {msg}")
3778
+ transferred_items.append(f"{usdc_amount:.2f} USDC")
3779
+ logger.info(f"USDC transfer successful: {usdc_amount:.2f} USDC")
2824
3780
 
2825
- # Step 3: Transfer remaining gas to main wallet (keep reserve for tx fee)
2826
- gas_transferred = 0.0
3781
+ # Transfer ETH (minus small reserve for tx fees) to main wallet
2827
3782
  gas_balance = await self._get_balance_raw(
2828
3783
  token_id=ETH_TOKEN_ID, wallet_address=self._get_strategy_wallet_address()
2829
3784
  )
@@ -2831,6 +3786,7 @@ class MoonwellWstethLoopStrategy(Strategy):
2831
3786
  transferable_gas = gas_balance - tx_fee_reserve
2832
3787
  if transferable_gas > 0:
2833
3788
  gas_amount = transferable_gas / 10**18
3789
+ logger.info(f"Transferring {gas_amount:.6f} ETH to main wallet")
2834
3790
  (
2835
3791
  gas_success,
2836
3792
  gas_msg,
@@ -2838,23 +3794,32 @@ class MoonwellWstethLoopStrategy(Strategy):
2838
3794
  ETH_TOKEN_ID, gas_amount
2839
3795
  )
2840
3796
  if gas_success:
2841
- gas_transferred = gas_amount
3797
+ transferred_items.append(f"{gas_amount:.6f} ETH")
3798
+ logger.info(f"ETH transfer successful: {gas_amount:.6f} ETH")
2842
3799
  else:
2843
- logger.warning(f"Gas transfer failed (non-critical): {gas_msg}")
3800
+ logger.warning(f"ETH transfer failed (non-critical): {gas_msg}")
2844
3801
 
2845
- return (
2846
- True,
2847
- f"Withdrew {usdc_amount:.2f} USDC and {gas_transferred:.6f} ETH to main wallet",
2848
- )
3802
+ if not transferred_items:
3803
+ return (True, "No funds to transfer to main wallet")
3804
+
3805
+ return (True, f"Transferred to main wallet: {', '.join(transferred_items)}")
2849
3806
 
2850
- async def _unlend_remaining_positions(self) -> None:
3807
+ async def _unlend_remaining_positions(self) -> tuple[bool, str]:
2851
3808
  """Unlend remaining collateral and convert to USDC."""
3809
+ logger.info("UNLEND: Redeeming remaining Moonwell positions...")
3810
+
2852
3811
  # Unlend remaining wstETH
2853
3812
  wsteth_pos = await self.moonwell_adapter.get_pos(mtoken=M_WSTETH)
2854
3813
  if wsteth_pos[0]:
2855
3814
  mtoken_bal = wsteth_pos[1].get("mtoken_balance", 0)
3815
+ underlying = wsteth_pos[1].get("underlying_balance", 0)
2856
3816
  if mtoken_bal > 0:
2857
- await self.moonwell_adapter.unlend(mtoken=M_WSTETH, amount=mtoken_bal)
3817
+ logger.info(f" Unlending wstETH: {underlying / 10**18:.6f} wstETH")
3818
+ ok, msg = await self.moonwell_adapter.unlend(
3819
+ mtoken=M_WSTETH, amount=mtoken_bal
3820
+ )
3821
+ if not ok:
3822
+ return (False, f"Failed to unlend wstETH: {msg}")
2858
3823
  # Swap to USDC with retries
2859
3824
  wsteth_bal = await self._get_balance_raw(
2860
3825
  token_id=WSTETH_TOKEN_ID,
@@ -2867,23 +3832,34 @@ class MoonwellWstethLoopStrategy(Strategy):
2867
3832
  amount=wsteth_bal,
2868
3833
  )
2869
3834
  if swap_result is None:
2870
- logger.warning("Failed to swap wstETH to USDC after retries")
3835
+ return (False, "Failed to swap wstETH to USDC after retries")
2871
3836
 
2872
3837
  # Unlend remaining USDC
2873
3838
  usdc_pos = await self.moonwell_adapter.get_pos(mtoken=M_USDC)
2874
3839
  if usdc_pos[0]:
2875
3840
  mtoken_bal = usdc_pos[1].get("mtoken_balance", 0)
3841
+ underlying = usdc_pos[1].get("underlying_balance", 0)
2876
3842
  if mtoken_bal > 0:
2877
- await self.moonwell_adapter.unlend(mtoken=M_USDC, amount=mtoken_bal)
3843
+ logger.info(f" Unlending USDC: {underlying / 10**6:.2f} USDC")
3844
+ ok, msg = await self.moonwell_adapter.unlend(
3845
+ mtoken=M_USDC, amount=mtoken_bal
3846
+ )
3847
+ if not ok:
3848
+ return (False, f"Failed to unlend USDC: {msg}")
2878
3849
 
2879
3850
  # Claim any remaining rewards
2880
3851
  await self.moonwell_adapter.claim_rewards(min_rewards_usd=0)
2881
3852
 
2882
3853
  # Sweep any remaining tokens to USDC
2883
- await self._sweep_token_balances(
3854
+ ok, msg = await self._sweep_token_balances(
2884
3855
  target_token_id=USDC_TOKEN_ID,
2885
- exclude={ETH_TOKEN_ID}, # Keep gas token
3856
+ exclude={ETH_TOKEN_ID, WELL_TOKEN_ID}, # Keep gas + reward token
3857
+ min_usd_value=float(self.sweep_min_usd),
3858
+ strict=True,
2886
3859
  )
3860
+ if not ok:
3861
+ return (False, msg)
3862
+ return (True, "Unlent remaining positions")
2887
3863
 
2888
3864
  async def get_peg_diff(self) -> float | dict:
2889
3865
  """Get stETH/ETH peg difference."""
@@ -2903,16 +3879,10 @@ class MoonwellWstethLoopStrategy(Strategy):
2903
3879
 
2904
3880
  async def _status(self) -> StatusDict:
2905
3881
  """Report strategy status."""
2906
- self._clear_price_cache()
2907
-
2908
- # Fetch positions and collateral factors in parallel
2909
- (_totals_token, totals_usd), collateral_factors = await asyncio.gather(
2910
- self._aggregate_positions(),
2911
- self._get_collateral_factors(),
2912
- )
3882
+ snap, _ = await self._accounting_snapshot()
3883
+ totals_usd = dict(snap.totals_usd)
2913
3884
 
2914
- # Calculate LTV and health factor using pre-fetched data
2915
- ltv = await self.compute_ltv(totals_usd, collateral_factors=collateral_factors)
3885
+ ltv = float(snap.ltv)
2916
3886
  hf = (1 / ltv) if ltv and ltv > 0 and not (ltv != ltv) else None
2917
3887
 
2918
3888
  # Get gas balance
@@ -2924,8 +3894,7 @@ class MoonwellWstethLoopStrategy(Strategy):
2924
3894
  borrowable_amt = self._normalize_usd_value(borrowable_amt_raw)
2925
3895
 
2926
3896
  # Calculate credit remaining
2927
- weth_key = f"Base_{WETH}"
2928
- total_borrowed = abs(totals_usd.get(weth_key, 0))
3897
+ total_borrowed = float(snap.debt_usd)
2929
3898
  credit_remaining = 1.0
2930
3899
  if (borrowable_amt + total_borrowed) > 0:
2931
3900
  credit_remaining = round(
@@ -2936,9 +3905,7 @@ class MoonwellWstethLoopStrategy(Strategy):
2936
3905
  peg_diff = await self.get_peg_diff()
2937
3906
 
2938
3907
  # Calculate portfolio value
2939
- portfolio_value = sum(
2940
- v for k, v in totals_usd.items() if k != f"Base_{WETH}"
2941
- ) + totals_usd.get(f"Base_{WETH}", 0)
3908
+ portfolio_value = float(snap.net_equity_usd)
2942
3909
 
2943
3910
  # Get projected earnings
2944
3911
  quote = await self.quote()