wayfinder-paths 0.1.22__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 (156) hide show
  1. wayfinder_paths/__init__.py +0 -4
  2. wayfinder_paths/adapters/balance_adapter/README.md +0 -1
  3. wayfinder_paths/adapters/balance_adapter/adapter.py +313 -167
  4. wayfinder_paths/adapters/balance_adapter/manifest.yaml +8 -0
  5. wayfinder_paths/adapters/balance_adapter/test_adapter.py +41 -124
  6. wayfinder_paths/adapters/boros_adapter/__init__.py +17 -0
  7. wayfinder_paths/adapters/boros_adapter/adapter.py +1574 -0
  8. wayfinder_paths/adapters/boros_adapter/client.py +476 -0
  9. wayfinder_paths/adapters/boros_adapter/manifest.yaml +10 -0
  10. wayfinder_paths/adapters/boros_adapter/parsers.py +88 -0
  11. wayfinder_paths/adapters/boros_adapter/test_adapter.py +460 -0
  12. wayfinder_paths/adapters/boros_adapter/test_golden.py +156 -0
  13. wayfinder_paths/adapters/boros_adapter/types.py +70 -0
  14. wayfinder_paths/adapters/boros_adapter/utils.py +85 -0
  15. wayfinder_paths/adapters/brap_adapter/README.md +22 -75
  16. wayfinder_paths/adapters/brap_adapter/adapter.py +187 -576
  17. wayfinder_paths/adapters/brap_adapter/examples.json +21 -140
  18. wayfinder_paths/adapters/brap_adapter/manifest.yaml +9 -0
  19. wayfinder_paths/adapters/brap_adapter/test_adapter.py +6 -234
  20. wayfinder_paths/adapters/hyperlend_adapter/adapter.py +180 -92
  21. wayfinder_paths/adapters/hyperlend_adapter/manifest.yaml +9 -0
  22. wayfinder_paths/adapters/hyperlend_adapter/test_adapter.py +82 -14
  23. wayfinder_paths/adapters/hyperliquid_adapter/__init__.py +2 -9
  24. wayfinder_paths/adapters/hyperliquid_adapter/adapter.py +586 -61
  25. wayfinder_paths/adapters/hyperliquid_adapter/executor.py +47 -68
  26. wayfinder_paths/adapters/hyperliquid_adapter/manifest.yaml +14 -0
  27. wayfinder_paths/adapters/hyperliquid_adapter/paired_filler.py +2 -3
  28. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter.py +17 -21
  29. wayfinder_paths/adapters/hyperliquid_adapter/test_adapter_live.py +3 -6
  30. wayfinder_paths/adapters/hyperliquid_adapter/test_executor.py +4 -8
  31. wayfinder_paths/adapters/hyperliquid_adapter/test_utils.py +2 -2
  32. wayfinder_paths/adapters/ledger_adapter/README.md +4 -1
  33. wayfinder_paths/adapters/ledger_adapter/adapter.py +3 -3
  34. wayfinder_paths/adapters/ledger_adapter/manifest.yaml +7 -0
  35. wayfinder_paths/adapters/ledger_adapter/test_adapter.py +1 -2
  36. wayfinder_paths/adapters/moonwell_adapter/adapter.py +649 -547
  37. wayfinder_paths/adapters/moonwell_adapter/manifest.yaml +14 -0
  38. wayfinder_paths/adapters/moonwell_adapter/test_adapter.py +160 -239
  39. wayfinder_paths/adapters/multicall_adapter/__init__.py +7 -0
  40. wayfinder_paths/adapters/multicall_adapter/adapter.py +166 -0
  41. wayfinder_paths/adapters/multicall_adapter/manifest.yaml +5 -0
  42. wayfinder_paths/adapters/multicall_adapter/test_adapter.py +97 -0
  43. wayfinder_paths/adapters/pendle_adapter/README.md +102 -0
  44. wayfinder_paths/adapters/pendle_adapter/__init__.py +7 -0
  45. wayfinder_paths/adapters/pendle_adapter/adapter.py +1992 -0
  46. wayfinder_paths/adapters/pendle_adapter/examples.json +11 -0
  47. wayfinder_paths/adapters/pendle_adapter/manifest.yaml +21 -0
  48. wayfinder_paths/adapters/pendle_adapter/test_adapter.py +666 -0
  49. wayfinder_paths/adapters/pool_adapter/manifest.yaml +6 -0
  50. wayfinder_paths/adapters/token_adapter/adapter.py +14 -0
  51. wayfinder_paths/adapters/token_adapter/examples.json +0 -4
  52. wayfinder_paths/adapters/token_adapter/manifest.yaml +7 -0
  53. wayfinder_paths/conftest.py +24 -17
  54. wayfinder_paths/core/__init__.py +0 -3
  55. wayfinder_paths/core/adapters/BaseAdapter.py +0 -25
  56. wayfinder_paths/core/adapters/models.py +17 -7
  57. wayfinder_paths/core/clients/BRAPClient.py +4 -1
  58. wayfinder_paths/core/clients/ClientManager.py +0 -7
  59. wayfinder_paths/core/clients/LedgerClient.py +196 -172
  60. wayfinder_paths/core/clients/TokenClient.py +47 -1
  61. wayfinder_paths/core/clients/WayfinderClient.py +1 -3
  62. wayfinder_paths/core/clients/__init__.py +0 -5
  63. wayfinder_paths/core/clients/protocols.py +21 -35
  64. wayfinder_paths/core/clients/test_ledger_client.py +448 -0
  65. wayfinder_paths/core/config.py +10 -162
  66. wayfinder_paths/core/constants/__init__.py +73 -2
  67. wayfinder_paths/core/constants/base.py +8 -17
  68. wayfinder_paths/core/constants/chains.py +36 -0
  69. wayfinder_paths/core/constants/contracts.py +52 -0
  70. wayfinder_paths/core/constants/erc20_abi.py +0 -1
  71. wayfinder_paths/core/constants/hyperlend_abi.py +0 -4
  72. wayfinder_paths/core/constants/hyperliquid.py +16 -0
  73. wayfinder_paths/core/constants/moonwell_abi.py +0 -15
  74. wayfinder_paths/core/constants/tokens.py +9 -0
  75. wayfinder_paths/core/engine/manifest.py +66 -0
  76. wayfinder_paths/core/strategies/Strategy.py +0 -71
  77. wayfinder_paths/core/strategies/__init__.py +10 -1
  78. wayfinder_paths/core/strategies/opa_loop.py +167 -0
  79. wayfinder_paths/core/utils/evm_helpers.py +5 -15
  80. wayfinder_paths/core/utils/test_transaction.py +289 -0
  81. wayfinder_paths/core/utils/tokens.py +28 -0
  82. wayfinder_paths/core/utils/transaction.py +57 -8
  83. wayfinder_paths/core/utils/web3.py +8 -3
  84. wayfinder_paths/mcp/__init__.py +5 -0
  85. wayfinder_paths/mcp/preview.py +185 -0
  86. wayfinder_paths/mcp/scripting.py +84 -0
  87. wayfinder_paths/mcp/server.py +52 -0
  88. wayfinder_paths/mcp/state/profile_store.py +195 -0
  89. wayfinder_paths/mcp/state/store.py +89 -0
  90. wayfinder_paths/mcp/test_scripting.py +267 -0
  91. wayfinder_paths/mcp/tools/__init__.py +0 -0
  92. wayfinder_paths/mcp/tools/balances.py +290 -0
  93. wayfinder_paths/mcp/tools/discovery.py +158 -0
  94. wayfinder_paths/mcp/tools/execute.py +770 -0
  95. wayfinder_paths/mcp/tools/hyperliquid.py +931 -0
  96. wayfinder_paths/mcp/tools/quotes.py +288 -0
  97. wayfinder_paths/mcp/tools/run_script.py +286 -0
  98. wayfinder_paths/mcp/tools/strategies.py +188 -0
  99. wayfinder_paths/mcp/tools/tokens.py +46 -0
  100. wayfinder_paths/mcp/tools/wallets.py +354 -0
  101. wayfinder_paths/mcp/utils.py +129 -0
  102. wayfinder_paths/policies/enso.py +1 -2
  103. wayfinder_paths/policies/hyper_evm.py +6 -3
  104. wayfinder_paths/policies/hyperlend.py +1 -2
  105. wayfinder_paths/policies/hyperliquid.py +1 -1
  106. wayfinder_paths/policies/lifi.py +18 -0
  107. wayfinder_paths/policies/moonwell.py +12 -7
  108. wayfinder_paths/policies/prjx.py +1 -3
  109. wayfinder_paths/policies/util.py +8 -2
  110. wayfinder_paths/run_strategy.py +97 -300
  111. wayfinder_paths/strategies/basis_trading_strategy/constants.py +3 -1
  112. wayfinder_paths/strategies/basis_trading_strategy/strategy.py +47 -133
  113. wayfinder_paths/strategies/basis_trading_strategy/test_strategy.py +24 -53
  114. wayfinder_paths/strategies/boros_hype_strategy/__init__.py +3 -0
  115. wayfinder_paths/strategies/boros_hype_strategy/boros_ops_mixin.py +450 -0
  116. wayfinder_paths/strategies/boros_hype_strategy/constants.py +255 -0
  117. wayfinder_paths/strategies/boros_hype_strategy/examples.json +37 -0
  118. wayfinder_paths/strategies/boros_hype_strategy/hyperevm_ops_mixin.py +114 -0
  119. wayfinder_paths/strategies/boros_hype_strategy/hyperliquid_ops_mixin.py +642 -0
  120. wayfinder_paths/strategies/boros_hype_strategy/manifest.yaml +36 -0
  121. wayfinder_paths/strategies/boros_hype_strategy/planner.py +460 -0
  122. wayfinder_paths/strategies/boros_hype_strategy/risk_ops_mixin.py +886 -0
  123. wayfinder_paths/strategies/boros_hype_strategy/snapshot_mixin.py +494 -0
  124. wayfinder_paths/strategies/boros_hype_strategy/strategy.py +1194 -0
  125. wayfinder_paths/strategies/boros_hype_strategy/test_planner_golden.py +374 -0
  126. wayfinder_paths/{templates/strategy → strategies/boros_hype_strategy}/test_strategy.py +99 -63
  127. wayfinder_paths/strategies/boros_hype_strategy/types.py +365 -0
  128. wayfinder_paths/strategies/boros_hype_strategy/withdraw_mixin.py +997 -0
  129. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/strategy.py +15 -23
  130. wayfinder_paths/strategies/hyperlend_stable_yield_strategy/test_strategy.py +27 -62
  131. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/strategy.py +84 -58
  132. wayfinder_paths/strategies/moonwell_wsteth_loop_strategy/test_strategy.py +5 -15
  133. wayfinder_paths/strategies/stablecoin_yield_strategy/strategy.py +69 -164
  134. wayfinder_paths/strategies/stablecoin_yield_strategy/test_strategy.py +43 -76
  135. wayfinder_paths/tests/test_mcp_quote_swap.py +165 -0
  136. wayfinder_paths/tests/test_test_coverage.py +1 -4
  137. wayfinder_paths-0.1.24.dist-info/METADATA +378 -0
  138. wayfinder_paths-0.1.24.dist-info/RECORD +185 -0
  139. {wayfinder_paths-0.1.22.dist-info → wayfinder_paths-0.1.24.dist-info}/WHEEL +1 -1
  140. wayfinder_paths/core/clients/WalletClient.py +0 -41
  141. wayfinder_paths/core/engine/StrategyJob.py +0 -110
  142. wayfinder_paths/core/services/test_local_evm_txn.py +0 -145
  143. wayfinder_paths/scripts/create_strategy.py +0 -139
  144. wayfinder_paths/scripts/make_wallets.py +0 -142
  145. wayfinder_paths/templates/adapter/README.md +0 -150
  146. wayfinder_paths/templates/adapter/adapter.py +0 -16
  147. wayfinder_paths/templates/adapter/examples.json +0 -8
  148. wayfinder_paths/templates/adapter/test_adapter.py +0 -30
  149. wayfinder_paths/templates/strategy/README.md +0 -186
  150. wayfinder_paths/templates/strategy/examples.json +0 -11
  151. wayfinder_paths/templates/strategy/strategy.py +0 -35
  152. wayfinder_paths/tests/test_smoke_manifest.py +0 -63
  153. wayfinder_paths-0.1.22.dist-info/METADATA +0 -355
  154. wayfinder_paths-0.1.22.dist-info/RECORD +0 -129
  155. /wayfinder_paths/{scripts → mcp/state}/__init__.py +0 -0
  156. {wayfinder_paths-0.1.22.dist-info → wayfinder_paths-0.1.24.dist-info}/LICENSE +0 -0
@@ -2,20 +2,34 @@ 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
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,
23
+ )
8
24
 
9
25
  if TYPE_CHECKING:
10
26
  from wayfinder_paths.core.clients.protocols import (
11
27
  HyperliquidExecutorProtocol as HyperliquidExecutor,
12
28
  )
13
29
 
14
- # Hyperliquid L1 bridge address on Arbitrum - send USDC here to deposit
15
- HYPERLIQUID_BRIDGE_ADDRESS = "0x2Df1c51E09aECF9cacB7bc98cB1742757f163dF7"
16
-
17
- # USDC contract on Arbitrum
18
- ARBITRUM_USDC_ADDRESS = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"
30
+ # Re-export Bridge2 constants for backwards compatibility.
31
+ HYPERLIQUID_BRIDGE_ADDRESS = _HYPERLIQUID_BRIDGE_ADDRESS
32
+ ARBITRUM_USDC_ADDRESS = _ARBITRUM_USDC_ADDRESS
19
33
 
20
34
  try:
21
35
  from hyperliquid.info import Info
@@ -28,36 +42,26 @@ except ImportError:
28
42
  constants = None
29
43
 
30
44
 
31
- class SimpleCache:
32
- def __init__(self):
33
- self._cache: dict[str, Any] = {}
34
- self._expiry: dict[str, float] = {}
35
-
36
- def get(self, key: str) -> Any | None:
37
- if key in self._cache:
38
- if time.time() < self._expiry.get(key, 0):
39
- return self._cache[key]
40
- del self._cache[key]
41
- if key in self._expiry:
42
- del self._expiry[key]
43
- return None
44
-
45
- def set(self, key: str, value: Any, timeout: int = 300) -> None:
46
- self._cache[key] = value
47
- self._expiry[key] = time.time() + timeout
48
-
49
- def clear(self) -> None:
50
- self._cache.clear()
51
- self._expiry.clear()
52
-
53
-
54
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
+
55
58
  adapter_type = "HYPERLIQUID"
56
59
 
57
60
  def __init__(
58
61
  self,
59
62
  config: dict[str, Any] | None = None,
60
63
  *,
64
+ simulation: bool = False,
61
65
  executor: HyperliquidExecutor | None = None,
62
66
  ) -> None:
63
67
  super().__init__("hyperliquid_adapter", config)
@@ -68,9 +72,11 @@ class HyperliquidAdapter(BaseAdapter):
68
72
  "Install with: poetry add hyperliquid"
69
73
  )
70
74
 
71
- self._cache = SimpleCache()
75
+ self.simulation = simulation
76
+ self._cache = Cache(Cache.MEMORY)
72
77
  self._executor = executor
73
78
 
79
+ # Initialize Hyperliquid Info client
74
80
  self.info = Info(constants.MAINNET_API_URL, skip_ws=True)
75
81
 
76
82
  # Cache asset mappings after first fetch
@@ -94,13 +100,14 @@ class HyperliquidAdapter(BaseAdapter):
94
100
 
95
101
  async def get_meta_and_asset_ctxs(self) -> tuple[bool, Any]:
96
102
  cache_key = "hl_meta_and_asset_ctxs"
97
- cached = self._cache.get(cache_key)
103
+ cached = await self._cache.get(cache_key)
98
104
  if cached:
99
105
  return True, cached
100
106
 
101
107
  try:
102
108
  data = self.info.meta_and_asset_ctxs()
103
- self._cache.set(cache_key, data, timeout=60)
109
+ # Cache for 1 minute
110
+ await self._cache.set(cache_key, data, ttl=60)
104
111
  return True, data
105
112
  except Exception as exc:
106
113
  self.logger.error(f"Failed to fetch meta_and_asset_ctxs: {exc}")
@@ -108,25 +115,85 @@ class HyperliquidAdapter(BaseAdapter):
108
115
 
109
116
  async def get_spot_meta(self) -> tuple[bool, Any]:
110
117
  cache_key = "hl_spot_meta"
111
- cached = self._cache.get(cache_key)
118
+ cached = await self._cache.get(cache_key)
112
119
  if cached:
113
120
  return True, cached
114
121
 
115
122
  try:
123
+ # Handle both callable and property access patterns
116
124
  spot_meta = self.info.spot_meta
117
125
  if callable(spot_meta):
118
126
  data = spot_meta()
119
127
  else:
120
128
  data = spot_meta
121
- self._cache.set(cache_key, data, timeout=60)
129
+ await self._cache.set(cache_key, data, ttl=60)
122
130
  return True, data
123
131
  except Exception as exc:
124
132
  self.logger.error(f"Failed to fetch spot_meta: {exc}")
125
133
  return False, str(exc)
126
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
+
127
194
  async def get_spot_assets(self) -> tuple[bool, dict[str, int]]:
128
195
  cache_key = "hl_spot_assets"
129
- cached = self._cache.get(cache_key)
196
+ cached = await self._cache.get(cache_key)
130
197
  if cached:
131
198
  return True, cached
132
199
 
@@ -146,6 +213,7 @@ class HyperliquidAdapter(BaseAdapter):
146
213
 
147
214
  base_idx, quote_idx = pair_tokens[0], pair_tokens[1]
148
215
 
216
+ # Get token names
149
217
  base_info = tokens[base_idx] if base_idx < len(tokens) else {}
150
218
  quote_info = tokens[quote_idx] if quote_idx < len(tokens) else {}
151
219
 
@@ -156,16 +224,19 @@ class HyperliquidAdapter(BaseAdapter):
156
224
  spot_asset_id = pair.get("index", 0) + 10000
157
225
  response[name] = spot_asset_id
158
226
 
159
- self._cache.set(cache_key, response, timeout=300)
227
+ # Cache for 5 min
228
+ await self._cache.set(cache_key, response, ttl=300)
160
229
  return True, response
161
230
 
162
231
  except Exception as exc:
163
232
  self.logger.error(f"Failed to get spot assets: {exc}")
164
233
  return False, {}
165
234
 
166
- 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:
167
238
  cache_key = "hl_spot_assets"
168
- cached = self._cache.get(cache_key)
239
+ cached = await self._cache.get(cache_key)
169
240
  if cached:
170
241
  pair_name = f"{base_coin}/{quote_coin}"
171
242
  return cached.get(pair_name)
@@ -228,9 +299,63 @@ class HyperliquidAdapter(BaseAdapter):
228
299
  self.logger.error(f"Failed to fetch spot_user_state for {address}: {exc}")
229
300
  return False, str(exc)
230
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
+
231
356
  async def get_margin_table(self, margin_table_id: int) -> tuple[bool, list[dict]]:
232
357
  cache_key = f"hl_margin_table_{margin_table_id}"
233
- cached = self._cache.get(cache_key)
358
+ cached = await self._cache.get(cache_key)
234
359
  if cached:
235
360
  return True, cached
236
361
 
@@ -243,7 +368,7 @@ class HyperliquidAdapter(BaseAdapter):
243
368
  except Exception: # noqa: BLE001 - try alternate payload key
244
369
  body = {"type": "marginTable", "marginTableId": int(margin_table_id)}
245
370
  data = self.info.post("/info", body)
246
- self._cache.set(cache_key, data, timeout=86400)
371
+ await self._cache.set(cache_key, data, ttl=86400) # Cache for 24h
247
372
  return True, data
248
373
  except Exception as exc:
249
374
  self.logger.error(f"Failed to fetch margin_table {margin_table_id}: {exc}")
@@ -284,6 +409,7 @@ class HyperliquidAdapter(BaseAdapter):
284
409
 
285
410
  @property
286
411
  def coin_to_asset(self) -> dict[str, int]:
412
+ """Get coin name to asset ID mapping (perps only)."""
287
413
  if self._coin_to_asset is None:
288
414
  self._coin_to_asset = dict(self.info.coin_to_asset)
289
415
  return self._coin_to_asset
@@ -296,10 +422,10 @@ class HyperliquidAdapter(BaseAdapter):
296
422
  f"Unknown asset_id {asset_id}: missing szDecimals"
297
423
  ) from None
298
424
 
299
- def refresh_mappings(self) -> None:
425
+ async def refresh_mappings(self) -> None:
300
426
  self._asset_to_sz_decimals = None
301
427
  self._coin_to_asset = None
302
- self._cache.clear()
428
+ await self._cache.clear()
303
429
 
304
430
  # ------------------------------------------------------------------ #
305
431
  # Utility Methods #
@@ -315,8 +441,6 @@ class HyperliquidAdapter(BaseAdapter):
315
441
 
316
442
  def get_valid_order_size(self, asset_id: int, size: float) -> float:
317
443
  decimals = self.get_sz_decimals(asset_id)
318
- from decimal import ROUND_DOWN, Decimal
319
-
320
444
  step = Decimal(10) ** (-decimals)
321
445
  if size <= 0:
322
446
  return 0.0
@@ -329,6 +453,49 @@ class HyperliquidAdapter(BaseAdapter):
329
453
  # Execution Methods (require signing callback) #
330
454
  # ------------------------------------------------------------------ #
331
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
+
332
499
  async def place_market_order(
333
500
  self,
334
501
  asset_id: int,
@@ -341,6 +508,31 @@ class HyperliquidAdapter(BaseAdapter):
341
508
  cloid: str | None = None,
342
509
  builder: dict[str, Any] | None = None,
343
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
+
344
536
  if not self._executor:
345
537
  raise NotImplementedError(
346
538
  "No Hyperliquid executor configured. "
@@ -358,7 +550,17 @@ class HyperliquidAdapter(BaseAdapter):
358
550
  builder=builder,
359
551
  )
360
552
 
553
+ # Check both the API status and the order statuses for errors
361
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
362
564
  return success, result
363
565
 
364
566
  async def cancel_order(
@@ -367,6 +569,12 @@ class HyperliquidAdapter(BaseAdapter):
367
569
  order_id: int | str,
368
570
  address: str,
369
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
+
370
578
  if not self._executor:
371
579
  raise NotImplementedError(
372
580
  "No Hyperliquid executor configured. "
@@ -382,6 +590,163 @@ class HyperliquidAdapter(BaseAdapter):
382
590
  success = result.get("status") == "ok"
383
591
  return success, result
384
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
+
385
750
  async def update_leverage(
386
751
  self,
387
752
  asset_id: int,
@@ -389,6 +754,12 @@ class HyperliquidAdapter(BaseAdapter):
389
754
  is_cross: bool,
390
755
  address: str,
391
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
+
392
763
  if not self._executor:
393
764
  raise NotImplementedError("No Hyperliquid executor configured.")
394
765
 
@@ -407,6 +778,10 @@ class HyperliquidAdapter(BaseAdapter):
407
778
  amount: float,
408
779
  address: str,
409
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
+
410
785
  if not self._executor:
411
786
  raise NotImplementedError("No Hyperliquid executor configured.")
412
787
 
@@ -423,6 +798,10 @@ class HyperliquidAdapter(BaseAdapter):
423
798
  amount: float,
424
799
  address: str,
425
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
+
426
805
  if not self._executor:
427
806
  raise NotImplementedError("No Hyperliquid executor configured.")
428
807
 
@@ -442,6 +821,13 @@ class HyperliquidAdapter(BaseAdapter):
442
821
  size: float,
443
822
  address: str,
444
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
+
445
831
  if not self._executor:
446
832
  raise NotImplementedError("No Hyperliquid executor configured.")
447
833
 
@@ -464,6 +850,49 @@ class HyperliquidAdapter(BaseAdapter):
464
850
  self.logger.error(f"Failed to fetch user_fills for {address}: {exc}")
465
851
  return False, str(exc)
466
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
+
467
896
  async def get_order_status(
468
897
  self, address: str, order_id: int | str
469
898
  ) -> tuple[bool, dict[str, Any]]:
@@ -486,6 +915,18 @@ class HyperliquidAdapter(BaseAdapter):
486
915
  async def get_frontend_open_orders(
487
916
  self, address: str
488
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
+ """
489
930
  try:
490
931
  data = self.info.frontend_open_orders(address)
491
932
  return True, data if isinstance(data, list) else []
@@ -501,6 +942,15 @@ class HyperliquidAdapter(BaseAdapter):
501
942
  amount: float,
502
943
  address: str,
503
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
+
504
954
  if not self._executor:
505
955
  raise NotImplementedError("No Hyperliquid executor configured.")
506
956
 
@@ -511,22 +961,6 @@ class HyperliquidAdapter(BaseAdapter):
511
961
  success = result.get("status") == "ok"
512
962
  return success, result
513
963
 
514
- # ------------------------------------------------------------------ #
515
- # Health Check #
516
- # ------------------------------------------------------------------ #
517
-
518
- async def health_check(self) -> dict[str, Any]:
519
- try:
520
- success, meta = await self.get_meta_and_asset_ctxs()
521
- if success and meta:
522
- return {
523
- "status": "healthy",
524
- "perp_markets": len(meta[0].get("universe", [])) if meta else 0,
525
- }
526
- return {"status": "unhealthy", "error": "Failed to fetch metadata"}
527
- except Exception as exc:
528
- return {"status": "unhealthy", "error": str(exc)}
529
-
530
964
  # ------------------------------------------------------------------ #
531
965
  # Deposit/Withdrawal Helpers #
532
966
  # ------------------------------------------------------------------ #
@@ -563,6 +997,13 @@ class HyperliquidAdapter(BaseAdapter):
563
997
  max_fee_rate: str,
564
998
  address: str,
565
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
+
566
1007
  if not self._executor:
567
1008
  raise NotImplementedError("No Hyperliquid executor configured.")
568
1009
 
@@ -575,6 +1016,47 @@ class HyperliquidAdapter(BaseAdapter):
575
1016
  success = result.get("status") == "ok"
576
1017
  return success, result
577
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
+
578
1060
  async def place_limit_order(
579
1061
  self,
580
1062
  asset_id: int,
@@ -586,6 +1068,32 @@ class HyperliquidAdapter(BaseAdapter):
586
1068
  reduce_only: bool = False,
587
1069
  builder: dict[str, Any] | None = None,
588
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
+
589
1097
  if not self._executor:
590
1098
  raise NotImplementedError("No Hyperliquid executor configured.")
591
1099
 
@@ -612,6 +1120,7 @@ class HyperliquidAdapter(BaseAdapter):
612
1120
  ) -> tuple[bool, float]:
613
1121
  iterations = timeout_s // poll_interval_s
614
1122
 
1123
+ # Get initial balance
615
1124
  success, initial_state = await self.get_user_state(address)
616
1125
  if not success:
617
1126
  self.logger.warning(f"Could not fetch initial state: {initial_state}")
@@ -649,8 +1158,9 @@ class HyperliquidAdapter(BaseAdapter):
649
1158
 
650
1159
  self.logger.warning(
651
1160
  f"Hyperliquid deposit not confirmed after {timeout_s}s. "
652
- f"Deposits typically take 1-2 minutes."
1161
+ "Deposits typically credit in < 1 minute (but can take longer)."
653
1162
  )
1163
+ # Return current balance even if not confirmed
654
1164
  success, state = await self.get_user_state(address)
655
1165
  final_balance = (
656
1166
  self.get_perp_margin_amount(state) if success else initial_balance
@@ -698,6 +1208,21 @@ class HyperliquidAdapter(BaseAdapter):
698
1208
  max_poll_time_s: int = 30 * 60,
699
1209
  poll_interval_s: int = 5,
700
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
+ """
701
1226
  import time
702
1227
 
703
1228
  start_time_ms = time.time() * 1000
@@ -719,7 +1244,7 @@ class HyperliquidAdapter(BaseAdapter):
719
1244
  remaining_s = i * poll_interval_s
720
1245
  self.logger.info(
721
1246
  f"Waiting for withdrawal to appear on-chain... "
722
- f"{remaining_s}s remaining (withdrawals often take 10+ minutes)"
1247
+ f"{remaining_s}s remaining (withdrawals often take a few minutes)"
723
1248
  )
724
1249
  await asyncio.sleep(poll_interval_s)
725
1250