wayfinder-paths 0.1.14__py3-none-any.whl → 0.1.15__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of wayfinder-paths might be problematic. Click here for more details.
- wayfinder_paths/adapters/balance_adapter/adapter.py +40 -0
- wayfinder_paths/adapters/balance_adapter/test_adapter.py +3 -3
- wayfinder_paths/adapters/brap_adapter/adapter.py +66 -15
- wayfinder_paths/adapters/brap_adapter/test_adapter.py +6 -6
- wayfinder_paths/adapters/hyperlend_adapter/adapter.py +14 -0
- wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +7 -7
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +6 -6
- wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +12 -12
- wayfinder_paths/adapters/ledger_adapter/test_adapter.py +6 -6
- wayfinder_paths/adapters/moonwell_adapter/adapter.py +332 -9
- wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +13 -13
- wayfinder_paths/adapters/pool_adapter/test_adapter.py +2 -2
- wayfinder_paths/adapters/token_adapter/test_adapter.py +4 -4
- wayfinder_paths/core/constants/erc20_abi.py +0 -11
- wayfinder_paths/core/engine/StrategyJob.py +3 -1
- wayfinder_paths/core/services/base.py +1 -0
- wayfinder_paths/core/services/local_evm_txn.py +19 -3
- wayfinder_paths/core/services/local_token_txn.py +1 -5
- wayfinder_paths/core/services/test_local_evm_txn.py +145 -0
- wayfinder_paths/core/strategies/Strategy.py +16 -2
- wayfinder_paths/core/utils/evm_helpers.py +0 -7
- wayfinder_paths/policies/erc20.py +1 -1
- wayfinder_paths/run_strategy.py +5 -0
- wayfinder_paths/strategies/basis_trading_strategy/strategy.py +67 -0
- wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +6 -6
- wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +71 -2
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/README.md +6 -5
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +2249 -1282
- wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +282 -121
- wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +65 -0
- wayfinder_paths/templates/adapter/README.md +1 -1
- {wayfinder_paths-0.1.14.dist-info → wayfinder_paths-0.1.15.dist-info}/METADATA +1 -1
- {wayfinder_paths-0.1.14.dist-info → wayfinder_paths-0.1.15.dist-info}/RECORD +35 -35
- wayfinder_paths/abis/generic/erc20.json +0 -383
- {wayfinder_paths-0.1.14.dist-info → wayfinder_paths-0.1.15.dist-info}/LICENSE +0 -0
- {wayfinder_paths-0.1.14.dist-info → wayfinder_paths-0.1.15.dist-info}/WHEEL +0 -0
|
@@ -20,6 +20,9 @@ SUGGESTED_PRIORITY_FEE_MULTIPLIER = 1.5
|
|
|
20
20
|
MAX_BASE_FEE_GROWTH_MULTIPLIER = 2
|
|
21
21
|
GAS_LIMIT_BUFFER_MULTIPLIER = 1.5
|
|
22
22
|
|
|
23
|
+
# Base chain ID (Base mainnet)
|
|
24
|
+
BASE_CHAIN_ID = 8453
|
|
25
|
+
|
|
23
26
|
# Chains that don't support EIP-1559 (London) and need legacy gas pricing
|
|
24
27
|
PRE_LONDON_GAS_CHAIN_IDS: set[int] = {56, 42161}
|
|
25
28
|
POA_MIDDLEWARE_CHAIN_IDS: set = {56, 137, 43114}
|
|
@@ -182,12 +185,20 @@ class LocalEvmTxn(EvmTxn):
|
|
|
182
185
|
*,
|
|
183
186
|
wait_for_receipt: bool = True,
|
|
184
187
|
timeout: int = DEFAULT_TRANSACTION_TIMEOUT,
|
|
185
|
-
confirmations: int =
|
|
188
|
+
confirmations: int | None = None,
|
|
186
189
|
) -> tuple[bool, Any]:
|
|
187
190
|
try:
|
|
188
191
|
chain_id = transaction["chainId"]
|
|
189
192
|
from_address = transaction["from"]
|
|
190
193
|
|
|
194
|
+
# Default confirmation behavior:
|
|
195
|
+
# - Base: wait for 2 additional blocks after the receipt block
|
|
196
|
+
# - Others: do not wait for additional confirmations
|
|
197
|
+
effective_confirmations = confirmations
|
|
198
|
+
if effective_confirmations is None:
|
|
199
|
+
effective_confirmations = 2 if int(chain_id) == BASE_CHAIN_ID else 0
|
|
200
|
+
effective_confirmations = max(0, int(effective_confirmations))
|
|
201
|
+
|
|
191
202
|
web3 = self.get_web3(chain_id)
|
|
192
203
|
try:
|
|
193
204
|
transaction = self._validate_transaction(transaction)
|
|
@@ -210,6 +221,8 @@ class LocalEvmTxn(EvmTxn):
|
|
|
210
221
|
result["receipt"] = self._format_receipt(receipt)
|
|
211
222
|
# Add block_number at top level for convenience
|
|
212
223
|
result["block_number"] = result["receipt"].get("blockNumber")
|
|
224
|
+
result["confirmations"] = effective_confirmations
|
|
225
|
+
result["confirmed_block_number"] = result["block_number"]
|
|
213
226
|
|
|
214
227
|
receipt_status = result["receipt"].get("status")
|
|
215
228
|
if receipt_status is not None and int(receipt_status) != 1:
|
|
@@ -219,11 +232,14 @@ class LocalEvmTxn(EvmTxn):
|
|
|
219
232
|
)
|
|
220
233
|
|
|
221
234
|
# Wait for additional confirmations if requested
|
|
222
|
-
if
|
|
235
|
+
if effective_confirmations > 0:
|
|
223
236
|
tx_block = result["receipt"].get("blockNumber")
|
|
224
237
|
if tx_block:
|
|
225
238
|
await self._wait_for_confirmations(
|
|
226
|
-
web3, tx_block,
|
|
239
|
+
web3, tx_block, effective_confirmations
|
|
240
|
+
)
|
|
241
|
+
result["confirmed_block_number"] = int(tx_block) + int(
|
|
242
|
+
effective_confirmations
|
|
227
243
|
)
|
|
228
244
|
|
|
229
245
|
return (True, result)
|
|
@@ -6,7 +6,7 @@ from typing import Any
|
|
|
6
6
|
|
|
7
7
|
from eth_utils import to_checksum_address
|
|
8
8
|
from loguru import logger
|
|
9
|
-
from web3 import
|
|
9
|
+
from web3 import Web3
|
|
10
10
|
|
|
11
11
|
from wayfinder_paths.core.clients.TokenClient import TokenClient
|
|
12
12
|
from wayfinder_paths.core.constants import ZERO_ADDRESS
|
|
@@ -85,7 +85,6 @@ class LocalTokenTxnService(TokenTxn):
|
|
|
85
85
|
) -> tuple[bool, dict[str, Any] | str]:
|
|
86
86
|
"""Build the transaction dictionary for an ERC20 approval."""
|
|
87
87
|
try:
|
|
88
|
-
web3 = self.wallet_provider.get_web3(chain_id)
|
|
89
88
|
token_checksum = to_checksum_address(token_address)
|
|
90
89
|
from_checksum = to_checksum_address(from_address)
|
|
91
90
|
spender_checksum = to_checksum_address(spender)
|
|
@@ -99,7 +98,6 @@ class LocalTokenTxnService(TokenTxn):
|
|
|
99
98
|
from_address=from_checksum,
|
|
100
99
|
spender=spender_checksum,
|
|
101
100
|
amount=amount_int,
|
|
102
|
-
web3=web3,
|
|
103
101
|
)
|
|
104
102
|
return True, approve_tx
|
|
105
103
|
|
|
@@ -215,10 +213,8 @@ class LocalTokenTxnService(TokenTxn):
|
|
|
215
213
|
from_address: str,
|
|
216
214
|
spender: str,
|
|
217
215
|
amount: int,
|
|
218
|
-
web3: AsyncWeb3,
|
|
219
216
|
) -> dict[str, Any]:
|
|
220
217
|
"""Build an ERC20 approval transaction dict."""
|
|
221
|
-
del web3 # Use sync Web3 for encoding (AsyncContract doesn't have encodeABI)
|
|
222
218
|
token_checksum = to_checksum_address(token_address)
|
|
223
219
|
spender_checksum = to_checksum_address(spender)
|
|
224
220
|
from_checksum = to_checksum_address(from_address)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from wayfinder_paths.core.services.local_evm_txn import BASE_CHAIN_ID, LocalEvmTxn
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _FakeTxHash:
|
|
11
|
+
def __init__(self, value: str):
|
|
12
|
+
self._value = value
|
|
13
|
+
|
|
14
|
+
def hex(self) -> str:
|
|
15
|
+
return self._value
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.mark.asyncio
|
|
19
|
+
async def test_base_defaults_to_two_confirmations():
|
|
20
|
+
txn = LocalEvmTxn(config={})
|
|
21
|
+
|
|
22
|
+
fake_web3 = MagicMock()
|
|
23
|
+
fake_web3.eth = MagicMock()
|
|
24
|
+
fake_web3.eth.send_raw_transaction = AsyncMock(return_value=_FakeTxHash("0x1"))
|
|
25
|
+
fake_web3.eth.wait_for_transaction_receipt = AsyncMock(
|
|
26
|
+
return_value={
|
|
27
|
+
"status": 1,
|
|
28
|
+
"blockNumber": 100,
|
|
29
|
+
"transactionHash": "0x1",
|
|
30
|
+
"gasUsed": 21_000,
|
|
31
|
+
"logs": [],
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
txn.get_web3 = MagicMock(return_value=fake_web3)
|
|
36
|
+
txn._validate_transaction = MagicMock(side_effect=lambda tx: tx)
|
|
37
|
+
txn._nonce_transaction = AsyncMock(side_effect=lambda tx, _w3: tx)
|
|
38
|
+
txn._gas_limit_transaction = AsyncMock(side_effect=lambda tx, _w3: tx)
|
|
39
|
+
txn._gas_price_transaction = AsyncMock(side_effect=lambda tx, _chain_id, _w3: tx)
|
|
40
|
+
txn._sign_transaction = MagicMock(return_value=b"signed")
|
|
41
|
+
txn._close_web3 = AsyncMock()
|
|
42
|
+
txn._wait_for_confirmations = AsyncMock()
|
|
43
|
+
|
|
44
|
+
ok, result = await txn.broadcast_transaction(
|
|
45
|
+
{
|
|
46
|
+
"chainId": BASE_CHAIN_ID,
|
|
47
|
+
"from": "0x0000000000000000000000000000000000000001",
|
|
48
|
+
"to": "0x0000000000000000000000000000000000000002",
|
|
49
|
+
"value": 0,
|
|
50
|
+
},
|
|
51
|
+
wait_for_receipt=True,
|
|
52
|
+
timeout=1,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
assert ok is True
|
|
56
|
+
txn._wait_for_confirmations.assert_awaited_once_with(fake_web3, 100, 2)
|
|
57
|
+
assert result["confirmations"] == 2
|
|
58
|
+
assert result["confirmed_block_number"] == 102
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.mark.asyncio
|
|
62
|
+
async def test_non_base_defaults_to_zero_confirmations():
|
|
63
|
+
txn = LocalEvmTxn(config={})
|
|
64
|
+
|
|
65
|
+
fake_web3 = MagicMock()
|
|
66
|
+
fake_web3.eth = MagicMock()
|
|
67
|
+
fake_web3.eth.send_raw_transaction = AsyncMock(return_value=_FakeTxHash("0x1"))
|
|
68
|
+
fake_web3.eth.wait_for_transaction_receipt = AsyncMock(
|
|
69
|
+
return_value={
|
|
70
|
+
"status": 1,
|
|
71
|
+
"blockNumber": 100,
|
|
72
|
+
"transactionHash": "0x1",
|
|
73
|
+
"gasUsed": 21_000,
|
|
74
|
+
"logs": [],
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
txn.get_web3 = MagicMock(return_value=fake_web3)
|
|
79
|
+
txn._validate_transaction = MagicMock(side_effect=lambda tx: tx)
|
|
80
|
+
txn._nonce_transaction = AsyncMock(side_effect=lambda tx, _w3: tx)
|
|
81
|
+
txn._gas_limit_transaction = AsyncMock(side_effect=lambda tx, _w3: tx)
|
|
82
|
+
txn._gas_price_transaction = AsyncMock(side_effect=lambda tx, _chain_id, _w3: tx)
|
|
83
|
+
txn._sign_transaction = MagicMock(return_value=b"signed")
|
|
84
|
+
txn._close_web3 = AsyncMock()
|
|
85
|
+
txn._wait_for_confirmations = AsyncMock()
|
|
86
|
+
|
|
87
|
+
ok, result = await txn.broadcast_transaction(
|
|
88
|
+
{
|
|
89
|
+
"chainId": 1,
|
|
90
|
+
"from": "0x0000000000000000000000000000000000000001",
|
|
91
|
+
"to": "0x0000000000000000000000000000000000000002",
|
|
92
|
+
"value": 0,
|
|
93
|
+
},
|
|
94
|
+
wait_for_receipt=True,
|
|
95
|
+
timeout=1,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
assert ok is True
|
|
99
|
+
txn._wait_for_confirmations.assert_not_awaited()
|
|
100
|
+
assert result["confirmations"] == 0
|
|
101
|
+
assert result["confirmed_block_number"] == 100
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@pytest.mark.asyncio
|
|
105
|
+
async def test_explicit_confirmations_override_defaults():
|
|
106
|
+
txn = LocalEvmTxn(config={})
|
|
107
|
+
|
|
108
|
+
fake_web3 = MagicMock()
|
|
109
|
+
fake_web3.eth = MagicMock()
|
|
110
|
+
fake_web3.eth.send_raw_transaction = AsyncMock(return_value=_FakeTxHash("0x1"))
|
|
111
|
+
fake_web3.eth.wait_for_transaction_receipt = AsyncMock(
|
|
112
|
+
return_value={
|
|
113
|
+
"status": 1,
|
|
114
|
+
"blockNumber": 100,
|
|
115
|
+
"transactionHash": "0x1",
|
|
116
|
+
"gasUsed": 21_000,
|
|
117
|
+
"logs": [],
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
txn.get_web3 = MagicMock(return_value=fake_web3)
|
|
122
|
+
txn._validate_transaction = MagicMock(side_effect=lambda tx: tx)
|
|
123
|
+
txn._nonce_transaction = AsyncMock(side_effect=lambda tx, _w3: tx)
|
|
124
|
+
txn._gas_limit_transaction = AsyncMock(side_effect=lambda tx, _w3: tx)
|
|
125
|
+
txn._gas_price_transaction = AsyncMock(side_effect=lambda tx, _chain_id, _w3: tx)
|
|
126
|
+
txn._sign_transaction = MagicMock(return_value=b"signed")
|
|
127
|
+
txn._close_web3 = AsyncMock()
|
|
128
|
+
txn._wait_for_confirmations = AsyncMock()
|
|
129
|
+
|
|
130
|
+
ok, result = await txn.broadcast_transaction(
|
|
131
|
+
{
|
|
132
|
+
"chainId": BASE_CHAIN_ID,
|
|
133
|
+
"from": "0x0000000000000000000000000000000000000001",
|
|
134
|
+
"to": "0x0000000000000000000000000000000000000002",
|
|
135
|
+
"value": 0,
|
|
136
|
+
},
|
|
137
|
+
wait_for_receipt=True,
|
|
138
|
+
timeout=1,
|
|
139
|
+
confirmations=0,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
assert ok is True
|
|
143
|
+
txn._wait_for_confirmations.assert_not_awaited()
|
|
144
|
+
assert result["confirmations"] == 0
|
|
145
|
+
assert result["confirmed_block_number"] == 100
|
|
@@ -143,8 +143,22 @@ class Strategy(ABC):
|
|
|
143
143
|
@abstractmethod
|
|
144
144
|
async def update(self) -> StatusTuple:
|
|
145
145
|
"""
|
|
146
|
-
|
|
147
|
-
|
|
146
|
+
Deploy funds to protocols (no main wallet access).
|
|
147
|
+
Called after deposit() has transferred assets to strategy wallet.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Tuple of (success: bool, message: str)
|
|
151
|
+
"""
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
@abstractmethod
|
|
155
|
+
async def exit(self, **kwargs) -> StatusTuple:
|
|
156
|
+
"""
|
|
157
|
+
Transfer funds from strategy wallet to main wallet.
|
|
158
|
+
Called after withdraw() has liquidated all positions.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Tuple of (success: bool, message: str)
|
|
148
162
|
"""
|
|
149
163
|
pass
|
|
150
164
|
|
|
@@ -7,7 +7,6 @@ across multiple adapters, extracted from evm_transaction_adapter.
|
|
|
7
7
|
|
|
8
8
|
import json
|
|
9
9
|
import os
|
|
10
|
-
from pathlib import Path
|
|
11
10
|
from typing import Any
|
|
12
11
|
|
|
13
12
|
from loguru import logger
|
|
@@ -170,9 +169,3 @@ async def get_abi_filtered(
|
|
|
170
169
|
if item.get("type") == "function" and item.get("name") in function_names
|
|
171
170
|
]
|
|
172
171
|
return filtered_abi
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
with open(Path(__file__).parent.parent.parent.joinpath("abis/generic/erc20.json")) as f:
|
|
176
|
-
erc20_abi_raw = f.read()
|
|
177
|
-
|
|
178
|
-
ERC20_ABI = json.loads(erc20_abi_raw)
|
wayfinder_paths/run_strategy.py
CHANGED
|
@@ -197,6 +197,10 @@ async def run_strategy(
|
|
|
197
197
|
result = await strategy_job.execute_strategy("update")
|
|
198
198
|
logger.info(f"Update result: {result}")
|
|
199
199
|
|
|
200
|
+
elif action == "exit":
|
|
201
|
+
result = await strategy_job.execute_strategy("exit")
|
|
202
|
+
logger.info(f"Exit result: {result}")
|
|
203
|
+
|
|
200
204
|
elif action == "partial-liquidate":
|
|
201
205
|
usd_value = kwargs.get("amount")
|
|
202
206
|
if not usd_value:
|
|
@@ -292,6 +296,7 @@ def main():
|
|
|
292
296
|
"withdraw",
|
|
293
297
|
"status",
|
|
294
298
|
"update",
|
|
299
|
+
"exit",
|
|
295
300
|
"policy",
|
|
296
301
|
"script",
|
|
297
302
|
"partial-liquidate",
|
|
@@ -1072,6 +1072,73 @@ class BasisTradingStrategy(BasisSnapshotMixin, Strategy):
|
|
|
1072
1072
|
f"Withdrew ${total_withdrawn:.2f} total to main wallet ({main_address}).",
|
|
1073
1073
|
)
|
|
1074
1074
|
|
|
1075
|
+
async def exit(self, **kwargs) -> StatusTuple:
|
|
1076
|
+
"""Transfer funds from strategy wallet to main wallet."""
|
|
1077
|
+
self.logger.info("EXIT: Transferring remaining funds to main wallet")
|
|
1078
|
+
|
|
1079
|
+
strategy_address = self._get_strategy_wallet_address()
|
|
1080
|
+
main_address = self._get_main_wallet_address()
|
|
1081
|
+
|
|
1082
|
+
if strategy_address.lower() == main_address.lower():
|
|
1083
|
+
return (True, "Main wallet is strategy wallet, no transfer needed")
|
|
1084
|
+
|
|
1085
|
+
transferred_items = []
|
|
1086
|
+
|
|
1087
|
+
# Transfer USDC to main wallet
|
|
1088
|
+
usdc_ok, usdc_raw = await self.balance_adapter.get_balance(
|
|
1089
|
+
token_id=USDC_ARBITRUM_TOKEN_ID,
|
|
1090
|
+
wallet_address=strategy_address,
|
|
1091
|
+
)
|
|
1092
|
+
if usdc_ok and usdc_raw:
|
|
1093
|
+
usdc_balance = float(usdc_raw.get("balance", 0))
|
|
1094
|
+
if usdc_balance > 1.0:
|
|
1095
|
+
self.logger.info(f"Transferring {usdc_balance:.2f} USDC to main wallet")
|
|
1096
|
+
(
|
|
1097
|
+
success,
|
|
1098
|
+
msg,
|
|
1099
|
+
) = await self.balance_adapter.move_from_strategy_wallet_to_main_wallet(
|
|
1100
|
+
query=USDC_ARBITRUM_TOKEN_ID,
|
|
1101
|
+
amount=usdc_balance,
|
|
1102
|
+
strategy_name=self.name,
|
|
1103
|
+
skip_ledger=False,
|
|
1104
|
+
)
|
|
1105
|
+
if success:
|
|
1106
|
+
transferred_items.append(f"{usdc_balance:.2f} USDC")
|
|
1107
|
+
else:
|
|
1108
|
+
self.logger.warning(f"USDC transfer failed: {msg}")
|
|
1109
|
+
|
|
1110
|
+
# Transfer ETH (minus reserve for tx fees) to main wallet
|
|
1111
|
+
eth_ok, eth_raw = await self.balance_adapter.get_balance(
|
|
1112
|
+
token_id="ethereum-arbitrum",
|
|
1113
|
+
wallet_address=strategy_address,
|
|
1114
|
+
)
|
|
1115
|
+
if eth_ok and eth_raw:
|
|
1116
|
+
eth_balance = float(eth_raw.get("balance", 0))
|
|
1117
|
+
tx_fee_reserve = 0.0002
|
|
1118
|
+
transferable_eth = eth_balance - tx_fee_reserve
|
|
1119
|
+
if transferable_eth > 0.0001:
|
|
1120
|
+
self.logger.info(
|
|
1121
|
+
f"Transferring {transferable_eth:.6f} ETH to main wallet"
|
|
1122
|
+
)
|
|
1123
|
+
(
|
|
1124
|
+
success,
|
|
1125
|
+
msg,
|
|
1126
|
+
) = await self.balance_adapter.move_from_strategy_wallet_to_main_wallet(
|
|
1127
|
+
query="ethereum-arbitrum",
|
|
1128
|
+
amount=transferable_eth,
|
|
1129
|
+
strategy_name=self.name,
|
|
1130
|
+
skip_ledger=False,
|
|
1131
|
+
)
|
|
1132
|
+
if success:
|
|
1133
|
+
transferred_items.append(f"{transferable_eth:.6f} ETH")
|
|
1134
|
+
else:
|
|
1135
|
+
self.logger.warning(f"ETH transfer failed: {msg}")
|
|
1136
|
+
|
|
1137
|
+
if not transferred_items:
|
|
1138
|
+
return (True, "No funds to transfer to main wallet")
|
|
1139
|
+
|
|
1140
|
+
return (True, f"Transferred to main wallet: {', '.join(transferred_items)}")
|
|
1141
|
+
|
|
1075
1142
|
async def _status(self) -> StatusDict:
|
|
1076
1143
|
"""Return portfolio value and strategy status with live data."""
|
|
1077
1144
|
total_value, hl_value, vault_value = await self._get_total_portfolio_value()
|
|
@@ -461,7 +461,7 @@ class TestBasisTradingStrategy:
|
|
|
461
461
|
# Try to scale with $5 (below $10 minimum notional)
|
|
462
462
|
# With 2x leverage, order_usd = 5 * (2/3) = 3.33, below $10
|
|
463
463
|
success, msg = await strategy._scale_up_position(5.0)
|
|
464
|
-
assert success
|
|
464
|
+
assert success # Returns success=True but with message
|
|
465
465
|
assert "below minimum notional" in msg
|
|
466
466
|
|
|
467
467
|
@pytest.mark.asyncio
|
|
@@ -563,7 +563,7 @@ class TestBasisTradingStrategy:
|
|
|
563
563
|
|
|
564
564
|
# Should have called fill_pair_units to scale up
|
|
565
565
|
assert mock_filler.fill_pair_units.called
|
|
566
|
-
assert success
|
|
566
|
+
assert success
|
|
567
567
|
|
|
568
568
|
@pytest.mark.asyncio
|
|
569
569
|
async def test_ensure_builder_fee_approved_already_approved(
|
|
@@ -612,7 +612,7 @@ class TestBasisTradingStrategy:
|
|
|
612
612
|
)
|
|
613
613
|
|
|
614
614
|
success, msg = await s.ensure_builder_fee_approved()
|
|
615
|
-
assert success
|
|
615
|
+
assert success
|
|
616
616
|
assert "already approved" in msg.lower()
|
|
617
617
|
# Should not have called approve_builder_fee
|
|
618
618
|
mock_hyperliquid_adapter.approve_builder_fee.assert_not_called()
|
|
@@ -661,7 +661,7 @@ class TestBasisTradingStrategy:
|
|
|
661
661
|
)
|
|
662
662
|
|
|
663
663
|
success, msg = await s.ensure_builder_fee_approved()
|
|
664
|
-
assert success
|
|
664
|
+
assert success
|
|
665
665
|
assert "approved" in msg.lower()
|
|
666
666
|
# Should have called approve_builder_fee
|
|
667
667
|
mock_hyperliquid_adapter.approve_builder_fee.assert_called_once()
|
|
@@ -832,7 +832,7 @@ class TestBasisTradingStrategy:
|
|
|
832
832
|
mock_filler_class.return_value = mock_filler
|
|
833
833
|
|
|
834
834
|
success, _ = await strategy.update()
|
|
835
|
-
assert success
|
|
835
|
+
assert success
|
|
836
836
|
|
|
837
837
|
# Target spot was $66.67, so we should transfer $33.33 spot->perp.
|
|
838
838
|
mock_hyperliquid_adapter.transfer_spot_to_perp.assert_called_once()
|
|
@@ -907,7 +907,7 @@ class TestBasisTradingStrategy:
|
|
|
907
907
|
strategy._find_and_open_position = AsyncMock(return_value=(True, "redeployed"))
|
|
908
908
|
|
|
909
909
|
success, msg = await strategy.update()
|
|
910
|
-
assert success
|
|
910
|
+
assert success
|
|
911
911
|
assert msg == "redeployed"
|
|
912
912
|
strategy._close_position.assert_awaited_once()
|
|
913
913
|
strategy._find_and_open_position.assert_awaited_once()
|
|
@@ -719,8 +719,8 @@ class HyperlendStableYieldStrategy(Strategy):
|
|
|
719
719
|
result,
|
|
720
720
|
tx_data,
|
|
721
721
|
) = await self.brap_adapter.swap_from_token_ids(
|
|
722
|
-
|
|
723
|
-
|
|
722
|
+
from_token_id=from_token_id,
|
|
723
|
+
to_token_id=to_token_id,
|
|
724
724
|
from_address=strategy_address,
|
|
725
725
|
amount=amount_wei_str,
|
|
726
726
|
slippage=slippage,
|
|
@@ -935,6 +935,75 @@ class HyperlendStableYieldStrategy(Strategy):
|
|
|
935
935
|
|
|
936
936
|
return (True, ". ".join(messages))
|
|
937
937
|
|
|
938
|
+
async def exit(self, **kwargs) -> StatusTuple:
|
|
939
|
+
"""Transfer funds from strategy wallet to main wallet."""
|
|
940
|
+
self.logger.info("EXIT: Transferring remaining funds to main wallet")
|
|
941
|
+
|
|
942
|
+
strategy_address = self._get_strategy_wallet_address()
|
|
943
|
+
main_address = self._get_main_wallet_address()
|
|
944
|
+
|
|
945
|
+
if strategy_address.lower() == main_address.lower():
|
|
946
|
+
return (True, "Main wallet is strategy wallet, no transfer needed")
|
|
947
|
+
|
|
948
|
+
transferred_items = []
|
|
949
|
+
|
|
950
|
+
# Transfer USDT0 to main wallet
|
|
951
|
+
usdt_ok, usdt_raw = await self.balance_adapter.get_balance(
|
|
952
|
+
token_id="usdt0-hyperevm",
|
|
953
|
+
wallet_address=strategy_address,
|
|
954
|
+
)
|
|
955
|
+
if usdt_ok and usdt_raw:
|
|
956
|
+
usdt_balance = float(usdt_raw.get("balance", 0))
|
|
957
|
+
if usdt_balance > 1.0:
|
|
958
|
+
self.logger.info(
|
|
959
|
+
f"Transferring {usdt_balance:.2f} USDT0 to main wallet"
|
|
960
|
+
)
|
|
961
|
+
(
|
|
962
|
+
success,
|
|
963
|
+
msg,
|
|
964
|
+
) = await self.balance_adapter.move_from_strategy_wallet_to_main_wallet(
|
|
965
|
+
query="usdt0-hyperevm",
|
|
966
|
+
amount=usdt_balance,
|
|
967
|
+
strategy_name=self.name,
|
|
968
|
+
skip_ledger=False,
|
|
969
|
+
)
|
|
970
|
+
if success:
|
|
971
|
+
transferred_items.append(f"{usdt_balance:.2f} USDT0")
|
|
972
|
+
else:
|
|
973
|
+
self.logger.warning(f"USDT0 transfer failed: {msg}")
|
|
974
|
+
|
|
975
|
+
# Transfer HYPE (minus reserve for tx fees) to main wallet
|
|
976
|
+
hype_ok, hype_raw = await self.balance_adapter.get_balance(
|
|
977
|
+
token_id="hyperliquid-hyperevm",
|
|
978
|
+
wallet_address=strategy_address,
|
|
979
|
+
)
|
|
980
|
+
if hype_ok and hype_raw:
|
|
981
|
+
hype_balance = float(hype_raw.get("balance", 0))
|
|
982
|
+
tx_fee_reserve = 0.1 # Reserve 0.1 HYPE for tx fees
|
|
983
|
+
transferable_hype = hype_balance - tx_fee_reserve
|
|
984
|
+
if transferable_hype > 0.01:
|
|
985
|
+
self.logger.info(
|
|
986
|
+
f"Transferring {transferable_hype:.4f} HYPE to main wallet"
|
|
987
|
+
)
|
|
988
|
+
(
|
|
989
|
+
success,
|
|
990
|
+
msg,
|
|
991
|
+
) = await self.balance_adapter.move_from_strategy_wallet_to_main_wallet(
|
|
992
|
+
query="hyperliquid-hyperevm",
|
|
993
|
+
amount=transferable_hype,
|
|
994
|
+
strategy_name=self.name,
|
|
995
|
+
skip_ledger=False,
|
|
996
|
+
)
|
|
997
|
+
if success:
|
|
998
|
+
transferred_items.append(f"{transferable_hype:.4f} HYPE")
|
|
999
|
+
else:
|
|
1000
|
+
self.logger.warning(f"HYPE transfer failed: {msg}")
|
|
1001
|
+
|
|
1002
|
+
if not transferred_items:
|
|
1003
|
+
return (True, "No funds to transfer to main wallet")
|
|
1004
|
+
|
|
1005
|
+
return (True, f"Transferred to main wallet: {', '.join(transferred_items)}")
|
|
1006
|
+
|
|
938
1007
|
async def _swap_residual_balances_to_token(
|
|
939
1008
|
self, token_info: dict[str, Any], include_native: bool = False
|
|
940
1009
|
) -> list[str]:
|
|
@@ -31,9 +31,10 @@ The position is **delta-neutral**: WETH debt offsets wstETH collateral, so PnL i
|
|
|
31
31
|
## Safety features
|
|
32
32
|
|
|
33
33
|
- **Depeg guard**: `_max_safe_F()` calculates leverage ceiling based on wstETH collateral factor and max depeg tolerance.
|
|
34
|
-
- **Delta-neutrality**: `
|
|
34
|
+
- **Delta-neutrality**: `_post_run_guard()` enforces wstETH collateral ≥ WETH debt (within tolerance) via `_reconcile_wallet_into_position()` and `_settle_weth_debt_to_target_usd()`.
|
|
35
35
|
- **Swap retries**: `_swap_with_retries()` uses progressive slippage (0.5% → 1% → 1.5%) with exponential backoff.
|
|
36
36
|
- **Health monitoring**: Automatic deleveraging when health factor drops below `MIN_HEALTH_FACTOR`.
|
|
37
|
+
- **Deterministic Base reads**: waits 2 blocks after receipts by default and pins ETH/ERC20 balance reads to the confirmed block to avoid stale RPC reads on Base.
|
|
37
38
|
- **Rollback protection**: Checks actual balances before rollback swaps to prevent failed transactions.
|
|
38
39
|
|
|
39
40
|
## Adapters used
|
|
@@ -58,9 +59,9 @@ The position is **delta-neutral**: WETH debt offsets wstETH collateral, so PnL i
|
|
|
58
59
|
### Update
|
|
59
60
|
|
|
60
61
|
- Checks gas balance meets maintenance threshold.
|
|
61
|
-
-
|
|
62
|
-
- Computes
|
|
63
|
-
- If HF < MIN: triggers deleveraging via `
|
|
62
|
+
- Reconciles wallet leftovers into the intended position (`_reconcile_wallet_into_position()`).
|
|
63
|
+
- Computes HF/LTV/delta from a single accounting snapshot.
|
|
64
|
+
- If HF < MIN: triggers deleveraging via `_settle_weth_debt_to_target_usd()`.
|
|
64
65
|
- If HF > MAX: executes additional leverage loops to optimize yield.
|
|
65
66
|
- Claims WELL rewards if above minimum threshold.
|
|
66
67
|
|
|
@@ -75,7 +76,7 @@ The position is **delta-neutral**: WETH debt offsets wstETH collateral, so PnL i
|
|
|
75
76
|
### Withdraw
|
|
76
77
|
|
|
77
78
|
- Sweeps miscellaneous token balances to WETH.
|
|
78
|
-
- Repays all WETH debt via `
|
|
79
|
+
- Repays all WETH debt via `_settle_weth_debt_to_target_usd(target_debt_usd=0.0, mode="exit")`.
|
|
79
80
|
- Unlends wstETH, swaps to USDC.
|
|
80
81
|
- Unlends USDC collateral.
|
|
81
82
|
- Returns USDC and remaining ETH to main wallet.
|