wayfinder-paths 0.1.23__py3-none-any.whl → 0.1.24__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 (122) hide show
  1. wayfinder_paths/adapters/balance_adapter/adapter.py +250 -0
  2. wayfinder_paths/adapters/balance_adapter/manifest.yaml +8 -0
  3. wayfinder_paths/adapters/balance_adapter/test_adapter.py +0 -11
  4. wayfinder_paths/adapters/boros_adapter/__init__.py +17 -0
  5. wayfinder_paths/adapters/boros_adapter/adapter.py +1574 -0
  6. wayfinder_paths/adapters/boros_adapter/client.py +476 -0
  7. wayfinder_paths/adapters/boros_adapter/manifest.yaml +10 -0
  8. wayfinder_paths/adapters/boros_adapter/parsers.py +88 -0
  9. wayfinder_paths/adapters/boros_adapter/test_adapter.py +460 -0
  10. wayfinder_paths/adapters/boros_adapter/test_golden.py +156 -0
  11. wayfinder_paths/adapters/boros_adapter/types.py +70 -0
  12. wayfinder_paths/adapters/boros_adapter/utils.py +85 -0
  13. wayfinder_paths/adapters/brap_adapter/adapter.py +1 -1
  14. wayfinder_paths/adapters/brap_adapter/manifest.yaml +9 -0
  15. wayfinder_paths/adapters/hyperlend_adapter/adapter.py +161 -26
  16. wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +9 -0
  17. wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +77 -13
  18. wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +2 -9
  19. wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +585 -61
  20. wayfinder_paths/adapters/hyperliquid_adapter/executor.py +47 -68
  21. wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +14 -0
  22. wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +2 -3
  23. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +17 -21
  24. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +3 -6
  25. wayfinder_paths/adapters/hyperliquid_adapter/test_executor.py +4 -8
  26. wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +2 -2
  27. wayfinder_paths/adapters/ledger_adapter/manifest.yaml +7 -0
  28. wayfinder_paths/adapters/ledger_adapter/test_adapter.py +1 -2
  29. wayfinder_paths/adapters/moonwell_adapter/adapter.py +592 -400
  30. wayfinder_paths/adapters/moonwell_adapter/manifest.yaml +14 -0
  31. wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +126 -219
  32. wayfinder_paths/adapters/multicall_adapter/__init__.py +7 -0
  33. wayfinder_paths/adapters/multicall_adapter/adapter.py +166 -0
  34. wayfinder_paths/adapters/multicall_adapter/manifest.yaml +5 -0
  35. wayfinder_paths/adapters/multicall_adapter/test_adapter.py +97 -0
  36. wayfinder_paths/adapters/pendle_adapter/README.md +102 -0
  37. wayfinder_paths/adapters/pendle_adapter/__init__.py +7 -0
  38. wayfinder_paths/adapters/pendle_adapter/adapter.py +1992 -0
  39. wayfinder_paths/adapters/pendle_adapter/examples.json +11 -0
  40. wayfinder_paths/adapters/pendle_adapter/manifest.yaml +21 -0
  41. wayfinder_paths/adapters/pendle_adapter/test_adapter.py +666 -0
  42. wayfinder_paths/adapters/pool_adapter/manifest.yaml +6 -0
  43. wayfinder_paths/adapters/token_adapter/examples.json +0 -4
  44. wayfinder_paths/adapters/token_adapter/manifest.yaml +7 -0
  45. wayfinder_paths/conftest.py +24 -17
  46. wayfinder_paths/core/adapters/BaseAdapter.py +0 -25
  47. wayfinder_paths/core/adapters/models.py +17 -7
  48. wayfinder_paths/core/clients/BRAPClient.py +1 -1
  49. wayfinder_paths/core/clients/TokenClient.py +47 -1
  50. wayfinder_paths/core/clients/WayfinderClient.py +1 -2
  51. wayfinder_paths/core/clients/protocols.py +21 -22
  52. wayfinder_paths/core/clients/test_ledger_client.py +448 -0
  53. wayfinder_paths/core/config.py +12 -0
  54. wayfinder_paths/core/constants/__init__.py +15 -0
  55. wayfinder_paths/core/constants/base.py +6 -1
  56. wayfinder_paths/core/constants/contracts.py +39 -26
  57. wayfinder_paths/core/constants/erc20_abi.py +0 -1
  58. wayfinder_paths/core/constants/hyperlend_abi.py +0 -4
  59. wayfinder_paths/core/constants/hyperliquid.py +16 -0
  60. wayfinder_paths/core/constants/moonwell_abi.py +0 -15
  61. wayfinder_paths/core/engine/manifest.py +66 -0
  62. wayfinder_paths/core/strategies/Strategy.py +0 -61
  63. wayfinder_paths/core/strategies/__init__.py +10 -1
  64. wayfinder_paths/core/strategies/opa_loop.py +167 -0
  65. wayfinder_paths/core/utils/test_transaction.py +289 -0
  66. wayfinder_paths/core/utils/transaction.py +44 -1
  67. wayfinder_paths/core/utils/web3.py +3 -0
  68. wayfinder_paths/mcp/__init__.py +5 -0
  69. wayfinder_paths/mcp/preview.py +185 -0
  70. wayfinder_paths/mcp/scripting.py +84 -0
  71. wayfinder_paths/mcp/server.py +52 -0
  72. wayfinder_paths/mcp/state/profile_store.py +195 -0
  73. wayfinder_paths/mcp/state/store.py +89 -0
  74. wayfinder_paths/mcp/test_scripting.py +267 -0
  75. wayfinder_paths/mcp/tools/__init__.py +0 -0
  76. wayfinder_paths/mcp/tools/balances.py +290 -0
  77. wayfinder_paths/mcp/tools/discovery.py +158 -0
  78. wayfinder_paths/mcp/tools/execute.py +770 -0
  79. wayfinder_paths/mcp/tools/hyperliquid.py +931 -0
  80. wayfinder_paths/mcp/tools/quotes.py +288 -0
  81. wayfinder_paths/mcp/tools/run_script.py +286 -0
  82. wayfinder_paths/mcp/tools/strategies.py +188 -0
  83. wayfinder_paths/mcp/tools/tokens.py +46 -0
  84. wayfinder_paths/mcp/tools/wallets.py +354 -0
  85. wayfinder_paths/mcp/utils.py +129 -0
  86. wayfinder_paths/policies/hyperliquid.py +1 -1
  87. wayfinder_paths/policies/lifi.py +18 -0
  88. wayfinder_paths/policies/util.py +8 -2
  89. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +28 -119
  90. wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +24 -53
  91. wayfinder_paths/strategies/boros_hype_strategy/__init__.py +3 -0
  92. wayfinder_paths/strategies/boros_hype_strategy/boros_ops_mixin.py +450 -0
  93. wayfinder_paths/strategies/boros_hype_strategy/constants.py +255 -0
  94. wayfinder_paths/strategies/boros_hype_strategy/examples.json +37 -0
  95. wayfinder_paths/strategies/boros_hype_strategy/hyperevm_ops_mixin.py +114 -0
  96. wayfinder_paths/strategies/boros_hype_strategy/hyperliquid_ops_mixin.py +642 -0
  97. wayfinder_paths/strategies/boros_hype_strategy/manifest.yaml +36 -0
  98. wayfinder_paths/strategies/boros_hype_strategy/planner.py +460 -0
  99. wayfinder_paths/strategies/boros_hype_strategy/risk_ops_mixin.py +886 -0
  100. wayfinder_paths/strategies/boros_hype_strategy/snapshot_mixin.py +494 -0
  101. wayfinder_paths/strategies/boros_hype_strategy/strategy.py +1194 -0
  102. wayfinder_paths/strategies/boros_hype_strategy/test_planner_golden.py +374 -0
  103. wayfinder_paths/strategies/boros_hype_strategy/test_strategy.py +202 -0
  104. wayfinder_paths/strategies/boros_hype_strategy/types.py +365 -0
  105. wayfinder_paths/strategies/boros_hype_strategy/withdraw_mixin.py +997 -0
  106. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +3 -12
  107. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +7 -29
  108. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +63 -40
  109. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +5 -15
  110. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +0 -34
  111. wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +11 -34
  112. wayfinder_paths/tests/test_mcp_quote_swap.py +165 -0
  113. wayfinder_paths/tests/test_test_coverage.py +1 -4
  114. wayfinder_paths-0.1.24.dist-info/METADATA +378 -0
  115. wayfinder_paths-0.1.24.dist-info/RECORD +185 -0
  116. {wayfinder_paths-0.1.23.dist-info → wayfinder_paths-0.1.24.dist-info}/WHEEL +1 -1
  117. wayfinder_paths/scripts/create_strategy.py +0 -139
  118. wayfinder_paths/scripts/make_wallets.py +0 -142
  119. wayfinder_paths-0.1.23.dist-info/METADATA +0 -354
  120. wayfinder_paths-0.1.23.dist-info/RECORD +0 -120
  121. /wayfinder_paths/{scripts → mcp/state}/__init__.py +0 -0
  122. {wayfinder_paths-0.1.23.dist-info → wayfinder_paths-0.1.24.dist-info}/LICENSE +0 -0
@@ -0,0 +1,997 @@
1
+ """
2
+ Withdrawal operations for BorosHypeStrategy.
3
+
4
+ Kept as a mixin so the main strategy file stays readable without changing behavior.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import time
11
+
12
+ from loguru import logger
13
+
14
+ from wayfinder_paths.adapters.hyperliquid_adapter.adapter import HyperliquidAdapter
15
+ from wayfinder_paths.adapters.hyperliquid_adapter.paired_filler import MIN_NOTIONAL_USD
16
+ from wayfinder_paths.core.strategies import StatusTuple
17
+ from wayfinder_paths.core.utils.transaction import encode_call, send_transaction
18
+
19
+ from .constants import (
20
+ ARBITRUM_CHAIN_ID,
21
+ BOROS_HYPE_MARKET_ID,
22
+ BOROS_HYPE_TOKEN_ID,
23
+ HYPE_NATIVE,
24
+ HYPE_OFT_ADDRESS,
25
+ HYPEREVM_CHAIN_ID,
26
+ KHYPE_LST,
27
+ LOOPED_HYPE,
28
+ MIN_HYPE_GAS,
29
+ USDC_ARB,
30
+ USDT_ARB,
31
+ WHYPE,
32
+ WHYPE_ABI,
33
+ WHYPE_ADDRESS,
34
+ )
35
+
36
+
37
+ class BorosHypeWithdrawMixin:
38
+ async def withdraw(self, **kwargs) -> StatusTuple:
39
+ # Liquidates to USDC on Arb but does NOT transfer to main wallet (call exit() after)
40
+ if self.simulation:
41
+ return True, "[SIMULATION] Withdrawal complete"
42
+
43
+ max_wait_s = int(
44
+ kwargs.get("max_wait_s") or kwargs.get("max_wait_seconds") or 20 * 60
45
+ )
46
+ poll_interval_s = int(kwargs.get("poll_interval_s") or 10)
47
+ if max_wait_s < 0:
48
+ max_wait_s = 0
49
+ if poll_interval_s < 1:
50
+ poll_interval_s = 1
51
+ withdraw_start_ts = time.time()
52
+ deadline_ts = withdraw_start_ts + max_wait_s
53
+
54
+ if not self.balance_adapter:
55
+ return False, "Balance adapter not configured"
56
+ if not self.hyperliquid_adapter:
57
+ return False, "Hyperliquid adapter not configured"
58
+ if not self.brap_adapter:
59
+ return False, "BRAP adapter not configured"
60
+ if not self.boros_adapter:
61
+ return False, "Boros adapter not configured"
62
+ if not self._sign_callback:
63
+ return False, "No strategy wallet signing callback configured"
64
+
65
+ strategy_wallet = self._config.get("strategy_wallet", {})
66
+ address = strategy_wallet.get("address")
67
+ if not address:
68
+ return False, "No strategy wallet address configured"
69
+
70
+ # Ensure builder fee is approved before placing any orders
71
+ if self.hyperliquid_adapter and self.builder_fee:
72
+ ok, msg = await self.hyperliquid_adapter.ensure_builder_fee_approved(
73
+ address=address,
74
+ builder_fee=self.builder_fee,
75
+ )
76
+ if not ok:
77
+ return False, f"Builder fee approval failed: {msg}"
78
+ logger.info(f"Builder fee status: {msg}")
79
+
80
+ # Get inventory once - use it for initial decisions/logging.
81
+ inv = await self.observe()
82
+ isolated_usd = float(inv.boros_idle_collateral_isolated or 0.0) * float(
83
+ inv.hype_price_usd or 0.0
84
+ )
85
+ cross_usd = float(inv.boros_idle_collateral_cross or 0.0) * float(
86
+ inv.hype_price_usd or 0.0
87
+ )
88
+ logger.info(
89
+ "Withdraw starting. Inventory: "
90
+ f"hl_perp_margin=${inv.hl_perp_margin:.2f}, "
91
+ f"boros_collateral=${inv.boros_collateral_usd:.2f} "
92
+ f"(isolated={inv.boros_idle_collateral_isolated:.6f} HYPE ≈${isolated_usd:.2f}, "
93
+ f"cross={inv.boros_idle_collateral_cross:.6f} HYPE ≈${cross_usd:.2f}), "
94
+ f"boros_position_size=${inv.boros_position_size:.2f}, "
95
+ f"boros_market_ids={inv.boros_position_market_ids}, "
96
+ f"spot=${inv.spot_value_usd:.2f}, "
97
+ f"hl_short={inv.hl_short_size_hype:.4f} HYPE"
98
+ )
99
+
100
+ try:
101
+ await self._cancel_hl_open_orders_for_hype(address)
102
+ except Exception as exc: # noqa: BLE001
103
+ logger.debug(f"Failed to cancel HL open orders pre-withdraw: {exc}")
104
+
105
+ # ─────────────────────────────────────────────────────────────────
106
+ # STEP 1: Close all Boros positions (settles to USDT, no delta risk)
107
+ # ─────────────────────────────────────────────────────────────────
108
+ market_ids_to_close = inv.boros_position_market_ids or []
109
+ if not market_ids_to_close and inv.boros_position_size > 0:
110
+ try:
111
+ ok_pos, positions = await self.boros_adapter.get_active_positions()
112
+ if ok_pos and isinstance(positions, list):
113
+ mids: set[int] = set()
114
+ for pos in positions:
115
+ mid = pos.get("marketId") or pos.get("market_id")
116
+ try:
117
+ mid_int = int(mid) if mid is not None else None
118
+ except (TypeError, ValueError):
119
+ mid_int = None
120
+ if mid_int and mid_int > 0:
121
+ mids.add(mid_int)
122
+ market_ids_to_close = sorted(mids)
123
+ except Exception as exc: # noqa: BLE001
124
+ logger.warning(f"Failed to fetch Boros market IDs for close: {exc}")
125
+
126
+ if inv.boros_position_size > 0 and not market_ids_to_close:
127
+ logger.warning(
128
+ f"Boros position size > 0 but no market IDs found; trying default {BOROS_HYPE_MARKET_ID}"
129
+ )
130
+ market_ids_to_close = [BOROS_HYPE_MARKET_ID]
131
+
132
+ boros_position_closed = False
133
+ for market_id in market_ids_to_close:
134
+ try:
135
+ ok_close, res_close = await self.boros_adapter.close_positions_market(
136
+ market_id, token_id=BOROS_HYPE_TOKEN_ID
137
+ )
138
+ if ok_close:
139
+ boros_position_closed = True
140
+ logger.info(f"Closed Boros position in market {market_id}")
141
+ else:
142
+ logger.warning(
143
+ f"Failed to close Boros position in market {market_id}: {res_close}"
144
+ )
145
+ except Exception as exc: # noqa: BLE001
146
+ logger.warning(
147
+ f"Failed to close Boros position in market {market_id}: {exc}"
148
+ )
149
+
150
+ if boros_position_closed:
151
+ await asyncio.sleep(5)
152
+
153
+ # ─────────────────────────────────────────────────────────────────
154
+ # STEP 2: Move isolated HYPE collateral to cross margin, then withdraw
155
+ # ─────────────────────────────────────────────────────────────────
156
+ boros_wait_min_hype_raw: int | None = None
157
+ try:
158
+ ok_bal, balances = await self.boros_adapter.get_account_balances(
159
+ token_id=BOROS_HYPE_TOKEN_ID
160
+ )
161
+ if ok_bal and isinstance(balances, dict):
162
+ isolated_hype = float(balances.get("isolated", 0.0))
163
+ cross_hype = float(balances.get("cross", 0.0))
164
+ total_hype = float(balances.get("total", 0.0))
165
+ isolated_positions = balances.get("isolated_positions", [])
166
+ logger.info(
167
+ "Boros balances after position close: "
168
+ f"isolated={isolated_hype:.6f}, cross={cross_hype:.6f}, total={total_hype:.6f}"
169
+ )
170
+
171
+ for iso_pos in isolated_positions:
172
+ iso_market_id = iso_pos.get("market_id")
173
+ iso_balance = float(iso_pos.get("balance", 0) or 0.0)
174
+ if iso_market_id and iso_balance > 0.001:
175
+ iso_wei = int(
176
+ iso_balance * 1e18
177
+ ) # Boros cashTransfer uses 1e18 cash units
178
+ logger.info(
179
+ f"Moving {iso_balance:.6f} collateral from isolated market {iso_market_id} to cross"
180
+ )
181
+ ok_xfer, res_xfer = await self.boros_adapter.cash_transfer(
182
+ market_id=iso_market_id, # Use actual market ID
183
+ amount_wei=iso_wei,
184
+ is_deposit=False, # isolated -> cross
185
+ )
186
+ if ok_xfer:
187
+ await asyncio.sleep(2)
188
+ else:
189
+ logger.warning(
190
+ f"Failed Boros isolated->cross transfer for market {iso_market_id}: {res_xfer}"
191
+ )
192
+
193
+ # Re-fetch balances after transfers
194
+ if isolated_positions:
195
+ ok_bal, balances = await self.boros_adapter.get_account_balances(
196
+ token_id=BOROS_HYPE_TOKEN_ID
197
+ )
198
+ if ok_bal and isinstance(balances, dict):
199
+ cross_hype = float(balances.get("cross", 0.0))
200
+
201
+ if cross_hype > 0.001:
202
+ withdraw_native = int(
203
+ cross_hype * 1e18
204
+ ) # HYPE native decimals (18)
205
+
206
+ (
207
+ ok_hype0,
208
+ res0,
209
+ ) = await self.balance_adapter.get_wallet_balances_multicall(
210
+ assets=[
211
+ {
212
+ "token_address": HYPE_OFT_ADDRESS,
213
+ "chain_id": ARBITRUM_CHAIN_ID,
214
+ }
215
+ ]
216
+ )
217
+ hype_raw_before_int = (
218
+ int(res0[0].get("balance_raw") or 0)
219
+ if ok_hype0
220
+ and isinstance(res0, list)
221
+ and res0
222
+ and res0[0].get("success")
223
+ else 0
224
+ )
225
+
226
+ ok_wd, res_wd = await self.boros_adapter.withdraw_collateral(
227
+ token_id=BOROS_HYPE_TOKEN_ID,
228
+ amount_native=withdraw_native,
229
+ )
230
+ if ok_wd:
231
+ logger.info(
232
+ f"Withdrew {cross_hype:.6f} HYPE collateral from Boros"
233
+ )
234
+ min_expected = max(
235
+ 0, int(withdraw_native * 0.99)
236
+ ) # 1% tolerance
237
+ boros_wait_min_hype_raw = hype_raw_before_int + min_expected
238
+ else:
239
+ logger.warning(f"Failed to withdraw Boros collateral: {res_wd}")
240
+ except Exception as exc: # noqa: BLE001
241
+ logger.warning(f"Boros collateral withdrawal step failed: {exc}")
242
+
243
+ # ─────────────────────────────────────────────────────────────────
244
+ # STEP 3: Sell spot positions on HyperEVM to HYPE (hedge still active)
245
+ # ─────────────────────────────────────────────────────────────────
246
+ try:
247
+ ok_khype, khype_raw = await self.balance_adapter.get_vault_wallet_balance(
248
+ KHYPE_LST
249
+ )
250
+ if ok_khype and khype_raw > 0:
251
+ ok, res = await self.brap_adapter.swap_from_token_ids(
252
+ from_token_id=KHYPE_LST,
253
+ to_token_id=HYPE_NATIVE,
254
+ from_address=address,
255
+ amount=str(int(khype_raw)),
256
+ slippage=0.01,
257
+ strategy_name="boros_hype_strategy",
258
+ )
259
+ if ok:
260
+ logger.info(f"Sold kHYPE → HYPE: {khype_raw / 1e18:.4f} kHYPE")
261
+ await asyncio.sleep(2)
262
+ else:
263
+ logger.warning(f"Failed to sell kHYPE → HYPE: {res}")
264
+
265
+ ok_lhype, lhype_raw = await self.balance_adapter.get_vault_wallet_balance(
266
+ LOOPED_HYPE
267
+ )
268
+ if ok_lhype and lhype_raw > 0:
269
+ ok, res = await self.brap_adapter.swap_from_token_ids(
270
+ from_token_id=LOOPED_HYPE,
271
+ to_token_id=HYPE_NATIVE,
272
+ from_address=address,
273
+ amount=str(int(lhype_raw)),
274
+ slippage=0.01,
275
+ strategy_name="boros_hype_strategy",
276
+ )
277
+ if ok:
278
+ logger.info(
279
+ f"Sold looped HYPE → HYPE: {lhype_raw / 1e18:.4f} lHYPE"
280
+ )
281
+ await asyncio.sleep(2)
282
+ else:
283
+ logger.warning(f"Failed to sell looped HYPE → HYPE: {res}")
284
+
285
+ # Check for WHYPE and unwrap if present (swap may output WHYPE instead of native HYPE)
286
+ ok_whype, whype_raw = await self.balance_adapter.get_vault_wallet_balance(
287
+ WHYPE
288
+ )
289
+ if ok_whype and whype_raw > 0:
290
+ logger.info(
291
+ f"Unwrapping {float(whype_raw) / 1e18:.4f} WHYPE to native HYPE"
292
+ )
293
+ ok_unwrap, unwrap_res = await self._unwrap_whype(
294
+ address, int(whype_raw)
295
+ )
296
+ if ok_unwrap:
297
+ await asyncio.sleep(2)
298
+ else:
299
+ logger.warning(f"WHYPE unwrap failed: {unwrap_res}")
300
+ except Exception as exc: # noqa: BLE001
301
+ logger.warning(f"Failed selling HyperEVM spot to HYPE: {exc}")
302
+
303
+ # ─────────────────────────────────────────────────────────────────
304
+ # STEP 4: Transfer HYPE from HyperEVM to Hyperliquid spot (keep gas)
305
+ # ─────────────────────────────────────────────────────────────────
306
+ sent_hype_to_hl = False
307
+ sent_hype_to_hl_amount_hype = 0.0
308
+ try:
309
+ ok_hype, hype_raw = await self.balance_adapter.get_vault_wallet_balance(
310
+ HYPE_NATIVE
311
+ )
312
+ hype_raw_int = int(hype_raw) if ok_hype and hype_raw and hype_raw > 0 else 0
313
+ gas_reserve_wei = int(float(MIN_HYPE_GAS) * 1e18)
314
+ hype_to_transfer_raw = max(0, hype_raw_int - gas_reserve_wei)
315
+ hype_to_transfer = float(hype_to_transfer_raw) / 1e18
316
+
317
+ if hype_to_transfer_raw > int(0.01 * 1e18):
318
+ destination = HyperliquidAdapter.hypercore_index_to_system_address(
319
+ 150
320
+ ) # native HYPE
321
+ ok_send, send_res = await self.balance_adapter.send_to_address(
322
+ token_id=HYPE_NATIVE,
323
+ amount=int(hype_to_transfer_raw),
324
+ from_wallet=strategy_wallet,
325
+ to_address=destination,
326
+ signing_callback=self._sign_callback,
327
+ )
328
+ if ok_send:
329
+ logger.info(
330
+ f"Transferred {hype_to_transfer:.4f} HYPE to Hyperliquid spot (kept {MIN_HYPE_GAS} for gas)"
331
+ )
332
+ sent_hype_to_hl = True
333
+ sent_hype_to_hl_amount_hype = float(hype_to_transfer)
334
+ await asyncio.sleep(3)
335
+ else:
336
+ logger.warning(
337
+ f"Failed to transfer HYPE to Hyperliquid: {send_res}"
338
+ )
339
+ except Exception as exc: # noqa: BLE001
340
+ logger.warning(f"Failed to transfer HYPE to Hyperliquid: {exc}")
341
+
342
+ # ─────────────────────────────────────────────────────────────────
343
+ # SAFETY CHECK: Verify delta-neutral before closing perp
344
+ # If significant HyperEVM value AND perp exists, wait for spot to arrive
345
+ # ─────────────────────────────────────────────────────────────────
346
+ hyperevm_hype_value = 0.0
347
+ try:
348
+ ok_hype, hype_raw = await self.balance_adapter.get_vault_wallet_balance(
349
+ HYPE_NATIVE
350
+ )
351
+ ok_whype, whype_raw = await self.balance_adapter.get_vault_wallet_balance(
352
+ WHYPE
353
+ )
354
+ ok_khype, khype_raw = await self.balance_adapter.get_vault_wallet_balance(
355
+ KHYPE_LST
356
+ )
357
+ ok_lhype, lhype_raw = await self.balance_adapter.get_vault_wallet_balance(
358
+ LOOPED_HYPE
359
+ )
360
+
361
+ # Calculate total HYPE-equivalent value on HyperEVM (above gas reserve)
362
+ native_hype = (float(hype_raw) / 1e18) if ok_hype and hype_raw > 0 else 0.0
363
+ whype_bal = (float(whype_raw) / 1e18) if ok_whype and whype_raw > 0 else 0.0
364
+ khype_bal = (float(khype_raw) / 1e18) if ok_khype and khype_raw > 0 else 0.0
365
+ lhype_bal = (float(lhype_raw) / 1e18) if ok_lhype and lhype_raw > 0 else 0.0
366
+
367
+ # Native HYPE above gas reserve
368
+ hedgeable_hype = max(0.0, native_hype - MIN_HYPE_GAS)
369
+ # WHYPE is 1:1, LSTs use approximate ratio (close enough for safety check)
370
+ hyperevm_hype_value = (
371
+ hedgeable_hype + whype_bal + khype_bal * 1.1 + lhype_bal * 1.1
372
+ )
373
+
374
+ logger.info(
375
+ f"HyperEVM balances: native={native_hype:.4f}, whype={whype_bal:.4f}, "
376
+ f"khype={khype_bal:.4f}, lhype={lhype_bal:.4f}, total={hyperevm_hype_value:.4f}"
377
+ )
378
+ except Exception as exc:
379
+ logger.debug(f"Error checking HyperEVM balances: {exc}")
380
+
381
+ spot_hype_for_check = 0.0
382
+ perp_short_for_check = 0.0
383
+
384
+ try:
385
+ (
386
+ ok_spot_chk,
387
+ spot_state_chk,
388
+ ) = await self.hyperliquid_adapter.get_spot_user_state(address)
389
+ if ok_spot_chk and isinstance(spot_state_chk, dict):
390
+ for bal in spot_state_chk.get("balances", []):
391
+ if (bal.get("coin") or bal.get("token")) == "HYPE":
392
+ spot_hype_for_check = float(bal.get("total", 0)) - float(
393
+ bal.get("hold", 0)
394
+ )
395
+ break
396
+ except Exception as exc:
397
+ logger.debug(f"Error checking HL spot state: {exc}")
398
+
399
+ try:
400
+ ok_perp_chk, perp_state_chk = await self.hyperliquid_adapter.get_user_state(
401
+ address
402
+ )
403
+ if ok_perp_chk and isinstance(perp_state_chk, dict):
404
+ for pos in perp_state_chk.get("assetPositions", []):
405
+ p = pos.get("position", {}) if isinstance(pos, dict) else {}
406
+ if p.get("coin") == "HYPE" and float(p.get("szi", 0)) < 0:
407
+ perp_short_for_check = abs(float(p.get("szi", 0)))
408
+ break
409
+ except Exception as exc:
410
+ logger.debug(f"Error checking perp state: {exc}")
411
+
412
+ logger.info(
413
+ f"Pre-unwind check: hl_spot_hype={spot_hype_for_check:.4f}, "
414
+ f"perp_short={perp_short_for_check:.4f}, hyperevm_hype_value={hyperevm_hype_value:.4f}"
415
+ )
416
+
417
+ dust_threshold = 0.1
418
+
419
+ if sent_hype_to_hl and spot_hype_for_check < 0.01:
420
+ logger.info(
421
+ f"Sent {sent_hype_to_hl_amount_hype:.4f} HYPE to HL spot but balance not visible yet; waiting..."
422
+ )
423
+ for attempt in range(6): # 60s max additional wait
424
+ await asyncio.sleep(10)
425
+ try:
426
+ (
427
+ ok_spot_chk,
428
+ spot_state_chk,
429
+ ) = await self.hyperliquid_adapter.get_spot_user_state(address)
430
+ if ok_spot_chk and isinstance(spot_state_chk, dict):
431
+ for bal in spot_state_chk.get("balances", []):
432
+ if (bal.get("coin") or bal.get("token")) == "HYPE":
433
+ spot_hype_for_check = float(
434
+ bal.get("total", 0)
435
+ ) - float(bal.get("hold", 0))
436
+ break
437
+ if spot_hype_for_check > 0.01:
438
+ logger.info(
439
+ f"HYPE arrived on HL spot: {spot_hype_for_check:.4f}"
440
+ )
441
+ break
442
+ except Exception as exc: # noqa: BLE001
443
+ logger.debug(f"Error re-checking HL spot: {exc}")
444
+ logger.info(
445
+ f"Still waiting for HYPE to arrive on HL spot (attempt {attempt + 1}/6)"
446
+ )
447
+
448
+ if (
449
+ hyperevm_hype_value > dust_threshold
450
+ and perp_short_for_check > dust_threshold
451
+ ):
452
+ logger.warning(
453
+ f"Significant value on HyperEVM ({hyperevm_hype_value:.4f} HYPE) "
454
+ f"but perp short still open ({perp_short_for_check:.4f}). "
455
+ "Waiting for spot to arrive on HL before closing perp..."
456
+ )
457
+ for attempt in range(6): # 60s max additional wait
458
+ await asyncio.sleep(10)
459
+ try:
460
+ (
461
+ ok_spot_chk,
462
+ spot_state_chk,
463
+ ) = await self.hyperliquid_adapter.get_spot_user_state(address)
464
+ if ok_spot_chk and isinstance(spot_state_chk, dict):
465
+ for bal in spot_state_chk.get("balances", []):
466
+ if (bal.get("coin") or bal.get("token")) == "HYPE":
467
+ spot_hype_for_check = float(
468
+ bal.get("total", 0)
469
+ ) - float(bal.get("hold", 0))
470
+ break
471
+ if spot_hype_for_check > dust_threshold:
472
+ logger.info(
473
+ f"HYPE arrived on HL spot: {spot_hype_for_check:.4f}"
474
+ )
475
+ break
476
+ except Exception as exc:
477
+ logger.debug(f"Error re-checking HL spot: {exc}")
478
+ logger.info(
479
+ f"Still waiting for HYPE to arrive on HL spot (attempt {attempt + 1}/6)"
480
+ )
481
+
482
+ if (
483
+ spot_hype_for_check > dust_threshold
484
+ and perp_short_for_check < dust_threshold
485
+ ):
486
+ logger.info(
487
+ "Spot HYPE present, perp already closed - proceeding with spot sale only"
488
+ )
489
+ elif (
490
+ perp_short_for_check > dust_threshold
491
+ and spot_hype_for_check < dust_threshold
492
+ ):
493
+ if hyperevm_hype_value < dust_threshold:
494
+ logger.info(
495
+ "Perp short present, no spot - proceeding with perp close only (one-leg scenario)"
496
+ )
497
+ else:
498
+ logger.warning(
499
+ f"Perp short present but spot HYPE not yet on HL "
500
+ f"(HyperEVM value: {hyperevm_hype_value:.4f}) - delta risk!"
501
+ )
502
+
503
+ # ─────────────────────────────────────────────────────────────────
504
+ # STEP 5: Sell HYPE to USDC on Hyperliquid spot (still hedged)
505
+ # ─────────────────────────────────────────────────────────────────
506
+ sold_hl_spot_hype = False
507
+ try:
508
+ ok_spot, spot_state = await self.hyperliquid_adapter.get_spot_user_state(
509
+ address
510
+ )
511
+ spot_hype_balance = 0.0
512
+ if ok_spot and isinstance(spot_state, dict):
513
+ for bal in spot_state.get("balances", []):
514
+ token = bal.get("coin") or bal.get("token")
515
+ hold = float(bal.get("hold", 0))
516
+ total = float(bal.get("total", 0))
517
+ available = total - hold
518
+ if token == "HYPE":
519
+ spot_hype_balance = available
520
+ break
521
+
522
+ if spot_hype_balance > 0.01:
523
+ await self.hyperliquid_adapter.get_spot_assets()
524
+ spot_asset_id = await self.hyperliquid_adapter.get_spot_asset_id(
525
+ "HYPE", "USDC"
526
+ )
527
+ if spot_asset_id is None:
528
+ raise ValueError("Missing Hyperliquid spot asset id for HYPE/USDC")
529
+
530
+ rounded_size = self.hyperliquid_adapter.get_valid_order_size(
531
+ spot_asset_id, spot_hype_balance
532
+ )
533
+ if rounded_size > 0:
534
+ (
535
+ ok_sell,
536
+ res_sell,
537
+ ) = await self.hyperliquid_adapter.place_market_order(
538
+ asset_id=spot_asset_id,
539
+ is_buy=False, # selling HYPE
540
+ slippage=0.10,
541
+ size=rounded_size,
542
+ address=address,
543
+ builder=self.builder_fee,
544
+ )
545
+ if ok_sell:
546
+ logger.info(f"Sold {rounded_size:.4f} HYPE to USDC on HL spot")
547
+ sold_hl_spot_hype = True
548
+ await asyncio.sleep(
549
+ 10
550
+ ) # HL spot trades need time to clear hold
551
+ else:
552
+ logger.warning(f"Failed to sell spot HYPE: {res_sell}")
553
+ except Exception as exc: # noqa: BLE001
554
+ logger.warning(f"Failed to sell HYPE on HL spot: {exc}")
555
+
556
+ # ─────────────────────────────────────────────────────────────────
557
+ # STEP 6: Close HL perp short (safe now - we hold USDC, not HYPE)
558
+ # ─────────────────────────────────────────────────────────────────
559
+ try:
560
+ await self._cancel_hl_open_orders_for_hype(address)
561
+ except Exception as exc: # noqa: BLE001
562
+ logger.debug(f"Failed to cancel HL open orders before hedge close: {exc}")
563
+
564
+ try:
565
+ ok_state, user_state = await self.hyperliquid_adapter.get_user_state(
566
+ address
567
+ )
568
+ current_short_size = 0.0
569
+ hype_price_usd = float(inv.hype_price_usd or 0.0)
570
+ if ok_state and isinstance(user_state, dict):
571
+ for pos in user_state.get("assetPositions", []):
572
+ p = pos.get("position", {}) if isinstance(pos, dict) else {}
573
+ if p.get("coin") == "HYPE":
574
+ szi = float(p.get("szi", 0))
575
+ if szi < 0:
576
+ current_short_size = abs(szi)
577
+ break
578
+
579
+ if current_short_size > 0.01:
580
+ hype_asset_id = self.hyperliquid_adapter.coin_to_asset.get("HYPE")
581
+ if hype_asset_id is None:
582
+ raise ValueError("Missing Hyperliquid perp asset id for HYPE")
583
+
584
+ rounded_size = self.hyperliquid_adapter.get_valid_order_size(
585
+ hype_asset_id, current_short_size
586
+ )
587
+ if (
588
+ rounded_size > 0
589
+ and (rounded_size * hype_price_usd) >= MIN_NOTIONAL_USD
590
+ ):
591
+ (
592
+ ok_close,
593
+ res_close,
594
+ ) = await self.hyperliquid_adapter.place_market_order(
595
+ asset_id=hype_asset_id,
596
+ is_buy=True, # buy to close short
597
+ slippage=0.01,
598
+ size=rounded_size,
599
+ address=address,
600
+ reduce_only=True,
601
+ builder=self.builder_fee,
602
+ )
603
+ if not ok_close:
604
+ return False, f"Failed to close HL hedge: {res_close}"
605
+ logger.info(f"Closed HL perp short: {rounded_size:.4f} HYPE")
606
+ await asyncio.sleep(2)
607
+ except Exception as exc: # noqa: BLE001
608
+ return False, f"Failed to close HL hedge: {exc}"
609
+
610
+ # ─────────────────────────────────────────────────────────────────
611
+ # STEP 7: Move all USDC from spot to perp margin (poll until cleared)
612
+ # ─────────────────────────────────────────────────────────────────
613
+ usdc_sz_decimals = await self.hyperliquid_adapter.get_spot_token_sz_decimals(
614
+ "USDC"
615
+ )
616
+ if usdc_sz_decimals is None:
617
+ usdc_sz_decimals = 2
618
+
619
+ spot_transfer_succeeded = False
620
+ did_transfer_spot_usdc_to_perp = False
621
+ observed_spot_usdc_after_sell = not sold_hl_spot_hype
622
+ spot_total = 0.0
623
+ spot_hold = 0.0
624
+ spot_usdc = 0.0
625
+ attempt = 0
626
+ while True:
627
+ attempt += 1
628
+ try:
629
+ spot_total = 0.0
630
+ spot_hold = 0.0
631
+ spot_usdc = 0.0
632
+ spot_total_s = "0"
633
+ spot_hold_s = "0"
634
+
635
+ (
636
+ ok_spot,
637
+ spot_state,
638
+ ) = await self.hyperliquid_adapter.get_spot_user_state(address)
639
+ if ok_spot and isinstance(spot_state, dict):
640
+ for bal in spot_state.get("balances", []):
641
+ token = bal.get("coin") or bal.get("token")
642
+ if token == "USDC":
643
+ spot_total_s = str(bal.get("total", "0") or "0")
644
+ spot_hold_s = str(bal.get("hold", "0") or "0")
645
+ spot_hold = float(spot_hold_s)
646
+ spot_total = float(spot_total_s)
647
+ spot_usdc = spot_total - spot_hold
648
+ break
649
+
650
+ logger.info(
651
+ f"Spot USDC balance (attempt {attempt}): "
652
+ f"total={spot_total:.2f}, hold={spot_hold:.2f}, available={spot_usdc:.2f}"
653
+ )
654
+
655
+ if not observed_spot_usdc_after_sell:
656
+ if spot_total > 1.0 or spot_hold > 0.5 or spot_usdc > 1.0:
657
+ observed_spot_usdc_after_sell = True
658
+ else:
659
+ if time.time() >= deadline_ts:
660
+ break
661
+ logger.info(
662
+ "Waiting for HL spot USDC to settle after HYPE sale..."
663
+ )
664
+ await asyncio.sleep(poll_interval_s)
665
+ continue
666
+
667
+ if spot_total <= 1.0:
668
+ # No significant USDC remaining on spot (including hold), nothing to transfer.
669
+ spot_transfer_succeeded = True
670
+ break
671
+
672
+ if spot_usdc > 1.0:
673
+ # Compute a safe amount using Decimal math and szDecimals, leaving 1 tick.
674
+ spot_usdc_to_xfer = (
675
+ self.hyperliquid_adapter.max_transferable_amount(
676
+ spot_total_s,
677
+ spot_hold_s,
678
+ sz_decimals=int(usdc_sz_decimals),
679
+ leave_one_tick=True,
680
+ )
681
+ )
682
+ # Fallback: some Hyperliquid client versions effectively round to 2dp
683
+ # internally for usdClassTransfer. If we get an "insufficient balance"
684
+ # error, retry with 2dp floor.
685
+ fallback_2dp = (
686
+ self.hyperliquid_adapter.max_transferable_amount(
687
+ spot_total_s,
688
+ spot_hold_s,
689
+ sz_decimals=2,
690
+ leave_one_tick=True,
691
+ )
692
+ if int(usdc_sz_decimals) != 2
693
+ else 0.0
694
+ )
695
+
696
+ if spot_usdc_to_xfer <= 1.0:
697
+ if time.time() >= deadline_ts:
698
+ break
699
+ await asyncio.sleep(poll_interval_s)
700
+ continue
701
+
702
+ # Transfer the full available amount (fresh balance query each attempt)
703
+ (
704
+ ok_xfer,
705
+ res_xfer,
706
+ ) = await self.hyperliquid_adapter.transfer_spot_to_perp(
707
+ amount=float(spot_usdc_to_xfer),
708
+ address=address,
709
+ )
710
+ if ok_xfer:
711
+ logger.info(
712
+ f"Transferred ${spot_usdc_to_xfer:.2f} USDC from spot to perp"
713
+ )
714
+ did_transfer_spot_usdc_to_perp = True
715
+ await asyncio.sleep(3)
716
+ else:
717
+ res_s = str(res_xfer)
718
+ if fallback_2dp > 1.0 and (
719
+ "insufficient balance" in res_s.lower()
720
+ ):
721
+ (
722
+ ok_xfer2,
723
+ res_xfer2,
724
+ ) = await self.hyperliquid_adapter.transfer_spot_to_perp(
725
+ amount=float(fallback_2dp),
726
+ address=address,
727
+ )
728
+ if ok_xfer2:
729
+ logger.info(
730
+ f"Transferred ${fallback_2dp:.2f} USDC from spot to perp (2dp fallback)"
731
+ )
732
+ did_transfer_spot_usdc_to_perp = True
733
+ await asyncio.sleep(3)
734
+ continue
735
+ res_xfer = res_xfer2
736
+
737
+ logger.warning(
738
+ f"Failed to move USDC spot→perp (attempt {attempt}): {res_xfer}"
739
+ )
740
+ else:
741
+ # USDC exists but is still held (trade settlement). Wait and retry.
742
+ if time.time() >= deadline_ts:
743
+ break
744
+ await asyncio.sleep(poll_interval_s)
745
+ except Exception as exc: # noqa: BLE001
746
+ logger.warning(
747
+ f"Failed to move USDC spot→perp (attempt {attempt}): {exc}"
748
+ )
749
+ if time.time() >= deadline_ts:
750
+ break
751
+ await asyncio.sleep(poll_interval_s)
752
+
753
+ remaining_spot_usdc = 0.0
754
+ if not spot_transfer_succeeded:
755
+ remaining_spot_usdc = spot_total
756
+ logger.error(
757
+ f"Failed to transfer spot USDC to perp before timeout. "
758
+ f"Spot USDC may still be on Hyperliquid spot account (total=${spot_total:.2f}, hold=${spot_hold:.2f})."
759
+ )
760
+
761
+ # ─────────────────────────────────────────────────────────────────
762
+ # STEP 8: Withdraw all from Hyperliquid to Arbitrum
763
+ # ─────────────────────────────────────────────────────────────────
764
+ hl_wait_min_usdc_raw: int | None = None
765
+ try:
766
+ attempt = 0
767
+ while True:
768
+ attempt += 1
769
+ ok_state, user_state = await self.hyperliquid_adapter.get_user_state(
770
+ address
771
+ )
772
+ perp_balance = (
773
+ self.hyperliquid_adapter.get_perp_margin_amount(user_state)
774
+ if ok_state and isinstance(user_state, dict)
775
+ else 0.0
776
+ )
777
+
778
+ if perp_balance > 1.0:
779
+ (
780
+ ok_usdc,
781
+ usdc_raw_before,
782
+ ) = await self.balance_adapter.get_vault_wallet_balance(USDC_ARB)
783
+ usdc_raw_before_int = int(usdc_raw_before) if ok_usdc else 0
784
+ expected_usdc_raw = max(0, int(float(perp_balance) * 1e6))
785
+ ok_wd, res_wd = await self.hyperliquid_adapter.withdraw(
786
+ amount=float(perp_balance),
787
+ address=address,
788
+ )
789
+ if ok_wd:
790
+ min_expected = max(int(1e6), int(expected_usdc_raw * 0.99))
791
+ hl_wait_min_usdc_raw = usdc_raw_before_int + min_expected
792
+ break
793
+ logger.warning(f"Failed to withdraw from Hyperliquid: {res_wd}")
794
+ if time.time() >= deadline_ts:
795
+ break
796
+ await asyncio.sleep(poll_interval_s)
797
+ continue
798
+
799
+ # No perp balance yet. If we just moved spot→perp, wait for it to reflect.
800
+ if not did_transfer_spot_usdc_to_perp:
801
+ break
802
+ if time.time() >= deadline_ts:
803
+ break
804
+ logger.info(
805
+ f"Waiting for spot→perp transfer to reflect in HL margin (attempt {attempt})..."
806
+ )
807
+ await asyncio.sleep(poll_interval_s)
808
+ except Exception as exc: # noqa: BLE001
809
+ logger.warning(f"Failed Hyperliquid withdrawal: {exc}")
810
+
811
+ # ─────────────────────────────────────────────────────────────────
812
+ # WAIT: Boros + Hyperliquid withdrawals concurrently
813
+ # ─────────────────────────────────────────────────────────────────
814
+ async def _wait_for_wallet_balance_at_least(
815
+ *,
816
+ token_id: str | None = None,
817
+ token_address: str | None = None,
818
+ chain_id: int | None = None,
819
+ min_raw: int,
820
+ ) -> tuple[bool, int]:
821
+ if token_address and chain_id is not None:
822
+ assets = [{"token_address": token_address, "chain_id": int(chain_id)}]
823
+
824
+ async def _get_raw() -> tuple[bool, int]:
825
+ ok, res = await self.balance_adapter.get_wallet_balances_multicall(
826
+ assets=assets
827
+ )
828
+ if not ok or not isinstance(res, list) or not res:
829
+ return False, 0
830
+ item = res[0]
831
+ if not item.get("success"):
832
+ return False, 0
833
+ return True, int(item.get("balance_raw") or 0)
834
+
835
+ if min_raw <= 0:
836
+ return await _get_raw()
837
+ else:
838
+ if token_id is None:
839
+ return False, 0
840
+
841
+ async def _get_raw() -> tuple[bool, int]:
842
+ ok, raw = await self.balance_adapter.get_vault_wallet_balance(
843
+ token_id
844
+ )
845
+ return bool(ok), int(raw) if ok else 0
846
+
847
+ if min_raw <= 0:
848
+ return await _get_raw()
849
+
850
+ deadline = deadline_ts
851
+ last_raw = 0
852
+ while True:
853
+ ok, raw = await _get_raw()
854
+ if ok:
855
+ last_raw = int(raw)
856
+ if last_raw >= int(min_raw):
857
+ return True, last_raw
858
+ if time.time() >= deadline:
859
+ return False, last_raw
860
+ await asyncio.sleep(poll_interval_s)
861
+
862
+ wait_tasks: list[asyncio.Task] = []
863
+ if max_wait_s > 0 and time.time() < deadline_ts:
864
+ if boros_wait_min_hype_raw is not None:
865
+ wait_tasks.append(
866
+ asyncio.create_task(
867
+ _wait_for_wallet_balance_at_least(
868
+ token_address=HYPE_OFT_ADDRESS,
869
+ chain_id=ARBITRUM_CHAIN_ID,
870
+ min_raw=int(boros_wait_min_hype_raw),
871
+ )
872
+ )
873
+ )
874
+ if hl_wait_min_usdc_raw is not None:
875
+ wait_tasks.append(
876
+ asyncio.create_task(
877
+ _wait_for_wallet_balance_at_least(
878
+ token_id=USDC_ARB,
879
+ min_raw=int(hl_wait_min_usdc_raw),
880
+ )
881
+ )
882
+ )
883
+
884
+ if wait_tasks:
885
+ try:
886
+ await asyncio.gather(*wait_tasks)
887
+ except Exception as exc: # noqa: BLE001
888
+ logger.warning(f"Withdrawal wait phase errored: {exc}")
889
+
890
+ # ─────────────────────────────────────────────────────────────────
891
+ # STEP 9: Swap any USDT to USDC on Arbitrum
892
+ # ─────────────────────────────────────────────────────────────────
893
+ try:
894
+ ok_usdt, usdt_raw = await self.balance_adapter.get_vault_wallet_balance(
895
+ USDT_ARB
896
+ )
897
+ if ok_usdt and usdt_raw > 0:
898
+ ok_swap, swap_res = await self.brap_adapter.swap_from_token_ids(
899
+ from_token_id=USDT_ARB,
900
+ to_token_id=USDC_ARB,
901
+ from_address=address,
902
+ amount=str(int(usdt_raw)),
903
+ slippage=0.005,
904
+ strategy_name="boros_hype_strategy",
905
+ )
906
+ if ok_swap:
907
+ await asyncio.sleep(2)
908
+ else:
909
+ logger.warning(f"Failed to swap USDT→USDC: {swap_res}")
910
+ except Exception as exc: # noqa: BLE001
911
+ logger.warning(f"Failed to swap USDT to USDC: {exc}")
912
+
913
+ ok_usdc, vault_usdc_raw = await self.balance_adapter.get_vault_wallet_balance(
914
+ USDC_ARB
915
+ )
916
+ usdc_tokens = (
917
+ float(vault_usdc_raw) / 1e6 if ok_usdc and vault_usdc_raw > 0 else 0.0
918
+ )
919
+
920
+ try:
921
+ elapsed_s = int(time.time() - withdraw_start_ts)
922
+ inv_final = await self.observe()
923
+ remaining_hype_usd = 0.0
924
+ if inv_final.whype_balance > 0.001:
925
+ remaining_hype_usd += inv_final.whype_value_usd
926
+ if inv_final.khype_balance > 0.001:
927
+ remaining_hype_usd += inv_final.khype_value_usd
928
+ if inv_final.looped_hype_balance > 0.001:
929
+ remaining_hype_usd += inv_final.looped_hype_value_usd
930
+
931
+ hedgeable_hype = max(0.0, inv_final.hype_hyperevm_balance - MIN_HYPE_GAS)
932
+ if hedgeable_hype > 0.001:
933
+ remaining_hype_usd += hedgeable_hype * float(inv_final.hype_price_usd)
934
+
935
+ if inv_final.hl_spot_hype > 0.01:
936
+ remaining_hype_usd += inv_final.hl_spot_hype_value_usd
937
+
938
+ remaining_non_usdc_arb_usd = 0.0
939
+ if inv_final.hl_spot_usdc > 1.0:
940
+ remaining_non_usdc_arb_usd += inv_final.hl_spot_usdc
941
+ if inv_final.hl_perp_margin > 1.0:
942
+ remaining_non_usdc_arb_usd += inv_final.hl_perp_margin
943
+ if inv_final.boros_collateral_usd > 1.0:
944
+ remaining_non_usdc_arb_usd += inv_final.boros_collateral_usd
945
+ if inv_final.boros_pending_withdrawal_usd > 1.0:
946
+ remaining_non_usdc_arb_usd += inv_final.boros_pending_withdrawal_usd
947
+ if inv_final.usdt_arb_idle > 1.0:
948
+ remaining_non_usdc_arb_usd += inv_final.usdt_arb_idle
949
+ if inv_final.hype_oft_arb_value_usd > 1.0:
950
+ remaining_non_usdc_arb_usd += inv_final.hype_oft_arb_value_usd
951
+
952
+ if remaining_hype_usd > 1.0:
953
+ return False, (
954
+ f"Withdrawal incomplete after {elapsed_s}s: "
955
+ f"~${remaining_hype_usd:.2f} still in HYPE/WHYPE on HyperEVM/HL spot. "
956
+ "Run withdraw again (or increase max_wait_s)."
957
+ )
958
+ if remaining_non_usdc_arb_usd > 1.0:
959
+ return False, (
960
+ f"Withdrawal incomplete after {elapsed_s}s: "
961
+ f"~${remaining_non_usdc_arb_usd:.2f} still not in Arbitrum USDC (HL/Boros/HYPE/USDT). "
962
+ "Run withdraw again (or increase max_wait_s)."
963
+ )
964
+ except Exception as exc: # noqa: BLE001
965
+ logger.debug(f"Final withdrawal inventory check failed: {exc}")
966
+
967
+ if remaining_spot_usdc > 1.0:
968
+ return False, (
969
+ f"Withdrawal incomplete: ${remaining_spot_usdc:.2f} USDC still on Hyperliquid spot. "
970
+ "Run withdraw again (or increase max_wait_s) to retry the spot→perp transfer."
971
+ )
972
+
973
+ return (
974
+ True,
975
+ f"Fully unwound all positions. USDC balance: ${usdc_tokens:.2f}. Call exit() to transfer to main wallet.",
976
+ )
977
+
978
+ async def _unwrap_whype(self, address: str, amount_wei: int) -> tuple[bool, str]:
979
+ try:
980
+ if not self._sign_callback:
981
+ return False, "No signing callback configured"
982
+
983
+ tx = await encode_call(
984
+ target=WHYPE_ADDRESS,
985
+ abi=WHYPE_ABI,
986
+ fn_name="withdraw",
987
+ args=[int(amount_wei)],
988
+ from_address=address,
989
+ chain_id=HYPEREVM_CHAIN_ID,
990
+ )
991
+
992
+ txn_hash = await send_transaction(
993
+ tx, self._sign_callback, wait_for_receipt=True
994
+ )
995
+ return True, txn_hash
996
+ except Exception as exc: # noqa: BLE001
997
+ return False, str(exc)