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.

Files changed (36) hide show
  1. wayfinder_paths/adapters/balance_adapter/adapter.py +40 -0
  2. wayfinder_paths/adapters/balance_adapter/test_adapter.py +3 -3
  3. wayfinder_paths/adapters/brap_adapter/adapter.py +66 -15
  4. wayfinder_paths/adapters/brap_adapter/test_adapter.py +6 -6
  5. wayfinder_paths/adapters/hyperlend_adapter/adapter.py +14 -0
  6. wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +7 -7
  7. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +6 -6
  8. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +12 -12
  9. wayfinder_paths/adapters/ledger_adapter/test_adapter.py +6 -6
  10. wayfinder_paths/adapters/moonwell_adapter/adapter.py +332 -9
  11. wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +13 -13
  12. wayfinder_paths/adapters/pool_adapter/test_adapter.py +2 -2
  13. wayfinder_paths/adapters/token_adapter/test_adapter.py +4 -4
  14. wayfinder_paths/core/constants/erc20_abi.py +0 -11
  15. wayfinder_paths/core/engine/StrategyJob.py +3 -1
  16. wayfinder_paths/core/services/base.py +1 -0
  17. wayfinder_paths/core/services/local_evm_txn.py +19 -3
  18. wayfinder_paths/core/services/local_token_txn.py +1 -5
  19. wayfinder_paths/core/services/test_local_evm_txn.py +145 -0
  20. wayfinder_paths/core/strategies/Strategy.py +16 -2
  21. wayfinder_paths/core/utils/evm_helpers.py +0 -7
  22. wayfinder_paths/policies/erc20.py +1 -1
  23. wayfinder_paths/run_strategy.py +5 -0
  24. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +67 -0
  25. wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +6 -6
  26. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +71 -2
  27. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/README.md +6 -5
  28. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +2249 -1282
  29. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +282 -121
  30. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +65 -0
  31. wayfinder_paths/templates/adapter/README.md +1 -1
  32. {wayfinder_paths-0.1.14.dist-info → wayfinder_paths-0.1.15.dist-info}/METADATA +1 -1
  33. {wayfinder_paths-0.1.14.dist-info → wayfinder_paths-0.1.15.dist-info}/RECORD +35 -35
  34. wayfinder_paths/abis/generic/erc20.json +0 -383
  35. {wayfinder_paths-0.1.14.dist-info → wayfinder_paths-0.1.15.dist-info}/LICENSE +0 -0
  36. {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 = 1,
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 confirmations > 0:
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, confirmations
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 AsyncWeb3, Web3
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
- Update strategy positions/rebalance
147
- Returns: (success: bool, message: str)
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)
@@ -1,4 +1,4 @@
1
- from wayfinder_paths.core.utils.evm_helpers import ERC20_ABI
1
+ from wayfinder_paths.core.constants.erc20_abi import ERC20_ABI
2
2
 
3
3
 
4
4
  def any_erc20_function(token_address: str) -> dict:
@@ -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 is True # Returns success=True but with message
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 is True
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 is True
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 is True
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 is True
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 is True
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
- from_query=from_token_id,
723
- to_query=to_token_id,
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**: `_balance_weth_debt()` rebalances when WETH debt exceeds wstETH collateral value.
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
- - Balances WETH debt against wstETH collateral for delta-neutrality.
62
- - Computes health factor from aggregated positions.
63
- - If HF < MIN: triggers deleveraging via `_repay_debt_loop()`.
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 `_repay_debt_loop()`.
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.