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