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