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