wayfinder-paths 0.1.14__py3-none-any.whl → 0.1.16__py3-none-any.whl

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