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
@@ -2,12 +2,24 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import time
5
+ from decimal import ROUND_DOWN, Decimal, getcontext
5
6
  from typing import TYPE_CHECKING, Any
6
7
 
8
+ from aiocache import Cache
9
+ from eth_utils import to_checksum_address
10
+ from loguru import logger
11
+
7
12
  from wayfinder_paths.core.adapters.BaseAdapter import BaseAdapter
8
- from wayfinder_paths.core.constants.contracts import (
9
- ARBITRUM_USDC,
10
- HYPERLIQUID_BRIDGE,
13
+ from wayfinder_paths.core.constants import ZERO_ADDRESS
14
+ from wayfinder_paths.core.constants.hyperliquid import (
15
+ ARBITRUM_USDC_ADDRESS as _ARBITRUM_USDC_ADDRESS,
16
+ )
17
+ from wayfinder_paths.core.constants.hyperliquid import (
18
+ DEFAULT_HYPERLIQUID_BUILDER_FEE_TENTHS_BP,
19
+ HYPE_FEE_WALLET,
20
+ )
21
+ from wayfinder_paths.core.constants.hyperliquid import (
22
+ HYPERLIQUID_BRIDGE_ADDRESS as _HYPERLIQUID_BRIDGE_ADDRESS,
11
23
  )
12
24
 
13
25
  if TYPE_CHECKING:
@@ -15,8 +27,9 @@ if TYPE_CHECKING:
15
27
  HyperliquidExecutorProtocol as HyperliquidExecutor,
16
28
  )
17
29
 
18
- HYPERLIQUID_BRIDGE_ADDRESS = HYPERLIQUID_BRIDGE
19
- ARBITRUM_USDC_ADDRESS = ARBITRUM_USDC
30
+ # Re-export Bridge2 constants for backwards compatibility.
31
+ HYPERLIQUID_BRIDGE_ADDRESS = _HYPERLIQUID_BRIDGE_ADDRESS
32
+ ARBITRUM_USDC_ADDRESS = _ARBITRUM_USDC_ADDRESS
20
33
 
21
34
  try:
22
35
  from hyperliquid.info import Info
@@ -29,36 +42,26 @@ except ImportError:
29
42
  constants = None
30
43
 
31
44
 
32
- class SimpleCache:
33
- def __init__(self):
34
- self._cache: dict[str, Any] = {}
35
- self._expiry: dict[str, float] = {}
36
-
37
- def get(self, key: str) -> Any | None:
38
- if key in self._cache:
39
- if time.time() < self._expiry.get(key, 0):
40
- return self._cache[key]
41
- del self._cache[key]
42
- if key in self._expiry:
43
- del self._expiry[key]
44
- return None
45
-
46
- def set(self, key: str, value: Any, timeout: int = 300) -> None:
47
- self._cache[key] = value
48
- self._expiry[key] = time.time() + timeout
49
-
50
- def clear(self) -> None:
51
- self._cache.clear()
52
- self._expiry.clear()
53
-
54
-
55
45
  class HyperliquidAdapter(BaseAdapter):
46
+ """
47
+ Adapter for Hyperliquid exchange operations.
48
+
49
+ Wraps the hyperliquid SDK directly for market data access.
50
+ Uses Hyperliquid's public API for:
51
+ - Market metadata (perp and spot)
52
+ - Funding rate history
53
+ - Price candles
54
+ - Order book snapshots
55
+ - User positions and balances
56
+ """
57
+
56
58
  adapter_type = "HYPERLIQUID"
57
59
 
58
60
  def __init__(
59
61
  self,
60
62
  config: dict[str, Any] | None = None,
61
63
  *,
64
+ simulation: bool = False,
62
65
  executor: HyperliquidExecutor | None = None,
63
66
  ) -> None:
64
67
  super().__init__("hyperliquid_adapter", config)
@@ -69,9 +72,11 @@ class HyperliquidAdapter(BaseAdapter):
69
72
  "Install with: poetry add hyperliquid"
70
73
  )
71
74
 
72
- self._cache = SimpleCache()
75
+ self.simulation = simulation
76
+ self._cache = Cache(Cache.MEMORY)
73
77
  self._executor = executor
74
78
 
79
+ # Initialize Hyperliquid Info client
75
80
  self.info = Info(constants.MAINNET_API_URL, skip_ws=True)
76
81
 
77
82
  # Cache asset mappings after first fetch
@@ -95,13 +100,14 @@ class HyperliquidAdapter(BaseAdapter):
95
100
 
96
101
  async def get_meta_and_asset_ctxs(self) -> tuple[bool, Any]:
97
102
  cache_key = "hl_meta_and_asset_ctxs"
98
- cached = self._cache.get(cache_key)
103
+ cached = await self._cache.get(cache_key)
99
104
  if cached:
100
105
  return True, cached
101
106
 
102
107
  try:
103
108
  data = self.info.meta_and_asset_ctxs()
104
- self._cache.set(cache_key, data, timeout=60)
109
+ # Cache for 1 minute
110
+ await self._cache.set(cache_key, data, ttl=60)
105
111
  return True, data
106
112
  except Exception as exc:
107
113
  self.logger.error(f"Failed to fetch meta_and_asset_ctxs: {exc}")
@@ -109,25 +115,85 @@ class HyperliquidAdapter(BaseAdapter):
109
115
 
110
116
  async def get_spot_meta(self) -> tuple[bool, Any]:
111
117
  cache_key = "hl_spot_meta"
112
- cached = self._cache.get(cache_key)
118
+ cached = await self._cache.get(cache_key)
113
119
  if cached:
114
120
  return True, cached
115
121
 
116
122
  try:
123
+ # Handle both callable and property access patterns
117
124
  spot_meta = self.info.spot_meta
118
125
  if callable(spot_meta):
119
126
  data = spot_meta()
120
127
  else:
121
128
  data = spot_meta
122
- self._cache.set(cache_key, data, timeout=60)
129
+ await self._cache.set(cache_key, data, ttl=60)
123
130
  return True, data
124
131
  except Exception as exc:
125
132
  self.logger.error(f"Failed to fetch spot_meta: {exc}")
126
133
  return False, str(exc)
127
134
 
135
+ async def get_spot_token_sz_decimals(self, coin: str) -> int | None:
136
+ try:
137
+ success, spot_meta = await self.get_spot_meta()
138
+ if not success or not isinstance(spot_meta, dict):
139
+ return None
140
+
141
+ for token in spot_meta.get("tokens", []):
142
+ name = token.get("name") or token.get("coin") or token.get("symbol")
143
+ if not name:
144
+ continue
145
+ if str(name).upper() != str(coin).upper():
146
+ continue
147
+ sz_decimals = token.get("szDecimals") or token.get("sz_decimals")
148
+ if sz_decimals is None:
149
+ return None
150
+ return int(sz_decimals)
151
+ except Exception: # noqa: BLE001
152
+ return None
153
+ return None
154
+
155
+ @staticmethod
156
+ def max_transferable_amount(
157
+ total: str,
158
+ hold: str,
159
+ *,
160
+ sz_decimals: int,
161
+ leave_one_tick: bool = True,
162
+ ) -> float:
163
+ """Compute a safe transferable amount (Decimal math, round down, leave 1 tick).
164
+
165
+ Hyperliquid requires amounts to respect szDecimals. This helper avoids
166
+ float rounding edge cases by:
167
+ - parsing balances as Decimal
168
+ - computing available = total - hold
169
+ - rounding down to sz_decimals
170
+ - optionally leaving 1 tick so we don't request the full balance
171
+ """
172
+ getcontext().prec = 50
173
+
174
+ if sz_decimals < 0:
175
+ sz_decimals = 0
176
+
177
+ step = Decimal(10) ** (-int(sz_decimals))
178
+
179
+ total_d = Decimal(str(total or "0"))
180
+ hold_d = Decimal(str(hold or "0"))
181
+ available = total_d - hold_d
182
+ if available <= 0:
183
+ return 0.0
184
+
185
+ safe = available - step if leave_one_tick else available
186
+ if safe <= 0:
187
+ return 0.0
188
+
189
+ quantized = (safe / step).to_integral_value(rounding=ROUND_DOWN) * step
190
+ if quantized <= 0:
191
+ return 0.0
192
+ return float(quantized)
193
+
128
194
  async def get_spot_assets(self) -> tuple[bool, dict[str, int]]:
129
195
  cache_key = "hl_spot_assets"
130
- cached = self._cache.get(cache_key)
196
+ cached = await self._cache.get(cache_key)
131
197
  if cached:
132
198
  return True, cached
133
199
 
@@ -147,6 +213,7 @@ class HyperliquidAdapter(BaseAdapter):
147
213
 
148
214
  base_idx, quote_idx = pair_tokens[0], pair_tokens[1]
149
215
 
216
+ # Get token names
150
217
  base_info = tokens[base_idx] if base_idx < len(tokens) else {}
151
218
  quote_info = tokens[quote_idx] if quote_idx < len(tokens) else {}
152
219
 
@@ -157,16 +224,19 @@ class HyperliquidAdapter(BaseAdapter):
157
224
  spot_asset_id = pair.get("index", 0) + 10000
158
225
  response[name] = spot_asset_id
159
226
 
160
- self._cache.set(cache_key, response, timeout=300)
227
+ # Cache for 5 min
228
+ await self._cache.set(cache_key, response, ttl=300)
161
229
  return True, response
162
230
 
163
231
  except Exception as exc:
164
232
  self.logger.error(f"Failed to get spot assets: {exc}")
165
233
  return False, {}
166
234
 
167
- def get_spot_asset_id(self, base_coin: str, quote_coin: str = "USDC") -> int | None:
235
+ async def get_spot_asset_id(
236
+ self, base_coin: str, quote_coin: str = "USDC"
237
+ ) -> int | None:
168
238
  cache_key = "hl_spot_assets"
169
- cached = self._cache.get(cache_key)
239
+ cached = await self._cache.get(cache_key)
170
240
  if cached:
171
241
  pair_name = f"{base_coin}/{quote_coin}"
172
242
  return cached.get(pair_name)
@@ -229,9 +299,63 @@ class HyperliquidAdapter(BaseAdapter):
229
299
  self.logger.error(f"Failed to fetch spot_user_state for {address}: {exc}")
230
300
  return False, str(exc)
231
301
 
302
+ async def get_full_user_state(
303
+ self,
304
+ *,
305
+ account: str,
306
+ include_spot: bool = True,
307
+ include_open_orders: bool = True,
308
+ include_frontend_open_orders: bool = True,
309
+ ) -> tuple[bool, dict[str, Any] | str]:
310
+ """
311
+ Full Hyperliquid user state snapshot.
312
+
313
+ Includes perp positions (user_state), optional spot balances, and optional open
314
+ orders (frontendOpenOrders by default, since it includes trigger orders).
315
+ """
316
+ out: dict[str, Any] = {
317
+ "protocol": "hyperliquid",
318
+ "account": account,
319
+ "perp": None,
320
+ "spot": None,
321
+ "openOrders": None,
322
+ "errors": {},
323
+ }
324
+
325
+ ok_any = False
326
+
327
+ ok_perp, perp = await self.get_user_state(account)
328
+ if ok_perp:
329
+ ok_any = True
330
+ out["perp"] = perp
331
+ out["positions"] = perp.get("assetPositions", [])
332
+ else:
333
+ out["errors"]["perp"] = perp
334
+
335
+ if include_spot:
336
+ ok_spot, spot = await self.get_spot_user_state(account)
337
+ if ok_spot:
338
+ ok_any = True
339
+ out["spot"] = spot
340
+ else:
341
+ out["errors"]["spot"] = spot
342
+
343
+ if include_open_orders:
344
+ if include_frontend_open_orders:
345
+ ok_orders, orders = await self.get_frontend_open_orders(account)
346
+ else:
347
+ ok_orders, orders = await self.get_open_orders(account)
348
+ if ok_orders:
349
+ ok_any = True
350
+ out["openOrders"] = orders
351
+ else:
352
+ out["errors"]["openOrders"] = orders
353
+
354
+ return ok_any, out
355
+
232
356
  async def get_margin_table(self, margin_table_id: int) -> tuple[bool, list[dict]]:
233
357
  cache_key = f"hl_margin_table_{margin_table_id}"
234
- cached = self._cache.get(cache_key)
358
+ cached = await self._cache.get(cache_key)
235
359
  if cached:
236
360
  return True, cached
237
361
 
@@ -244,7 +368,7 @@ class HyperliquidAdapter(BaseAdapter):
244
368
  except Exception: # noqa: BLE001 - try alternate payload key
245
369
  body = {"type": "marginTable", "marginTableId": int(margin_table_id)}
246
370
  data = self.info.post("/info", body)
247
- self._cache.set(cache_key, data, timeout=86400)
371
+ await self._cache.set(cache_key, data, ttl=86400) # Cache for 24h
248
372
  return True, data
249
373
  except Exception as exc:
250
374
  self.logger.error(f"Failed to fetch margin_table {margin_table_id}: {exc}")
@@ -285,6 +409,7 @@ class HyperliquidAdapter(BaseAdapter):
285
409
 
286
410
  @property
287
411
  def coin_to_asset(self) -> dict[str, int]:
412
+ """Get coin name to asset ID mapping (perps only)."""
288
413
  if self._coin_to_asset is None:
289
414
  self._coin_to_asset = dict(self.info.coin_to_asset)
290
415
  return self._coin_to_asset
@@ -297,10 +422,10 @@ class HyperliquidAdapter(BaseAdapter):
297
422
  f"Unknown asset_id {asset_id}: missing szDecimals"
298
423
  ) from None
299
424
 
300
- def refresh_mappings(self) -> None:
425
+ async def refresh_mappings(self) -> None:
301
426
  self._asset_to_sz_decimals = None
302
427
  self._coin_to_asset = None
303
- self._cache.clear()
428
+ await self._cache.clear()
304
429
 
305
430
  # ------------------------------------------------------------------ #
306
431
  # Utility Methods #
@@ -316,8 +441,6 @@ class HyperliquidAdapter(BaseAdapter):
316
441
 
317
442
  def get_valid_order_size(self, asset_id: int, size: float) -> float:
318
443
  decimals = self.get_sz_decimals(asset_id)
319
- from decimal import ROUND_DOWN, Decimal
320
-
321
444
  step = Decimal(10) ** (-decimals)
322
445
  if size <= 0:
323
446
  return 0.0
@@ -330,6 +453,49 @@ class HyperliquidAdapter(BaseAdapter):
330
453
  # Execution Methods (require signing callback) #
331
454
  # ------------------------------------------------------------------ #
332
455
 
456
+ def _mandatory_builder_fee(self, builder: dict[str, Any] | None) -> dict[str, Any]:
457
+ """
458
+ Resolve the builder fee config to attach to orders.
459
+
460
+ Builder attribution is mandatory in this repo and is always directed to
461
+ the Wayfinder builder wallet (`HYPE_FEE_WALLET`).
462
+ """
463
+ expected_builder = HYPE_FEE_WALLET.lower()
464
+
465
+ if isinstance(builder, dict) and builder.get("b") is not None:
466
+ provided_builder = str(builder.get("b") or "").strip()
467
+ if provided_builder and provided_builder.lower() != expected_builder:
468
+ raise ValueError(
469
+ f"builder wallet must be {expected_builder} (got {provided_builder})"
470
+ )
471
+
472
+ fee = None
473
+ if isinstance(builder, dict) and builder.get("f") is not None:
474
+ fee = builder.get("f")
475
+
476
+ if fee is None and isinstance(self.config, dict):
477
+ cfg = self.config.get("builder_fee")
478
+ if isinstance(cfg, dict):
479
+ cfg_builder = str(cfg.get("b") or "").strip()
480
+ if cfg_builder and cfg_builder.lower() != expected_builder:
481
+ raise ValueError(
482
+ f"config builder_fee.b must be {expected_builder} (got {cfg_builder})"
483
+ )
484
+ if cfg.get("f") is not None:
485
+ fee = cfg.get("f")
486
+
487
+ if fee is None:
488
+ fee = DEFAULT_HYPERLIQUID_BUILDER_FEE_TENTHS_BP
489
+
490
+ try:
491
+ fee_i = int(fee)
492
+ except (TypeError, ValueError) as exc:
493
+ raise ValueError("builder fee f must be an int (tenths of bp)") from exc
494
+ if fee_i <= 0:
495
+ raise ValueError("builder fee f must be > 0 (tenths of bp)")
496
+
497
+ return {"b": expected_builder, "f": fee_i}
498
+
333
499
  async def place_market_order(
334
500
  self,
335
501
  asset_id: int,
@@ -342,6 +508,31 @@ class HyperliquidAdapter(BaseAdapter):
342
508
  cloid: str | None = None,
343
509
  builder: dict[str, Any] | None = None,
344
510
  ) -> tuple[bool, dict[str, Any]]:
511
+ """
512
+ Place a market order (IOC with slippage).
513
+
514
+ Args:
515
+ asset_id: Asset ID (perp < 10000, spot >= 10000)
516
+ is_buy: True for buy, False for sell
517
+ slippage: Slippage tolerance (0.0 to 1.0)
518
+ size: Order size in base units
519
+ address: Wallet address
520
+ reduce_only: If True, only reduce existing position
521
+ cloid: Client order ID (optional)
522
+ builder: Builder fee config; if omitted, a mandatory default is applied.
523
+
524
+ Returns:
525
+ (success, response_data or error_message)
526
+ """
527
+ builder = self._mandatory_builder_fee(builder)
528
+
529
+ if self.simulation:
530
+ self.logger.info(
531
+ f"[SIMULATION] place_market_order: asset={asset_id}, "
532
+ f"is_buy={is_buy}, size={size}, address={address}"
533
+ )
534
+ return True, {"simulation": True, "status": "ok"}
535
+
345
536
  if not self._executor:
346
537
  raise NotImplementedError(
347
538
  "No Hyperliquid executor configured. "
@@ -359,7 +550,17 @@ class HyperliquidAdapter(BaseAdapter):
359
550
  builder=builder,
360
551
  )
361
552
 
553
+ # Check both the API status and the order statuses for errors
362
554
  success = result.get("status") == "ok"
555
+ if success:
556
+ # Check if the order itself has errors in statuses
557
+ response = result.get("response", {})
558
+ data = response.get("data", {})
559
+ statuses = data.get("statuses", [])
560
+ for status in statuses:
561
+ if isinstance(status, dict) and status.get("error"):
562
+ success = False
563
+ break
363
564
  return success, result
364
565
 
365
566
  async def cancel_order(
@@ -368,6 +569,12 @@ class HyperliquidAdapter(BaseAdapter):
368
569
  order_id: int | str,
369
570
  address: str,
370
571
  ) -> tuple[bool, dict[str, Any]]:
572
+ if self.simulation:
573
+ self.logger.info(
574
+ f"[SIMULATION] cancel_order: asset={asset_id}, oid={order_id}"
575
+ )
576
+ return True, {"simulation": True, "status": "ok"}
577
+
371
578
  if not self._executor:
372
579
  raise NotImplementedError(
373
580
  "No Hyperliquid executor configured. "
@@ -383,6 +590,163 @@ class HyperliquidAdapter(BaseAdapter):
383
590
  success = result.get("status") == "ok"
384
591
  return success, result
385
592
 
593
+ async def cancel_order_by_cloid(
594
+ self,
595
+ asset_id: int,
596
+ cloid: str,
597
+ address: str,
598
+ ) -> tuple[bool, dict[str, Any]]:
599
+ if self.simulation:
600
+ logger.info(
601
+ f"[SIMULATION] cancel_order_by_cloid: asset={asset_id}, cloid={cloid}"
602
+ )
603
+ return True, {"simulation": True, "status": "ok"}
604
+
605
+ if not self._executor:
606
+ raise NotImplementedError(
607
+ "No Hyperliquid executor configured. "
608
+ "Inject a HyperliquidExecutor implementation (e.g., LocalHyperliquidExecutor)."
609
+ )
610
+
611
+ result = await self._executor.cancel_order_by_cloid(
612
+ asset_id=asset_id,
613
+ cloid=cloid,
614
+ address=address,
615
+ )
616
+
617
+ success = result.get("status") == "ok"
618
+ return success, result
619
+
620
+ async def spot_transfer(
621
+ self,
622
+ *,
623
+ amount: float,
624
+ destination: str,
625
+ token: str,
626
+ address: str,
627
+ ) -> tuple[bool, dict[str, Any]]:
628
+ """
629
+ Transfer a spot token to a destination address (signed spotSend action).
630
+
631
+ This is used for:
632
+ - user-to-user spot transfers
633
+ - HyperCore → HyperEVM routing (destination = system address)
634
+ """
635
+ if self.simulation:
636
+ logger.info(
637
+ f"[SIMULATION] spot_transfer: token={token}, amount={amount}, destination={destination}"
638
+ )
639
+ return True, {"simulation": True, "status": "ok"}
640
+
641
+ if not self._executor:
642
+ raise NotImplementedError("No Hyperliquid executor configured.")
643
+
644
+ result = await self._executor.spot_transfer(
645
+ amount=float(amount),
646
+ destination=str(destination),
647
+ token=str(token),
648
+ address=address,
649
+ )
650
+
651
+ success = result.get("status") == "ok"
652
+ return success, result
653
+
654
+ @staticmethod
655
+ def hypercore_index_to_system_address(index: int) -> str:
656
+ if index == 150:
657
+ return "0x2222222222222222222222222222222222222222"
658
+
659
+ hex_index = f"{index:x}"
660
+ padding_length = 42 - len("0x20") - len(hex_index)
661
+ result = "0x20" + "0" * padding_length + hex_index
662
+ return to_checksum_address(result)
663
+
664
+ async def hypercore_get_token_metadata(
665
+ self, token_address: str | None
666
+ ) -> dict[str, Any] | None:
667
+ """
668
+ Resolve spot token metadata from Hyperliquid spot meta by EVM contract address.
669
+
670
+ Special-case: native HYPE uses the 0-address and maps to tokens[150].
671
+ """
672
+ token_addr = (token_address or ZERO_ADDRESS).strip()
673
+ token_addr_lower = token_addr.lower()
674
+
675
+ success, spot_meta = await self.get_spot_meta()
676
+ if not success or not isinstance(spot_meta, dict):
677
+ return None
678
+
679
+ tokens = spot_meta.get("tokens", [])
680
+ if not isinstance(tokens, list) or not tokens:
681
+ return None
682
+
683
+ if token_addr_lower == ZERO_ADDRESS.lower():
684
+ token = tokens[150] if len(tokens) > 150 else None
685
+ return token if isinstance(token, dict) else None
686
+
687
+ for token_data in tokens:
688
+ if not isinstance(token_data, dict):
689
+ continue
690
+ evm_contract = token_data.get("evmContract")
691
+ if not isinstance(evm_contract, dict):
692
+ continue
693
+ address = evm_contract.get("address")
694
+ if isinstance(address, str) and address.lower() == token_addr_lower:
695
+ return token_data
696
+
697
+ return None
698
+
699
+ async def hypercore_to_hyperevm(
700
+ self,
701
+ *,
702
+ amount: float,
703
+ address: str,
704
+ token_address: str | None = None,
705
+ ) -> tuple[bool, dict[str, Any]]:
706
+ """
707
+ Transfer a spot token from HyperCore (Hyperliquid spot) to HyperEVM.
708
+
709
+ Notes:
710
+ - destination is the token's HyperEVM system address (NOT the user's wallet)
711
+ - token is formatted as "name:tokenId" from spot meta
712
+ """
713
+ token_data = await self.hypercore_get_token_metadata(token_address)
714
+ if not token_data:
715
+ return False, {
716
+ "status": "err",
717
+ "response": {"type": "error", "data": "Token not found in spot meta"},
718
+ }
719
+
720
+ try:
721
+ index = int(token_data.get("index"))
722
+ except (TypeError, ValueError):
723
+ return False, {
724
+ "status": "err",
725
+ "response": {"type": "error", "data": "Token metadata missing index"},
726
+ }
727
+
728
+ destination = self.hypercore_index_to_system_address(index)
729
+ name = token_data.get("name")
730
+ token_id = token_data.get("tokenId")
731
+ if not isinstance(name, str) or not name:
732
+ return False, {
733
+ "status": "err",
734
+ "response": {"type": "error", "data": "Token metadata missing name"},
735
+ }
736
+ if token_id is None:
737
+ return False, {
738
+ "status": "err",
739
+ "response": {"type": "error", "data": "Token metadata missing tokenId"},
740
+ }
741
+ token_string = f"{name}:{token_id}"
742
+
743
+ return await self.spot_transfer(
744
+ amount=float(amount),
745
+ destination=destination,
746
+ token=token_string,
747
+ address=address,
748
+ )
749
+
386
750
  async def update_leverage(
387
751
  self,
388
752
  asset_id: int,
@@ -390,6 +754,12 @@ class HyperliquidAdapter(BaseAdapter):
390
754
  is_cross: bool,
391
755
  address: str,
392
756
  ) -> tuple[bool, dict[str, Any]]:
757
+ if self.simulation:
758
+ self.logger.info(
759
+ f"[SIMULATION] update_leverage: asset={asset_id}, leverage={leverage}"
760
+ )
761
+ return True, {"simulation": True, "status": "ok"}
762
+
393
763
  if not self._executor:
394
764
  raise NotImplementedError("No Hyperliquid executor configured.")
395
765
 
@@ -408,6 +778,10 @@ class HyperliquidAdapter(BaseAdapter):
408
778
  amount: float,
409
779
  address: str,
410
780
  ) -> tuple[bool, dict[str, Any]]:
781
+ if self.simulation:
782
+ self.logger.info(f"[SIMULATION] transfer_spot_to_perp: {amount} USDC")
783
+ return True, {"simulation": True, "status": "ok"}
784
+
411
785
  if not self._executor:
412
786
  raise NotImplementedError("No Hyperliquid executor configured.")
413
787
 
@@ -424,6 +798,10 @@ class HyperliquidAdapter(BaseAdapter):
424
798
  amount: float,
425
799
  address: str,
426
800
  ) -> tuple[bool, dict[str, Any]]:
801
+ if self.simulation:
802
+ self.logger.info(f"[SIMULATION] transfer_perp_to_spot: {amount} USDC")
803
+ return True, {"simulation": True, "status": "ok"}
804
+
427
805
  if not self._executor:
428
806
  raise NotImplementedError("No Hyperliquid executor configured.")
429
807
 
@@ -443,6 +821,13 @@ class HyperliquidAdapter(BaseAdapter):
443
821
  size: float,
444
822
  address: str,
445
823
  ) -> tuple[bool, dict[str, Any]]:
824
+ if self.simulation:
825
+ self.logger.info(
826
+ f"[SIMULATION] place_stop_loss: asset={asset_id}, "
827
+ f"trigger={trigger_price}, size={size}"
828
+ )
829
+ return True, {"simulation": True, "status": "ok"}
830
+
446
831
  if not self._executor:
447
832
  raise NotImplementedError("No Hyperliquid executor configured.")
448
833
 
@@ -465,6 +850,49 @@ class HyperliquidAdapter(BaseAdapter):
465
850
  self.logger.error(f"Failed to fetch user_fills for {address}: {exc}")
466
851
  return False, str(exc)
467
852
 
853
+ async def check_recent_liquidations(
854
+ self, address: str, since_ms: int
855
+ ) -> tuple[bool, list[dict[str, Any]]]:
856
+ """
857
+ Check if user was liquidated since a given timestamp.
858
+
859
+ Fills have an optional 'liquidation' field with:
860
+ - liquidatedUser: who got liquidated
861
+ - markPx: price at liquidation
862
+ - method: "market" or "backstop"
863
+
864
+ Args:
865
+ address: Wallet address
866
+ since_ms: Epoch milliseconds to check from
867
+
868
+ Returns:
869
+ (success, list of liquidation fills where user was liquidated)
870
+ """
871
+ try:
872
+ now_ms = int(time.time() * 1000)
873
+ body = {
874
+ "type": "userFillsByTime",
875
+ "user": address,
876
+ "startTime": since_ms,
877
+ "endTime": now_ms,
878
+ }
879
+ data = self.info.post("/info", body)
880
+ fills = data if isinstance(data, list) else []
881
+
882
+ # Filter for liquidation fills where we were the liquidated user
883
+ liquidation_fills = [
884
+ f
885
+ for f in fills
886
+ if f.get("liquidation")
887
+ and f["liquidation"].get("liquidatedUser", "").lower()
888
+ == address.lower()
889
+ ]
890
+
891
+ return True, liquidation_fills
892
+ except Exception as exc:
893
+ self.logger.error(f"Failed to check liquidations for {address}: {exc}")
894
+ return False, []
895
+
468
896
  async def get_order_status(
469
897
  self, address: str, order_id: int | str
470
898
  ) -> tuple[bool, dict[str, Any]]:
@@ -487,6 +915,18 @@ class HyperliquidAdapter(BaseAdapter):
487
915
  async def get_frontend_open_orders(
488
916
  self, address: str
489
917
  ) -> tuple[bool, list[dict[str, Any]]]:
918
+ """
919
+ Get all open orders including trigger orders (stop-loss, take-profit).
920
+
921
+ Uses frontendOpenOrders endpoint which returns both limit and trigger orders
922
+ with full order details including orderType and triggerPx.
923
+
924
+ Args:
925
+ address: Wallet address
926
+
927
+ Returns:
928
+ List of open order records including trigger orders
929
+ """
490
930
  try:
491
931
  data = self.info.frontend_open_orders(address)
492
932
  return True, data if isinstance(data, list) else []
@@ -502,6 +942,15 @@ class HyperliquidAdapter(BaseAdapter):
502
942
  amount: float,
503
943
  address: str,
504
944
  ) -> tuple[bool, dict[str, Any]]:
945
+ """
946
+ Withdraw USDC from Hyperliquid to Arbitrum.
947
+
948
+ Note: This is an L1 withdrawal handled by the Hyperliquid executor (signing required).
949
+ """
950
+ if self.simulation:
951
+ self.logger.info(f"[SIMULATION] withdraw: {amount} USDC")
952
+ return True, {"simulation": True, "status": "ok"}
953
+
505
954
  if not self._executor:
506
955
  raise NotImplementedError("No Hyperliquid executor configured.")
507
956
 
@@ -512,22 +961,6 @@ class HyperliquidAdapter(BaseAdapter):
512
961
  success = result.get("status") == "ok"
513
962
  return success, result
514
963
 
515
- # ------------------------------------------------------------------ #
516
- # Health Check #
517
- # ------------------------------------------------------------------ #
518
-
519
- async def health_check(self) -> dict[str, Any]:
520
- try:
521
- success, meta = await self.get_meta_and_asset_ctxs()
522
- if success and meta:
523
- return {
524
- "status": "healthy",
525
- "perp_markets": len(meta[0].get("universe", [])) if meta else 0,
526
- }
527
- return {"status": "unhealthy", "error": "Failed to fetch metadata"}
528
- except Exception as exc:
529
- return {"status": "unhealthy", "error": str(exc)}
530
-
531
964
  # ------------------------------------------------------------------ #
532
965
  # Deposit/Withdrawal Helpers #
533
966
  # ------------------------------------------------------------------ #
@@ -564,6 +997,13 @@ class HyperliquidAdapter(BaseAdapter):
564
997
  max_fee_rate: str,
565
998
  address: str,
566
999
  ) -> tuple[bool, dict[str, Any]]:
1000
+ if self.simulation:
1001
+ self.logger.info(
1002
+ f"[SIMULATION] approve_builder_fee: builder={builder}, "
1003
+ f"rate={max_fee_rate}, address={address}"
1004
+ )
1005
+ return True, {"simulation": True, "status": "ok"}
1006
+
567
1007
  if not self._executor:
568
1008
  raise NotImplementedError("No Hyperliquid executor configured.")
569
1009
 
@@ -576,6 +1016,47 @@ class HyperliquidAdapter(BaseAdapter):
576
1016
  success = result.get("status") == "ok"
577
1017
  return success, result
578
1018
 
1019
+ async def ensure_builder_fee_approved(
1020
+ self,
1021
+ address: str,
1022
+ builder_fee: dict[str, Any] | None = None,
1023
+ ) -> tuple[bool, str]:
1024
+ if self.simulation:
1025
+ return True, "Simulation mode - builder fee not needed"
1026
+
1027
+ # Resolve fee config from parameter or config
1028
+ fee_config = builder_fee
1029
+ if not fee_config and isinstance(self.config, dict):
1030
+ fee_config = self.config.get("builder_fee")
1031
+
1032
+ if not fee_config or not isinstance(fee_config, dict):
1033
+ return True, "No builder fee configured"
1034
+
1035
+ builder = fee_config.get("b")
1036
+ required_fee = fee_config.get("f", 0)
1037
+ if not builder or not required_fee:
1038
+ return True, "Builder fee not configured"
1039
+
1040
+ # Check current approval
1041
+ try:
1042
+ ok, current_fee = await self.get_max_builder_fee(address, builder)
1043
+ if ok and int(current_fee) >= int(required_fee):
1044
+ return (
1045
+ True,
1046
+ f"Builder fee already approved ({current_fee} >= {required_fee})",
1047
+ )
1048
+ except Exception as e:
1049
+ logger.warning(
1050
+ f"Failed to check builder fee: {e}, proceeding with approval"
1051
+ )
1052
+
1053
+ # Approve
1054
+ max_fee_rate = f"{int(required_fee) / 1000:.3f}%"
1055
+ ok, result = await self.approve_builder_fee(builder, max_fee_rate, address)
1056
+ if ok:
1057
+ return True, f"Builder fee approved: {max_fee_rate}"
1058
+ return False, f"Builder fee approval failed: {result}"
1059
+
579
1060
  async def place_limit_order(
580
1061
  self,
581
1062
  asset_id: int,
@@ -587,6 +1068,32 @@ class HyperliquidAdapter(BaseAdapter):
587
1068
  reduce_only: bool = False,
588
1069
  builder: dict[str, Any] | None = None,
589
1070
  ) -> tuple[bool, dict[str, Any]]:
1071
+ """
1072
+ Place a limit order (GTC - Good Till Cancelled).
1073
+
1074
+ Used for spot stop-loss orders in basis trading.
1075
+
1076
+ Args:
1077
+ asset_id: Asset ID (perp < 10000, spot >= 10000)
1078
+ is_buy: True for buy, False for sell
1079
+ price: Limit price
1080
+ size: Order size
1081
+ address: Wallet address
1082
+ reduce_only: If True, only reduces existing position
1083
+ builder: Builder fee config; if omitted, a mandatory default is applied.
1084
+
1085
+ Returns:
1086
+ (success, response_data or error_message)
1087
+ """
1088
+ builder = self._mandatory_builder_fee(builder)
1089
+
1090
+ if self.simulation:
1091
+ self.logger.info(
1092
+ f"[SIMULATION] place_limit_order: asset={asset_id}, "
1093
+ f"is_buy={is_buy}, price={price}, size={size}"
1094
+ )
1095
+ return True, {"simulation": True, "status": "ok"}
1096
+
590
1097
  if not self._executor:
591
1098
  raise NotImplementedError("No Hyperliquid executor configured.")
592
1099
 
@@ -613,6 +1120,7 @@ class HyperliquidAdapter(BaseAdapter):
613
1120
  ) -> tuple[bool, float]:
614
1121
  iterations = timeout_s // poll_interval_s
615
1122
 
1123
+ # Get initial balance
616
1124
  success, initial_state = await self.get_user_state(address)
617
1125
  if not success:
618
1126
  self.logger.warning(f"Could not fetch initial state: {initial_state}")
@@ -650,8 +1158,9 @@ class HyperliquidAdapter(BaseAdapter):
650
1158
 
651
1159
  self.logger.warning(
652
1160
  f"Hyperliquid deposit not confirmed after {timeout_s}s. "
653
- f"Deposits typically take 1-2 minutes."
1161
+ "Deposits typically credit in < 1 minute (but can take longer)."
654
1162
  )
1163
+ # Return current balance even if not confirmed
655
1164
  success, state = await self.get_user_state(address)
656
1165
  final_balance = (
657
1166
  self.get_perp_margin_amount(state) if success else initial_balance
@@ -699,6 +1208,21 @@ class HyperliquidAdapter(BaseAdapter):
699
1208
  max_poll_time_s: int = 30 * 60,
700
1209
  poll_interval_s: int = 5,
701
1210
  ) -> tuple[bool, dict[str, float]]:
1211
+ """
1212
+ Wait for a withdrawal to appear on-chain.
1213
+
1214
+ Polls Hyperliquid's ledger updates until a withdrawal is detected.
1215
+ Withdrawals typically take ~3-4 minutes to process (but can take longer).
1216
+
1217
+ Args:
1218
+ address: Wallet address
1219
+ lookback_s: How far back to look for withdrawals (small buffer for latency)
1220
+ max_poll_time_s: Maximum time to wait (default 30 minutes)
1221
+ poll_interval_s: Time between polls
1222
+
1223
+ Returns:
1224
+ (success, {tx_hash: usdc_amount}) - withdrawals found
1225
+ """
702
1226
  import time
703
1227
 
704
1228
  start_time_ms = time.time() * 1000
@@ -720,7 +1244,7 @@ class HyperliquidAdapter(BaseAdapter):
720
1244
  remaining_s = i * poll_interval_s
721
1245
  self.logger.info(
722
1246
  f"Waiting for withdrawal to appear on-chain... "
723
- f"{remaining_s}s remaining (withdrawals often take 10+ minutes)"
1247
+ f"{remaining_s}s remaining (withdrawals often take a few minutes)"
724
1248
  )
725
1249
  await asyncio.sleep(poll_interval_s)
726
1250