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
@@ -0,0 +1,931 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ from typing import Any, Literal
6
+
7
+ from wayfinder_paths.adapters.hyperliquid_adapter.adapter import HyperliquidAdapter
8
+ from wayfinder_paths.adapters.hyperliquid_adapter.executor import (
9
+ LocalHyperliquidExecutor,
10
+ )
11
+ from wayfinder_paths.core.constants.hyperliquid import (
12
+ DEFAULT_HYPERLIQUID_BUILDER_FEE_TENTHS_BP,
13
+ HYPE_FEE_WALLET,
14
+ )
15
+ from wayfinder_paths.mcp.preview import build_hyperliquid_execute_preview
16
+ from wayfinder_paths.mcp.state.profile_store import WalletProfileStore
17
+ from wayfinder_paths.mcp.state.store import IdempotencyStore
18
+ from wayfinder_paths.mcp.utils import (
19
+ err,
20
+ find_wallet_by_label,
21
+ load_config_json,
22
+ normalize_address,
23
+ ok,
24
+ sha256_json,
25
+ )
26
+
27
+ _PERP_SUFFIX_RE = re.compile(r"[-_ ]?perp$", re.IGNORECASE)
28
+
29
+
30
+ def _resolve_wallet_address(
31
+ *, wallet_label: str | None, wallet_address: str | None
32
+ ) -> str | None:
33
+ waddr = normalize_address(wallet_address)
34
+ if waddr:
35
+ return waddr
36
+ want = (wallet_label or "").strip()
37
+ if not want:
38
+ return None
39
+ w = find_wallet_by_label(want)
40
+ if not w:
41
+ return None
42
+ return normalize_address(w.get("address"))
43
+
44
+
45
+ def _resolve_builder_fee(
46
+ *,
47
+ config: dict[str, Any],
48
+ builder_fee_tenths_bp: int | None,
49
+ ) -> dict[str, Any]:
50
+ """
51
+ Resolve builder fee config for Hyperliquid orders.
52
+
53
+ Builder attribution is **mandatory** and always uses the Wayfinder builder wallet.
54
+ Fee priority:
55
+ 1) explicit builder_fee_tenths_bp
56
+ 2) config["builder_fee"]["f"] (typically config.json["strategy"]["builder_fee"])
57
+ 3) DEFAULT_HYPERLIQUID_BUILDER_FEE_TENTHS_BP
58
+ """
59
+ expected_builder = HYPE_FEE_WALLET.lower()
60
+ fee = builder_fee_tenths_bp
61
+ if fee is None:
62
+ cfg = config.get("builder_fee") if isinstance(config, dict) else None
63
+ if isinstance(cfg, dict):
64
+ cfg_builder = str(cfg.get("b") or "").strip()
65
+ if cfg_builder and cfg_builder.lower() != expected_builder:
66
+ raise ValueError(
67
+ f"config builder_fee.b must be {expected_builder} (got {cfg_builder})"
68
+ )
69
+ if cfg.get("f") is not None:
70
+ fee = cfg.get("f")
71
+ if fee is None:
72
+ fee = DEFAULT_HYPERLIQUID_BUILDER_FEE_TENTHS_BP
73
+
74
+ try:
75
+ fee_i = int(fee)
76
+ except (TypeError, ValueError) as exc:
77
+ raise ValueError("builder_fee_tenths_bp must be an int") from exc
78
+ if fee_i <= 0:
79
+ raise ValueError("builder_fee_tenths_bp must be > 0")
80
+
81
+ return {"b": expected_builder, "f": fee_i}
82
+
83
+
84
+ def _resolve_perp_asset_id(
85
+ adapter: HyperliquidAdapter, *, coin: str | None, asset_id: int | None
86
+ ) -> tuple[bool, int | dict[str, Any]]:
87
+ if asset_id is not None:
88
+ try:
89
+ return True, int(asset_id)
90
+ except (TypeError, ValueError):
91
+ return False, {"code": "invalid_request", "message": "asset_id must be int"}
92
+
93
+ c = (coin or "").strip()
94
+ if not c:
95
+ return False, {
96
+ "code": "invalid_request",
97
+ "message": "coin or asset_id is required",
98
+ }
99
+
100
+ c = _PERP_SUFFIX_RE.sub("", c).strip()
101
+ if not c:
102
+ return False, {"code": "invalid_request", "message": "coin is required"}
103
+
104
+ mapping = adapter.coin_to_asset or {}
105
+ lower = {str(k).lower(): int(v) for k, v in mapping.items()}
106
+ aid = lower.get(c.lower())
107
+ if aid is None:
108
+ return (
109
+ False,
110
+ {
111
+ "code": "not_found",
112
+ "message": f"Unknown perp coin: {c}",
113
+ "details": {"coin": c},
114
+ },
115
+ )
116
+ return True, aid
117
+
118
+
119
+ async def hyperliquid(
120
+ action: Literal[
121
+ "user_state",
122
+ "spot_user_state",
123
+ "mid_prices",
124
+ "meta_and_asset_ctxs",
125
+ "spot_assets",
126
+ "l2_book",
127
+ "wait_for_deposit",
128
+ "wait_for_withdrawal",
129
+ ],
130
+ *,
131
+ wallet_label: str | None = None,
132
+ wallet_address: str | None = None,
133
+ coin: str | None = None,
134
+ n_levels: int = 20,
135
+ expected_increase: float | None = None,
136
+ timeout_s: int = 120,
137
+ poll_interval_s: int = 5,
138
+ lookback_s: int = 5,
139
+ max_poll_time_s: int = 15 * 60,
140
+ ) -> dict[str, Any]:
141
+ adapter = HyperliquidAdapter(simulation=True)
142
+
143
+ if action in {
144
+ "user_state",
145
+ "spot_user_state",
146
+ "wait_for_deposit",
147
+ "wait_for_withdrawal",
148
+ }:
149
+ addr = _resolve_wallet_address(
150
+ wallet_label=wallet_label, wallet_address=wallet_address
151
+ )
152
+ if not addr:
153
+ return err(
154
+ "invalid_request",
155
+ "wallet_label or wallet_address is required",
156
+ {"wallet_label": wallet_label, "wallet_address": wallet_address},
157
+ )
158
+
159
+ if action == "user_state":
160
+ success, data = await adapter.get_user_state(addr)
161
+ return ok(
162
+ {
163
+ "wallet_address": addr,
164
+ "action": action,
165
+ "data": data,
166
+ "success": success,
167
+ }
168
+ )
169
+
170
+ if action == "spot_user_state":
171
+ success, data = await adapter.get_spot_user_state(addr)
172
+ return ok(
173
+ {
174
+ "wallet_address": addr,
175
+ "action": action,
176
+ "data": data,
177
+ "success": success,
178
+ }
179
+ )
180
+
181
+ if action == "wait_for_deposit":
182
+ if expected_increase is None:
183
+ return err(
184
+ "invalid_request",
185
+ "expected_increase is required for wait_for_deposit",
186
+ {"expected_increase": expected_increase},
187
+ )
188
+ try:
189
+ inc = float(expected_increase)
190
+ except (TypeError, ValueError):
191
+ return err("invalid_request", "expected_increase must be a number")
192
+ if inc <= 0:
193
+ return err("invalid_request", "expected_increase must be positive")
194
+
195
+ ok_dep, final_bal = await adapter.wait_for_deposit(
196
+ addr,
197
+ inc,
198
+ timeout_s=int(timeout_s),
199
+ poll_interval_s=int(poll_interval_s),
200
+ )
201
+ return ok(
202
+ {
203
+ "wallet_address": addr,
204
+ "action": action,
205
+ "expected_increase": inc,
206
+ "confirmed": bool(ok_dep),
207
+ "final_balance_usd": float(final_bal),
208
+ "timeout_s": int(timeout_s),
209
+ "poll_interval_s": int(poll_interval_s),
210
+ }
211
+ )
212
+
213
+ # wait_for_withdrawal
214
+ ok_wd, withdrawals = await adapter.wait_for_withdrawal(
215
+ addr,
216
+ lookback_s=int(lookback_s),
217
+ max_poll_time_s=int(max_poll_time_s),
218
+ poll_interval_s=int(poll_interval_s),
219
+ )
220
+ return ok(
221
+ {
222
+ "wallet_address": addr,
223
+ "action": action,
224
+ "confirmed": bool(ok_wd),
225
+ "withdrawals": withdrawals,
226
+ "lookback_s": int(lookback_s),
227
+ "max_poll_time_s": int(max_poll_time_s),
228
+ "poll_interval_s": int(poll_interval_s),
229
+ }
230
+ )
231
+
232
+ if action == "mid_prices":
233
+ success, data = await adapter.get_all_mid_prices()
234
+ if not coin:
235
+ return ok({"action": action, "data": data, "success": success})
236
+
237
+ want = _PERP_SUFFIX_RE.sub("", str(coin).strip()).strip()
238
+ if not want:
239
+ return err("invalid_request", "coin is required for mid_prices filter")
240
+
241
+ price = None
242
+ if isinstance(data, dict):
243
+ for k, v in data.items():
244
+ if str(k).lower() == want.lower():
245
+ try:
246
+ price = float(v)
247
+ except (TypeError, ValueError):
248
+ price = None
249
+ break
250
+
251
+ return ok(
252
+ {
253
+ "action": action,
254
+ "coin": want,
255
+ "price": price,
256
+ "success": bool(success and price is not None),
257
+ }
258
+ )
259
+
260
+ if action == "meta_and_asset_ctxs":
261
+ success, data = await adapter.get_meta_and_asset_ctxs()
262
+ return ok({"action": action, "data": data, "success": success})
263
+
264
+ if action == "spot_assets":
265
+ success, data = await adapter.get_spot_assets()
266
+ return ok({"action": action, "data": data, "success": success})
267
+
268
+ if action == "l2_book":
269
+ c = (coin or "").strip()
270
+ if not c:
271
+ return err("invalid_request", "coin is required for l2_book")
272
+ success, data = await adapter.get_l2_book(c, n_levels=int(n_levels))
273
+ return ok({"action": action, "coin": c, "data": data, "success": success})
274
+
275
+ return err("invalid_request", f"Unknown hyperliquid action: {action}")
276
+
277
+
278
+ def _annotate_hl_profile(
279
+ *,
280
+ address: str,
281
+ label: str,
282
+ action: str,
283
+ status: str,
284
+ details: dict[str, Any] | None = None,
285
+ idempotency_key: str | None = None,
286
+ ) -> None:
287
+ store = WalletProfileStore.default()
288
+ store.annotate_safe(
289
+ address=address,
290
+ label=label,
291
+ protocol="hyperliquid",
292
+ action=action,
293
+ tool="hyperliquid_execute",
294
+ status=status,
295
+ chain_id=999, # Hyperliquid chain ID
296
+ details=details,
297
+ idempotency_key=idempotency_key,
298
+ )
299
+
300
+
301
+ async def hyperliquid_execute(
302
+ action: Literal["place_order", "cancel_order", "update_leverage", "withdraw"],
303
+ *,
304
+ wallet_label: str,
305
+ dry_run: bool | None = None,
306
+ coin: str | None = None,
307
+ asset_id: int | None = None,
308
+ order_type: Literal["market", "limit"] = "market",
309
+ is_buy: bool | None = None,
310
+ size: float | None = None,
311
+ usd_amount: float | None = None,
312
+ usd_amount_kind: Literal["notional", "margin"] | None = None,
313
+ price: float | None = None,
314
+ slippage: float = 0.01,
315
+ reduce_only: bool = False,
316
+ cloid: str | None = None,
317
+ order_id: int | None = None,
318
+ cancel_cloid: str | None = None,
319
+ leverage: int | None = None,
320
+ is_cross: bool = True,
321
+ amount_usdc: float | None = None,
322
+ builder_fee_tenths_bp: int | None = None,
323
+ idempotency_key: str | None = None,
324
+ force: bool = False,
325
+ ) -> dict[str, Any]:
326
+ want = str(wallet_label or "").strip()
327
+ if not want:
328
+ return err("invalid_request", "wallet_label is required")
329
+
330
+ store = IdempotencyStore.default()
331
+ key_input = {
332
+ "action": action,
333
+ "wallet_label": want,
334
+ "dry_run": dry_run,
335
+ "coin": coin,
336
+ "asset_id": asset_id,
337
+ "order_type": order_type,
338
+ "is_buy": is_buy,
339
+ "size": size,
340
+ "usd_amount": usd_amount,
341
+ "usd_amount_kind": usd_amount_kind,
342
+ "price": price,
343
+ "slippage": slippage,
344
+ "reduce_only": reduce_only,
345
+ "cloid": cloid,
346
+ "order_id": order_id,
347
+ "cancel_cloid": cancel_cloid,
348
+ "leverage": leverage,
349
+ "is_cross": is_cross,
350
+ "amount_usdc": amount_usdc,
351
+ "builder_fee_tenths_bp": builder_fee_tenths_bp,
352
+ }
353
+ tool_input = {"request": key_input, "idempotency_key": idempotency_key}
354
+ preview_obj = build_hyperliquid_execute_preview(tool_input)
355
+ preview_text = str(preview_obj.get("summary") or "").strip()
356
+
357
+ key = (idempotency_key or "").strip() or sha256_json(tool_input.get("request"))
358
+ prior = store.get(key)
359
+ if prior and not force:
360
+ try:
361
+ prior_result = prior.get("result") if isinstance(prior, dict) else None
362
+ if isinstance(prior_result, dict):
363
+ prior_result["status"] = "duplicate"
364
+ prior_result["idempotency_key"] = key
365
+ prior_result.setdefault("preview", preview_text)
366
+ except Exception:
367
+ pass
368
+ return prior
369
+
370
+ w = find_wallet_by_label(want)
371
+ if not w:
372
+ response = err("not_found", f"Unknown wallet_label: {want}")
373
+ store.put(key, tool_input, response)
374
+ return response
375
+
376
+ sender = normalize_address(w.get("address"))
377
+ pk = (
378
+ (w.get("private_key") or w.get("private_key_hex"))
379
+ if isinstance(w, dict)
380
+ else None
381
+ )
382
+ if not sender or not pk:
383
+ response = err(
384
+ "invalid_wallet",
385
+ "Wallet must include address and private_key_hex in wallets.json (local dev only)",
386
+ {"wallet_label": want},
387
+ )
388
+ store.put(key, tool_input, response)
389
+ return response
390
+
391
+ dry = (
392
+ os.getenv("DRY_RUN", "").lower() in ("1", "true")
393
+ if dry_run is None
394
+ else bool(dry_run)
395
+ )
396
+
397
+ cfg_json = load_config_json()
398
+ strategy_cfg = (
399
+ cfg_json.get("strategy") if isinstance(cfg_json.get("strategy"), dict) else {}
400
+ )
401
+ config: dict[str, Any] = dict(strategy_cfg)
402
+ config["main_wallet"] = {"address": sender, "private_key_hex": pk}
403
+ config["strategy_wallet"] = {"address": sender, "private_key_hex": pk}
404
+
405
+ effects: list[dict[str, Any]] = []
406
+
407
+ executor = None
408
+ if not dry:
409
+ try:
410
+ executor = LocalHyperliquidExecutor(config=config, network="mainnet")
411
+ except Exception as exc: # noqa: BLE001
412
+ response = err("executor_error", str(exc))
413
+ store.put(key, tool_input, response)
414
+ return response
415
+
416
+ adapter = HyperliquidAdapter(config=config, simulation=dry, executor=executor)
417
+
418
+ if action == "withdraw":
419
+ if amount_usdc is None:
420
+ response = err("invalid_request", "amount_usdc is required for withdraw")
421
+ store.put(key, tool_input, response)
422
+ return response
423
+ try:
424
+ amt = float(amount_usdc)
425
+ except (TypeError, ValueError):
426
+ response = err("invalid_request", "amount_usdc must be a number")
427
+ store.put(key, tool_input, response)
428
+ return response
429
+ if amt <= 0:
430
+ response = err("invalid_request", "amount_usdc must be positive")
431
+ store.put(key, tool_input, response)
432
+ return response
433
+
434
+ ok_wd, res = await adapter.withdraw(amount=amt, address=sender)
435
+ effects.append({"type": "hl", "label": "withdraw", "ok": ok_wd, "result": res})
436
+ status = "dry_run" if dry else ("confirmed" if ok_wd else "failed")
437
+ response = ok(
438
+ {
439
+ "idempotency_key": key,
440
+ "status": status,
441
+ "dry_run": dry,
442
+ "action": action,
443
+ "wallet_label": want,
444
+ "address": sender,
445
+ "amount_usdc": amt,
446
+ "preview": preview_text,
447
+ "effects": effects,
448
+ }
449
+ )
450
+ store.put(key, tool_input, response)
451
+ _annotate_hl_profile(
452
+ address=sender,
453
+ label=want,
454
+ action="withdraw",
455
+ status=status,
456
+ details={"amount_usdc": amt},
457
+ idempotency_key=key,
458
+ )
459
+
460
+ return response
461
+
462
+ def _coin_from_asset_id(aid: int) -> str | None:
463
+ for k, v in (adapter.coin_to_asset or {}).items():
464
+ try:
465
+ if int(v) == int(aid):
466
+ return str(k)
467
+ except Exception:
468
+ continue
469
+ return None
470
+
471
+ ok_aid, aid_or_err = _resolve_perp_asset_id(adapter, coin=coin, asset_id=asset_id)
472
+ if not ok_aid:
473
+ payload = aid_or_err if isinstance(aid_or_err, dict) else {}
474
+ response = err(
475
+ payload.get("code") or "invalid_request",
476
+ payload.get("message") or "Invalid asset",
477
+ payload.get("details"),
478
+ )
479
+ store.put(key, tool_input, response)
480
+ return response
481
+ resolved_asset_id = int(aid_or_err)
482
+
483
+ if action == "update_leverage":
484
+ if leverage is None:
485
+ response = err(
486
+ "invalid_request", "leverage is required for update_leverage"
487
+ )
488
+ store.put(key, tool_input, response)
489
+ return response
490
+ try:
491
+ lev = int(leverage)
492
+ except (TypeError, ValueError):
493
+ response = err("invalid_request", "leverage must be an int")
494
+ store.put(key, tool_input, response)
495
+ return response
496
+ if lev <= 0:
497
+ response = err("invalid_request", "leverage must be positive")
498
+ store.put(key, tool_input, response)
499
+ return response
500
+
501
+ ok_lev, res = await adapter.update_leverage(
502
+ resolved_asset_id, lev, bool(is_cross), sender
503
+ )
504
+ effects.append(
505
+ {"type": "hl", "label": "update_leverage", "ok": ok_lev, "result": res}
506
+ )
507
+ status = "dry_run" if dry else ("confirmed" if ok_lev else "failed")
508
+ response = ok(
509
+ {
510
+ "idempotency_key": key,
511
+ "status": status,
512
+ "dry_run": dry,
513
+ "action": action,
514
+ "wallet_label": want,
515
+ "address": sender,
516
+ "asset_id": resolved_asset_id,
517
+ "coin": coin,
518
+ "preview": preview_text,
519
+ "effects": effects,
520
+ }
521
+ )
522
+ store.put(key, tool_input, response)
523
+ _annotate_hl_profile(
524
+ address=sender,
525
+ label=want,
526
+ action="update_leverage",
527
+ status=status,
528
+ details={"asset_id": resolved_asset_id, "coin": coin, "leverage": lev},
529
+ idempotency_key=key,
530
+ )
531
+
532
+ return response
533
+
534
+ if action == "cancel_order":
535
+ if cancel_cloid:
536
+ ok_cancel, res = await adapter.cancel_order_by_cloid(
537
+ resolved_asset_id, str(cancel_cloid), sender
538
+ )
539
+ effects.append(
540
+ {
541
+ "type": "hl",
542
+ "label": "cancel_order_by_cloid",
543
+ "ok": ok_cancel,
544
+ "result": res,
545
+ }
546
+ )
547
+ else:
548
+ if order_id is None:
549
+ response = err(
550
+ "invalid_request",
551
+ "order_id or cancel_cloid is required for cancel_order",
552
+ )
553
+ store.put(key, tool_input, response)
554
+ return response
555
+ ok_cancel, res = await adapter.cancel_order(
556
+ resolved_asset_id, int(order_id), sender
557
+ )
558
+ effects.append(
559
+ {"type": "hl", "label": "cancel_order", "ok": ok_cancel, "result": res}
560
+ )
561
+
562
+ ok_all = all(bool(e.get("ok")) for e in effects) if effects else False
563
+ status = "dry_run" if dry else ("confirmed" if ok_all else "failed")
564
+ response = ok(
565
+ {
566
+ "idempotency_key": key,
567
+ "status": status,
568
+ "dry_run": dry,
569
+ "action": action,
570
+ "wallet_label": want,
571
+ "address": sender,
572
+ "asset_id": resolved_asset_id,
573
+ "coin": coin,
574
+ "preview": preview_text,
575
+ "effects": effects,
576
+ }
577
+ )
578
+ store.put(key, tool_input, response)
579
+ _annotate_hl_profile(
580
+ address=sender,
581
+ label=want,
582
+ action="cancel_order",
583
+ status=status,
584
+ details={
585
+ "asset_id": resolved_asset_id,
586
+ "coin": coin,
587
+ "order_id": order_id,
588
+ "cancel_cloid": cancel_cloid,
589
+ },
590
+ idempotency_key=key,
591
+ )
592
+
593
+ return response
594
+
595
+ if size is not None and usd_amount is not None:
596
+ response = err(
597
+ "invalid_request",
598
+ "Provide either size (coin units) or usd_amount (USD notional/margin), not both",
599
+ )
600
+ store.put(key, tool_input, response)
601
+ return response
602
+ if usd_amount_kind is not None and usd_amount is None:
603
+ response = err(
604
+ "invalid_request",
605
+ "usd_amount_kind is only valid when usd_amount is provided",
606
+ )
607
+ store.put(key, tool_input, response)
608
+ return response
609
+
610
+ if is_buy is None:
611
+ response = err("invalid_request", "is_buy is required for place_order")
612
+ store.put(key, tool_input, response)
613
+ return response
614
+
615
+ if order_type == "limit":
616
+ if price is None:
617
+ response = err("invalid_request", "price is required for limit orders")
618
+ store.put(key, tool_input, response)
619
+ return response
620
+ try:
621
+ px_for_sizing = float(price)
622
+ except (TypeError, ValueError):
623
+ response = err("invalid_request", "price must be a number")
624
+ store.put(key, tool_input, response)
625
+ return response
626
+ if px_for_sizing <= 0:
627
+ response = err("invalid_request", "price must be positive")
628
+ store.put(key, tool_input, response)
629
+ return response
630
+ else:
631
+ try:
632
+ slip = float(slippage)
633
+ except (TypeError, ValueError):
634
+ response = err("invalid_request", "slippage must be a number")
635
+ store.put(key, tool_input, response)
636
+ return response
637
+ if slip < 0:
638
+ response = err("invalid_request", "slippage must be >= 0")
639
+ store.put(key, tool_input, response)
640
+ return response
641
+ if slip > 0.25:
642
+ response = err("invalid_request", "slippage > 0.25 is too risky")
643
+ store.put(key, tool_input, response)
644
+ return response
645
+ px_for_sizing = None
646
+
647
+ sizing: dict[str, Any] = {"source": "size"}
648
+ if size is not None:
649
+ try:
650
+ sz = float(size)
651
+ except (TypeError, ValueError):
652
+ response = err("invalid_request", "size must be a number")
653
+ store.put(key, tool_input, response)
654
+ return response
655
+ if sz <= 0:
656
+ response = err("invalid_request", "size must be positive")
657
+ store.put(key, tool_input, response)
658
+ return response
659
+ else:
660
+ if usd_amount is None:
661
+ response = err(
662
+ "invalid_request",
663
+ "Provide either size (coin units) or usd_amount (USD notional/margin) for place_order",
664
+ )
665
+ store.put(key, tool_input, response)
666
+ return response
667
+ if usd_amount_kind is None:
668
+ response = err(
669
+ "invalid_request",
670
+ "usd_amount_kind is required when providing usd_amount: choose 'notional' (position USD) or 'margin' (collateral USD; notional = margin * leverage)",
671
+ )
672
+ store.put(key, tool_input, response)
673
+ return response
674
+ try:
675
+ usd_amt = float(usd_amount)
676
+ except (TypeError, ValueError):
677
+ response = err("invalid_request", "usd_amount must be a number")
678
+ store.put(key, tool_input, response)
679
+ return response
680
+ if usd_amt <= 0:
681
+ response = err("invalid_request", "usd_amount must be positive")
682
+ store.put(key, tool_input, response)
683
+ return response
684
+
685
+ if usd_amount_kind == "margin":
686
+ if leverage is None:
687
+ response = err(
688
+ "invalid_request",
689
+ "leverage is required when usd_amount_kind='margin' (notional = margin * leverage)",
690
+ )
691
+ store.put(key, tool_input, response)
692
+ return response
693
+ try:
694
+ lev = int(leverage)
695
+ except (TypeError, ValueError):
696
+ response = err("invalid_request", "leverage must be an int")
697
+ store.put(key, tool_input, response)
698
+ return response
699
+ if lev <= 0:
700
+ response = err("invalid_request", "leverage must be positive")
701
+ store.put(key, tool_input, response)
702
+ return response
703
+ notional_usd = usd_amt * float(lev)
704
+ margin_usd = usd_amt
705
+ else:
706
+ notional_usd = usd_amt
707
+ margin_usd = None
708
+ if leverage is not None:
709
+ try:
710
+ lev = int(leverage)
711
+ if lev > 0:
712
+ margin_usd = notional_usd / float(lev)
713
+ except Exception:
714
+ margin_usd = None
715
+
716
+ if px_for_sizing is None:
717
+ coin_name = _PERP_SUFFIX_RE.sub("", str(coin or "").strip()).strip()
718
+ if not coin_name:
719
+ coin_name = _coin_from_asset_id(resolved_asset_id) or ""
720
+ if not coin_name:
721
+ response = err(
722
+ "invalid_request",
723
+ "coin is required when computing size from usd_amount for market orders",
724
+ )
725
+ store.put(key, tool_input, response)
726
+ return response
727
+ ok_mids, mids = await adapter.get_all_mid_prices()
728
+ if not ok_mids or not isinstance(mids, dict):
729
+ response = err("price_error", "Failed to fetch mid prices")
730
+ store.put(key, tool_input, response)
731
+ return response
732
+ mid = None
733
+ for k, v in mids.items():
734
+ if str(k).lower() == coin_name.lower():
735
+ try:
736
+ mid = float(v)
737
+ except (TypeError, ValueError):
738
+ mid = None
739
+ break
740
+ if mid is None or mid <= 0:
741
+ response = err(
742
+ "price_error",
743
+ f"Could not resolve mid price for {coin_name}",
744
+ )
745
+ store.put(key, tool_input, response)
746
+ return response
747
+ px_for_sizing = mid
748
+
749
+ sz = notional_usd / float(px_for_sizing)
750
+ sizing = {
751
+ "source": "usd_amount",
752
+ "usd_amount": float(usd_amt),
753
+ "usd_amount_kind": usd_amount_kind,
754
+ "notional_usd": float(notional_usd),
755
+ "margin_usd_estimate": float(margin_usd)
756
+ if margin_usd is not None
757
+ else None,
758
+ "price_used": float(px_for_sizing),
759
+ }
760
+
761
+ sz_valid = adapter.get_valid_order_size(resolved_asset_id, sz)
762
+ if sz_valid <= 0:
763
+ response = err("invalid_request", "size is too small after lot-size rounding")
764
+ store.put(key, tool_input, response)
765
+ return response
766
+
767
+ try:
768
+ builder = _resolve_builder_fee(
769
+ config=config,
770
+ builder_fee_tenths_bp=builder_fee_tenths_bp,
771
+ )
772
+ except ValueError as exc:
773
+ response = err("invalid_request", str(exc))
774
+ store.put(key, tool_input, response)
775
+ return response
776
+
777
+ if leverage is not None:
778
+ try:
779
+ lev = int(leverage)
780
+ except (TypeError, ValueError):
781
+ response = err("invalid_request", "leverage must be an int")
782
+ store.put(key, tool_input, response)
783
+ return response
784
+ if lev <= 0:
785
+ response = err("invalid_request", "leverage must be positive")
786
+ store.put(key, tool_input, response)
787
+ return response
788
+ ok_lev, res = await adapter.update_leverage(
789
+ resolved_asset_id, lev, bool(is_cross), sender
790
+ )
791
+ effects.append(
792
+ {"type": "hl", "label": "update_leverage", "ok": ok_lev, "result": res}
793
+ )
794
+ if not ok_lev and not dry:
795
+ response = ok(
796
+ {
797
+ "idempotency_key": key,
798
+ "status": "failed",
799
+ "dry_run": dry,
800
+ "action": action,
801
+ "wallet_label": want,
802
+ "address": sender,
803
+ "asset_id": resolved_asset_id,
804
+ "coin": coin,
805
+ "preview": preview_text,
806
+ "effects": effects,
807
+ }
808
+ )
809
+ store.put(key, tool_input, response)
810
+ return response
811
+
812
+ # Builder attribution is mandatory; ensure approval before placing orders.
813
+ desired = int(builder.get("f") or 0)
814
+ builder_addr = str(builder.get("b") or "").strip()
815
+ ok_fee, current = await adapter.get_max_builder_fee(
816
+ user=sender, builder=builder_addr
817
+ )
818
+ effects.append(
819
+ {
820
+ "type": "hl",
821
+ "label": "get_max_builder_fee",
822
+ "ok": ok_fee,
823
+ "result": {"current_tenths_bp": int(current), "desired_tenths_bp": desired},
824
+ }
825
+ )
826
+ if not ok_fee or int(current) < desired:
827
+ max_fee_rate = f"{desired / 1000:.3f}%"
828
+ ok_appr, appr = await adapter.approve_builder_fee(
829
+ builder=builder_addr,
830
+ max_fee_rate=max_fee_rate,
831
+ address=sender,
832
+ )
833
+ effects.append(
834
+ {
835
+ "type": "hl",
836
+ "label": "approve_builder_fee",
837
+ "ok": ok_appr,
838
+ "result": appr,
839
+ }
840
+ )
841
+ if not ok_appr and not dry:
842
+ response = ok(
843
+ {
844
+ "idempotency_key": key,
845
+ "status": "failed",
846
+ "dry_run": dry,
847
+ "action": action,
848
+ "wallet_label": want,
849
+ "address": sender,
850
+ "asset_id": resolved_asset_id,
851
+ "coin": coin,
852
+ "preview": preview_text,
853
+ "effects": effects,
854
+ }
855
+ )
856
+ store.put(key, tool_input, response)
857
+ return response
858
+
859
+ if order_type == "limit":
860
+ ok_order, res = await adapter.place_limit_order(
861
+ resolved_asset_id,
862
+ bool(is_buy),
863
+ float(price),
864
+ float(sz_valid),
865
+ sender,
866
+ reduce_only=bool(reduce_only),
867
+ builder=builder,
868
+ )
869
+ effects.append(
870
+ {"type": "hl", "label": "place_limit_order", "ok": ok_order, "result": res}
871
+ )
872
+ else:
873
+ ok_order, res = await adapter.place_market_order(
874
+ resolved_asset_id,
875
+ bool(is_buy),
876
+ float(slippage),
877
+ float(sz_valid),
878
+ sender,
879
+ reduce_only=bool(reduce_only),
880
+ cloid=cloid,
881
+ builder=builder,
882
+ )
883
+ effects.append(
884
+ {"type": "hl", "label": "place_market_order", "ok": ok_order, "result": res}
885
+ )
886
+
887
+ ok_all = all(bool(e.get("ok")) for e in effects) if effects else False
888
+ status = "dry_run" if dry else ("confirmed" if ok_all else "failed")
889
+ response = ok(
890
+ {
891
+ "idempotency_key": key,
892
+ "status": status,
893
+ "dry_run": dry,
894
+ "action": action,
895
+ "wallet_label": want,
896
+ "address": sender,
897
+ "asset_id": resolved_asset_id,
898
+ "coin": coin,
899
+ "order": {
900
+ "order_type": order_type,
901
+ "is_buy": bool(is_buy),
902
+ "size_requested": float(sz),
903
+ "size_valid": float(sz_valid),
904
+ "price": float(price) if price is not None else None,
905
+ "slippage": float(slippage),
906
+ "reduce_only": bool(reduce_only),
907
+ "cloid": cloid,
908
+ "builder": builder,
909
+ "sizing": sizing,
910
+ },
911
+ "preview": preview_text,
912
+ "effects": effects,
913
+ }
914
+ )
915
+ store.put(key, tool_input, response)
916
+ _annotate_hl_profile(
917
+ address=sender,
918
+ label=want,
919
+ action="place_order",
920
+ status=status,
921
+ details={
922
+ "asset_id": resolved_asset_id,
923
+ "coin": coin,
924
+ "order_type": order_type,
925
+ "is_buy": bool(is_buy),
926
+ "size": float(sz_valid),
927
+ },
928
+ idempotency_key=key,
929
+ )
930
+
931
+ return response