wayfinder-paths 0.1.8__py3-none-any.whl → 0.1.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (80) hide show
  1. wayfinder_paths/CONFIG_GUIDE.md +6 -15
  2. wayfinder_paths/adapters/balance_adapter/README.md +1 -2
  3. wayfinder_paths/adapters/balance_adapter/adapter.py +4 -4
  4. wayfinder_paths/adapters/brap_adapter/README.md +1 -1
  5. wayfinder_paths/adapters/brap_adapter/adapter.py +139 -74
  6. wayfinder_paths/adapters/hyperlend_adapter/adapter.py +0 -7
  7. wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +0 -54
  8. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +1 -1
  9. wayfinder_paths/adapters/ledger_adapter/README.md +1 -1
  10. wayfinder_paths/adapters/moonwell_adapter/README.md +174 -0
  11. wayfinder_paths/adapters/moonwell_adapter/__init__.py +7 -0
  12. wayfinder_paths/adapters/moonwell_adapter/adapter.py +1226 -0
  13. wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +635 -0
  14. wayfinder_paths/adapters/pool_adapter/README.md +1 -77
  15. wayfinder_paths/adapters/pool_adapter/adapter.py +0 -122
  16. wayfinder_paths/adapters/pool_adapter/examples.json +0 -57
  17. wayfinder_paths/adapters/pool_adapter/test_adapter.py +0 -86
  18. wayfinder_paths/adapters/token_adapter/README.md +1 -1
  19. wayfinder_paths/core/clients/ClientManager.py +1 -22
  20. wayfinder_paths/core/clients/WalletClient.py +0 -8
  21. wayfinder_paths/core/clients/WayfinderClient.py +7 -12
  22. wayfinder_paths/core/clients/__init__.py +0 -8
  23. wayfinder_paths/core/clients/protocols.py +0 -60
  24. wayfinder_paths/core/config.py +5 -45
  25. wayfinder_paths/core/constants/__init__.py +0 -2
  26. wayfinder_paths/core/constants/base.py +6 -2
  27. wayfinder_paths/core/constants/moonwell_abi.py +411 -0
  28. wayfinder_paths/core/services/base.py +7 -1
  29. wayfinder_paths/core/services/local_evm_txn.py +223 -222
  30. wayfinder_paths/core/services/local_token_txn.py +103 -92
  31. wayfinder_paths/core/services/web3_service.py +0 -2
  32. wayfinder_paths/core/settings.py +8 -8
  33. wayfinder_paths/core/strategies/Strategy.py +1 -5
  34. wayfinder_paths/core/strategies/descriptors.py +1 -1
  35. wayfinder_paths/core/utils/evm_helpers.py +7 -12
  36. wayfinder_paths/core/wallets/README.md +3 -6
  37. wayfinder_paths/run_strategy.py +62 -105
  38. wayfinder_paths/scripts/create_strategy.py +2 -27
  39. wayfinder_paths/scripts/make_wallets.py +1 -25
  40. wayfinder_paths/scripts/run_strategy.py +37 -9
  41. wayfinder_paths/strategies/basis_trading_strategy/snapshot_mixin.py +1 -3
  42. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +87 -138
  43. wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +96 -58
  44. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/README.md +2 -17
  45. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/examples.json +4 -1
  46. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +107 -29
  47. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +53 -14
  48. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/README.md +108 -0
  49. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/examples.json +11 -0
  50. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +2975 -0
  51. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +886 -0
  52. wayfinder_paths/strategies/stablecoin_yield_strategy/README.md +0 -7
  53. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +2 -7
  54. wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +0 -4
  55. wayfinder_paths/templates/adapter/README.md +5 -21
  56. wayfinder_paths/templates/adapter/adapter.py +1 -2
  57. wayfinder_paths/templates/adapter/test_adapter.py +1 -1
  58. wayfinder_paths/templates/strategy/README.md +4 -21
  59. wayfinder_paths/templates/strategy/test_strategy.py +0 -4
  60. wayfinder_paths/tests/test_smoke_manifest.py +17 -2
  61. {wayfinder_paths-0.1.8.dist-info → wayfinder_paths-0.1.10.dist-info}/METADATA +64 -201
  62. {wayfinder_paths-0.1.8.dist-info → wayfinder_paths-0.1.10.dist-info}/RECORD +64 -71
  63. wayfinder_paths/adapters/balance_adapter/manifest.yaml +0 -8
  64. wayfinder_paths/adapters/brap_adapter/manifest.yaml +0 -11
  65. wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +0 -10
  66. wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +0 -8
  67. wayfinder_paths/adapters/ledger_adapter/manifest.yaml +0 -11
  68. wayfinder_paths/adapters/pool_adapter/manifest.yaml +0 -10
  69. wayfinder_paths/adapters/token_adapter/manifest.yaml +0 -6
  70. wayfinder_paths/core/clients/SimulationClient.py +0 -192
  71. wayfinder_paths/core/clients/TransactionClient.py +0 -63
  72. wayfinder_paths/core/engine/manifest.py +0 -97
  73. wayfinder_paths/scripts/validate_manifests.py +0 -213
  74. wayfinder_paths/strategies/basis_trading_strategy/manifest.yaml +0 -23
  75. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/manifest.yaml +0 -7
  76. wayfinder_paths/strategies/stablecoin_yield_strategy/manifest.yaml +0 -17
  77. wayfinder_paths/templates/adapter/manifest.yaml +0 -6
  78. wayfinder_paths/templates/strategy/manifest.yaml +0 -8
  79. {wayfinder_paths-0.1.8.dist-info → wayfinder_paths-0.1.10.dist-info}/LICENSE +0 -0
  80. {wayfinder_paths-0.1.8.dist-info → wayfinder_paths-0.1.10.dist-info}/WHEEL +0 -0
@@ -0,0 +1,2975 @@
1
+ """
2
+ Moonwell wstETH Loop Strategy
3
+
4
+ A leveraged liquid-staking carry on Base that loops USDC → borrow WETH → swap to wstETH → lend wstETH.
5
+ The loop repeats while keeping debt as a fraction F of borrow capacity, chosen conservatively
6
+ so the position remains safe under a stETH/ETH depeg.
7
+ """
8
+
9
+ import asyncio
10
+ import time
11
+ from typing import Any
12
+
13
+ import httpx
14
+ from loguru import logger
15
+
16
+ from wayfinder_paths.adapters.balance_adapter.adapter import BalanceAdapter
17
+ from wayfinder_paths.adapters.brap_adapter.adapter import BRAPAdapter
18
+ from wayfinder_paths.adapters.ledger_adapter.adapter import LedgerAdapter
19
+ from wayfinder_paths.adapters.moonwell_adapter.adapter import MoonwellAdapter
20
+ 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.strategies.descriptors import (
25
+ Complexity,
26
+ Directionality,
27
+ Frequency,
28
+ StratDescriptor,
29
+ TokenExposure,
30
+ Volatility,
31
+ )
32
+ from wayfinder_paths.core.strategies.Strategy import StatusDict, StatusTuple, Strategy
33
+ from wayfinder_paths.core.wallets.WalletManager import WalletManager
34
+ from wayfinder_paths.policies.enso import ENSO_ROUTER, enso_swap
35
+ from wayfinder_paths.policies.erc20 import erc20_spender_for_any_token
36
+ from wayfinder_paths.policies.moonwell import (
37
+ M_USDC,
38
+ M_WETH,
39
+ M_WSTETH,
40
+ WETH,
41
+ moonwell_comptroller_enter_markets_or_claim_rewards,
42
+ musdc_mint_or_approve_or_redeem,
43
+ mweth_approve_or_borrow_or_repay,
44
+ mwsteth_approve_or_mint_or_redeem,
45
+ weth_deposit,
46
+ )
47
+
48
+ # Token addresses on Base
49
+ USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
50
+ WSTETH = "0xc1CBa3fCea344f92D9239c08C0568f6F2F0ee452"
51
+
52
+ # Token IDs
53
+ USDC_TOKEN_ID = "usd-coin-base"
54
+ WETH_TOKEN_ID = "l2-standard-bridged-weth-base-base"
55
+ WSTETH_TOKEN_ID = "superbridge-bridged-wsteth-base-base"
56
+ ETH_TOKEN_ID = "ethereum-base"
57
+ WELL_TOKEN_ID = "moonwell-artemis-base"
58
+ STETH_TOKEN_ID = "staked-ether-ethereum"
59
+
60
+ # Base chain ID
61
+ BASE_CHAIN_ID = 8453
62
+
63
+ # Safety parameters
64
+ # 0.98 = 2% safety margin when borrowing to avoid hitting exact liquidation threshold
65
+ COLLATERAL_SAFETY_FACTOR = 0.98
66
+
67
+
68
+ class SwapOutcomeUnknownError(RuntimeError):
69
+ """Raised when a swap transaction's outcome is unknown (e.g., receipt timeout).
70
+
71
+ In this case we must not retry (risk duplicate fills) and should halt the strategy
72
+ so the caller can inspect on-chain state.
73
+ """
74
+
75
+
76
+ class MoonwellWstethLoopStrategy(Strategy):
77
+ """Leveraged wstETH yield strategy using Moonwell lending protocol on Base."""
78
+
79
+ name = "Moonwell wstETH Loop Strategy"
80
+ description = "Leveraged wstETH yield strategy using Moonwell lending protocol."
81
+ summary = "Loop wstETH on Moonwell for amplified staking yields."
82
+
83
+ # Strategy parameters
84
+ MIN_GAS = 0.002 # Minimum Base ETH (in ETH) required for gas fees (Base L2)
85
+ MAINTENANCE_GAS = MIN_GAS / 10
86
+ # When wrapping ETH to WETH for swaps/repayment, avoid draining gas below this floor.
87
+ # We can dip below MIN_GAS temporarily, but should not wipe the wallet.
88
+ WRAP_GAS_RESERVE = 0.0014
89
+ MIN_USDC_DEPOSIT = 20.0 # Minimum USDC deposit required as initial collateral
90
+ MAX_DEPEG = 0.01 # Maximum allowed stETH depeg threshold (1%)
91
+ MAX_HEALTH_FACTOR = 1.5
92
+ MIN_HEALTH_FACTOR = 1.2
93
+ # Continue levering up if HF is more than this amount above MIN_HEALTH_FACTOR
94
+ HF_LEVER_UP_BUFFER = 0.05 # Lever up if HF > MIN + 0.05 (i.e., > 1.25)
95
+ _MAX_LOOP_LIMIT = 30 # Prevents infinite loops
96
+
97
+ # Parameters
98
+ leverage_limit = 10 # Limit on leverage multiplier
99
+ min_withdraw_usd = 2
100
+ max_swap_retries = 3 # Maximum number of swap retry attempts
101
+ swap_slippage_tolerance = 0.005 # Base slippage of 50 bps
102
+ MAX_SLIPPAGE_TOLERANCE = 0.03 # 3% absolute maximum slippage to prevent MEV attacks
103
+ PRICE_STALENESS_THRESHOLD = 300 # 5 minutes - max age for cached prices
104
+
105
+ # 50 basis points (0.0050) - minimum leverage gain per loop iteration to continue
106
+ # If marginal gain drops below this, stop looping as gas costs outweigh benefit
107
+ _MIN_LEVERAGE_GAIN_BPS = 50e-4 # 50 bps = 0.50%
108
+
109
+ INFO = StratDescriptor(
110
+ description="Leveraged wstETH carry: loops USDC → borrow WETH → swap wstETH → lend. "
111
+ "Depeg-aware sizing with safety factor. ETH-neutral: WETH debt vs wstETH collateral.",
112
+ summary="Leveraged wstETH carry on Base with depeg-aware sizing.",
113
+ gas_token_symbol="ETH",
114
+ gas_token_id=ETH_TOKEN_ID,
115
+ deposit_token_id=USDC_TOKEN_ID,
116
+ minimum_net_deposit=20,
117
+ gas_maximum=0.05,
118
+ gas_threshold=0.01,
119
+ volatility=Volatility.LOW,
120
+ volatility_description="APYs can vary significantly but are almost always positive",
121
+ directionality=Directionality.DELTA_NEUTRAL,
122
+ directionality_description="Balances wstETH collateral and WETH debt so ETH delta stays close to flat.",
123
+ complexity=Complexity.MEDIUM,
124
+ complexity_description="Manages recursive lend/borrow loops, peg monitoring, and health-factor controls.",
125
+ token_exposure=TokenExposure.MAJORS,
126
+ token_exposure_description="Risk is concentrated in ETH (wstETH vs WETH) and USDC on Base.",
127
+ frequency=Frequency.LOW,
128
+ frequency_description="Runs every 2 hours but will trade rarely to minimize transaction fees.",
129
+ return_drivers=["leveraged lend APY"],
130
+ config={
131
+ "deposit": {
132
+ "description": "Lend USDC as seed collateral, then execute leverage loop.",
133
+ "parameters": {
134
+ "main_token_amount": {
135
+ "type": "float",
136
+ "unit": "USDC tokens",
137
+ "description": "Amount of USDC to deposit as initial collateral.",
138
+ "minimum": 20.0,
139
+ "examples": ["100.0", "500.0", "1000.0"],
140
+ },
141
+ "gas_token_amount": {
142
+ "type": "float",
143
+ "unit": "ETH tokens",
144
+ "description": "Amount of ETH to transfer for gas.",
145
+ "minimum": 0.0,
146
+ "recommended": 0.01,
147
+ },
148
+ },
149
+ "result": "Delta-neutral leveraged wstETH position.",
150
+ },
151
+ "withdraw": {
152
+ "description": "Unwind positions, repay debt, and return funds.",
153
+ "parameters": {},
154
+ "result": "All debt repaid, collateral returned in USDC.",
155
+ },
156
+ "update": {
157
+ "description": "Rebalance positions and manage leverage.",
158
+ "parameters": {},
159
+ "result": "Position maintained at target leverage.",
160
+ },
161
+ },
162
+ )
163
+
164
+ def __init__(
165
+ self,
166
+ config: dict | None = None,
167
+ *,
168
+ main_wallet: dict | None = None,
169
+ strategy_wallet: dict | None = None,
170
+ simulation: bool = False,
171
+ web3_service: Web3Service | None = None,
172
+ api_key: str | None = None,
173
+ ):
174
+ super().__init__(api_key=api_key)
175
+ merged_config: dict[str, Any] = dict(config or {})
176
+ if main_wallet is not None:
177
+ merged_config["main_wallet"] = main_wallet
178
+ if strategy_wallet is not None:
179
+ merged_config["strategy_wallet"] = strategy_wallet
180
+
181
+ self.config = merged_config
182
+ self.simulation = simulation
183
+ self.web3_service = web3_service
184
+
185
+ # Adapter references
186
+ self.balance_adapter: BalanceAdapter | None = None
187
+ self.moonwell_adapter: MoonwellAdapter | None = None
188
+ self.brap_adapter: BRAPAdapter | None = None
189
+ self.token_adapter: TokenAdapter | None = None
190
+ self.ledger_adapter: LedgerAdapter | None = None
191
+
192
+ # Token info cache
193
+ self._token_info_cache: dict[str, dict] = {}
194
+ self._token_price_cache: dict[str, float] = {}
195
+ self._token_price_timestamps: dict[str, float] = {}
196
+
197
+ try:
198
+ main_wallet_cfg = self.config.get("main_wallet")
199
+ strategy_wallet_cfg = self.config.get("strategy_wallet")
200
+
201
+ if not strategy_wallet_cfg or not strategy_wallet_cfg.get("address"):
202
+ raise ValueError(
203
+ "strategy_wallet not configured. Provide strategy_wallet address in config."
204
+ )
205
+
206
+ adapter_config = {
207
+ "main_wallet": main_wallet_cfg or None,
208
+ "strategy_wallet": strategy_wallet_cfg or None,
209
+ "strategy": self.config,
210
+ }
211
+
212
+ # Initialize web3_service if not provided
213
+ if self.web3_service is None:
214
+ wallet_provider = WalletManager.get_provider(adapter_config)
215
+ token_transaction_service = LocalTokenTxnService(
216
+ adapter_config,
217
+ wallet_provider=wallet_provider,
218
+ )
219
+ web3_service = DefaultWeb3Service(
220
+ wallet_provider=wallet_provider,
221
+ evm_transactions=token_transaction_service,
222
+ )
223
+ else:
224
+ web3_service = self.web3_service
225
+ token_transaction_service = web3_service.token_transactions
226
+
227
+ # Initialize adapters
228
+ balance = BalanceAdapter(adapter_config, web3_service=web3_service)
229
+ token_adapter = TokenAdapter()
230
+ ledger_adapter = LedgerAdapter()
231
+ brap_adapter = BRAPAdapter(
232
+ web3_service=web3_service,
233
+ )
234
+ moonwell_adapter = MoonwellAdapter(
235
+ adapter_config,
236
+ simulation=self.simulation,
237
+ web3_service=web3_service,
238
+ )
239
+
240
+ self.register_adapters(
241
+ [
242
+ balance,
243
+ token_adapter,
244
+ ledger_adapter,
245
+ brap_adapter,
246
+ moonwell_adapter,
247
+ token_transaction_service,
248
+ ]
249
+ )
250
+
251
+ self.balance_adapter = balance
252
+ self.token_adapter = token_adapter
253
+ self.ledger_adapter = ledger_adapter
254
+ self.brap_adapter = brap_adapter
255
+ self.moonwell_adapter = moonwell_adapter
256
+ self.web3_service = web3_service
257
+
258
+ except Exception as e:
259
+ logger.error(f"Failed to initialize strategy adapters: {e}")
260
+ raise
261
+
262
+ def _max_safe_F(self, cf_w: float) -> float:
263
+ """Max safe debt fraction vs borrow capacity under a depeg.
264
+
265
+ Let a = 1 - MAX_DEPEG. If the position is sized at par (a=1) with debt
266
+ fraction F = Debt / BorrowCapacity, then after an instantaneous depeg to a
267
+ the borrow capacity shrinks by cf_w * (1-a) * Debt. Requiring Debt to
268
+ remain <= new capacity yields:
269
+ F_max = 1 / (1 + cf_w * (1 - a))
270
+
271
+ Returns F_max clipped to [0, 1].
272
+ """
273
+ a = 1 - self.MAX_DEPEG
274
+ if not (0 < a):
275
+ return 0.0
276
+ if not (0 <= cf_w < 1):
277
+ return 0.0
278
+
279
+ f_bound = 1.0 / (1.0 + cf_w * (1.0 - a))
280
+ # Extra feasibility guard (usually >1, but keep for safety).
281
+ f_feasible = 1.0 / (cf_w * a) if cf_w > 0 else 1.0
282
+ return max(0.0, min(1.0, min(f_bound, f_feasible, 1.0)))
283
+
284
+ def _get_strategy_wallet_address(self) -> str:
285
+ """Get the strategy wallet address."""
286
+ wallet = self.config.get("strategy_wallet", {})
287
+ return wallet.get("address", "")
288
+
289
+ def _get_main_wallet_address(self) -> str:
290
+ """Get the main wallet address."""
291
+ wallet = self.config.get("main_wallet", {})
292
+ return wallet.get("address", "")
293
+
294
+ async def setup(self):
295
+ """Initialize token info and validate configuration."""
296
+ if self.token_adapter is None:
297
+ raise RuntimeError("Token adapter not initialized.")
298
+
299
+ # Pre-fetch token info
300
+ for token_id in [USDC_TOKEN_ID, WETH_TOKEN_ID, WSTETH_TOKEN_ID, ETH_TOKEN_ID]:
301
+ try:
302
+ success, info = await self.token_adapter.get_token(token_id)
303
+ if success:
304
+ self._token_info_cache[token_id] = info
305
+ except Exception as e:
306
+ logger.warning(f"Failed to fetch token info for {token_id}: {e}")
307
+
308
+ async def _get_token_info(self, token_id: str) -> dict:
309
+ """Get token info from cache or fetch it."""
310
+ if token_id in self._token_info_cache:
311
+ return self._token_info_cache[token_id]
312
+
313
+ success, info = await self.token_adapter.get_token(token_id)
314
+ if success:
315
+ self._token_info_cache[token_id] = info
316
+ return info
317
+ return {}
318
+
319
+ async def _get_token_price(self, token_id: str) -> float:
320
+ """Get token price with staleness check."""
321
+ now = time.time()
322
+
323
+ # Check cache with staleness
324
+ if token_id in self._token_price_cache:
325
+ timestamp = self._token_price_timestamps.get(token_id, 0)
326
+ if now - timestamp < self.PRICE_STALENESS_THRESHOLD:
327
+ return self._token_price_cache[token_id]
328
+ else:
329
+ logger.debug(f"Price cache stale for {token_id}, refreshing")
330
+
331
+ success, price_data = await self.token_adapter.get_token_price(token_id)
332
+ if success and isinstance(price_data, dict):
333
+ price = price_data.get("current_price", 0.0)
334
+ if price and price > 0:
335
+ self._token_price_cache[token_id] = price
336
+ self._token_price_timestamps[token_id] = now
337
+ return price
338
+
339
+ logger.warning(
340
+ f"Failed to get fresh price for {token_id}, success={success}, price_data={price_data}"
341
+ )
342
+ return 0.0
343
+
344
+ def _clear_price_cache(self):
345
+ """Clear the price cache to force refresh."""
346
+ self._token_price_cache.clear()
347
+ self._token_price_timestamps.clear()
348
+
349
+ async def _get_token_data(self, token_id: str) -> tuple[float, int]:
350
+ """Get price and decimals for a token in one call."""
351
+ price = await self._get_token_price(token_id)
352
+ info = await self._get_token_info(token_id)
353
+ return price, info.get("decimals", 18)
354
+
355
+ async def _swap_with_retries(
356
+ self,
357
+ from_token_id: str,
358
+ to_token_id: str,
359
+ amount: int,
360
+ max_retries: int | None = None,
361
+ base_slippage: float | None = None,
362
+ preferred_providers: list[str] | None = None,
363
+ ) -> dict | None:
364
+ """Swap with retries, progressive slippage, and exponential backoff."""
365
+ if max_retries is None:
366
+ max_retries = self.max_swap_retries
367
+ if base_slippage is None:
368
+ base_slippage = self.swap_slippage_tolerance
369
+
370
+ last_error: Exception | None = None
371
+ strategy_address = self._get_strategy_wallet_address()
372
+
373
+ # Always balance-check swap inputs to avoid on-chain reverts from stale/rounded values.
374
+ try:
375
+ wallet_balance = await self._get_balance_raw(
376
+ token_id=from_token_id,
377
+ wallet_address=strategy_address,
378
+ )
379
+ if from_token_id == ETH_TOKEN_ID:
380
+ reserve = int(self.WRAP_GAS_RESERVE * 10**18)
381
+ wallet_balance = max(0, wallet_balance - reserve)
382
+ amount = min(int(amount), wallet_balance)
383
+ except Exception as exc:
384
+ logger.warning(f"Failed to check swap balance for {from_token_id}: {exc}")
385
+
386
+ if amount <= 0:
387
+ logger.warning(
388
+ f"Swap skipped: no available balance for {from_token_id} (post-reserve)"
389
+ )
390
+ return None
391
+
392
+ # Wrap ETH to WETH before swapping - direct ETH swaps get bad fills
393
+ if from_token_id == ETH_TOKEN_ID:
394
+ logger.info(f"Wrapping {amount / 10**18:.6f} ETH to WETH before swap")
395
+ wrap_success, wrap_msg = await self.moonwell_adapter.wrap_eth(amount=amount)
396
+ if not wrap_success:
397
+ logger.error(f"Failed to wrap ETH to WETH: {wrap_msg}")
398
+ return None
399
+ from_token_id = WETH_TOKEN_ID
400
+
401
+ def _is_unknown_outcome_message(msg: str) -> bool:
402
+ m = (msg or "").lower()
403
+ return (
404
+ "transaction pending" in m
405
+ or "dropped/unknown" in m
406
+ or "not in the chain after" in m
407
+ or "no receipt after" in m
408
+ )
409
+
410
+ for i in range(max_retries):
411
+ # Cap slippage at MAX_SLIPPAGE_TOLERANCE to prevent MEV attacks
412
+ slippage = min(base_slippage * (i + 1), self.MAX_SLIPPAGE_TOLERANCE)
413
+ try:
414
+ success, result = await self.brap_adapter.swap_from_token_ids(
415
+ from_token_id=from_token_id,
416
+ to_token_id=to_token_id,
417
+ from_address=strategy_address,
418
+ amount=str(amount),
419
+ slippage=slippage,
420
+ preferred_providers=preferred_providers,
421
+ )
422
+ if success and result:
423
+ logger.info(
424
+ f"Swap succeeded on attempt {i + 1} with slippage {slippage * 100:.1f}%"
425
+ )
426
+ # Ensure result is a dict with to_amount
427
+ if isinstance(result, dict):
428
+ return result
429
+ return {"to_amount": result if isinstance(result, int) else 0}
430
+
431
+ # Do not retry when the transaction outcome is unknown (pending/dropped).
432
+ # Retrying swaps can create nonce gaps or duplicate fills.
433
+ if isinstance(result, str) and _is_unknown_outcome_message(result):
434
+ raise SwapOutcomeUnknownError(result)
435
+
436
+ last_error = Exception(str(result))
437
+ logger.warning(
438
+ f"Swap attempt {i + 1}/{max_retries} returned unsuccessful: {result}"
439
+ )
440
+ except SwapOutcomeUnknownError:
441
+ raise
442
+ except Exception as e:
443
+ if _is_unknown_outcome_message(str(e)):
444
+ raise SwapOutcomeUnknownError(str(e)) from e
445
+ last_error = e
446
+ logger.warning(
447
+ f"Swap attempt {i + 1}/{max_retries} failed with slippage "
448
+ f"{slippage * 100:.1f}%: {e}"
449
+ )
450
+ if i < max_retries - 1:
451
+ # Exponential backoff: 1s, 2s, 4s
452
+ await asyncio.sleep(2**i)
453
+
454
+ logger.error(
455
+ f"All {max_retries} swap attempts failed. Last error: {last_error}"
456
+ )
457
+ return None
458
+
459
+ def _parse_balance(self, raw: Any) -> int:
460
+ """Parse balance value to integer, handling various formats."""
461
+ if raw is None:
462
+ return 0
463
+ if isinstance(raw, dict):
464
+ raw = raw.get("balance", 0)
465
+ try:
466
+ return int(raw)
467
+ except (ValueError, TypeError):
468
+ try:
469
+ return int(float(raw))
470
+ except (ValueError, TypeError):
471
+ return 0
472
+
473
+ def _token_address_for_id(self, token_id: str) -> str | None:
474
+ if token_id == ETH_TOKEN_ID:
475
+ return None
476
+ if token_id == USDC_TOKEN_ID:
477
+ return USDC
478
+ if token_id == WETH_TOKEN_ID:
479
+ return WETH
480
+ if token_id == WSTETH_TOKEN_ID:
481
+ return WSTETH
482
+ return None
483
+
484
+ async def _get_balance_raw(
485
+ self,
486
+ *,
487
+ token_id: str,
488
+ wallet_address: str,
489
+ block_identifier: int | str | None = None,
490
+ ) -> int:
491
+ """Read a wallet balance directly from chain (falls back to adapter in simulation).
492
+
493
+ Args:
494
+ token_id: Token identifier (e.g., WETH_TOKEN_ID)
495
+ wallet_address: Address to query balance for
496
+ block_identifier: Block to query at. Can be:
497
+ - int: specific block number (for pinning to tx block)
498
+ - "safe": OP Stack safe block (data posted to L1)
499
+ - None/"latest": current head (default)
500
+ """
501
+ if not token_id or not wallet_address:
502
+ return 0
503
+
504
+ # Tests/simulations patch adapters; avoid RPC calls there.
505
+ if self.simulation or self.web3_service is None:
506
+ if self.balance_adapter is None:
507
+ return 0
508
+ success, raw = await self.balance_adapter.get_balance(
509
+ token_id=token_id,
510
+ wallet_address=wallet_address,
511
+ )
512
+ return self._parse_balance(raw) if success else 0
513
+
514
+ token_address = self._token_address_for_id(token_id)
515
+ if token_id != ETH_TOKEN_ID and not token_address:
516
+ # Try to resolve address via token metadata (not a balance read).
517
+ if self.token_adapter is not None:
518
+ try:
519
+ success, info = await self.token_adapter.get_token(token_id)
520
+ if success and isinstance(info, dict):
521
+ token_address = info.get("address") or None
522
+ except Exception as exc:
523
+ logger.warning(
524
+ f"Failed to resolve token address for {token_id}: {exc}"
525
+ )
526
+
527
+ if token_id != ETH_TOKEN_ID and not token_address:
528
+ # Do not fall back to API balances for execution-critical paths.
529
+ logger.warning(
530
+ f"Unknown token address for {token_id}; skipping balance read"
531
+ )
532
+ return 0
533
+
534
+ try:
535
+ evm = getattr(self.web3_service, "evm_transactions", None)
536
+ if evm is None:
537
+ return 0
538
+ ok, bal = await evm.get_balance(
539
+ wallet_address, token_address, BASE_CHAIN_ID, block_identifier
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
545
+
546
+ def _normalize_usd_value(self, raw: Any) -> float:
547
+ """Normalize a USD value that may be 18-decimal scaled (Compound/Moonwell style).
548
+
549
+ Moonwell's Comptroller `getAccountLiquidity` returns USD with 18 decimals as an int.
550
+ Some mocks may return a float already in USD.
551
+ """
552
+ if raw is None:
553
+ return 0.0
554
+
555
+ # Preserve int-ness check before coercion: ints are assumed 1e18-scaled.
556
+ is_int = isinstance(raw, int) and not isinstance(raw, bool)
557
+ try:
558
+ val = float(raw)
559
+ except (ValueError, TypeError):
560
+ return 0.0
561
+
562
+ if is_int:
563
+ return val / 1e18
564
+
565
+ # Defensive: if a float looks like a 1e18-scaled value, de-scale it.
566
+ return val / 1e18 if val > 1e12 else val
567
+
568
+ def _mtoken_amount_for_underlying(
569
+ self, withdraw_info: dict[str, Any], underlying_raw: int
570
+ ) -> int:
571
+ """Convert desired underlying (raw) to mToken amount (raw), capped by max withdrawable."""
572
+ if underlying_raw <= 0:
573
+ return 0
574
+
575
+ max_ctokens = int(withdraw_info.get("cTokens_raw", 0) or 0)
576
+ if max_ctokens <= 0:
577
+ return 0
578
+
579
+ exchange_rate_raw = int(withdraw_info.get("exchangeRate_raw", 0) or 0)
580
+ conversion_factor = withdraw_info.get("conversion_factor", 0) or 0
581
+
582
+ if exchange_rate_raw > 0:
583
+ # 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)
1026
+ else:
1027
+ # Add 0.5% buffer for slippage/fees, but never exceed debt.
1028
+ target_weth_in = (
1029
+ int(deficit_usd / weth_price * 10**weth_decimals / (1 - 0.005)) + 1
1030
+ )
1031
+ target_weth_in = min(int(target_weth_in), int(weth_debt))
1032
+
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")
1036
+
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")
1040
+
1041
+ wsteth_before = await self._get_balance_raw(
1042
+ token_id=WSTETH_TOKEN_ID, wallet_address=strategy_address
1043
+ )
1044
+
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
1080
+ )
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
+
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}")
1095
+
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",
1100
+ )
1101
+
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
1107
+ )
1108
+
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
1119
+ )
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}")
1123
+
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")
1130
+
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,
1135
+ )
1136
+ if swap_result is None:
1137
+ return (False, "WETH→USDC swap failed when converting excess ETH")
1138
+
1139
+ return (True, "Converted excess ETH to USDC")
1140
+
1141
+ async def _convert_spot_wsteth_to_usdc(self) -> tuple[bool, str]:
1142
+ """Convert wallet (spot) wstETH into USDC so it can be redeployed.
1143
+
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")
1152
+
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")
1160
+
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),
1165
+ )
1166
+ if swap_result is None:
1167
+ return (False, "wstETH→USDC swap failed")
1168
+
1169
+ return (True, f"Converted wallet wstETH (~${usd_value:.2f}) to USDC")
1170
+
1171
+ async def _sweep_token_balances(
1172
+ self,
1173
+ target_token_id: str,
1174
+ exclude: set[str] | None = None,
1175
+ min_usd_value: float = 1.0,
1176
+ ) -> tuple[bool, str]:
1177
+ """Sweep miscellaneous tokens above min_usd_value to target token."""
1178
+ if exclude is None:
1179
+ exclude = set()
1180
+
1181
+ # Always exclude gas token and target
1182
+ exclude.add(ETH_TOKEN_ID)
1183
+ exclude.add(target_token_id)
1184
+
1185
+ tokens_to_check = [USDC_TOKEN_ID, WETH_TOKEN_ID, WSTETH_TOKEN_ID, WELL_TOKEN_ID]
1186
+ total_swept_usd = 0.0
1187
+ swept_count = 0
1188
+
1189
+ for token_id in tokens_to_check:
1190
+ if token_id in exclude:
1191
+ continue
1192
+
1193
+ balance = await self._get_balance_raw(
1194
+ token_id=token_id, wallet_address=self._get_strategy_wallet_address()
1195
+ )
1196
+ if balance <= 0:
1197
+ continue
1198
+
1199
+ price, decimals = await self._get_token_data(token_id)
1200
+ usd_value = (balance / 10**decimals) * price
1201
+
1202
+ if usd_value < min_usd_value:
1203
+ continue
1204
+
1205
+ try:
1206
+ swap_result = await self._swap_with_retries(
1207
+ from_token_id=token_id,
1208
+ to_token_id=target_token_id,
1209
+ amount=balance,
1210
+ )
1211
+ if swap_result:
1212
+ total_swept_usd += usd_value
1213
+ swept_count += 1
1214
+ logger.info(
1215
+ f"Swept {balance / 10**decimals:.6f} {token_id} "
1216
+ f"(${usd_value:.2f}) to {target_token_id}"
1217
+ )
1218
+ except Exception as e:
1219
+ logger.warning(f"Failed to sweep {token_id}: {e}")
1220
+
1221
+ if swept_count == 0:
1222
+ return (True, "No tokens to sweep")
1223
+
1224
+ return (True, f"Swept {swept_count} tokens totaling ${total_swept_usd:.2f}")
1225
+
1226
+ async def deposit(
1227
+ self, main_token_amount: float = 0.0, gas_token_amount: float = 0.0
1228
+ ) -> StatusTuple:
1229
+ """Deposit USDC and execute leverage loop."""
1230
+ self._clear_price_cache()
1231
+
1232
+ # Validate deposit amount is positive
1233
+ if main_token_amount <= 0:
1234
+ return (False, "Deposit amount must be positive")
1235
+
1236
+ # Check quote profitability
1237
+ success, message = await self._check_quote_profitability()
1238
+ if not success:
1239
+ return (False, message)
1240
+
1241
+ # Validate USDC deposit amount
1242
+ success, message, validated_amount = await self._validate_usdc_deposit(
1243
+ main_token_amount
1244
+ )
1245
+ if not success:
1246
+ return (False, message)
1247
+ usdc_amount = validated_amount
1248
+
1249
+ # Validate gas balance
1250
+ success, message = await self._validate_gas_balance()
1251
+ if not success:
1252
+ return (False, message)
1253
+
1254
+ # Transfer gas to vault wallet first (if this fails, USDC stays in main wallet)
1255
+ success, message = await self._transfer_gas_to_vault()
1256
+ if not success:
1257
+ return (False, message)
1258
+
1259
+ # Transfer USDC to vault wallet
1260
+ success, message = await self._transfer_usdc_to_vault(usdc_amount)
1261
+ if not success:
1262
+ return (False, message)
1263
+
1264
+ # Execute the leverage loop via update
1265
+ return await self.update()
1266
+
1267
+ async def _get_collateral_factors(self) -> tuple[float, float]:
1268
+ """Fetch both collateral factors (USDC and wstETH), using adapter cache.
1269
+
1270
+ Returns (cf_usdc, cf_wsteth).
1271
+ """
1272
+ cf_u_result, cf_w_result = await asyncio.gather(
1273
+ self.moonwell_adapter.get_collateral_factor(mtoken=M_USDC),
1274
+ self.moonwell_adapter.get_collateral_factor(mtoken=M_WSTETH),
1275
+ )
1276
+ cf_u = cf_u_result[1] if cf_u_result[0] else 0.0
1277
+ cf_w = cf_w_result[1] if cf_w_result[0] else 0.0
1278
+ return cf_u, cf_w
1279
+
1280
+ async def _get_current_leverage(
1281
+ self,
1282
+ positions: tuple[dict, dict] | None = None,
1283
+ ) -> tuple[float, float, float]:
1284
+ """Returns (usdc_lend_value, wsteth_lend_value, current_leverage).
1285
+
1286
+ 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
+ await asyncio.sleep(2.0) # 2s delay between positions for public RPC
1344
+
1345
+ # Token data can be fetched in parallel (uses cache, minimal RPC)
1346
+ token_data = await asyncio.gather(
1347
+ self._get_token_data(USDC_TOKEN_ID),
1348
+ self._get_token_data(WETH_TOKEN_ID),
1349
+ self._get_token_data(WSTETH_TOKEN_ID),
1350
+ )
1351
+
1352
+ total_bals: dict[str, float] = {}
1353
+ total_usd_bals: dict[str, float] = {}
1354
+
1355
+ for i, mtoken in enumerate(mtoken_list):
1356
+ success, pos = positions[i]
1357
+ if not success:
1358
+ logger.warning(f"get_pos failed for {mtoken}: {pos}")
1359
+ continue
1360
+
1361
+ price, decimals = token_data[i]
1362
+ underlying_addr = underlying_list[i]
1363
+
1364
+ underlying_bal = pos.get("underlying_balance", 0)
1365
+ borrow_bal = pos.get("borrow_balance", 0)
1366
+
1367
+ key_mtoken = f"Base_{mtoken}"
1368
+ key_underlying = f"Base_{underlying_addr}"
1369
+
1370
+ # Store underlying as positive if lent
1371
+ if underlying_bal > 0:
1372
+ total_bals[key_mtoken] = underlying_bal
1373
+ total_usd_bals[key_mtoken] = (underlying_bal / 10**decimals) * price
1374
+
1375
+ # Store borrow as negative
1376
+ if borrow_bal > 0:
1377
+ total_bals[key_underlying] = -borrow_bal
1378
+ total_usd_bals[key_underlying] = -(borrow_bal / 10**decimals) * price
1379
+
1380
+ return total_bals, total_usd_bals
1381
+
1382
+ async def compute_ltv(
1383
+ self,
1384
+ total_usd_bals: dict,
1385
+ collateral_factors: tuple[float, float] | None = None,
1386
+ ) -> float:
1387
+ """Compute loan-to-value ratio.
1388
+
1389
+ LTV = Debt / (cf_u * C_u + cf_s * C_s)
1390
+
1391
+ Args:
1392
+ total_usd_bals: USD balances from _aggregate_positions().
1393
+ collateral_factors: Optional (cf_usdc, cf_wsteth) tuple.
1394
+ If provided, skips collateral factor fetches.
1395
+ """
1396
+ # Get debt (WETH borrow)
1397
+ weth_key = f"Base_{WETH}"
1398
+ debt_usd = abs(float(total_usd_bals.get(weth_key, 0.0)))
1399
+
1400
+ # Get collateral values
1401
+ usdc_key = f"Base_{M_USDC}"
1402
+ wsteth_key = f"Base_{M_WSTETH}"
1403
+ usdc_collateral = float(total_usd_bals.get(usdc_key, 0.0))
1404
+ wsteth_collateral = float(total_usd_bals.get(wsteth_key, 0.0))
1405
+
1406
+ # Use provided collateral factors or fetch them
1407
+ if collateral_factors is not None:
1408
+ cf_u, cf_s = collateral_factors
1409
+ else:
1410
+ cf_u, cf_s = await self._get_collateral_factors()
1411
+
1412
+ capacity = cf_u * usdc_collateral + cf_s * wsteth_collateral
1413
+
1414
+ if capacity <= 0:
1415
+ return float("nan")
1416
+
1417
+ return debt_usd / capacity
1418
+
1419
+ async def _can_withdraw_token(
1420
+ self,
1421
+ total_usd_bals: dict[str, float],
1422
+ withdraw_token_id: str,
1423
+ withdraw_token_usd_val: float,
1424
+ *,
1425
+ collateral_factors: tuple[float, float] | None = None,
1426
+ ) -> bool:
1427
+ """Simulate withdrawing collateral and check resulting HF stays >= MIN_HEALTH_FACTOR."""
1428
+ current_val = float(total_usd_bals.get(withdraw_token_id, 0.0))
1429
+ if withdraw_token_usd_val <= 0:
1430
+ return True
1431
+ if withdraw_token_usd_val > current_val:
1432
+ return False
1433
+
1434
+ simulated_bals = dict(total_usd_bals)
1435
+ simulated_bals[withdraw_token_id] = current_val - withdraw_token_usd_val
1436
+
1437
+ new_ltv = await self.compute_ltv(simulated_bals, collateral_factors)
1438
+ if new_ltv == 0:
1439
+ return True
1440
+ if not new_ltv or new_ltv != new_ltv:
1441
+ return False
1442
+
1443
+ new_hf = 1.0 / new_ltv
1444
+ return new_hf >= self.MIN_HEALTH_FACTOR
1445
+
1446
+ async def _get_steth_apy(self) -> float | None:
1447
+ """Fetch wstETH APY from Lido API."""
1448
+ url = "https://eth-api.lido.fi/v1/protocol/steth/apr/sma"
1449
+ try:
1450
+ async with httpx.AsyncClient(timeout=60) as client:
1451
+ r = await client.get(url)
1452
+ r.raise_for_status()
1453
+ data = r.json()
1454
+
1455
+ apy = data.get("data", {}).get("smaApr", None)
1456
+ if apy:
1457
+ return apy / 100
1458
+ except Exception as e:
1459
+ logger.warning(f"Failed to fetch stETH APY: {e}")
1460
+ return None
1461
+
1462
+ async def quote(self) -> dict:
1463
+ """Calculate projected APY for the strategy."""
1464
+ # Get APYs and collateral factors in parallel
1465
+ (
1466
+ usdc_apy_result,
1467
+ weth_apy_result,
1468
+ wsteth_apy,
1469
+ cf_u_result,
1470
+ cf_w_result,
1471
+ ) = await asyncio.gather(
1472
+ self.moonwell_adapter.get_apy(mtoken=M_USDC, apy_type="supply"),
1473
+ self.moonwell_adapter.get_apy(mtoken=M_WETH, apy_type="borrow"),
1474
+ self._get_steth_apy(),
1475
+ self.moonwell_adapter.get_collateral_factor(mtoken=M_USDC),
1476
+ self.moonwell_adapter.get_collateral_factor(mtoken=M_WSTETH),
1477
+ )
1478
+
1479
+ usdc_lend_apy = usdc_apy_result[1] if usdc_apy_result[0] else 0.0
1480
+ weth_borrow_apy = weth_apy_result[1] if weth_apy_result[0] else 0.0
1481
+ wsteth_lend_apy = wsteth_apy or 0.0
1482
+
1483
+ if not wsteth_lend_apy:
1484
+ return {
1485
+ "apy": 0,
1486
+ "information": "Failed to get Lido wstETH APY",
1487
+ "data": {},
1488
+ }
1489
+
1490
+ cf_u = cf_u_result[1] if cf_u_result[0] else 0.0
1491
+ cf_w = cf_w_result[1] if cf_w_result[0] else 0.0
1492
+
1493
+ if not cf_u or cf_u <= 0:
1494
+ return {"apy": 0, "information": "Invalid collateral factor", "data": {}}
1495
+
1496
+ # Calculate target borrow and leverage
1497
+ denominator = self.MIN_HEALTH_FACTOR - cf_w
1498
+ if denominator <= 0:
1499
+ return {"apy": 0, "information": "Invalid health factor params", "data": {}}
1500
+ target_borrow = cf_u / denominator
1501
+ total_apy = target_borrow * (wsteth_lend_apy - weth_borrow_apy) + usdc_lend_apy
1502
+ total_leverage = target_borrow + 1
1503
+
1504
+ return {
1505
+ "apy": total_apy,
1506
+ "information": f"Strategy would return {total_apy * 100:.2f}% APY with leverage of {total_leverage:.2f}x",
1507
+ "data": {
1508
+ "rates": {
1509
+ "usdc_lend": usdc_lend_apy,
1510
+ "wsteth_lend": wsteth_lend_apy,
1511
+ "weth_borrow": weth_borrow_apy,
1512
+ },
1513
+ "leverage_achievable": total_leverage,
1514
+ "apy_achievable": total_apy,
1515
+ },
1516
+ }
1517
+
1518
+ async def _atomic_deposit_iteration(self, borrow_amt_wei: int) -> int:
1519
+ """One atomic iteration: borrow WETH → swap wstETH → lend. Returns wstETH lent."""
1520
+ safe_borrow_amt = int(borrow_amt_wei * COLLATERAL_SAFETY_FACTOR)
1521
+ strategy_address = self._get_strategy_wallet_address()
1522
+
1523
+ # Snapshot balances so we can detect whether the borrow surfaced as native ETH or WETH.
1524
+ # (On Base, some integrations auto-unwrap borrowed WETH to native ETH.)
1525
+ eth_before, weth_before = await asyncio.gather(
1526
+ self._get_balance_raw(
1527
+ token_id=ETH_TOKEN_ID, wallet_address=strategy_address
1528
+ ),
1529
+ self._get_balance_raw(
1530
+ token_id=WETH_TOKEN_ID, wallet_address=strategy_address
1531
+ ),
1532
+ )
1533
+
1534
+ # Step 1: Borrow (debt is WETH-denominated)
1535
+ success, borrow_result = await self.moonwell_adapter.borrow(
1536
+ mtoken=M_WETH, amount=safe_borrow_amt
1537
+ )
1538
+ if not success:
1539
+ raise Exception(f"Borrow failed: {borrow_result}")
1540
+
1541
+ # Extract block number from transaction result for block-pinned reads
1542
+ tx_block: int | None = None
1543
+ if isinstance(borrow_result, dict):
1544
+ tx_block = borrow_result.get("block_number") or (
1545
+ borrow_result.get("receipt", {}).get("blockNumber")
1546
+ )
1547
+
1548
+ logger.info(
1549
+ f"Borrowed {safe_borrow_amt / 10**18:.6f} WETH (may arrive as ETH) "
1550
+ f"in block {tx_block}"
1551
+ )
1552
+
1553
+ # Use block-pinned reads to check balances at the transaction's block
1554
+ # This avoids stale reads from RPC indexing lag on L2s like Base
1555
+ eth_delta = 0
1556
+ weth_delta = 0
1557
+ eth_after = 0
1558
+ weth_after = 0
1559
+ for attempt in range(5):
1560
+ if attempt > 0:
1561
+ # Exponential backoff: 1, 2, 4, 8 seconds
1562
+ await asyncio.sleep(2 ** (attempt - 1))
1563
+
1564
+ # Read at the specific block where the borrow occurred
1565
+ eth_after, weth_after = await asyncio.gather(
1566
+ self._get_balance_raw(
1567
+ token_id=ETH_TOKEN_ID,
1568
+ wallet_address=strategy_address,
1569
+ block_identifier=tx_block,
1570
+ ),
1571
+ self._get_balance_raw(
1572
+ token_id=WETH_TOKEN_ID,
1573
+ wallet_address=strategy_address,
1574
+ block_identifier=tx_block,
1575
+ ),
1576
+ )
1577
+
1578
+ eth_delta = max(0, int(eth_after) - int(eth_before))
1579
+ weth_delta = max(0, int(weth_after) - int(weth_before))
1580
+
1581
+ if eth_delta > 0 or weth_delta > 0:
1582
+ break
1583
+ logger.debug(
1584
+ f"Balance check attempt {attempt + 1} at block {tx_block}: "
1585
+ f"no delta detected yet, retrying..."
1586
+ )
1587
+
1588
+ gas_reserve = int(self.WRAP_GAS_RESERVE * 10**18)
1589
+ # Usable ETH is the minimum of what we received (eth_delta) and what's available after gas reserve
1590
+ usable_eth = min(eth_delta, max(0, int(eth_after) - gas_reserve))
1591
+
1592
+ logger.debug(
1593
+ f"Post-borrow balances: ETH delta={eth_delta / 10**18:.6f}, "
1594
+ f"WETH delta={weth_delta / 10**18:.6f}, usable_eth={usable_eth / 10**18:.6f}"
1595
+ )
1596
+
1597
+ # Always swap WETH (not ETH directly) - ETH swaps get bad fills.
1598
+ # If borrow arrived as native ETH, wrap it first.
1599
+ weth_bal = int(weth_after)
1600
+
1601
+ if eth_delta > 0 and usable_eth > 0:
1602
+ # Borrow arrived as native ETH - wrap it first
1603
+ wrap_amt = min(int(safe_borrow_amt), int(usable_eth))
1604
+ logger.info(
1605
+ f"Borrow arrived as native ETH, wrapping {wrap_amt / 10**18:.6f} ETH to WETH"
1606
+ )
1607
+ wrap_success, wrap_msg = await self.moonwell_adapter.wrap_eth(
1608
+ amount=wrap_amt
1609
+ )
1610
+ if not wrap_success:
1611
+ raise Exception(f"Wrap ETH→WETH failed: {wrap_msg}")
1612
+ # WETH wrapping is 1:1, so we know exactly how much we have now
1613
+ # (avoids stale RPC reads after the wrap tx)
1614
+ weth_bal = int(weth_after) + wrap_amt
1615
+ logger.info(f"Post-wrap WETH balance (calculated): {weth_bal / 10**18:.6f}")
1616
+ elif weth_delta > 0:
1617
+ logger.info(f"Borrow arrived as WETH: {weth_delta / 10**18:.6f}")
1618
+ elif eth_delta == 0 and weth_delta == 0:
1619
+ # Borrow succeeded but balance reads are stale - assume it arrived as ETH
1620
+ # and try to wrap what we can (this is common on Base L2)
1621
+ available_eth = max(0, int(eth_after) - gas_reserve)
1622
+ if available_eth > 0:
1623
+ wrap_amt = min(int(safe_borrow_amt), available_eth)
1624
+ logger.warning(
1625
+ f"Balance delta not detected but borrow succeeded. "
1626
+ f"Assuming ETH arrival, wrapping {wrap_amt / 10**18:.6f} ETH"
1627
+ )
1628
+ wrap_success, wrap_msg = await self.moonwell_adapter.wrap_eth(
1629
+ amount=wrap_amt
1630
+ )
1631
+ if not wrap_success:
1632
+ raise Exception(f"Wrap ETH→WETH failed: {wrap_msg}")
1633
+ # WETH wrapping is 1:1, so we know exactly how much we have now
1634
+ weth_bal = int(weth_after) + wrap_amt
1635
+ logger.info(
1636
+ f"Post-wrap WETH balance (calculated): {weth_bal / 10**18:.6f}"
1637
+ )
1638
+
1639
+ amount_to_swap = min(int(safe_borrow_amt), int(weth_bal))
1640
+
1641
+ if amount_to_swap <= 0:
1642
+ raise Exception(
1643
+ f"No WETH available to swap after borrowing (weth_bal={weth_bal})"
1644
+ )
1645
+
1646
+ # Step 2: Swap WETH to wstETH with retries
1647
+ # Prefer enso/aerodrome for WETH→wstETH - LiFi gets bad fills
1648
+ swap_result = await self._swap_with_retries(
1649
+ from_token_id=WETH_TOKEN_ID,
1650
+ to_token_id=WSTETH_TOKEN_ID,
1651
+ amount=amount_to_swap,
1652
+ preferred_providers=["aerodrome", "enso"],
1653
+ )
1654
+ if swap_result is None:
1655
+ # Roll back: repay the borrowed amount to remain delta-neutral.
1656
+ try:
1657
+ # Prefer repaying directly with the borrowed WETH.
1658
+ weth_bal = await self._get_balance_raw(
1659
+ token_id=WETH_TOKEN_ID, wallet_address=strategy_address
1660
+ )
1661
+
1662
+ wrap_amt = 0
1663
+ if weth_bal < safe_borrow_amt:
1664
+ # If the borrow surfaced as native ETH (or WETH was otherwise reduced),
1665
+ # attempt to wrap ETH for the shortfall while preserving gas.
1666
+ eth_bal = await self._get_balance_raw(
1667
+ token_id=ETH_TOKEN_ID, wallet_address=strategy_address
1668
+ )
1669
+ gas_reserve = int(self.WRAP_GAS_RESERVE * 10**18)
1670
+ available_for_wrap = max(0, eth_bal - gas_reserve)
1671
+ shortfall = safe_borrow_amt - weth_bal
1672
+ wrap_amt = min(shortfall, available_for_wrap)
1673
+ if wrap_amt > 0:
1674
+ wrap_success, wrap_msg = await self.moonwell_adapter.wrap_eth(
1675
+ amount=wrap_amt
1676
+ )
1677
+ if not wrap_success:
1678
+ raise Exception(f"Wrap ETH→WETH failed: {wrap_msg}")
1679
+
1680
+ weth_bal = await self._get_balance_raw(
1681
+ token_id=WETH_TOKEN_ID, wallet_address=strategy_address
1682
+ )
1683
+
1684
+ repay_amt = min(safe_borrow_amt, weth_bal)
1685
+ if repay_amt <= 0:
1686
+ raise Exception("No WETH available to repay the borrow")
1687
+
1688
+ repay_success, repay_msg = await self.moonwell_adapter.repay(
1689
+ mtoken=M_WETH,
1690
+ underlying_token=WETH,
1691
+ amount=repay_amt,
1692
+ )
1693
+ if not repay_success:
1694
+ raise Exception(f"Repay failed: {repay_msg}")
1695
+
1696
+ if repay_amt < safe_borrow_amt:
1697
+ logger.warning(
1698
+ f"Swap failed; only repaid {repay_amt / 10**18:.6f} of "
1699
+ f"{safe_borrow_amt / 10**18:.6f} WETH. Position may be imbalanced."
1700
+ )
1701
+ else:
1702
+ logger.warning("Swap failed after retries. Borrow undone.")
1703
+ except Exception as repay_exc:
1704
+ raise Exception(
1705
+ f"Swap failed after retries and reverting borrow failed: {repay_exc}. "
1706
+ "Position may no longer be delta-neutral!"
1707
+ ) from repay_exc
1708
+ raise Exception("Atomic deposit failed at swap step after all retries")
1709
+
1710
+ # Parse to_amount from swap result (may be int or string)
1711
+ raw_to_amount = (
1712
+ swap_result.get("to_amount", 0) if isinstance(swap_result, dict) else 0
1713
+ )
1714
+ try:
1715
+ to_amount_wei = int(raw_to_amount) if raw_to_amount else 0
1716
+ except (ValueError, TypeError):
1717
+ to_amount_wei = 0
1718
+
1719
+ # Get actual wstETH balance
1720
+ wsteth_success, wsteth_bal_raw = await self.balance_adapter.get_balance(
1721
+ token_id=WSTETH_TOKEN_ID, wallet_address=self._get_strategy_wallet_address()
1722
+ )
1723
+ if not wsteth_success:
1724
+ raise Exception("Failed to get wstETH balance after swap")
1725
+ wsteth_bal = self._parse_balance(wsteth_bal_raw)
1726
+
1727
+ # Use the smaller of balance check and swap result to avoid over-lending
1728
+ lend_amt_wei = (
1729
+ min(to_amount_wei, wsteth_bal) if wsteth_bal > 0 else to_amount_wei
1730
+ )
1731
+
1732
+ # If swap produced 0 wstETH, rollback the borrow
1733
+ if lend_amt_wei <= 0:
1734
+ logger.warning("Swap resulted in 0 wstETH. Rolling back borrow...")
1735
+ try:
1736
+ # Get WETH balance to repay (swap may have returned WETH or nothing)
1737
+ weth_bal = await self._get_balance_raw(
1738
+ token_id=WETH_TOKEN_ID, wallet_address=strategy_address
1739
+ )
1740
+ if weth_bal > 0:
1741
+ repay_amt = min(weth_bal, safe_borrow_amt)
1742
+ await self.moonwell_adapter.repay(
1743
+ mtoken=M_WETH,
1744
+ underlying_token=WETH,
1745
+ amount=repay_amt,
1746
+ )
1747
+ logger.info(f"Rolled back: repaid {repay_amt / 10**18:.6f} WETH")
1748
+ except Exception as rollback_exc:
1749
+ raise Exception(
1750
+ f"Swap produced 0 wstETH and rollback failed: {rollback_exc}. "
1751
+ "Position may have excess WETH debt!"
1752
+ ) from rollback_exc
1753
+ raise Exception(
1754
+ "Swap resulted in 0 wstETH to lend. Borrow was rolled back."
1755
+ )
1756
+
1757
+ # Step 3: Lend wstETH
1758
+ mwsteth_before = 0
1759
+ minted_mwsteth = 0
1760
+ try:
1761
+ mwsteth_pos_before = await self.moonwell_adapter.get_pos(mtoken=M_WSTETH)
1762
+ if mwsteth_pos_before[0] and isinstance(mwsteth_pos_before[1], dict):
1763
+ mwsteth_before = int(
1764
+ (mwsteth_pos_before[1] or {}).get("mtoken_balance", 0) or 0
1765
+ )
1766
+ except Exception: # noqa: BLE001
1767
+ mwsteth_before = 0
1768
+
1769
+ try:
1770
+ success, msg = await self.moonwell_adapter.lend(
1771
+ mtoken=M_WSTETH,
1772
+ underlying_token=WSTETH,
1773
+ amount=lend_amt_wei,
1774
+ )
1775
+ if not success:
1776
+ raise Exception(f"Lend failed: {msg}")
1777
+
1778
+ # Track minted mTokens so we can redeem the correct amount on rollback.
1779
+ try:
1780
+ mwsteth_pos_after = await self.moonwell_adapter.get_pos(mtoken=M_WSTETH)
1781
+ if mwsteth_pos_after[0] and isinstance(mwsteth_pos_after[1], dict):
1782
+ mwsteth_after = int(
1783
+ (mwsteth_pos_after[1] or {}).get("mtoken_balance", 0) or 0
1784
+ )
1785
+ minted_mwsteth = max(0, int(mwsteth_after) - int(mwsteth_before))
1786
+ except Exception: # noqa: BLE001
1787
+ minted_mwsteth = 0
1788
+
1789
+ set_coll_success, set_coll_msg = await self.moonwell_adapter.set_collateral(
1790
+ mtoken=M_WSTETH
1791
+ )
1792
+ if not set_coll_success:
1793
+ # Must redeem mTokens (not underlying) since wstETH is now in protocol, not wallet.
1794
+ to_redeem = minted_mwsteth
1795
+ if to_redeem <= 0:
1796
+ # Fallback: redeem whatever balance we can see (best-effort).
1797
+ mwsteth_pos = await self.moonwell_adapter.get_pos(mtoken=M_WSTETH)
1798
+ if mwsteth_pos[0] and isinstance(mwsteth_pos[1], dict):
1799
+ to_redeem = int(
1800
+ (mwsteth_pos[1] or {}).get("mtoken_balance", 0) or 0
1801
+ )
1802
+ if to_redeem > 0:
1803
+ await self.moonwell_adapter.unlend(
1804
+ mtoken=M_WSTETH, amount=to_redeem
1805
+ )
1806
+ raise Exception(
1807
+ f"set_collateral failed: {set_coll_msg}. Lend reversed."
1808
+ )
1809
+ logger.info(f"Lent {lend_amt_wei / 10**18:.6f} wstETH")
1810
+
1811
+ except Exception as lend_exc:
1812
+ # Roll back: swap wstETH back to WETH and repay (only if we have wstETH)
1813
+ try:
1814
+ # Ensure wstETH is in the wallet (redeem minted mwstETH if needed).
1815
+ rollback_wsteth = await self._get_balance_raw(
1816
+ token_id=WSTETH_TOKEN_ID, wallet_address=strategy_address
1817
+ )
1818
+ if rollback_wsteth <= 0 and minted_mwsteth > 0:
1819
+ await self.moonwell_adapter.unlend(
1820
+ mtoken=M_WSTETH, amount=minted_mwsteth
1821
+ )
1822
+ rollback_wsteth = await self._get_balance_raw(
1823
+ token_id=WSTETH_TOKEN_ID, wallet_address=strategy_address
1824
+ )
1825
+
1826
+ if rollback_wsteth > 0:
1827
+ (
1828
+ revert_success,
1829
+ revert_result,
1830
+ ) = await self.brap_adapter.swap_from_token_ids(
1831
+ from_token_id=WSTETH_TOKEN_ID,
1832
+ to_token_id=WETH_TOKEN_ID,
1833
+ from_address=strategy_address,
1834
+ amount=str(rollback_wsteth),
1835
+ )
1836
+ if revert_success and revert_result:
1837
+ weth_after = await self._get_balance_raw(
1838
+ token_id=WETH_TOKEN_ID, wallet_address=strategy_address
1839
+ )
1840
+ repay_amt = min(weth_after, safe_borrow_amt)
1841
+ if repay_amt > 0:
1842
+ await self.moonwell_adapter.repay(
1843
+ mtoken=M_WETH,
1844
+ underlying_token=WETH,
1845
+ amount=repay_amt,
1846
+ )
1847
+ else:
1848
+ logger.warning(
1849
+ f"Lend failed but no wstETH to rollback. Lend error: {lend_exc}"
1850
+ )
1851
+ except Exception as revert_exc:
1852
+ raise Exception(
1853
+ f"Lend failed: {lend_exc} and revert failed: {revert_exc}"
1854
+ ) from revert_exc
1855
+ raise Exception(
1856
+ f"Deposit to wstETH failed and was reverted: {lend_exc}"
1857
+ ) from lend_exc
1858
+
1859
+ return lend_amt_wei
1860
+
1861
+ async def partial_liquidate(self, usd_value: float) -> StatusTuple:
1862
+ """Create USDC liquidity in the strategy wallet by safely redeeming collateral."""
1863
+ self._clear_price_cache()
1864
+
1865
+ if usd_value <= 0:
1866
+ raise ValueError(f"usd_value must be positive, got {usd_value}")
1867
+
1868
+ strategy_address = self._get_strategy_wallet_address()
1869
+
1870
+ usdc_info = await self._get_token_info(USDC_TOKEN_ID)
1871
+ usdc_decimals = usdc_info.get("decimals", 6)
1872
+
1873
+ # (1) Check current USDC in wallet
1874
+ usdc_raw = await self._get_usdc_balance()
1875
+ current_usdc = usdc_raw / (10**usdc_decimals)
1876
+ if current_usdc >= usd_value:
1877
+ target_raw = int(usd_value * (10**usdc_decimals))
1878
+ available = min(usdc_raw, target_raw) / (10**usdc_decimals)
1879
+ return (
1880
+ True,
1881
+ f"Partial liquidation not needed. Available: {available:.2f} USDC",
1882
+ )
1883
+
1884
+ missing = usd_value - current_usdc
1885
+
1886
+ # (2) Fetch Moonwell positions and collateral factors
1887
+ (_totals_token, total_usd_bals), collateral_factors = await asyncio.gather(
1888
+ self._aggregate_positions(),
1889
+ self._get_collateral_factors(),
1890
+ )
1891
+
1892
+ key_wsteth = f"Base_{M_WSTETH}"
1893
+ key_weth = f"Base_{WETH}"
1894
+ key_usdc = f"Base_{M_USDC}"
1895
+
1896
+ wsteth_usd = float(total_usd_bals.get(key_wsteth, 0.0))
1897
+ weth_debt_usd = abs(float(total_usd_bals.get(key_weth, 0.0)))
1898
+
1899
+ # (2a) If wstETH collateral exceeds WETH debt, redeem some wstETH and swap to USDC
1900
+ if missing > 0 and wsteth_usd > weth_debt_usd:
1901
+ unlend_usd = min(missing, wsteth_usd - weth_debt_usd)
1902
+ if await self._can_withdraw_token(
1903
+ total_usd_bals,
1904
+ key_wsteth,
1905
+ unlend_usd,
1906
+ collateral_factors=collateral_factors,
1907
+ ):
1908
+ wsteth_price = await self._get_token_price(WSTETH_TOKEN_ID)
1909
+ if not wsteth_price or wsteth_price <= 0:
1910
+ return (False, "Invalid wstETH price")
1911
+
1912
+ wsteth_info = await self._get_token_info(WSTETH_TOKEN_ID)
1913
+ wsteth_decimals = wsteth_info.get("decimals", 18)
1914
+
1915
+ token_qty = unlend_usd / wsteth_price
1916
+ unlend_underlying_raw = int(token_qty * (10**wsteth_decimals))
1917
+
1918
+ if unlend_underlying_raw > 0:
1919
+ mwsteth_res = await self.moonwell_adapter.max_withdrawable_mtoken(
1920
+ mtoken=M_WSTETH
1921
+ )
1922
+ if mwsteth_res[0]:
1923
+ withdraw_info = mwsteth_res[1]
1924
+ max_ctokens = int(withdraw_info.get("cTokens_raw", 0))
1925
+ exchange_rate_raw = int(
1926
+ withdraw_info.get("exchangeRate_raw", 0)
1927
+ )
1928
+ conversion_factor = float(
1929
+ withdraw_info.get("conversion_factor", 0) or 0
1930
+ )
1931
+
1932
+ if max_ctokens > 0:
1933
+ if exchange_rate_raw > 0:
1934
+ # underlying = cTokens * exchangeRate / 1e18
1935
+ ctokens_needed = (
1936
+ unlend_underlying_raw * 10**18
1937
+ + exchange_rate_raw
1938
+ - 1
1939
+ ) // exchange_rate_raw
1940
+ elif conversion_factor > 0:
1941
+ ctokens_needed = (
1942
+ int(conversion_factor * unlend_underlying_raw) + 1
1943
+ )
1944
+ else:
1945
+ ctokens_needed = max_ctokens
1946
+
1947
+ ctokens_to_redeem = min(int(ctokens_needed), max_ctokens)
1948
+ if ctokens_to_redeem > 0:
1949
+ success, msg = await self.moonwell_adapter.unlend(
1950
+ mtoken=M_WSTETH, amount=ctokens_to_redeem
1951
+ )
1952
+ if not success:
1953
+ return (
1954
+ False,
1955
+ f"Failed to redeem mwstETH for partial liquidation: {msg}",
1956
+ )
1957
+
1958
+ # Swap withdrawn wstETH → USDC
1959
+ wsteth_wallet_raw = await self._get_balance_raw(
1960
+ token_id=WSTETH_TOKEN_ID,
1961
+ wallet_address=strategy_address,
1962
+ )
1963
+ amount_to_swap = min(
1964
+ wsteth_wallet_raw, unlend_underlying_raw
1965
+ )
1966
+ if amount_to_swap > 0:
1967
+ swap_res = await self._swap_with_retries(
1968
+ from_token_id=WSTETH_TOKEN_ID,
1969
+ to_token_id=USDC_TOKEN_ID,
1970
+ amount=amount_to_swap,
1971
+ )
1972
+ if swap_res is None:
1973
+ # Restore collateral if swap fails
1974
+ restore_amt = min(
1975
+ amount_to_swap, wsteth_wallet_raw
1976
+ )
1977
+ if restore_amt > 0:
1978
+ await self.moonwell_adapter.lend(
1979
+ mtoken=M_WSTETH,
1980
+ underlying_token=WSTETH,
1981
+ amount=restore_amt,
1982
+ )
1983
+ await self.moonwell_adapter.set_collateral(
1984
+ mtoken=M_WSTETH
1985
+ )
1986
+
1987
+ # (3) Re-check wallet USDC balance
1988
+ usdc_raw = await self._get_balance_raw(
1989
+ token_id=USDC_TOKEN_ID, wallet_address=strategy_address
1990
+ )
1991
+ current_usdc = usdc_raw / (10**usdc_decimals)
1992
+
1993
+ # (4) If still short, redeem USDC collateral directly
1994
+ if current_usdc < usd_value:
1995
+ # Refresh Moonwell balances after any prior redemptions/swaps.
1996
+ (_totals_token, total_usd_bals) = await self._aggregate_positions()
1997
+
1998
+ missing_usdc = usd_value - current_usdc
1999
+ available_usdc = float(total_usd_bals.get(key_usdc, 0.0))
2000
+
2001
+ if missing_usdc > 0 and available_usdc > 0:
2002
+ unlend_usdc = min(missing_usdc, available_usdc)
2003
+ if await self._can_withdraw_token(
2004
+ total_usd_bals,
2005
+ key_usdc,
2006
+ unlend_usdc,
2007
+ collateral_factors=collateral_factors,
2008
+ ):
2009
+ unlend_underlying_raw = int(unlend_usdc * (10**usdc_decimals))
2010
+ if unlend_underlying_raw > 0:
2011
+ musdc_res = await self.moonwell_adapter.max_withdrawable_mtoken(
2012
+ mtoken=M_USDC
2013
+ )
2014
+ if not musdc_res[0]:
2015
+ return (
2016
+ False,
2017
+ f"Failed to compute withdrawable mUSDC: {musdc_res[1]}",
2018
+ )
2019
+ withdraw_info = musdc_res[1]
2020
+ max_ctokens = int(withdraw_info.get("cTokens_raw", 0))
2021
+ exchange_rate_raw = int(
2022
+ withdraw_info.get("exchangeRate_raw", 0)
2023
+ )
2024
+ conversion_factor = float(
2025
+ withdraw_info.get("conversion_factor", 0) or 0
2026
+ )
2027
+
2028
+ if max_ctokens > 0:
2029
+ if exchange_rate_raw > 0:
2030
+ ctokens_needed = (
2031
+ unlend_underlying_raw * 10**18
2032
+ + exchange_rate_raw
2033
+ - 1
2034
+ ) // exchange_rate_raw
2035
+ elif conversion_factor > 0:
2036
+ ctokens_needed = (
2037
+ int(conversion_factor * unlend_underlying_raw) + 1
2038
+ )
2039
+ else:
2040
+ ctokens_needed = max_ctokens
2041
+
2042
+ ctokens_to_redeem = min(int(ctokens_needed), max_ctokens)
2043
+ if ctokens_to_redeem > 0:
2044
+ success, msg = await self.moonwell_adapter.unlend(
2045
+ mtoken=M_USDC, amount=ctokens_to_redeem
2046
+ )
2047
+ if not success:
2048
+ return (
2049
+ False,
2050
+ f"Failed to redeem mUSDC for partial liquidation: {msg}",
2051
+ )
2052
+
2053
+ # (5) Final available USDC (capped to target)
2054
+ usdc_raw = await self._get_balance_raw(
2055
+ token_id=USDC_TOKEN_ID, wallet_address=strategy_address
2056
+ )
2057
+ target_raw = int(usd_value * (10**usdc_decimals))
2058
+ final_raw = min(usdc_raw, target_raw)
2059
+
2060
+ if final_raw <= 0:
2061
+ return (False, "Partial liquidation produced no USDC")
2062
+
2063
+ final_usdc = final_raw / (10**usdc_decimals)
2064
+ if final_raw < target_raw:
2065
+ return (
2066
+ True,
2067
+ f"Partial liquidation completed. Available: {final_usdc:.2f} USDC (requested {usd_value:.2f})",
2068
+ )
2069
+ return (
2070
+ True,
2071
+ f"Partial liquidation completed. Available: {final_usdc:.2f} USDC",
2072
+ )
2073
+
2074
+ async def _execute_deposit_loop(self, usdc_amount: float) -> tuple[bool, Any, int]:
2075
+ """Execute the recursive leverage loop."""
2076
+ token_info = await self._get_token_info(USDC_TOKEN_ID)
2077
+ decimals = token_info.get("decimals", 6)
2078
+ initial_deposit = int(usdc_amount * 10**decimals)
2079
+
2080
+ # Fetch prices and collateral factors in parallel (use cache, minimal RPC)
2081
+ wsteth_price, weth_price, collateral_factors = await asyncio.gather(
2082
+ self._get_token_price(WSTETH_TOKEN_ID),
2083
+ self._get_token_price(WETH_TOKEN_ID),
2084
+ self._get_collateral_factors(),
2085
+ )
2086
+
2087
+ # Fetch position separately to avoid overwhelming public RPC
2088
+ weth_pos = await self.moonwell_adapter.get_pos(mtoken=M_WETH)
2089
+
2090
+ current_borrowed_value = 0.0
2091
+ if weth_pos[0]:
2092
+ borrow_bal = weth_pos[1].get("borrow_balance", 0)
2093
+ current_borrowed_value = (borrow_bal / 10**18) * weth_price
2094
+
2095
+ # Lend USDC and enable as collateral
2096
+ success, msg = await self.moonwell_adapter.lend(
2097
+ mtoken=M_USDC,
2098
+ underlying_token=USDC,
2099
+ amount=initial_deposit,
2100
+ )
2101
+ if not success:
2102
+ return (False, f"Initial USDC lend failed: {msg}", 0)
2103
+
2104
+ await self.moonwell_adapter.set_collateral(mtoken=M_USDC)
2105
+ logger.info(f"Deposited {usdc_amount:.2f} USDC as initial collateral")
2106
+
2107
+ # Get current leverage (positions changed after lend, must re-fetch)
2108
+ (
2109
+ usdc_lend_value,
2110
+ wsteth_lend_value,
2111
+ initial_leverage,
2112
+ ) = await self._get_current_leverage()
2113
+
2114
+ return await self._loop_wsteth(
2115
+ wsteth_price=wsteth_price,
2116
+ weth_price=weth_price,
2117
+ current_borrowed_value=current_borrowed_value,
2118
+ initial_leverage=initial_leverage,
2119
+ usdc_lend_value=usdc_lend_value,
2120
+ wsteth_lend_value=wsteth_lend_value,
2121
+ collateral_factors=collateral_factors,
2122
+ )
2123
+
2124
+ async def _loop_wsteth(
2125
+ self,
2126
+ wsteth_price: float,
2127
+ weth_price: float,
2128
+ current_borrowed_value: float,
2129
+ initial_leverage: float,
2130
+ usdc_lend_value: float,
2131
+ wsteth_lend_value: float,
2132
+ collateral_factors: tuple[float, float] | None = None,
2133
+ ) -> tuple[bool, Any, int]:
2134
+ """Execute leverage loop until target health factor reached.
2135
+
2136
+ Args:
2137
+ collateral_factors: Optional (cf_usdc, cf_wsteth) tuple.
2138
+ If provided, skips collateral factor fetches.
2139
+ """
2140
+ # Ensure USDC and wstETH markets are entered as collateral before borrowing
2141
+ # This is idempotent - if already entered, Moonwell just returns success
2142
+ if usdc_lend_value > 0:
2143
+ set_coll_result = await self.moonwell_adapter.set_collateral(mtoken=M_USDC)
2144
+ if not set_coll_result[0]:
2145
+ logger.warning(
2146
+ f"Failed to ensure USDC collateral: {set_coll_result[1]}"
2147
+ )
2148
+ return (
2149
+ False,
2150
+ f"Failed to enable USDC as collateral: {set_coll_result[1]}",
2151
+ 0,
2152
+ )
2153
+
2154
+ if wsteth_lend_value > 0:
2155
+ set_coll_result = await self.moonwell_adapter.set_collateral(
2156
+ mtoken=M_WSTETH
2157
+ )
2158
+ if not set_coll_result[0]:
2159
+ logger.warning(
2160
+ f"Failed to ensure wstETH collateral: {set_coll_result[1]}"
2161
+ )
2162
+ # This is less critical - we can continue if wstETH collateral fails
2163
+
2164
+ # Enter M_WETH market to allow borrowing from it
2165
+ # In Compound v2/Moonwell, you must be in a market to borrow from it
2166
+ # (enterMarkets enables both collateral usage AND borrowing)
2167
+ set_weth_result = await self.moonwell_adapter.set_collateral(mtoken=M_WETH)
2168
+ if not set_weth_result[0]:
2169
+ logger.warning(f"Failed to enter M_WETH market: {set_weth_result[1]}")
2170
+ return (
2171
+ False,
2172
+ f"Failed to enter M_WETH market for borrowing: {set_weth_result[1]}",
2173
+ 0,
2174
+ )
2175
+ logger.info("Entered M_WETH market to enable borrowing")
2176
+
2177
+ # Use provided collateral factors or fetch them
2178
+ if collateral_factors is not None:
2179
+ cf_u, cf_w = collateral_factors
2180
+ else:
2181
+ cf_u, cf_w = await self._get_collateral_factors()
2182
+
2183
+ # Calculate depeg-aware max safe leverage fraction
2184
+ max_safe_f = self._max_safe_F(cf_w)
2185
+
2186
+ # Guard against division by zero/negative denominator
2187
+ denominator = self.MIN_HEALTH_FACTOR + 0.001 - cf_w
2188
+ if denominator <= 0:
2189
+ logger.warning(
2190
+ f"Cannot calculate target borrow: cf_w ({cf_w:.3f}) >= MIN_HF ({self.MIN_HEALTH_FACTOR})"
2191
+ )
2192
+ return (False, initial_leverage, -1)
2193
+
2194
+ # Calculate target borrow value
2195
+ target_borrow_value = (
2196
+ usdc_lend_value * cf_u / denominator - current_borrowed_value
2197
+ )
2198
+
2199
+ if target_borrow_value < 0:
2200
+ return (False, initial_leverage, -1)
2201
+
2202
+ # Track wstETH added THIS session (starts at 0), not total position
2203
+ session_wsteth_lend_value = 0.0
2204
+ total_wsteth_lend_value = wsteth_lend_value
2205
+ raw_leverage_limit = (
2206
+ (current_borrowed_value + target_borrow_value) / usdc_lend_value + 1
2207
+ if usdc_lend_value
2208
+ else 0
2209
+ )
2210
+
2211
+ # Apply depeg-aware leverage cap
2212
+ max_safe_leverage = max_safe_f * usdc_lend_value + 1 if usdc_lend_value else 0
2213
+ leverage_limit = min(raw_leverage_limit, max_safe_leverage, self.leverage_limit)
2214
+
2215
+ leverage_tracker: list[float] = [initial_leverage]
2216
+
2217
+ for i in range(self._MAX_LOOP_LIMIT):
2218
+ # Get borrowable amount (returns USD with 18 decimals)
2219
+ borrowable_result = await self.moonwell_adapter.get_borrowable_amount()
2220
+ if not borrowable_result[0]:
2221
+ logger.warning("Failed to get borrowable amount")
2222
+ break
2223
+
2224
+ if not weth_price or weth_price <= 0:
2225
+ logger.warning("Invalid WETH price; breaking loop")
2226
+ break
2227
+
2228
+ borrowable_usd = self._normalize_usd_value(borrowable_result[1])
2229
+ if borrowable_usd <= self.min_withdraw_usd:
2230
+ logger.info("No additional borrowing possible; breaking loop")
2231
+ break
2232
+
2233
+ weth_info = await self._get_token_info(WETH_TOKEN_ID)
2234
+ weth_decimals = weth_info.get("decimals", 18)
2235
+ # Convert USD to WETH wei: (USD / price) * 10^decimals
2236
+ max_borrow_wei = int(borrowable_usd / weth_price * 10**weth_decimals)
2237
+
2238
+ # remaining_value is how much more we need to borrow/lend THIS session
2239
+ remaining_value = target_borrow_value - session_wsteth_lend_value
2240
+ remaining_wei = int(remaining_value / weth_price * 10**weth_decimals) + 1
2241
+
2242
+ if remaining_value < 2:
2243
+ logger.info(
2244
+ f"Target reached: borrowed/lent ${session_wsteth_lend_value:.2f} of ${target_borrow_value:.2f} target"
2245
+ )
2246
+ break
2247
+
2248
+ # Scale up for swap slippage
2249
+ optimal_this_iter = int(remaining_wei / (1 - 0.005))
2250
+ borrow_amt_wei = min(optimal_this_iter, max_borrow_wei)
2251
+
2252
+ current_leverage = leverage_tracker[-1]
2253
+ logger.info(
2254
+ f"Current leverage {current_leverage:.2f}x. "
2255
+ f"Borrowing {borrow_amt_wei / 10**weth_decimals:.6f} WETH"
2256
+ )
2257
+
2258
+ try:
2259
+ lend_amt_wei = await self._atomic_deposit_iteration(borrow_amt_wei)
2260
+ except Exception as e:
2261
+ logger.error(f"Deposit iteration aborted: {e}")
2262
+ return (False, f"deposit iteration {i + 1} failed: {e}", i)
2263
+
2264
+ wsteth_info = await self._get_token_info(WSTETH_TOKEN_ID)
2265
+ wsteth_decimals = wsteth_info.get("decimals", 18)
2266
+
2267
+ lend_value_this_iter = wsteth_price * lend_amt_wei / 10**wsteth_decimals
2268
+ session_wsteth_lend_value += lend_value_this_iter
2269
+ total_wsteth_lend_value += lend_value_this_iter
2270
+ leverage_tracker.append(total_wsteth_lend_value / usdc_lend_value + 1)
2271
+
2272
+ # Stop if max leverage or marginal gain < threshold (diminishing returns vs gas cost)
2273
+ if (leverage_tracker[-1] > leverage_limit) or (
2274
+ len(leverage_tracker) > 1
2275
+ and leverage_tracker[-1] / leverage_tracker[-2] - 1
2276
+ < self._MIN_LEVERAGE_GAIN_BPS
2277
+ ):
2278
+ logger.info(
2279
+ f"Finished loop, final leverage: {leverage_tracker[-1]:.2f}"
2280
+ )
2281
+ break
2282
+
2283
+ if len(leverage_tracker) == 1:
2284
+ return (False, leverage_tracker[-1], 0)
2285
+
2286
+ return (True, leverage_tracker[-1], len(leverage_tracker) - 1)
2287
+
2288
+ async def update(self) -> StatusTuple:
2289
+ """Rebalance positions. Runs deposit loop only if HF > MAX_HEALTH_FACTOR."""
2290
+ self._clear_price_cache()
2291
+
2292
+ # Best-effort top-up if we dipped below MIN_GAS (non-critical).
2293
+ topup_success, topup_msg = await self._transfer_gas_to_vault()
2294
+ if not topup_success:
2295
+ logger.warning(f"Gas top-up failed (non-critical): {topup_msg}")
2296
+
2297
+ gas_amt = await self._get_gas_balance()
2298
+ if gas_amt < int(self.MAINTENANCE_GAS * 10**18):
2299
+ return (
2300
+ False,
2301
+ f"Less than {self.MAINTENANCE_GAS} ETH in strategy wallet. Please transfer more gas.",
2302
+ )
2303
+
2304
+ # Recovery: if a previous loop borrowed WETH but failed to swap/lend, complete it first.
2305
+ try:
2306
+ completed, msg = await self._complete_unpaired_weth_borrow()
2307
+ if not completed:
2308
+ logger.warning(
2309
+ f"Unpaired borrow completion failed (will continue): {msg}"
2310
+ )
2311
+ except Exception as exc:
2312
+ return (False, f"Failed while completing unpaired borrow: {exc}")
2313
+
2314
+ # Balance WETH debt first (critical for delta-neutrality)
2315
+ balance_success, balance_msg = await self._balance_weth_debt()
2316
+ if not balance_success:
2317
+ return (
2318
+ False,
2319
+ f"Failed to balance WETH debt: {balance_msg}. Consider calling withdraw to unwind safely.",
2320
+ )
2321
+
2322
+ # If we are holding excess native ETH, convert it into USDC so it can be redeployed.
2323
+ try:
2324
+ converted, msg = await self._convert_excess_eth_to_usdc()
2325
+ if not converted:
2326
+ logger.warning(f"Excess ETH conversion failed (non-critical): {msg}")
2327
+ except SwapOutcomeUnknownError as exc:
2328
+ return (False, f"Swap outcome unknown while converting excess ETH: {exc}")
2329
+ except Exception as exc:
2330
+ logger.warning(f"Excess ETH conversion raised (non-critical): {exc}")
2331
+
2332
+ # Fetch positions and collateral factors in parallel (single fetch for update)
2333
+ positions_task = self._aggregate_positions()
2334
+ cf_task = self._get_collateral_factors()
2335
+ (totals_token, totals_usd), collateral_factors = await asyncio.gather(
2336
+ positions_task, cf_task
2337
+ )
2338
+
2339
+ # Compute health factor using pre-fetched data
2340
+ ltv = await self.compute_ltv(totals_usd, collateral_factors=collateral_factors)
2341
+ hf = (1 / ltv) if ltv and ltv > 0 and not (ltv != ltv) else float("inf")
2342
+
2343
+ # Check if we need to deleverage
2344
+ if hf < self.MIN_HEALTH_FACTOR:
2345
+ cf_u, cf_w = collateral_factors
2346
+
2347
+ usdc_key = f"Base_{M_USDC}"
2348
+ weth_key = f"Base_{WETH}"
2349
+
2350
+ c_u = totals_usd.get(usdc_key, 0)
2351
+ debt = abs(totals_usd.get(weth_key, 0))
2352
+
2353
+ repay_usd = debt - (cf_u * c_u) / (self.MIN_HEALTH_FACTOR + 1e-4 - cf_w)
2354
+ weth_price = await self._get_token_price(WETH_TOKEN_ID)
2355
+
2356
+ repay_amt = int(repay_usd / weth_price * 10**18) + 1
2357
+ success, msg = await self._repay_debt_loop(target_repaid=repay_amt)
2358
+
2359
+ if not success:
2360
+ return (
2361
+ False,
2362
+ f"Health factor is {hf:.2f} which is dangerous. Deleveraging failed: {msg}",
2363
+ )
2364
+ return (success, msg)
2365
+
2366
+ # Claim rewards if above threshold
2367
+ await self.moonwell_adapter.claim_rewards(min_rewards_usd=self.MIN_USDC_DEPOSIT)
2368
+
2369
+ # Check profitability
2370
+ success, msg = await self._check_quote_profitability()
2371
+ if not success:
2372
+ return (False, msg)
2373
+
2374
+ # If we have idle wallet wstETH (spot long), convert it to USDC so it can be redeployed
2375
+ # via the leverage loop. This does not affect native ETH gas.
2376
+ try:
2377
+ converted, conv_msg = await self._convert_spot_wsteth_to_usdc()
2378
+ if not converted:
2379
+ logger.warning(
2380
+ f"Wallet wstETH conversion failed (non-critical): {conv_msg}"
2381
+ )
2382
+ except SwapOutcomeUnknownError as exc:
2383
+ return (
2384
+ False,
2385
+ f"Swap outcome unknown while converting wallet wstETH: {exc}",
2386
+ )
2387
+ except Exception as exc:
2388
+ logger.warning(f"Wallet wstETH conversion raised (non-critical): {exc}")
2389
+
2390
+ # Get USDC balance in wallet
2391
+ usdc_balance_wei = await self._get_usdc_balance()
2392
+ token_info = await self._get_token_info(USDC_TOKEN_ID)
2393
+ decimals = token_info.get("decimals", 6)
2394
+ usdc_balance = usdc_balance_wei / 10**decimals
2395
+
2396
+ # Get lend values from already-fetched aggregate positions
2397
+ usdc_key = f"Base_{M_USDC}"
2398
+ wsteth_key = f"Base_{M_WSTETH}"
2399
+ usdc_lend_value = totals_usd.get(usdc_key, 0)
2400
+ wsteth_lend_value = totals_usd.get(wsteth_key, 0)
2401
+ initial_leverage = (
2402
+ wsteth_lend_value / usdc_lend_value + 1 if usdc_lend_value else 0
2403
+ )
2404
+
2405
+ # If we have meaningful USDC in-wallet, redeploy it regardless of current HF.
2406
+ if usdc_balance >= self.MIN_USDC_DEPOSIT:
2407
+ success, final_leverage, n_loops = await self._execute_deposit_loop(
2408
+ usdc_balance
2409
+ )
2410
+ if not success:
2411
+ return (
2412
+ False,
2413
+ f"Redeploy loop failed: {final_leverage} after {n_loops} successful loops",
2414
+ )
2415
+ return (
2416
+ True,
2417
+ f"Redeployed {usdc_balance:.2f} USDC to {final_leverage:.2f}x with {n_loops} loops",
2418
+ )
2419
+
2420
+ # Lever-up when HF is significantly above target (MIN_HEALTH_FACTOR).
2421
+ # Only skip if HF is close enough to target (within HF_LEVER_UP_BUFFER).
2422
+ lever_up_threshold = self.MIN_HEALTH_FACTOR + self.HF_LEVER_UP_BUFFER
2423
+ if hf <= lever_up_threshold:
2424
+ return (
2425
+ True,
2426
+ f"HF={hf:.3f} <= target+buffer({lever_up_threshold:.2f}); no action needed.",
2427
+ )
2428
+
2429
+ # Use 95% threshold to handle rounding/slippage from deposit
2430
+ min_lend_threshold = self.MIN_USDC_DEPOSIT * 0.95
2431
+ if (
2432
+ usdc_balance < self.MIN_USDC_DEPOSIT
2433
+ and usdc_lend_value < min_lend_threshold
2434
+ ):
2435
+ return (
2436
+ False,
2437
+ f"No USDC lent ({usdc_lend_value:.2f}) and not enough in wallet ({usdc_balance:.2f}). Deposit funds.",
2438
+ )
2439
+
2440
+ if usdc_balance < self.MIN_USDC_DEPOSIT:
2441
+ # Lever-up path - use pre-fetched data
2442
+ wsteth_price = await self._get_token_price(WSTETH_TOKEN_ID)
2443
+ weth_price = await self._get_token_price(WETH_TOKEN_ID)
2444
+
2445
+ weth_key = f"Base_{WETH}"
2446
+ current_borrowed_value = abs(totals_usd.get(weth_key, 0))
2447
+
2448
+ success, final_leverage, n_loops = await self._loop_wsteth(
2449
+ wsteth_price=wsteth_price,
2450
+ weth_price=weth_price,
2451
+ current_borrowed_value=current_borrowed_value,
2452
+ initial_leverage=initial_leverage,
2453
+ usdc_lend_value=usdc_lend_value,
2454
+ wsteth_lend_value=wsteth_lend_value,
2455
+ collateral_factors=collateral_factors,
2456
+ )
2457
+ if not success:
2458
+ return (
2459
+ False,
2460
+ f"Leverage was {initial_leverage:.2f}x; adjustment failed. "
2461
+ f"Final: {final_leverage} after {n_loops} loops",
2462
+ )
2463
+ return (
2464
+ True,
2465
+ f"Adjusted leverage from {initial_leverage:.2f}x to {final_leverage:.2f}x "
2466
+ f"via {n_loops} loops",
2467
+ )
2468
+
2469
+ # Full redeposit loop
2470
+ success, final_leverage, n_loops = await self._execute_deposit_loop(
2471
+ usdc_balance
2472
+ )
2473
+ if not success:
2474
+ return (
2475
+ False,
2476
+ f"Loop failed: {final_leverage} after {n_loops} successful loops",
2477
+ )
2478
+ return (
2479
+ True,
2480
+ f"Executed redeposit loop to {final_leverage:.2f}x with {n_loops} loops",
2481
+ )
2482
+
2483
+ async def _repay_debt_loop(
2484
+ self, target_repaid: int | None = None
2485
+ ) -> tuple[bool, str]:
2486
+ """Iteratively repay debt."""
2487
+ total_repaid = 0
2488
+
2489
+ if target_repaid is not None and target_repaid < 0:
2490
+ return (False, "Target repay was negative")
2491
+
2492
+ for _ in range(self._MAX_LOOP_LIMIT * 2):
2493
+ # Get current debt
2494
+ pos_result = await self.moonwell_adapter.get_pos(mtoken=M_WETH)
2495
+ if not pos_result[0]:
2496
+ break
2497
+
2498
+ current_debt = pos_result[1].get("borrow_balance", 0)
2499
+ if current_debt < 1:
2500
+ break
2501
+
2502
+ # Attempt repayment
2503
+ try:
2504
+ repaid = await self._safe_repay(current_debt)
2505
+ except SwapOutcomeUnknownError as exc:
2506
+ return (False, f"Swap outcome unknown during debt repayment: {exc}")
2507
+ if repaid == 0:
2508
+ break
2509
+
2510
+ total_repaid += repaid
2511
+
2512
+ if target_repaid is not None and total_repaid >= target_repaid:
2513
+ return (True, f"Repaid {total_repaid} > {target_repaid} target")
2514
+
2515
+ # Check remaining debt
2516
+ pos_result = await self.moonwell_adapter.get_pos(mtoken=M_WETH)
2517
+ if not pos_result[0]:
2518
+ return (False, "Failed to check remaining debt after repayment")
2519
+
2520
+ remaining_debt = pos_result[1].get("borrow_balance", 0)
2521
+
2522
+ if remaining_debt > 0:
2523
+ return (
2524
+ False,
2525
+ f"Could not repay all debt. Remaining: {remaining_debt / 10**18:.6f} WETH",
2526
+ )
2527
+
2528
+ return (True, "Debt repayment completed")
2529
+
2530
+ async def _emergency_eth_repayment(self, debt: int) -> tuple[bool, str]:
2531
+ """Emergency fallback to repay debt using available ETH."""
2532
+ gas_balance = await self._get_gas_balance()
2533
+ # Reserve for gas: base reserve + buffer for wrap + repay tx gas
2534
+ tx_gas_buffer = int(0.001 * 10**18) # ~0.001 ETH for wrap + repay txs
2535
+ gas_buffer = int(self.WRAP_GAS_RESERVE * 10**18) + tx_gas_buffer
2536
+
2537
+ logger.debug(
2538
+ f"Emergency repay check: gas_balance={gas_balance / 10**18:.6f}, "
2539
+ f"gas_buffer={gas_buffer / 10**18:.6f}, debt={debt / 10**18:.6f}"
2540
+ )
2541
+
2542
+ if gas_balance > gas_buffer:
2543
+ available_eth = gas_balance - gas_buffer
2544
+ repay_amt = min(available_eth, debt)
2545
+
2546
+ try:
2547
+ wrap_success, wrap_msg = await self.moonwell_adapter.wrap_eth(
2548
+ amount=repay_amt
2549
+ )
2550
+ if not wrap_success:
2551
+ logger.warning(f"Emergency wrap failed: {wrap_msg}")
2552
+ return (False, f"Wrap failed: {wrap_msg}")
2553
+
2554
+ # Only use repay_full=True when we have enough to cover full debt
2555
+ can_repay_full = repay_amt >= debt
2556
+ success, _ = await self.moonwell_adapter.repay(
2557
+ mtoken=M_WETH,
2558
+ underlying_token=WETH,
2559
+ amount=repay_amt,
2560
+ repay_full=can_repay_full,
2561
+ )
2562
+ if success:
2563
+ logger.info(f"Emergency repayment: {repay_amt / 10**18:.6f} WETH")
2564
+ return (True, f"Emergency repaid {repay_amt}")
2565
+ else:
2566
+ logger.warning("Emergency repayment transaction failed")
2567
+ return (False, "Repay transaction failed")
2568
+ except Exception as e:
2569
+ logger.warning(f"Emergency ETH repayment failed: {e}")
2570
+ return (False, str(e))
2571
+
2572
+ return (False, "Insufficient ETH for emergency repayment")
2573
+
2574
+ async def _repay_weth(self, amount: int, remaining_debt: int) -> int:
2575
+ """Repay WETH debt. Returns amount actually repaid."""
2576
+ if amount <= 0:
2577
+ return 0
2578
+ repay_amt = min(amount, remaining_debt)
2579
+ success, _ = await self.moonwell_adapter.repay(
2580
+ mtoken=M_WETH,
2581
+ underlying_token=WETH,
2582
+ amount=repay_amt,
2583
+ repay_full=(repay_amt >= remaining_debt),
2584
+ )
2585
+ return repay_amt if success else 0
2586
+
2587
+ async def _swap_to_weth_and_repay(
2588
+ self, token_id: str, amount: int, remaining_debt: int
2589
+ ) -> int:
2590
+ """Swap token to WETH and repay. Returns amount repaid."""
2591
+ swap_result = await self._swap_with_retries(
2592
+ from_token_id=token_id, to_token_id=WETH_TOKEN_ID, amount=amount
2593
+ )
2594
+ if not swap_result:
2595
+ return 0
2596
+
2597
+ # Use swap quote amount as minimum expected, retry balance read until we see it
2598
+ expected_weth = int(swap_result.get("to_amount") or 0)
2599
+ addr = self._get_strategy_wallet_address()
2600
+ weth_bal = 0
2601
+
2602
+ for attempt in range(5):
2603
+ weth_bal = await self._get_balance_raw(
2604
+ token_id=WETH_TOKEN_ID, wallet_address=addr
2605
+ )
2606
+ if weth_bal >= expected_weth * 0.95 or weth_bal > 0:
2607
+ break
2608
+ logger.debug(
2609
+ f"WETH balance read {weth_bal}, expected ~{expected_weth}, retrying..."
2610
+ )
2611
+ await asyncio.sleep(1 + attempt)
2612
+
2613
+ if weth_bal <= 0:
2614
+ logger.warning(
2615
+ f"WETH balance still 0 after swap, using estimate {expected_weth}"
2616
+ )
2617
+ weth_bal = expected_weth
2618
+
2619
+ return await self._repay_weth(weth_bal, remaining_debt)
2620
+
2621
+ async def _safe_repay(self, debt_to_repay: int) -> int:
2622
+ """Attempt repayment using all available assets. Returns total amount repaid."""
2623
+ if debt_to_repay < 1:
2624
+ return 0
2625
+
2626
+ repaid = 0
2627
+ addr = self._get_strategy_wallet_address()
2628
+
2629
+ # 1. Use wallet WETH directly
2630
+ weth_bal = await self._get_balance_raw(
2631
+ token_id=WETH_TOKEN_ID, wallet_address=addr
2632
+ )
2633
+ if weth_bal > 0:
2634
+ repaid += await self._repay_weth(weth_bal, debt_to_repay - repaid)
2635
+ if repaid >= debt_to_repay:
2636
+ return repaid
2637
+
2638
+ # 2. Wrap ETH (above gas reserve) and repay
2639
+ eth_bal = await self._get_balance_raw(
2640
+ token_id=ETH_TOKEN_ID, wallet_address=addr
2641
+ )
2642
+ gas_reserve = int((self.WRAP_GAS_RESERVE + 0.0005) * 10**18)
2643
+ usable_eth = max(0, eth_bal - gas_reserve)
2644
+ if usable_eth > 0:
2645
+ wrap_amt = min(usable_eth, debt_to_repay - repaid)
2646
+ wrap_ok, _ = await self.moonwell_adapter.wrap_eth(amount=wrap_amt)
2647
+ if wrap_ok:
2648
+ weth_bal = await self._get_balance_raw(
2649
+ token_id=WETH_TOKEN_ID, wallet_address=addr
2650
+ )
2651
+ repaid += await self._repay_weth(weth_bal, debt_to_repay - repaid)
2652
+ if repaid >= debt_to_repay:
2653
+ return repaid
2654
+
2655
+ # 3. Swap wallet assets (wstETH, USDC) to WETH and repay
2656
+ weth_price, weth_dec = await self._get_token_data(WETH_TOKEN_ID)
2657
+ if not weth_price or weth_price <= 0:
2658
+ return repaid
2659
+
2660
+ for token_id in [WSTETH_TOKEN_ID, USDC_TOKEN_ID]:
2661
+ remaining = debt_to_repay - repaid
2662
+ if remaining <= 0:
2663
+ return repaid
2664
+
2665
+ bal = await self._get_balance_raw(token_id=token_id, wallet_address=addr)
2666
+ if bal <= 0:
2667
+ continue
2668
+
2669
+ price, dec = await self._get_token_data(token_id)
2670
+ if not price or price <= 0:
2671
+ continue
2672
+
2673
+ bal_usd = (bal / 10**dec) * price
2674
+ if bal_usd < self.min_withdraw_usd:
2675
+ continue
2676
+
2677
+ # Swap only what's needed (with 2% slippage buffer)
2678
+ needed_usd = (remaining / 10**weth_dec) * weth_price * 1.02
2679
+ needed_raw = int(needed_usd / price * 10**dec) + 1
2680
+ swap_amt = min(bal, needed_raw)
2681
+
2682
+ logger.info(
2683
+ f"Swapping {swap_amt / 10**dec:.6f} {token_id} to WETH for repayment"
2684
+ )
2685
+ repaid += await self._swap_to_weth_and_repay(
2686
+ token_id, swap_amt, debt_to_repay - repaid
2687
+ )
2688
+
2689
+ # 4. Unlend collateral, swap to WETH, and repay
2690
+ for mtoken, token_id in [(M_WSTETH, WSTETH_TOKEN_ID), (M_USDC, USDC_TOKEN_ID)]:
2691
+ remaining = debt_to_repay - repaid
2692
+ if remaining <= 0:
2693
+ return repaid
2694
+
2695
+ withdraw_result = await self.moonwell_adapter.max_withdrawable_mtoken(
2696
+ mtoken=mtoken
2697
+ )
2698
+ if not withdraw_result[0]:
2699
+ continue
2700
+
2701
+ withdraw_info = withdraw_result[1]
2702
+ underlying_raw = withdraw_info.get("underlying_raw", 0)
2703
+ if underlying_raw < 1:
2704
+ continue
2705
+
2706
+ price, dec = await self._get_token_data(token_id)
2707
+ if not price or price <= 0:
2708
+ continue
2709
+
2710
+ avail_raw = int(underlying_raw * COLLATERAL_SAFETY_FACTOR)
2711
+ avail_usd = (avail_raw / 10**dec) * price
2712
+ if avail_usd <= self.min_withdraw_usd:
2713
+ continue
2714
+
2715
+ # Calculate needed amount with buffer
2716
+ remaining_usd = (remaining / 10**weth_dec) * weth_price
2717
+ target_usd = max(remaining_usd * 1.02, float(self.min_withdraw_usd))
2718
+ needed_raw = int(target_usd / price * 10**dec) + 1
2719
+ unlend_raw = min(avail_raw, needed_raw)
2720
+
2721
+ mtoken_amt = self._mtoken_amount_for_underlying(withdraw_info, unlend_raw)
2722
+ if mtoken_amt <= 0:
2723
+ continue
2724
+
2725
+ success, _ = await self.moonwell_adapter.unlend(
2726
+ mtoken=mtoken, amount=mtoken_amt
2727
+ )
2728
+ if not success:
2729
+ continue
2730
+
2731
+ # Swap what we unlended
2732
+ bal = await self._get_balance_raw(token_id=token_id, wallet_address=addr)
2733
+ swap_amt = min(bal, unlend_raw)
2734
+ if swap_amt <= 0:
2735
+ continue
2736
+
2737
+ logger.info(f"Swapping {swap_amt / 10**dec:.6f} unlent {token_id} to WETH")
2738
+ amt_repaid = await self._swap_to_weth_and_repay(
2739
+ token_id, swap_amt, remaining
2740
+ )
2741
+ if amt_repaid > 0:
2742
+ repaid += amt_repaid
2743
+ else:
2744
+ # Swap failed - re-lend to restore position
2745
+ logger.warning(f"Swap failed for {token_id}, re-lending")
2746
+ underlying = WSTETH if mtoken == M_WSTETH else USDC
2747
+ relend_bal = await self._get_balance_raw(
2748
+ token_id=token_id, wallet_address=addr
2749
+ )
2750
+ if relend_bal > 0:
2751
+ await self.moonwell_adapter.lend(
2752
+ mtoken=mtoken, underlying_token=underlying, amount=relend_bal
2753
+ )
2754
+
2755
+ # Emergency fallback: use available ETH when nothing else worked
2756
+ if repaid == 0 and debt_to_repay > 0:
2757
+ success, _ = await self._emergency_eth_repayment(debt_to_repay)
2758
+ if success:
2759
+ pos_result = await self.moonwell_adapter.get_pos(mtoken=M_WETH)
2760
+ if pos_result[0]:
2761
+ new_debt = pos_result[1].get("borrow_balance", 0)
2762
+ repaid = debt_to_repay - new_debt
2763
+ logger.info(
2764
+ f"Emergency repayment succeeded: {repaid / 10**18:.6f} WETH"
2765
+ )
2766
+
2767
+ return repaid
2768
+
2769
+ async def withdraw(self, amount: float | None = None) -> StatusTuple:
2770
+ """Withdraw funds. If amount is None, withdraws all.
2771
+
2772
+ Logic:
2773
+ 1. Liquidate any Moonwell positions to USDC (if any exist)
2774
+ 2. Transfer any USDC > 0 to main wallet (regardless of step 1)
2775
+ """
2776
+ self._clear_price_cache()
2777
+
2778
+ # Step 1: Liquidate Moonwell positions if any exist
2779
+ totals_token, totals_usd = await self._aggregate_positions()
2780
+ has_positions = len(totals_token) > 0
2781
+
2782
+ if has_positions:
2783
+ # Sweep misc tokens to WETH first (helps with repayment)
2784
+ await self._sweep_token_balances(
2785
+ target_token_id=WETH_TOKEN_ID,
2786
+ exclude={USDC_TOKEN_ID, WSTETH_TOKEN_ID},
2787
+ )
2788
+
2789
+ # Execute debt repayment loop
2790
+ success, message = await self._repay_debt_loop()
2791
+ if not success:
2792
+ return (False, message)
2793
+
2794
+ # Unlend and convert remaining positions to USDC
2795
+ await self._unlend_remaining_positions()
2796
+
2797
+ # Always sweep any remaining tokens to USDC (catches WETH, wstETH, WELL even without positions)
2798
+ await self._sweep_token_balances(
2799
+ target_token_id=USDC_TOKEN_ID,
2800
+ exclude={ETH_TOKEN_ID}, # Keep gas token
2801
+ )
2802
+
2803
+ # Step 2: Transfer any USDC to main wallet
2804
+ usdc_balance = await self._get_balance_raw(
2805
+ token_id=USDC_TOKEN_ID, wallet_address=self._get_strategy_wallet_address()
2806
+ )
2807
+
2808
+ if usdc_balance <= 0:
2809
+ return (False, "No USDC to withdraw.")
2810
+
2811
+ token_info = await self._get_token_info(USDC_TOKEN_ID)
2812
+ decimals = token_info.get("decimals", 6)
2813
+ usdc_amount = usdc_balance / 10**decimals
2814
+
2815
+ (
2816
+ success,
2817
+ msg,
2818
+ ) = await self.balance_adapter.move_from_strategy_wallet_to_main_wallet(
2819
+ USDC_TOKEN_ID, usdc_amount
2820
+ )
2821
+ if not success:
2822
+ return (False, f"USDC transfer failed: {msg}")
2823
+
2824
+ # Step 3: Transfer remaining gas to main wallet (keep reserve for tx fee)
2825
+ gas_transferred = 0.0
2826
+ gas_balance = await self._get_balance_raw(
2827
+ token_id=ETH_TOKEN_ID, wallet_address=self._get_strategy_wallet_address()
2828
+ )
2829
+ tx_fee_reserve = int(0.0002 * 10**18) # Reserve 0.0002 ETH for tx fee
2830
+ transferable_gas = gas_balance - tx_fee_reserve
2831
+ if transferable_gas > 0:
2832
+ gas_amount = transferable_gas / 10**18
2833
+ (
2834
+ gas_success,
2835
+ gas_msg,
2836
+ ) = await self.balance_adapter.move_from_strategy_wallet_to_main_wallet(
2837
+ ETH_TOKEN_ID, gas_amount
2838
+ )
2839
+ if gas_success:
2840
+ gas_transferred = gas_amount
2841
+ else:
2842
+ logger.warning(f"Gas transfer failed (non-critical): {gas_msg}")
2843
+
2844
+ return (
2845
+ True,
2846
+ f"Withdrew {usdc_amount:.2f} USDC and {gas_transferred:.6f} ETH to main wallet",
2847
+ )
2848
+
2849
+ async def _unlend_remaining_positions(self) -> None:
2850
+ """Unlend remaining collateral and convert to USDC."""
2851
+ # Unlend remaining wstETH
2852
+ wsteth_pos = await self.moonwell_adapter.get_pos(mtoken=M_WSTETH)
2853
+ if wsteth_pos[0]:
2854
+ mtoken_bal = wsteth_pos[1].get("mtoken_balance", 0)
2855
+ if mtoken_bal > 0:
2856
+ await self.moonwell_adapter.unlend(mtoken=M_WSTETH, amount=mtoken_bal)
2857
+ # Swap to USDC with retries
2858
+ wsteth_bal = await self._get_balance_raw(
2859
+ token_id=WSTETH_TOKEN_ID,
2860
+ wallet_address=self._get_strategy_wallet_address(),
2861
+ )
2862
+ if wsteth_bal > 0:
2863
+ swap_result = await self._swap_with_retries(
2864
+ from_token_id=WSTETH_TOKEN_ID,
2865
+ to_token_id=USDC_TOKEN_ID,
2866
+ amount=wsteth_bal,
2867
+ )
2868
+ if swap_result is None:
2869
+ logger.warning("Failed to swap wstETH to USDC after retries")
2870
+
2871
+ # Unlend remaining USDC
2872
+ usdc_pos = await self.moonwell_adapter.get_pos(mtoken=M_USDC)
2873
+ if usdc_pos[0]:
2874
+ mtoken_bal = usdc_pos[1].get("mtoken_balance", 0)
2875
+ if mtoken_bal > 0:
2876
+ await self.moonwell_adapter.unlend(mtoken=M_USDC, amount=mtoken_bal)
2877
+
2878
+ # Claim any remaining rewards
2879
+ await self.moonwell_adapter.claim_rewards(min_rewards_usd=0)
2880
+
2881
+ # Sweep any remaining tokens to USDC
2882
+ await self._sweep_token_balances(
2883
+ target_token_id=USDC_TOKEN_ID,
2884
+ exclude={ETH_TOKEN_ID}, # Keep gas token
2885
+ )
2886
+
2887
+ async def get_peg_diff(self) -> float | dict:
2888
+ """Get stETH/ETH peg difference."""
2889
+ steth_price = await self._get_token_price(STETH_TOKEN_ID)
2890
+ weth_price = await self._get_token_price(WETH_TOKEN_ID)
2891
+
2892
+ if not steth_price or not weth_price or weth_price <= 0:
2893
+ return {
2894
+ "ok": False,
2895
+ "error": f"Bad price data stETH={steth_price}, WETH={weth_price}",
2896
+ }
2897
+
2898
+ peg_ratio = steth_price / weth_price
2899
+ peg_diff = abs(peg_ratio - 1)
2900
+
2901
+ return peg_diff
2902
+
2903
+ async def _status(self) -> StatusDict:
2904
+ """Report strategy status."""
2905
+ self._clear_price_cache()
2906
+
2907
+ # Fetch positions and collateral factors in parallel
2908
+ (_totals_token, totals_usd), collateral_factors = await asyncio.gather(
2909
+ self._aggregate_positions(),
2910
+ self._get_collateral_factors(),
2911
+ )
2912
+
2913
+ # Calculate LTV and health factor using pre-fetched data
2914
+ ltv = await self.compute_ltv(totals_usd, collateral_factors=collateral_factors)
2915
+ hf = (1 / ltv) if ltv and ltv > 0 and not (ltv != ltv) else None
2916
+
2917
+ # Get gas balance
2918
+ gas_balance = await self._get_gas_balance()
2919
+
2920
+ # Get borrowable amount
2921
+ borrowable_result = await self.moonwell_adapter.get_borrowable_amount()
2922
+ borrowable_amt_raw = borrowable_result[1] if borrowable_result[0] else 0
2923
+ borrowable_amt = self._normalize_usd_value(borrowable_amt_raw)
2924
+
2925
+ # Calculate credit remaining
2926
+ weth_key = f"Base_{WETH}"
2927
+ total_borrowed = abs(totals_usd.get(weth_key, 0))
2928
+ credit_remaining = 1.0
2929
+ if (borrowable_amt + total_borrowed) > 0:
2930
+ credit_remaining = round(
2931
+ borrowable_amt / (borrowable_amt + total_borrowed), 4
2932
+ )
2933
+
2934
+ # Get peg diff
2935
+ peg_diff = await self.get_peg_diff()
2936
+
2937
+ # Calculate portfolio value
2938
+ portfolio_value = sum(
2939
+ v for k, v in totals_usd.items() if k != f"Base_{WETH}"
2940
+ ) + totals_usd.get(f"Base_{WETH}", 0)
2941
+
2942
+ # Get projected earnings
2943
+ quote = await self.quote()
2944
+
2945
+ strategy_status = {
2946
+ "current_positions_usd_value": totals_usd,
2947
+ "credit_remaining": f"{credit_remaining * 100:.2f}%",
2948
+ "LTV": ltv,
2949
+ "health_factor": hf,
2950
+ "projected_earnings": quote.get("data", {}),
2951
+ "steth_eth_peg_difference": peg_diff,
2952
+ }
2953
+
2954
+ return StatusDict(
2955
+ portfolio_value=portfolio_value,
2956
+ net_deposit=0.0, # Would need ledger integration
2957
+ strategy_status=strategy_status,
2958
+ gas_available=gas_balance / 10**18,
2959
+ gassed_up=gas_balance >= int(self.MAINTENANCE_GAS * 10**18),
2960
+ )
2961
+
2962
+ @staticmethod
2963
+ async def policies() -> list[str]:
2964
+ """Return policy strings used to scope on-chain permissions."""
2965
+ return [
2966
+ # Moonwell operations
2967
+ await musdc_mint_or_approve_or_redeem(),
2968
+ await mweth_approve_or_borrow_or_repay(),
2969
+ await mwsteth_approve_or_mint_or_redeem(),
2970
+ await moonwell_comptroller_enter_markets_or_claim_rewards(),
2971
+ await weth_deposit(),
2972
+ # Swaps
2973
+ erc20_spender_for_any_token(ENSO_ROUTER),
2974
+ await enso_swap(),
2975
+ ]