wayfinder-paths 0.1.9__py3-none-any.whl → 0.1.10__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of wayfinder-paths might be problematic. Click here for more details.
- wayfinder_paths/CONFIG_GUIDE.md +1 -1
- 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/base.py +55 -0
- wayfinder_paths/core/services/local_evm_txn.py +288 -208
- wayfinder_paths/core/services/local_token_txn.py +46 -26
- wayfinder_paths/core/strategies/descriptors.py +1 -1
- 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.10.dist-info}/METADATA +60 -187
- {wayfinder_paths-0.1.9.dist-info → wayfinder_paths-0.1.10.dist-info}/RECORD +39 -44
- 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/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.10.dist-info}/LICENSE +0 -0
- {wayfinder_paths-0.1.9.dist-info → wayfinder_paths-0.1.10.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,1226 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Moonwell Adapter for lending, borrowing, and collateral management on Moonwell protocol.
|
|
3
|
+
|
|
4
|
+
This adapter provides functionality for interacting with Moonwell on Base chain,
|
|
5
|
+
including supplying/withdrawing collateral, borrowing/repaying, and claiming rewards.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, Literal
|
|
13
|
+
|
|
14
|
+
from eth_utils import to_checksum_address
|
|
15
|
+
|
|
16
|
+
from wayfinder_paths.core.adapters.BaseAdapter import BaseAdapter
|
|
17
|
+
from wayfinder_paths.core.clients.TokenClient import TokenClient
|
|
18
|
+
from wayfinder_paths.core.constants.base import DEFAULT_TRANSACTION_TIMEOUT
|
|
19
|
+
from wayfinder_paths.core.constants.moonwell_abi import (
|
|
20
|
+
COMPTROLLER_ABI,
|
|
21
|
+
MTOKEN_ABI,
|
|
22
|
+
REWARD_DISTRIBUTOR_ABI,
|
|
23
|
+
WETH_ABI,
|
|
24
|
+
)
|
|
25
|
+
from wayfinder_paths.core.services.base import Web3Service
|
|
26
|
+
from wayfinder_paths.core.settings import settings
|
|
27
|
+
|
|
28
|
+
# Moonwell Base chain addresses
|
|
29
|
+
MOONWELL_DEFAULTS = {
|
|
30
|
+
# mToken addresses
|
|
31
|
+
"m_usdc": "0xEdc817A28E8B93B03976FBd4a3dDBc9f7D176c22",
|
|
32
|
+
"m_weth": "0x628ff693426583D9a7FB391E54366292F509D457",
|
|
33
|
+
"m_wsteth": "0x627Fe393Bc6EdDA28e99AE648fD6fF362514304b",
|
|
34
|
+
# Underlying token addresses
|
|
35
|
+
"usdc": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
36
|
+
"weth": "0x4200000000000000000000000000000000000006",
|
|
37
|
+
"wsteth": "0xc1CBa3fCea344f92D9239c08C0568f6F2F0ee452",
|
|
38
|
+
# Protocol addresses
|
|
39
|
+
"reward_distributor": "0xe9005b078701e2a0948d2eac43010d35870ad9d2",
|
|
40
|
+
"comptroller": "0xfbb21d0380bee3312b33c4353c8936a0f13ef26c",
|
|
41
|
+
# WELL token address on Base
|
|
42
|
+
"well_token": "0xA88594D404727625A9437C3f886C7643872296AE",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# Base chain ID
|
|
46
|
+
BASE_CHAIN_ID = 8453
|
|
47
|
+
|
|
48
|
+
# Mantissa for collateral factor calculations (1e18)
|
|
49
|
+
MANTISSA = 10**18
|
|
50
|
+
|
|
51
|
+
# Seconds per year for APY calculations
|
|
52
|
+
SECONDS_PER_YEAR = 365 * 24 * 60 * 60
|
|
53
|
+
|
|
54
|
+
# Collateral factor cache TTL (1 hour - rarely changes, governance controlled)
|
|
55
|
+
CF_CACHE_TTL = 3600
|
|
56
|
+
|
|
57
|
+
# Default retry settings for rate-limited RPCs
|
|
58
|
+
DEFAULT_MAX_RETRIES = 5
|
|
59
|
+
DEFAULT_BASE_DELAY = 3.0 # seconds
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _is_rate_limit_error(error: Exception | str) -> bool:
|
|
63
|
+
"""Check if an error is a rate limit (429) error."""
|
|
64
|
+
error_str = str(error)
|
|
65
|
+
return "429" in error_str or "Too Many Requests" in error_str
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def _retry_with_backoff(
|
|
69
|
+
coro_factory,
|
|
70
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
71
|
+
base_delay: float = DEFAULT_BASE_DELAY,
|
|
72
|
+
):
|
|
73
|
+
"""Retry an async operation with exponential backoff on rate limit errors.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
coro_factory: A callable that returns a new coroutine each time.
|
|
77
|
+
max_retries: Maximum number of retry attempts.
|
|
78
|
+
base_delay: Base delay in seconds (doubles each retry).
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
The result of the coroutine if successful.
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
The last exception if all retries fail.
|
|
85
|
+
"""
|
|
86
|
+
last_error = None
|
|
87
|
+
for attempt in range(max_retries):
|
|
88
|
+
try:
|
|
89
|
+
return await coro_factory()
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
last_error = exc
|
|
92
|
+
if _is_rate_limit_error(exc) and attempt < max_retries - 1:
|
|
93
|
+
wait_time = base_delay * (2**attempt)
|
|
94
|
+
await asyncio.sleep(wait_time)
|
|
95
|
+
continue
|
|
96
|
+
raise
|
|
97
|
+
raise last_error
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _timestamp_rate_to_apy(rate: float) -> float:
|
|
101
|
+
"""Convert a per-second rate to APY."""
|
|
102
|
+
return (1 + rate) ** SECONDS_PER_YEAR - 1
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class MoonwellAdapter(BaseAdapter):
|
|
106
|
+
"""Moonwell adapter for lending/borrowing operations on Base chain."""
|
|
107
|
+
|
|
108
|
+
adapter_type = "MOONWELL"
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
config: dict[str, Any] | None = None,
|
|
113
|
+
web3_service: Web3Service | None = None,
|
|
114
|
+
token_client: TokenClient | None = None,
|
|
115
|
+
simulation: bool = False,
|
|
116
|
+
) -> None:
|
|
117
|
+
super().__init__("moonwell_adapter", config)
|
|
118
|
+
cfg = config or {}
|
|
119
|
+
adapter_cfg = cfg.get("moonwell_adapter") or {}
|
|
120
|
+
|
|
121
|
+
self.web3 = web3_service
|
|
122
|
+
self.simulation = simulation
|
|
123
|
+
self.token_client = token_client
|
|
124
|
+
self.token_txn_service = (
|
|
125
|
+
web3_service.token_transactions if web3_service else None
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
self.strategy_wallet = cfg.get("strategy_wallet") or {}
|
|
129
|
+
self.chain_id = adapter_cfg.get("chain_id", BASE_CHAIN_ID)
|
|
130
|
+
self.chain_name = "base"
|
|
131
|
+
|
|
132
|
+
# Protocol addresses (with config overrides)
|
|
133
|
+
self.comptroller_address = self._checksum(
|
|
134
|
+
adapter_cfg.get("comptroller") or MOONWELL_DEFAULTS["comptroller"]
|
|
135
|
+
)
|
|
136
|
+
self.reward_distributor_address = self._checksum(
|
|
137
|
+
adapter_cfg.get("reward_distributor")
|
|
138
|
+
or MOONWELL_DEFAULTS["reward_distributor"]
|
|
139
|
+
)
|
|
140
|
+
self.well_token = self._checksum(
|
|
141
|
+
adapter_cfg.get("well_token") or MOONWELL_DEFAULTS["well_token"]
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Token addresses
|
|
145
|
+
self.m_usdc = self._checksum(
|
|
146
|
+
adapter_cfg.get("m_usdc") or MOONWELL_DEFAULTS["m_usdc"]
|
|
147
|
+
)
|
|
148
|
+
self.m_weth = self._checksum(
|
|
149
|
+
adapter_cfg.get("m_weth") or MOONWELL_DEFAULTS["m_weth"]
|
|
150
|
+
)
|
|
151
|
+
self.m_wsteth = self._checksum(
|
|
152
|
+
adapter_cfg.get("m_wsteth") or MOONWELL_DEFAULTS["m_wsteth"]
|
|
153
|
+
)
|
|
154
|
+
self.usdc = self._checksum(adapter_cfg.get("usdc") or MOONWELL_DEFAULTS["usdc"])
|
|
155
|
+
self.weth = self._checksum(adapter_cfg.get("weth") or MOONWELL_DEFAULTS["weth"])
|
|
156
|
+
self.wsteth = self._checksum(
|
|
157
|
+
adapter_cfg.get("wsteth") or MOONWELL_DEFAULTS["wsteth"]
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Collateral factor cache: mtoken -> (value, timestamp)
|
|
161
|
+
self._cf_cache: dict[str, tuple[float, float]] = {}
|
|
162
|
+
|
|
163
|
+
# ------------------------------------------------------------------ #
|
|
164
|
+
# Public API - Lending Operations #
|
|
165
|
+
# ------------------------------------------------------------------ #
|
|
166
|
+
|
|
167
|
+
async def lend(
|
|
168
|
+
self,
|
|
169
|
+
*,
|
|
170
|
+
mtoken: str,
|
|
171
|
+
underlying_token: str,
|
|
172
|
+
amount: int,
|
|
173
|
+
) -> tuple[bool, Any]:
|
|
174
|
+
"""Supply tokens to Moonwell by minting mTokens."""
|
|
175
|
+
strategy = self._strategy_address()
|
|
176
|
+
amount = int(amount)
|
|
177
|
+
if amount <= 0:
|
|
178
|
+
return False, "amount must be positive"
|
|
179
|
+
|
|
180
|
+
mtoken = self._checksum(mtoken)
|
|
181
|
+
underlying_token = self._checksum(underlying_token)
|
|
182
|
+
|
|
183
|
+
# Approve mToken to spend underlying tokens
|
|
184
|
+
approved = await self._ensure_allowance(
|
|
185
|
+
token_address=underlying_token,
|
|
186
|
+
owner=strategy,
|
|
187
|
+
spender=mtoken,
|
|
188
|
+
amount=amount,
|
|
189
|
+
)
|
|
190
|
+
if not approved[0]:
|
|
191
|
+
return approved
|
|
192
|
+
|
|
193
|
+
# Mint mTokens (supply underlying)
|
|
194
|
+
tx = await self._encode_call(
|
|
195
|
+
target=mtoken,
|
|
196
|
+
abi=MTOKEN_ABI,
|
|
197
|
+
fn_name="mint",
|
|
198
|
+
args=[amount],
|
|
199
|
+
from_address=strategy,
|
|
200
|
+
)
|
|
201
|
+
return await self._execute(tx)
|
|
202
|
+
|
|
203
|
+
async def unlend(
|
|
204
|
+
self,
|
|
205
|
+
*,
|
|
206
|
+
mtoken: str,
|
|
207
|
+
amount: int,
|
|
208
|
+
) -> tuple[bool, Any]:
|
|
209
|
+
"""Withdraw tokens from Moonwell by redeeming mTokens."""
|
|
210
|
+
strategy = self._strategy_address()
|
|
211
|
+
amount = int(amount)
|
|
212
|
+
if amount <= 0:
|
|
213
|
+
return False, "amount must be positive"
|
|
214
|
+
|
|
215
|
+
mtoken = self._checksum(mtoken)
|
|
216
|
+
|
|
217
|
+
# Redeem mTokens for underlying
|
|
218
|
+
tx = await self._encode_call(
|
|
219
|
+
target=mtoken,
|
|
220
|
+
abi=MTOKEN_ABI,
|
|
221
|
+
fn_name="redeem",
|
|
222
|
+
args=[amount],
|
|
223
|
+
from_address=strategy,
|
|
224
|
+
)
|
|
225
|
+
return await self._execute(tx)
|
|
226
|
+
|
|
227
|
+
# ------------------------------------------------------------------ #
|
|
228
|
+
# Public API - Borrowing Operations #
|
|
229
|
+
# ------------------------------------------------------------------ #
|
|
230
|
+
|
|
231
|
+
async def borrow(
|
|
232
|
+
self,
|
|
233
|
+
*,
|
|
234
|
+
mtoken: str,
|
|
235
|
+
amount: int,
|
|
236
|
+
) -> tuple[bool, Any]:
|
|
237
|
+
"""Borrow tokens from Moonwell.
|
|
238
|
+
|
|
239
|
+
Note: Moonwell/Compound borrow() returns an error code, not a boolean.
|
|
240
|
+
Even if the transaction succeeds (status=1), the borrow may have failed
|
|
241
|
+
if the return value is non-zero. We verify success by checking that
|
|
242
|
+
the borrow balance actually increased.
|
|
243
|
+
"""
|
|
244
|
+
from loguru import logger
|
|
245
|
+
|
|
246
|
+
strategy = self._strategy_address()
|
|
247
|
+
amount = int(amount)
|
|
248
|
+
if amount <= 0:
|
|
249
|
+
return False, "amount must be positive"
|
|
250
|
+
|
|
251
|
+
mtoken = self._checksum(mtoken)
|
|
252
|
+
|
|
253
|
+
# Get borrow balance before the transaction for verification
|
|
254
|
+
borrow_before = 0
|
|
255
|
+
if self.web3:
|
|
256
|
+
try:
|
|
257
|
+
web3 = self.web3.get_web3(self.chain_id)
|
|
258
|
+
mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
|
|
259
|
+
|
|
260
|
+
borrow_before = await mtoken_contract.functions.borrowBalanceStored(
|
|
261
|
+
strategy
|
|
262
|
+
).call()
|
|
263
|
+
|
|
264
|
+
# Simulate borrow to check for errors before submitting
|
|
265
|
+
try:
|
|
266
|
+
borrow_return = await mtoken_contract.functions.borrow(amount).call(
|
|
267
|
+
{"from": strategy}
|
|
268
|
+
)
|
|
269
|
+
if borrow_return != 0:
|
|
270
|
+
logger.warning(
|
|
271
|
+
f"Borrow simulation returned error code {borrow_return}. "
|
|
272
|
+
"Codes: 3=COMPTROLLER_REJECTION, 9=INVALID_ACCOUNT_PAIR, "
|
|
273
|
+
"14=INSUFFICIENT_LIQUIDITY"
|
|
274
|
+
)
|
|
275
|
+
except Exception as call_err:
|
|
276
|
+
logger.debug(f"Borrow simulation failed: {call_err}")
|
|
277
|
+
|
|
278
|
+
except Exception as e:
|
|
279
|
+
logger.warning(f"Failed to get pre-borrow balance: {e}")
|
|
280
|
+
|
|
281
|
+
tx = await self._encode_call(
|
|
282
|
+
target=mtoken,
|
|
283
|
+
abi=MTOKEN_ABI,
|
|
284
|
+
fn_name="borrow",
|
|
285
|
+
args=[amount],
|
|
286
|
+
from_address=strategy,
|
|
287
|
+
)
|
|
288
|
+
result = await self._execute(tx)
|
|
289
|
+
|
|
290
|
+
if not result[0]:
|
|
291
|
+
return result
|
|
292
|
+
|
|
293
|
+
# Verify the borrow actually succeeded by checking balance increased
|
|
294
|
+
if self.web3:
|
|
295
|
+
try:
|
|
296
|
+
web3 = self.web3.get_web3(self.chain_id)
|
|
297
|
+
mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
|
|
298
|
+
borrow_after = await mtoken_contract.functions.borrowBalanceStored(
|
|
299
|
+
strategy
|
|
300
|
+
).call()
|
|
301
|
+
|
|
302
|
+
# Borrow balance should have increased by approximately the amount
|
|
303
|
+
# Allow for some interest accrual
|
|
304
|
+
expected_increase = amount * 0.99 # Allow 1% tolerance for interest
|
|
305
|
+
actual_increase = borrow_after - borrow_before
|
|
306
|
+
|
|
307
|
+
if actual_increase < expected_increase:
|
|
308
|
+
from loguru import logger
|
|
309
|
+
|
|
310
|
+
logger.error(
|
|
311
|
+
f"Borrow verification failed: balance only increased by "
|
|
312
|
+
f"{actual_increase} (expected ~{amount}). "
|
|
313
|
+
f"Moonwell likely returned an error code. "
|
|
314
|
+
f"Before: {borrow_before}, After: {borrow_after}"
|
|
315
|
+
)
|
|
316
|
+
return (
|
|
317
|
+
False,
|
|
318
|
+
f"Borrow failed: balance did not increase as expected. "
|
|
319
|
+
f"Before: {borrow_before}, After: {borrow_after}, Expected: +{amount}",
|
|
320
|
+
)
|
|
321
|
+
except Exception as e:
|
|
322
|
+
from loguru import logger
|
|
323
|
+
|
|
324
|
+
logger.warning(f"Could not verify borrow balance: {e}")
|
|
325
|
+
# Continue with the original result if verification fails
|
|
326
|
+
|
|
327
|
+
return result
|
|
328
|
+
|
|
329
|
+
async def repay(
|
|
330
|
+
self,
|
|
331
|
+
*,
|
|
332
|
+
mtoken: str,
|
|
333
|
+
underlying_token: str,
|
|
334
|
+
amount: int,
|
|
335
|
+
repay_full: bool = False,
|
|
336
|
+
) -> tuple[bool, Any]:
|
|
337
|
+
"""Repay borrowed tokens to Moonwell.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
mtoken: The mToken address
|
|
341
|
+
underlying_token: The underlying token address (e.g., WETH)
|
|
342
|
+
amount: Amount to repay (used for approval if repay_full=True)
|
|
343
|
+
repay_full: If True, uses type(uint256).max to repay exact debt
|
|
344
|
+
"""
|
|
345
|
+
strategy = self._strategy_address()
|
|
346
|
+
amount = int(amount)
|
|
347
|
+
if amount <= 0:
|
|
348
|
+
return False, "amount must be positive"
|
|
349
|
+
|
|
350
|
+
mtoken = self._checksum(mtoken)
|
|
351
|
+
underlying_token = self._checksum(underlying_token)
|
|
352
|
+
|
|
353
|
+
# Approve mToken to spend underlying tokens for repayment
|
|
354
|
+
# When repay_full=True, approve the amount we have, Moonwell will use only what's needed
|
|
355
|
+
approved = await self._ensure_allowance(
|
|
356
|
+
token_address=underlying_token,
|
|
357
|
+
owner=strategy,
|
|
358
|
+
spender=mtoken,
|
|
359
|
+
amount=amount,
|
|
360
|
+
)
|
|
361
|
+
if not approved[0]:
|
|
362
|
+
return approved
|
|
363
|
+
|
|
364
|
+
# Use max uint256 for full repayment to avoid balance calculation issues
|
|
365
|
+
repay_amount = self.MAX_UINT256 if repay_full else amount
|
|
366
|
+
|
|
367
|
+
tx = await self._encode_call(
|
|
368
|
+
target=mtoken,
|
|
369
|
+
abi=MTOKEN_ABI,
|
|
370
|
+
fn_name="repayBorrow",
|
|
371
|
+
args=[repay_amount],
|
|
372
|
+
from_address=strategy,
|
|
373
|
+
)
|
|
374
|
+
return await self._execute(tx)
|
|
375
|
+
|
|
376
|
+
# ------------------------------------------------------------------ #
|
|
377
|
+
# Public API - Collateral Management #
|
|
378
|
+
# ------------------------------------------------------------------ #
|
|
379
|
+
|
|
380
|
+
async def set_collateral(
|
|
381
|
+
self,
|
|
382
|
+
*,
|
|
383
|
+
mtoken: str,
|
|
384
|
+
) -> tuple[bool, Any]:
|
|
385
|
+
"""Enable a market as collateral (enter market).
|
|
386
|
+
|
|
387
|
+
Note: enterMarkets returns an array of error codes. We verify success
|
|
388
|
+
by checking if the account has actually entered the market.
|
|
389
|
+
"""
|
|
390
|
+
strategy = self._strategy_address()
|
|
391
|
+
mtoken = self._checksum(mtoken)
|
|
392
|
+
|
|
393
|
+
tx = await self._encode_call(
|
|
394
|
+
target=self.comptroller_address,
|
|
395
|
+
abi=COMPTROLLER_ABI,
|
|
396
|
+
fn_name="enterMarkets",
|
|
397
|
+
args=[[mtoken]],
|
|
398
|
+
from_address=strategy,
|
|
399
|
+
)
|
|
400
|
+
result = await self._execute(tx)
|
|
401
|
+
|
|
402
|
+
if not result[0]:
|
|
403
|
+
return result
|
|
404
|
+
|
|
405
|
+
# Verify the market was actually entered
|
|
406
|
+
if self.web3:
|
|
407
|
+
try:
|
|
408
|
+
web3 = self.web3.get_web3(self.chain_id)
|
|
409
|
+
comptroller = web3.eth.contract(
|
|
410
|
+
address=self.comptroller_address, abi=COMPTROLLER_ABI
|
|
411
|
+
)
|
|
412
|
+
is_member = await comptroller.functions.checkMembership(
|
|
413
|
+
strategy, mtoken
|
|
414
|
+
).call()
|
|
415
|
+
|
|
416
|
+
if not is_member:
|
|
417
|
+
from loguru import logger
|
|
418
|
+
|
|
419
|
+
logger.error(
|
|
420
|
+
f"set_collateral verification failed: account {strategy} "
|
|
421
|
+
f"is not a member of market {mtoken} after enterMarkets call"
|
|
422
|
+
)
|
|
423
|
+
return (
|
|
424
|
+
False,
|
|
425
|
+
f"enterMarkets succeeded but account is not a member of market {mtoken}",
|
|
426
|
+
)
|
|
427
|
+
except Exception as e:
|
|
428
|
+
from loguru import logger
|
|
429
|
+
|
|
430
|
+
logger.warning(f"Could not verify market membership: {e}")
|
|
431
|
+
|
|
432
|
+
return result
|
|
433
|
+
|
|
434
|
+
async def remove_collateral(
|
|
435
|
+
self,
|
|
436
|
+
*,
|
|
437
|
+
mtoken: str,
|
|
438
|
+
) -> tuple[bool, Any]:
|
|
439
|
+
"""Disable a market as collateral (exit market)."""
|
|
440
|
+
strategy = self._strategy_address()
|
|
441
|
+
mtoken = self._checksum(mtoken)
|
|
442
|
+
|
|
443
|
+
tx = await self._encode_call(
|
|
444
|
+
target=self.comptroller_address,
|
|
445
|
+
abi=COMPTROLLER_ABI,
|
|
446
|
+
fn_name="exitMarket",
|
|
447
|
+
args=[mtoken],
|
|
448
|
+
from_address=strategy,
|
|
449
|
+
)
|
|
450
|
+
return await self._execute(tx)
|
|
451
|
+
|
|
452
|
+
# ------------------------------------------------------------------ #
|
|
453
|
+
# Public API - Rewards #
|
|
454
|
+
# ------------------------------------------------------------------ #
|
|
455
|
+
|
|
456
|
+
async def claim_rewards(
|
|
457
|
+
self,
|
|
458
|
+
*,
|
|
459
|
+
min_rewards_usd: float = 0.0,
|
|
460
|
+
) -> tuple[bool, dict[str, int] | str]:
|
|
461
|
+
"""Claim WELL rewards from Moonwell. Skips if below min_rewards_usd threshold."""
|
|
462
|
+
strategy = self._strategy_address()
|
|
463
|
+
|
|
464
|
+
# Get outstanding rewards first
|
|
465
|
+
rewards = await self._get_outstanding_rewards(strategy)
|
|
466
|
+
|
|
467
|
+
# Skip if no rewards to claim
|
|
468
|
+
if not rewards:
|
|
469
|
+
return True, {}
|
|
470
|
+
|
|
471
|
+
# Check minimum threshold if token_client available
|
|
472
|
+
if min_rewards_usd > 0 and self.token_client:
|
|
473
|
+
total_usd = await self._calculate_rewards_usd(rewards)
|
|
474
|
+
if total_usd < min_rewards_usd:
|
|
475
|
+
return True, {} # Skip claiming, below threshold
|
|
476
|
+
|
|
477
|
+
# Claim via comptroller (like reference implementation)
|
|
478
|
+
tx = await self._encode_call(
|
|
479
|
+
target=self.comptroller_address,
|
|
480
|
+
abi=COMPTROLLER_ABI,
|
|
481
|
+
fn_name="claimReward",
|
|
482
|
+
args=[strategy],
|
|
483
|
+
from_address=strategy,
|
|
484
|
+
)
|
|
485
|
+
result = await self._execute(tx)
|
|
486
|
+
if not result[0]:
|
|
487
|
+
return result
|
|
488
|
+
|
|
489
|
+
return True, rewards
|
|
490
|
+
|
|
491
|
+
async def _get_outstanding_rewards(self, account: str) -> dict[str, int]:
|
|
492
|
+
"""Get outstanding rewards for an account across all markets."""
|
|
493
|
+
if not self.web3:
|
|
494
|
+
return {}
|
|
495
|
+
|
|
496
|
+
try:
|
|
497
|
+
web3 = self.web3.get_web3(self.chain_id)
|
|
498
|
+
contract = web3.eth.contract(
|
|
499
|
+
address=self.reward_distributor_address, abi=REWARD_DISTRIBUTOR_ABI
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
# Call getOutstandingRewardsForUser(user)
|
|
503
|
+
all_rewards = await contract.functions.getOutstandingRewardsForUser(
|
|
504
|
+
account
|
|
505
|
+
).call()
|
|
506
|
+
|
|
507
|
+
rewards: dict[str, int] = {}
|
|
508
|
+
for mtoken_data in all_rewards:
|
|
509
|
+
# mtoken_data is (mToken, [(rewardToken, totalReward, supplySide, borrowSide)])
|
|
510
|
+
if len(mtoken_data) >= 2:
|
|
511
|
+
token_rewards = mtoken_data[1] if len(mtoken_data) > 1 else []
|
|
512
|
+
for reward_info in token_rewards:
|
|
513
|
+
if len(reward_info) >= 2:
|
|
514
|
+
token_addr = reward_info[0]
|
|
515
|
+
total_reward = reward_info[1]
|
|
516
|
+
if total_reward > 0:
|
|
517
|
+
key = f"{self.chain_name}_{token_addr}"
|
|
518
|
+
rewards[key] = rewards.get(key, 0) + total_reward
|
|
519
|
+
return rewards
|
|
520
|
+
except Exception:
|
|
521
|
+
return {}
|
|
522
|
+
|
|
523
|
+
async def _calculate_rewards_usd(self, rewards: dict[str, int]) -> float:
|
|
524
|
+
"""Calculate total USD value of rewards."""
|
|
525
|
+
if not self.token_client:
|
|
526
|
+
return 0.0
|
|
527
|
+
|
|
528
|
+
total_usd = 0.0
|
|
529
|
+
for token_key, amount in rewards.items():
|
|
530
|
+
try:
|
|
531
|
+
token_data = await self.token_client.get_token_details(token_key)
|
|
532
|
+
if token_data:
|
|
533
|
+
price = token_data.get("price_usd") or token_data.get("price", 0)
|
|
534
|
+
decimals = token_data.get("decimals", 18)
|
|
535
|
+
total_usd += (amount / (10**decimals)) * price
|
|
536
|
+
except Exception:
|
|
537
|
+
pass
|
|
538
|
+
return total_usd
|
|
539
|
+
|
|
540
|
+
# ------------------------------------------------------------------ #
|
|
541
|
+
# Public API - Position & Market Data #
|
|
542
|
+
# ------------------------------------------------------------------ #
|
|
543
|
+
|
|
544
|
+
async def get_pos(
|
|
545
|
+
self,
|
|
546
|
+
*,
|
|
547
|
+
mtoken: str,
|
|
548
|
+
account: str | None = None,
|
|
549
|
+
include_usd: bool = False,
|
|
550
|
+
max_retries: int = 3,
|
|
551
|
+
block_identifier: int | str | None = None,
|
|
552
|
+
) -> tuple[bool, dict[str, Any] | str]:
|
|
553
|
+
"""Get position data (balances, rewards) for an account in a market.
|
|
554
|
+
|
|
555
|
+
Args:
|
|
556
|
+
mtoken: The mToken address
|
|
557
|
+
account: Account to query (defaults to strategy wallet)
|
|
558
|
+
include_usd: Whether to include USD values
|
|
559
|
+
max_retries: Number of retry attempts
|
|
560
|
+
block_identifier: Block to query at. Can be:
|
|
561
|
+
- int: specific block number (for pinning to tx block)
|
|
562
|
+
- "safe": OP Stack safe block (data posted to L1)
|
|
563
|
+
- None/"latest": current head (default)
|
|
564
|
+
|
|
565
|
+
Includes retry logic with exponential backoff for rate-limited RPCs.
|
|
566
|
+
"""
|
|
567
|
+
if not self.web3:
|
|
568
|
+
return False, "web3 service not configured"
|
|
569
|
+
|
|
570
|
+
mtoken = self._checksum(mtoken)
|
|
571
|
+
account = self._checksum(account) if account else self._strategy_address()
|
|
572
|
+
block_id = block_identifier if block_identifier is not None else "latest"
|
|
573
|
+
|
|
574
|
+
bal = exch = borrow = underlying = rewards = None
|
|
575
|
+
last_error = ""
|
|
576
|
+
|
|
577
|
+
for attempt in range(max_retries):
|
|
578
|
+
try:
|
|
579
|
+
web3 = self.web3.get_web3(self.chain_id)
|
|
580
|
+
mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
|
|
581
|
+
rewards_contract = web3.eth.contract(
|
|
582
|
+
address=self.reward_distributor_address, abi=REWARD_DISTRIBUTOR_ABI
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
# Fetch data sequentially to avoid overwhelming rate-limited public RPCs
|
|
586
|
+
# (parallel fetch would make 5 simultaneous calls per position)
|
|
587
|
+
bal = await mtoken_contract.functions.balanceOf(account).call(
|
|
588
|
+
block_identifier=block_id
|
|
589
|
+
)
|
|
590
|
+
exch = await mtoken_contract.functions.exchangeRateStored().call(
|
|
591
|
+
block_identifier=block_id
|
|
592
|
+
)
|
|
593
|
+
borrow = await mtoken_contract.functions.borrowBalanceStored(
|
|
594
|
+
account
|
|
595
|
+
).call(block_identifier=block_id)
|
|
596
|
+
underlying = await mtoken_contract.functions.underlying().call(
|
|
597
|
+
block_identifier=block_id
|
|
598
|
+
)
|
|
599
|
+
rewards = await rewards_contract.functions.getOutstandingRewardsForUser(
|
|
600
|
+
mtoken, account
|
|
601
|
+
).call(block_identifier=block_id)
|
|
602
|
+
break # Success, exit retry loop
|
|
603
|
+
except Exception as exc:
|
|
604
|
+
last_error = str(exc)
|
|
605
|
+
if "429" in last_error or "Too Many Requests" in last_error:
|
|
606
|
+
if attempt < max_retries - 1:
|
|
607
|
+
wait_time = 2 ** (attempt + 1) # 2, 4, 8 seconds
|
|
608
|
+
await asyncio.sleep(wait_time)
|
|
609
|
+
continue
|
|
610
|
+
return False, last_error
|
|
611
|
+
else:
|
|
612
|
+
# All retries exhausted
|
|
613
|
+
return False, last_error
|
|
614
|
+
|
|
615
|
+
try:
|
|
616
|
+
# Process rewards
|
|
617
|
+
reward_balances = self._process_rewards(rewards)
|
|
618
|
+
|
|
619
|
+
# Build balances dict
|
|
620
|
+
mtoken_key = f"{self.chain_name}_{mtoken}"
|
|
621
|
+
underlying_key = f"{self.chain_name}_{underlying}"
|
|
622
|
+
|
|
623
|
+
balances: dict[str, int] = {mtoken_key: bal}
|
|
624
|
+
balances.update(reward_balances)
|
|
625
|
+
|
|
626
|
+
if borrow > 0:
|
|
627
|
+
balances[underlying_key] = -borrow
|
|
628
|
+
|
|
629
|
+
result: dict[str, Any] = {
|
|
630
|
+
"balances": balances,
|
|
631
|
+
"mtoken_balance": bal,
|
|
632
|
+
"underlying_balance": (bal * exch) // MANTISSA,
|
|
633
|
+
"borrow_balance": borrow,
|
|
634
|
+
"exchange_rate": exch,
|
|
635
|
+
"underlying_token": underlying,
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
# Calculate USD values if requested and token_client available
|
|
639
|
+
if include_usd and self.token_client:
|
|
640
|
+
usd_balances = await self._calculate_usd_balances(
|
|
641
|
+
balances, underlying_key, exch
|
|
642
|
+
)
|
|
643
|
+
result["usd_balances"] = usd_balances
|
|
644
|
+
|
|
645
|
+
return True, result
|
|
646
|
+
except Exception as exc:
|
|
647
|
+
return False, str(exc)
|
|
648
|
+
|
|
649
|
+
def _process_rewards(self, rewards: list) -> dict[str, int]:
|
|
650
|
+
"""Process rewards tuple into dict mapping token keys to amounts."""
|
|
651
|
+
result: dict[str, int] = {}
|
|
652
|
+
for reward_info in rewards:
|
|
653
|
+
if len(reward_info) >= 2:
|
|
654
|
+
token_addr = reward_info[0]
|
|
655
|
+
total_reward = reward_info[1]
|
|
656
|
+
if total_reward > 0:
|
|
657
|
+
key = f"{self.chain_name}_{token_addr}"
|
|
658
|
+
result[key] = total_reward
|
|
659
|
+
return result
|
|
660
|
+
|
|
661
|
+
async def _calculate_usd_balances(
|
|
662
|
+
self, balances: dict[str, int], underlying_key: str, _exchange_rate: int
|
|
663
|
+
) -> dict[str, float | None]:
|
|
664
|
+
"""Calculate USD values for balances."""
|
|
665
|
+
if not self.token_client:
|
|
666
|
+
return {}
|
|
667
|
+
|
|
668
|
+
# Fetch token data for all tokens
|
|
669
|
+
tokens = set(balances.keys()) | {underlying_key}
|
|
670
|
+
token_data: dict[str, dict | None] = {}
|
|
671
|
+
for token_key in tokens:
|
|
672
|
+
try:
|
|
673
|
+
token_data[token_key] = await self.token_client.get_token_details(
|
|
674
|
+
token_key
|
|
675
|
+
)
|
|
676
|
+
except Exception:
|
|
677
|
+
token_data[token_key] = None
|
|
678
|
+
|
|
679
|
+
# Calculate USD values
|
|
680
|
+
usd_balances: dict[str, float | None] = {}
|
|
681
|
+
for token_key, bal in balances.items():
|
|
682
|
+
data = token_data.get(token_key)
|
|
683
|
+
if data:
|
|
684
|
+
price = data.get("price_usd") or data.get("price")
|
|
685
|
+
if price is not None:
|
|
686
|
+
decimals = data.get("decimals", 18)
|
|
687
|
+
usd_balances[token_key] = (bal / (10**decimals)) * price
|
|
688
|
+
else:
|
|
689
|
+
usd_balances[token_key] = None
|
|
690
|
+
else:
|
|
691
|
+
usd_balances[token_key] = None
|
|
692
|
+
|
|
693
|
+
return usd_balances
|
|
694
|
+
|
|
695
|
+
async def get_collateral_factor(
|
|
696
|
+
self,
|
|
697
|
+
*,
|
|
698
|
+
mtoken: str,
|
|
699
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
700
|
+
) -> tuple[bool, float | str]:
|
|
701
|
+
"""Get the collateral factor for a market as decimal (e.g., 0.75 for 75%).
|
|
702
|
+
|
|
703
|
+
Uses a 1-hour cache since collateral factors rarely change (governance controlled).
|
|
704
|
+
Includes retry logic with exponential backoff for rate-limited RPCs.
|
|
705
|
+
"""
|
|
706
|
+
if not self.web3:
|
|
707
|
+
return False, "web3 service not configured"
|
|
708
|
+
|
|
709
|
+
mtoken = self._checksum(mtoken)
|
|
710
|
+
|
|
711
|
+
# Check cache first
|
|
712
|
+
now = time.time()
|
|
713
|
+
if mtoken in self._cf_cache:
|
|
714
|
+
cached_value, cached_time = self._cf_cache[mtoken]
|
|
715
|
+
if now - cached_time < CF_CACHE_TTL:
|
|
716
|
+
return True, cached_value
|
|
717
|
+
|
|
718
|
+
last_error = ""
|
|
719
|
+
for attempt in range(max_retries):
|
|
720
|
+
try:
|
|
721
|
+
web3 = self.web3.get_web3(self.chain_id)
|
|
722
|
+
contract = web3.eth.contract(
|
|
723
|
+
address=self.comptroller_address, abi=COMPTROLLER_ABI
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
# markets() returns (isListed, collateralFactorMantissa)
|
|
727
|
+
result = await contract.functions.markets(mtoken).call()
|
|
728
|
+
is_listed, collateral_factor_mantissa = result
|
|
729
|
+
|
|
730
|
+
if not is_listed:
|
|
731
|
+
return False, f"Market {mtoken} is not listed"
|
|
732
|
+
|
|
733
|
+
# Convert from mantissa to decimal
|
|
734
|
+
collateral_factor = collateral_factor_mantissa / MANTISSA
|
|
735
|
+
|
|
736
|
+
# Cache the result
|
|
737
|
+
self._cf_cache[mtoken] = (collateral_factor, now)
|
|
738
|
+
|
|
739
|
+
return True, collateral_factor
|
|
740
|
+
except Exception as exc:
|
|
741
|
+
last_error = str(exc)
|
|
742
|
+
if _is_rate_limit_error(exc) and attempt < max_retries - 1:
|
|
743
|
+
wait_time = DEFAULT_BASE_DELAY * (2**attempt)
|
|
744
|
+
await asyncio.sleep(wait_time)
|
|
745
|
+
continue
|
|
746
|
+
return False, last_error
|
|
747
|
+
|
|
748
|
+
return False, last_error
|
|
749
|
+
|
|
750
|
+
async def get_apy(
|
|
751
|
+
self,
|
|
752
|
+
*,
|
|
753
|
+
mtoken: str,
|
|
754
|
+
apy_type: Literal["supply", "borrow"] = "supply",
|
|
755
|
+
include_rewards: bool = True,
|
|
756
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
757
|
+
) -> tuple[bool, float | str]:
|
|
758
|
+
"""Get supply or borrow APY for a market, optionally including WELL rewards.
|
|
759
|
+
|
|
760
|
+
Includes retry logic with exponential backoff for rate-limited RPCs.
|
|
761
|
+
"""
|
|
762
|
+
if not self.web3:
|
|
763
|
+
return False, "web3 service not configured"
|
|
764
|
+
|
|
765
|
+
mtoken = self._checksum(mtoken)
|
|
766
|
+
|
|
767
|
+
last_error = ""
|
|
768
|
+
for attempt in range(max_retries):
|
|
769
|
+
try:
|
|
770
|
+
web3 = self.web3.get_web3(self.chain_id)
|
|
771
|
+
mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
|
|
772
|
+
reward_distributor = web3.eth.contract(
|
|
773
|
+
address=self.reward_distributor_address, abi=REWARD_DISTRIBUTOR_ABI
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
# Get base rate (sequential to avoid rate limits)
|
|
777
|
+
if apy_type == "supply":
|
|
778
|
+
rate_per_timestamp = (
|
|
779
|
+
await mtoken_contract.functions.supplyRatePerTimestamp().call()
|
|
780
|
+
)
|
|
781
|
+
mkt_config = await reward_distributor.functions.getAllMarketConfigs(
|
|
782
|
+
mtoken
|
|
783
|
+
).call()
|
|
784
|
+
total_value = await mtoken_contract.functions.totalSupply().call()
|
|
785
|
+
else:
|
|
786
|
+
rate_per_timestamp = (
|
|
787
|
+
await mtoken_contract.functions.borrowRatePerTimestamp().call()
|
|
788
|
+
)
|
|
789
|
+
mkt_config = await reward_distributor.functions.getAllMarketConfigs(
|
|
790
|
+
mtoken
|
|
791
|
+
).call()
|
|
792
|
+
total_value = await mtoken_contract.functions.totalBorrows().call()
|
|
793
|
+
|
|
794
|
+
# Convert rate per second to APY
|
|
795
|
+
rate = rate_per_timestamp / MANTISSA
|
|
796
|
+
apy = _timestamp_rate_to_apy(rate)
|
|
797
|
+
|
|
798
|
+
# Add WELL rewards APY if requested and token_client available
|
|
799
|
+
if include_rewards and self.token_client and total_value > 0:
|
|
800
|
+
rewards_apr = await self._calculate_rewards_apr(
|
|
801
|
+
mtoken, mkt_config, total_value, apy_type
|
|
802
|
+
)
|
|
803
|
+
apy += rewards_apr
|
|
804
|
+
|
|
805
|
+
return True, apy
|
|
806
|
+
except Exception as exc:
|
|
807
|
+
last_error = str(exc)
|
|
808
|
+
if _is_rate_limit_error(exc) and attempt < max_retries - 1:
|
|
809
|
+
wait_time = DEFAULT_BASE_DELAY * (2**attempt)
|
|
810
|
+
await asyncio.sleep(wait_time)
|
|
811
|
+
continue
|
|
812
|
+
return False, last_error
|
|
813
|
+
|
|
814
|
+
return False, last_error
|
|
815
|
+
|
|
816
|
+
async def _calculate_rewards_apr(
|
|
817
|
+
self,
|
|
818
|
+
mtoken: str,
|
|
819
|
+
mkt_config: list,
|
|
820
|
+
total_value: int,
|
|
821
|
+
apy_type: str,
|
|
822
|
+
) -> float:
|
|
823
|
+
"""Calculate WELL rewards APR for a market."""
|
|
824
|
+
if not self.token_client:
|
|
825
|
+
return 0.0
|
|
826
|
+
|
|
827
|
+
try:
|
|
828
|
+
# Find WELL token config
|
|
829
|
+
well_config = None
|
|
830
|
+
for config in mkt_config:
|
|
831
|
+
if len(config) >= 6 and config[1].lower() == self.well_token.lower():
|
|
832
|
+
well_config = config
|
|
833
|
+
break
|
|
834
|
+
|
|
835
|
+
if not well_config:
|
|
836
|
+
return 0.0
|
|
837
|
+
|
|
838
|
+
# Get emission rate (supply or borrow)
|
|
839
|
+
# Config format: (mToken, rewardToken, owner, emissionCap, supplyEmissionsPerSec, borrowEmissionsPerSec, ...)
|
|
840
|
+
if apy_type == "supply":
|
|
841
|
+
well_rate = well_config[4] # supplyEmissionsPerSec
|
|
842
|
+
else:
|
|
843
|
+
well_rate = well_config[5] # borrowEmissionsPerSec
|
|
844
|
+
# Borrow rewards are shown as negative in some implementations
|
|
845
|
+
if well_rate < 0:
|
|
846
|
+
well_rate = -well_rate
|
|
847
|
+
|
|
848
|
+
if well_rate == 0:
|
|
849
|
+
return 0.0
|
|
850
|
+
|
|
851
|
+
# Get underlying token for decimals
|
|
852
|
+
web3 = self.web3.get_web3(self.chain_id)
|
|
853
|
+
mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
|
|
854
|
+
underlying_addr = await mtoken_contract.functions.underlying().call()
|
|
855
|
+
|
|
856
|
+
# Get prices
|
|
857
|
+
well_key = f"{self.chain_name}_{self.well_token}"
|
|
858
|
+
underlying_key = f"{self.chain_name}_{underlying_addr}"
|
|
859
|
+
|
|
860
|
+
well_data, underlying_data = await asyncio.gather(
|
|
861
|
+
self.token_client.get_token_details(well_key),
|
|
862
|
+
self.token_client.get_token_details(underlying_key),
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
well_price = (
|
|
866
|
+
well_data.get("price_usd") or well_data.get("price", 0)
|
|
867
|
+
if well_data
|
|
868
|
+
else 0
|
|
869
|
+
)
|
|
870
|
+
underlying_price = (
|
|
871
|
+
underlying_data.get("price_usd") or underlying_data.get("price", 0)
|
|
872
|
+
if underlying_data
|
|
873
|
+
else 0
|
|
874
|
+
)
|
|
875
|
+
underlying_decimals = (
|
|
876
|
+
underlying_data.get("decimals", 18) if underlying_data else 18
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
if not well_price or not underlying_price:
|
|
880
|
+
return 0.0
|
|
881
|
+
|
|
882
|
+
# Calculate total value in USD
|
|
883
|
+
total_value_usd = (
|
|
884
|
+
total_value / (10**underlying_decimals)
|
|
885
|
+
) * underlying_price
|
|
886
|
+
|
|
887
|
+
if total_value_usd == 0:
|
|
888
|
+
return 0.0
|
|
889
|
+
|
|
890
|
+
# Calculate rewards APR
|
|
891
|
+
# rewards_apr = well_price * emissions_per_second * seconds_per_year / total_value_usd
|
|
892
|
+
rewards_apr = (
|
|
893
|
+
well_price * (well_rate / MANTISSA) * SECONDS_PER_YEAR / total_value_usd
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
return rewards_apr
|
|
897
|
+
except Exception:
|
|
898
|
+
return 0.0
|
|
899
|
+
|
|
900
|
+
async def get_borrowable_amount(
|
|
901
|
+
self,
|
|
902
|
+
*,
|
|
903
|
+
account: str | None = None,
|
|
904
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
905
|
+
) -> tuple[bool, int | str]:
|
|
906
|
+
"""Get the maximum borrowable amount for an account (USD with 18 decimals).
|
|
907
|
+
|
|
908
|
+
Includes retry logic with exponential backoff for rate-limited RPCs.
|
|
909
|
+
"""
|
|
910
|
+
if not self.web3:
|
|
911
|
+
return False, "web3 service not configured"
|
|
912
|
+
|
|
913
|
+
account = self._checksum(account) if account else self._strategy_address()
|
|
914
|
+
|
|
915
|
+
last_error = ""
|
|
916
|
+
for attempt in range(max_retries):
|
|
917
|
+
try:
|
|
918
|
+
web3 = self.web3.get_web3(self.chain_id)
|
|
919
|
+
contract = web3.eth.contract(
|
|
920
|
+
address=self.comptroller_address, abi=COMPTROLLER_ABI
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
# getAccountLiquidity returns (error, liquidity, shortfall)
|
|
924
|
+
(
|
|
925
|
+
error,
|
|
926
|
+
liquidity,
|
|
927
|
+
shortfall,
|
|
928
|
+
) = await contract.functions.getAccountLiquidity(account).call()
|
|
929
|
+
|
|
930
|
+
if error != 0:
|
|
931
|
+
return False, f"Comptroller error: {error}"
|
|
932
|
+
|
|
933
|
+
if shortfall > 0:
|
|
934
|
+
return False, f"Account has shortfall: {shortfall}"
|
|
935
|
+
|
|
936
|
+
return True, liquidity
|
|
937
|
+
except Exception as exc:
|
|
938
|
+
last_error = str(exc)
|
|
939
|
+
if _is_rate_limit_error(exc) and attempt < max_retries - 1:
|
|
940
|
+
wait_time = DEFAULT_BASE_DELAY * (2**attempt)
|
|
941
|
+
await asyncio.sleep(wait_time)
|
|
942
|
+
continue
|
|
943
|
+
return False, last_error
|
|
944
|
+
|
|
945
|
+
return False, last_error
|
|
946
|
+
|
|
947
|
+
async def max_withdrawable_mtoken(
|
|
948
|
+
self,
|
|
949
|
+
*,
|
|
950
|
+
mtoken: str,
|
|
951
|
+
account: str | None = None,
|
|
952
|
+
) -> tuple[bool, dict[str, Any] | str]:
|
|
953
|
+
"""Calculate max mTokens withdrawable without liquidation using binary search."""
|
|
954
|
+
if not self.web3:
|
|
955
|
+
return False, "web3 service not configured"
|
|
956
|
+
|
|
957
|
+
mtoken = self._checksum(mtoken)
|
|
958
|
+
account = self._checksum(account) if account else self._strategy_address()
|
|
959
|
+
|
|
960
|
+
try:
|
|
961
|
+
web3 = self.web3.get_web3(self.chain_id)
|
|
962
|
+
comptroller = web3.eth.contract(
|
|
963
|
+
address=self.comptroller_address, abi=COMPTROLLER_ABI
|
|
964
|
+
)
|
|
965
|
+
mtoken_contract = web3.eth.contract(address=mtoken, abi=MTOKEN_ABI)
|
|
966
|
+
|
|
967
|
+
# Get all needed data in parallel
|
|
968
|
+
bal_raw, exch_raw, cash_raw, m_dec, u_addr = await asyncio.gather(
|
|
969
|
+
mtoken_contract.functions.balanceOf(account).call(),
|
|
970
|
+
mtoken_contract.functions.exchangeRateStored().call(),
|
|
971
|
+
mtoken_contract.functions.getCash().call(),
|
|
972
|
+
mtoken_contract.functions.decimals().call(),
|
|
973
|
+
mtoken_contract.functions.underlying().call(),
|
|
974
|
+
)
|
|
975
|
+
|
|
976
|
+
if bal_raw == 0 or exch_raw == 0:
|
|
977
|
+
return True, {
|
|
978
|
+
"cTokens_raw": 0,
|
|
979
|
+
"cTokens": 0.0,
|
|
980
|
+
"underlying_raw": 0,
|
|
981
|
+
"underlying": 0.0,
|
|
982
|
+
"bounds_raw": {"collateral_cTokens": 0, "cash_cTokens": 0},
|
|
983
|
+
"exchangeRate_raw": int(exch_raw),
|
|
984
|
+
"mToken_decimals": int(m_dec),
|
|
985
|
+
"underlying_decimals": None,
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
# Get underlying decimals
|
|
989
|
+
u_dec = 18 # Default
|
|
990
|
+
if self.token_client:
|
|
991
|
+
try:
|
|
992
|
+
u_key = f"{self.chain_name}_{u_addr}"
|
|
993
|
+
u_data = await self.token_client.get_token_details(u_key)
|
|
994
|
+
if u_data:
|
|
995
|
+
u_dec = u_data.get("decimals", 18)
|
|
996
|
+
except Exception:
|
|
997
|
+
pass
|
|
998
|
+
|
|
999
|
+
# Binary search: largest cTokens you can redeem without shortfall
|
|
1000
|
+
lo, hi = 0, int(bal_raw)
|
|
1001
|
+
while lo < hi:
|
|
1002
|
+
mid = (lo + hi + 1) // 2
|
|
1003
|
+
(
|
|
1004
|
+
err,
|
|
1005
|
+
_liq,
|
|
1006
|
+
short,
|
|
1007
|
+
) = await comptroller.functions.getHypotheticalAccountLiquidity(
|
|
1008
|
+
account, mtoken, mid, 0
|
|
1009
|
+
).call()
|
|
1010
|
+
if err != 0:
|
|
1011
|
+
return False, f"Comptroller error {err}"
|
|
1012
|
+
if short == 0:
|
|
1013
|
+
lo = mid # Safe, try more
|
|
1014
|
+
else:
|
|
1015
|
+
hi = mid - 1
|
|
1016
|
+
|
|
1017
|
+
c_by_collateral = lo
|
|
1018
|
+
|
|
1019
|
+
# Pool cash bound (convert underlying cash -> cToken capacity)
|
|
1020
|
+
c_by_cash = (int(cash_raw) * MANTISSA) // int(exch_raw)
|
|
1021
|
+
|
|
1022
|
+
redeem_c_raw = min(c_by_collateral, int(c_by_cash))
|
|
1023
|
+
|
|
1024
|
+
# Final underlying you actually receive (mirror Solidity floor)
|
|
1025
|
+
under_raw = (redeem_c_raw * int(exch_raw)) // MANTISSA
|
|
1026
|
+
|
|
1027
|
+
return True, {
|
|
1028
|
+
"cTokens_raw": int(redeem_c_raw),
|
|
1029
|
+
"cTokens": redeem_c_raw / (10 ** int(m_dec)),
|
|
1030
|
+
"underlying_raw": int(under_raw),
|
|
1031
|
+
"underlying": under_raw / (10 ** int(u_dec)),
|
|
1032
|
+
"bounds_raw": {
|
|
1033
|
+
"collateral_cTokens": int(c_by_collateral),
|
|
1034
|
+
"cash_cTokens": int(c_by_cash),
|
|
1035
|
+
},
|
|
1036
|
+
"exchangeRate_raw": int(exch_raw),
|
|
1037
|
+
"mToken_decimals": int(m_dec),
|
|
1038
|
+
"underlying_decimals": int(u_dec),
|
|
1039
|
+
"conversion_factor": redeem_c_raw / under_raw if under_raw > 0 else 0,
|
|
1040
|
+
}
|
|
1041
|
+
except Exception as exc:
|
|
1042
|
+
return False, str(exc)
|
|
1043
|
+
|
|
1044
|
+
# ------------------------------------------------------------------ #
|
|
1045
|
+
# Public API - ETH Wrapping #
|
|
1046
|
+
# ------------------------------------------------------------------ #
|
|
1047
|
+
|
|
1048
|
+
async def wrap_eth(
|
|
1049
|
+
self,
|
|
1050
|
+
*,
|
|
1051
|
+
amount: int,
|
|
1052
|
+
) -> tuple[bool, Any]:
|
|
1053
|
+
"""Wrap ETH to WETH."""
|
|
1054
|
+
strategy = self._strategy_address()
|
|
1055
|
+
amount = int(amount)
|
|
1056
|
+
if amount <= 0:
|
|
1057
|
+
return False, "amount must be positive"
|
|
1058
|
+
|
|
1059
|
+
tx = await self._encode_call(
|
|
1060
|
+
target=self.weth,
|
|
1061
|
+
abi=WETH_ABI,
|
|
1062
|
+
fn_name="deposit",
|
|
1063
|
+
args=[],
|
|
1064
|
+
from_address=strategy,
|
|
1065
|
+
value=amount,
|
|
1066
|
+
)
|
|
1067
|
+
return await self._execute(tx)
|
|
1068
|
+
|
|
1069
|
+
# ------------------------------------------------------------------ #
|
|
1070
|
+
# Helpers #
|
|
1071
|
+
# ------------------------------------------------------------------ #
|
|
1072
|
+
|
|
1073
|
+
# Max uint256 for unlimited approvals
|
|
1074
|
+
MAX_UINT256 = 2**256 - 1
|
|
1075
|
+
|
|
1076
|
+
async def _ensure_allowance(
|
|
1077
|
+
self,
|
|
1078
|
+
*,
|
|
1079
|
+
token_address: str,
|
|
1080
|
+
owner: str,
|
|
1081
|
+
spender: str,
|
|
1082
|
+
amount: int,
|
|
1083
|
+
) -> tuple[bool, Any]:
|
|
1084
|
+
"""Ensure token allowance is sufficient, approving if needed.
|
|
1085
|
+
|
|
1086
|
+
Approves for max uint256 to avoid precision issues with exact amounts.
|
|
1087
|
+
"""
|
|
1088
|
+
if not self.token_txn_service:
|
|
1089
|
+
return False, "token_txn_service not configured"
|
|
1090
|
+
|
|
1091
|
+
chain = {"id": self.chain_id}
|
|
1092
|
+
allowance = await self.token_txn_service.read_erc20_allowance(
|
|
1093
|
+
chain, token_address, owner, spender
|
|
1094
|
+
)
|
|
1095
|
+
if allowance.get("allowance", 0) >= amount:
|
|
1096
|
+
return True, {}
|
|
1097
|
+
|
|
1098
|
+
# Approve for max uint256 to avoid precision/timing issues
|
|
1099
|
+
build_success, approve_tx = self.token_txn_service.build_erc20_approve(
|
|
1100
|
+
chain_id=self.chain_id,
|
|
1101
|
+
token_address=token_address,
|
|
1102
|
+
from_address=owner,
|
|
1103
|
+
spender=spender,
|
|
1104
|
+
amount=self.MAX_UINT256,
|
|
1105
|
+
)
|
|
1106
|
+
if not build_success:
|
|
1107
|
+
return False, approve_tx
|
|
1108
|
+
|
|
1109
|
+
result = await self._broadcast_transaction(approve_tx)
|
|
1110
|
+
|
|
1111
|
+
# Small delay after approval to ensure state is propagated
|
|
1112
|
+
if result[0]:
|
|
1113
|
+
await asyncio.sleep(1.0)
|
|
1114
|
+
|
|
1115
|
+
return result
|
|
1116
|
+
|
|
1117
|
+
async def _execute(
|
|
1118
|
+
self, tx: dict[str, Any], max_retries: int = DEFAULT_MAX_RETRIES
|
|
1119
|
+
) -> tuple[bool, Any]:
|
|
1120
|
+
"""Execute a transaction (or return simulation data).
|
|
1121
|
+
|
|
1122
|
+
Includes retry logic with exponential backoff for rate-limited RPCs.
|
|
1123
|
+
"""
|
|
1124
|
+
if self.simulation:
|
|
1125
|
+
return True, {"simulation": tx}
|
|
1126
|
+
if not self.web3:
|
|
1127
|
+
return False, "web3 service not configured"
|
|
1128
|
+
|
|
1129
|
+
last_error = None
|
|
1130
|
+
for attempt in range(max_retries):
|
|
1131
|
+
try:
|
|
1132
|
+
return await self.web3.broadcast_transaction(
|
|
1133
|
+
tx, wait_for_receipt=True, timeout=DEFAULT_TRANSACTION_TIMEOUT
|
|
1134
|
+
)
|
|
1135
|
+
except Exception as exc:
|
|
1136
|
+
last_error = exc
|
|
1137
|
+
if _is_rate_limit_error(exc) and attempt < max_retries - 1:
|
|
1138
|
+
wait_time = DEFAULT_BASE_DELAY * (2**attempt)
|
|
1139
|
+
await asyncio.sleep(wait_time)
|
|
1140
|
+
continue
|
|
1141
|
+
return False, str(exc)
|
|
1142
|
+
|
|
1143
|
+
return False, str(last_error) if last_error else "Max retries exceeded"
|
|
1144
|
+
|
|
1145
|
+
async def _broadcast_transaction(
|
|
1146
|
+
self, tx: dict[str, Any], max_retries: int = DEFAULT_MAX_RETRIES
|
|
1147
|
+
) -> tuple[bool, Any]:
|
|
1148
|
+
"""Broadcast a pre-built transaction.
|
|
1149
|
+
|
|
1150
|
+
Includes retry logic with exponential backoff for rate-limited RPCs.
|
|
1151
|
+
"""
|
|
1152
|
+
if getattr(settings, "DRY_RUN", False):
|
|
1153
|
+
return True, {"dry_run": True, "transaction": tx}
|
|
1154
|
+
if not self.web3:
|
|
1155
|
+
return False, "web3 service not configured"
|
|
1156
|
+
|
|
1157
|
+
last_error = None
|
|
1158
|
+
for attempt in range(max_retries):
|
|
1159
|
+
try:
|
|
1160
|
+
return await self.web3.evm_transactions.broadcast_transaction(
|
|
1161
|
+
tx, wait_for_receipt=True, timeout=DEFAULT_TRANSACTION_TIMEOUT
|
|
1162
|
+
)
|
|
1163
|
+
except Exception as exc:
|
|
1164
|
+
last_error = exc
|
|
1165
|
+
if _is_rate_limit_error(exc) and attempt < max_retries - 1:
|
|
1166
|
+
wait_time = DEFAULT_BASE_DELAY * (2**attempt)
|
|
1167
|
+
await asyncio.sleep(wait_time)
|
|
1168
|
+
continue
|
|
1169
|
+
return False, str(exc)
|
|
1170
|
+
|
|
1171
|
+
return False, str(last_error) if last_error else "Max retries exceeded"
|
|
1172
|
+
|
|
1173
|
+
async def _encode_call(
|
|
1174
|
+
self,
|
|
1175
|
+
*,
|
|
1176
|
+
target: str,
|
|
1177
|
+
abi: list[dict[str, Any]],
|
|
1178
|
+
fn_name: str,
|
|
1179
|
+
args: list[Any],
|
|
1180
|
+
from_address: str,
|
|
1181
|
+
value: int = 0,
|
|
1182
|
+
) -> dict[str, Any]:
|
|
1183
|
+
"""Encode a contract call without touching the network."""
|
|
1184
|
+
if not self.web3:
|
|
1185
|
+
raise ValueError("web3 service not configured")
|
|
1186
|
+
|
|
1187
|
+
web3 = self.web3.get_web3(self.chain_id)
|
|
1188
|
+
contract = web3.eth.contract(address=target, abi=abi)
|
|
1189
|
+
|
|
1190
|
+
try:
|
|
1191
|
+
tx_data = await getattr(contract.functions, fn_name)(
|
|
1192
|
+
*args
|
|
1193
|
+
).build_transaction({"from": from_address})
|
|
1194
|
+
data = tx_data["data"]
|
|
1195
|
+
except ValueError as exc:
|
|
1196
|
+
raise ValueError(f"Failed to encode {fn_name}: {exc}") from exc
|
|
1197
|
+
|
|
1198
|
+
tx: dict[str, Any] = {
|
|
1199
|
+
"chainId": int(self.chain_id),
|
|
1200
|
+
"from": to_checksum_address(from_address),
|
|
1201
|
+
"to": to_checksum_address(target),
|
|
1202
|
+
"data": data,
|
|
1203
|
+
"value": int(value),
|
|
1204
|
+
}
|
|
1205
|
+
return tx
|
|
1206
|
+
|
|
1207
|
+
def _strategy_address(self) -> str:
|
|
1208
|
+
"""Get the strategy wallet address."""
|
|
1209
|
+
addr = None
|
|
1210
|
+
if isinstance(self.strategy_wallet, dict):
|
|
1211
|
+
addr = self.strategy_wallet.get("address") or (
|
|
1212
|
+
(self.strategy_wallet.get("evm") or {}).get("address")
|
|
1213
|
+
)
|
|
1214
|
+
elif isinstance(self.strategy_wallet, str):
|
|
1215
|
+
addr = self.strategy_wallet
|
|
1216
|
+
if not addr:
|
|
1217
|
+
raise ValueError(
|
|
1218
|
+
"strategy_wallet address is required for Moonwell operations"
|
|
1219
|
+
)
|
|
1220
|
+
return to_checksum_address(addr)
|
|
1221
|
+
|
|
1222
|
+
def _checksum(self, address: str | None) -> str:
|
|
1223
|
+
"""Convert address to checksum format."""
|
|
1224
|
+
if not address:
|
|
1225
|
+
raise ValueError("Missing required contract address in Moonwell config")
|
|
1226
|
+
return to_checksum_address(address)
|