openbroker 1.3.1 → 1.4.0

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.
Files changed (171) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/SKILL.md +7 -4
  3. package/dist/auto/audit.d.ts +57 -0
  4. package/dist/auto/audit.d.ts.map +1 -0
  5. package/dist/auto/audit.js +407 -0
  6. package/dist/auto/cli.d.ts +2 -0
  7. package/dist/auto/cli.d.ts.map +1 -0
  8. package/dist/auto/cli.js +423 -0
  9. package/dist/auto/events.d.ts +11 -0
  10. package/dist/auto/events.d.ts.map +1 -0
  11. package/dist/auto/events.js +36 -0
  12. package/dist/auto/examples/dca.d.ts +4 -0
  13. package/dist/auto/examples/dca.d.ts.map +1 -0
  14. package/dist/auto/examples/dca.js +60 -0
  15. package/dist/auto/examples/funding-arb.d.ts +4 -0
  16. package/dist/auto/examples/funding-arb.d.ts.map +1 -0
  17. package/dist/auto/examples/funding-arb.js +81 -0
  18. package/dist/auto/examples/grid.d.ts +4 -0
  19. package/dist/auto/examples/grid.d.ts.map +1 -0
  20. package/dist/auto/examples/grid.js +114 -0
  21. package/dist/auto/examples/mm-maker.d.ts +4 -0
  22. package/dist/auto/examples/mm-maker.d.ts.map +1 -0
  23. package/dist/auto/examples/mm-maker.js +131 -0
  24. package/dist/auto/examples/mm-spread.d.ts +4 -0
  25. package/dist/auto/examples/mm-spread.d.ts.map +1 -0
  26. package/dist/auto/examples/mm-spread.js +119 -0
  27. package/dist/auto/examples/price-alert.d.ts +4 -0
  28. package/dist/auto/examples/price-alert.d.ts.map +1 -0
  29. package/dist/auto/examples/price-alert.js +85 -0
  30. package/dist/auto/keep-awake.d.ts +11 -0
  31. package/dist/auto/keep-awake.d.ts.map +1 -0
  32. package/dist/auto/keep-awake.js +70 -0
  33. package/dist/auto/loader.d.ts +22 -0
  34. package/dist/auto/loader.d.ts.map +1 -0
  35. package/dist/auto/loader.js +127 -0
  36. package/dist/auto/prune.d.ts +40 -0
  37. package/dist/auto/prune.d.ts.map +1 -0
  38. package/dist/auto/prune.js +204 -0
  39. package/dist/auto/registry.d.ts +24 -0
  40. package/dist/auto/registry.d.ts.map +1 -0
  41. package/dist/auto/registry.js +93 -0
  42. package/dist/auto/report.d.ts +3 -0
  43. package/dist/auto/report.d.ts.map +1 -0
  44. package/dist/auto/report.js +385 -0
  45. package/dist/auto/runtime.d.ts +33 -0
  46. package/dist/auto/runtime.d.ts.map +1 -0
  47. package/dist/auto/runtime.js +844 -0
  48. package/dist/auto/types.d.ts +236 -0
  49. package/dist/auto/types.d.ts.map +1 -0
  50. package/dist/auto/types.js +3 -0
  51. package/dist/core/client.d.ts +684 -0
  52. package/dist/core/client.d.ts.map +1 -0
  53. package/dist/core/client.js +2040 -0
  54. package/dist/core/config.d.ts +22 -0
  55. package/dist/core/config.d.ts.map +1 -0
  56. package/dist/core/config.js +143 -0
  57. package/dist/core/types.d.ts +221 -0
  58. package/dist/core/types.d.ts.map +1 -0
  59. package/dist/core/types.js +2 -0
  60. package/dist/core/utils.d.ts +61 -0
  61. package/dist/core/utils.d.ts.map +1 -0
  62. package/dist/core/utils.js +142 -0
  63. package/dist/core/ws.d.ts +121 -0
  64. package/dist/core/ws.d.ts.map +1 -0
  65. package/dist/core/ws.js +222 -0
  66. package/dist/info/account.d.ts +3 -0
  67. package/dist/info/account.d.ts.map +1 -0
  68. package/dist/info/account.js +198 -0
  69. package/dist/info/all-markets.d.ts +3 -0
  70. package/dist/info/all-markets.d.ts.map +1 -0
  71. package/dist/info/all-markets.js +272 -0
  72. package/dist/info/candles.d.ts +3 -0
  73. package/dist/info/candles.d.ts.map +1 -0
  74. package/dist/info/candles.js +120 -0
  75. package/dist/info/fees.d.ts +3 -0
  76. package/dist/info/fees.d.ts.map +1 -0
  77. package/dist/info/fees.js +87 -0
  78. package/dist/info/fills.d.ts +3 -0
  79. package/dist/info/fills.d.ts.map +1 -0
  80. package/dist/info/fills.js +105 -0
  81. package/dist/info/funding-history.d.ts +3 -0
  82. package/dist/info/funding-history.d.ts.map +1 -0
  83. package/dist/info/funding-history.js +98 -0
  84. package/dist/info/funding-scan.d.ts +3 -0
  85. package/dist/info/funding-scan.d.ts.map +1 -0
  86. package/dist/info/funding-scan.js +178 -0
  87. package/dist/info/funding.d.ts +3 -0
  88. package/dist/info/funding.d.ts.map +1 -0
  89. package/dist/info/funding.js +158 -0
  90. package/dist/info/markets.d.ts +3 -0
  91. package/dist/info/markets.d.ts.map +1 -0
  92. package/dist/info/markets.js +178 -0
  93. package/dist/info/order-status.d.ts +3 -0
  94. package/dist/info/order-status.d.ts.map +1 -0
  95. package/dist/info/order-status.js +85 -0
  96. package/dist/info/orders.d.ts +3 -0
  97. package/dist/info/orders.d.ts.map +1 -0
  98. package/dist/info/orders.js +162 -0
  99. package/dist/info/outcomes.d.ts +3 -0
  100. package/dist/info/outcomes.d.ts.map +1 -0
  101. package/dist/info/outcomes.js +175 -0
  102. package/dist/info/positions.d.ts +3 -0
  103. package/dist/info/positions.d.ts.map +1 -0
  104. package/dist/info/positions.js +127 -0
  105. package/dist/info/rate-limit.d.ts +3 -0
  106. package/dist/info/rate-limit.d.ts.map +1 -0
  107. package/dist/info/rate-limit.js +58 -0
  108. package/dist/info/search-markets.d.ts +3 -0
  109. package/dist/info/search-markets.d.ts.map +1 -0
  110. package/dist/info/search-markets.js +296 -0
  111. package/dist/info/spot.d.ts +3 -0
  112. package/dist/info/spot.d.ts.map +1 -0
  113. package/dist/info/spot.js +192 -0
  114. package/dist/info/trades.d.ts +3 -0
  115. package/dist/info/trades.d.ts.map +1 -0
  116. package/dist/info/trades.js +97 -0
  117. package/dist/lib.d.ts +14 -0
  118. package/dist/lib.d.ts.map +1 -0
  119. package/dist/lib.js +17 -0
  120. package/dist/operations/bracket.d.ts +28 -0
  121. package/dist/operations/bracket.d.ts.map +1 -0
  122. package/dist/operations/bracket.js +266 -0
  123. package/dist/operations/cancel.d.ts +3 -0
  124. package/dist/operations/cancel.d.ts.map +1 -0
  125. package/dist/operations/cancel.js +107 -0
  126. package/dist/operations/chase.d.ts +25 -0
  127. package/dist/operations/chase.d.ts.map +1 -0
  128. package/dist/operations/chase.js +215 -0
  129. package/dist/operations/limit-order.d.ts +3 -0
  130. package/dist/operations/limit-order.d.ts.map +1 -0
  131. package/dist/operations/limit-order.js +144 -0
  132. package/dist/operations/market-order.d.ts +3 -0
  133. package/dist/operations/market-order.d.ts.map +1 -0
  134. package/dist/operations/market-order.js +153 -0
  135. package/dist/operations/outcome-order.d.ts +3 -0
  136. package/dist/operations/outcome-order.d.ts.map +1 -0
  137. package/dist/operations/outcome-order.js +171 -0
  138. package/dist/operations/scale.d.ts +3 -0
  139. package/dist/operations/scale.d.ts.map +1 -0
  140. package/dist/operations/scale.js +212 -0
  141. package/dist/operations/set-tpsl.d.ts +3 -0
  142. package/dist/operations/set-tpsl.d.ts.map +1 -0
  143. package/dist/operations/set-tpsl.js +277 -0
  144. package/dist/operations/spot-order.d.ts +3 -0
  145. package/dist/operations/spot-order.d.ts.map +1 -0
  146. package/dist/operations/spot-order.js +173 -0
  147. package/dist/operations/trigger-order.d.ts +3 -0
  148. package/dist/operations/trigger-order.d.ts.map +1 -0
  149. package/dist/operations/trigger-order.js +177 -0
  150. package/dist/operations/twap-cancel.d.ts +3 -0
  151. package/dist/operations/twap-cancel.d.ts.map +1 -0
  152. package/dist/operations/twap-cancel.js +57 -0
  153. package/dist/operations/twap-status.d.ts +3 -0
  154. package/dist/operations/twap-status.d.ts.map +1 -0
  155. package/dist/operations/twap-status.js +81 -0
  156. package/dist/operations/twap.d.ts +3 -0
  157. package/dist/operations/twap.d.ts.map +1 -0
  158. package/dist/operations/twap.js +124 -0
  159. package/dist/setup/approve-builder.d.ts +3 -0
  160. package/dist/setup/approve-builder.d.ts.map +1 -0
  161. package/dist/setup/approve-builder.js +155 -0
  162. package/dist/setup/env.d.ts +4 -0
  163. package/dist/setup/env.d.ts.map +1 -0
  164. package/dist/setup/env.js +8 -0
  165. package/dist/setup/onboard.d.ts +10 -0
  166. package/dist/setup/onboard.d.ts.map +1 -0
  167. package/dist/setup/onboard.js +462 -0
  168. package/package.json +10 -4
  169. package/scripts/core/client.ts +13 -3
  170. package/scripts/info/all-markets.ts +18 -2
  171. package/scripts/info/search-markets.ts +18 -2
@@ -0,0 +1,2040 @@
1
+ // Hyperliquid Client for Open Broker
2
+ // Uses @nktkas/hyperliquid SDK
3
+ import { HttpTransport, InfoClient, ExchangeClient } from '@nktkas/hyperliquid';
4
+ import { privateKeyToAccount } from 'viem/accounts';
5
+ import { loadConfig, isMainnet } from './config.js';
6
+ import { roundPrice, roundSize } from './utils.js';
7
+ export class HyperliquidClient {
8
+ config;
9
+ account;
10
+ transport;
11
+ info;
12
+ exchange;
13
+ meta = null;
14
+ assetMap = new Map();
15
+ szDecimalsMap = new Map();
16
+ /** Maps coin name → dex info for HIP-3 assets. Main dex assets have dexName=null */
17
+ coinDexMap = new Map();
18
+ /** Cache of perpDexs list */
19
+ perpDexsCache = null;
20
+ /** Whether HIP-3 assets have been loaded into maps */
21
+ hip3Loaded = false;
22
+ /** Whether API wallet setup has been validated */
23
+ apiWalletValidated = false;
24
+ /** Set of HIP-3 dex names that have been loaded (for testnet on-demand loading) */
25
+ loadedHip3Dexes = new Set();
26
+ /** HIP-3 assets that have had isolated margin set this session */
27
+ hip3IsolatedSet = new Set();
28
+ /** Cached maxLeverage for HIP-3 assets */
29
+ hip3MaxLeverageMap = new Map();
30
+ /** Cached account abstraction mode: 'standard' | 'unified' | 'portfolio' | 'dexAbstraction' */
31
+ accountMode = null;
32
+ /** Spot asset index map: coin name → 10000 + spotMeta.universe[i].index */
33
+ spotAssetMap = new Map();
34
+ /** Spot market key map: coin name → pair.name (e.g. "@230", "PURR/USDC") */
35
+ spotPairNameMap = new Map();
36
+ /** Spot szDecimals map: coin name → base token szDecimals */
37
+ spotSzDecimalsMap = new Map();
38
+ /** Whether spot metadata has been loaded */
39
+ spotMetaLoaded = false;
40
+ /** HIP-4 outcome metadata cache */
41
+ outcomeMeta = null;
42
+ verbose = false;
43
+ constructor(config) {
44
+ this.config = config ?? loadConfig();
45
+ this.account = privateKeyToAccount(this.config.privateKey);
46
+ this.verbose = this.config.verbose;
47
+ // Initialize SDK clients
48
+ this.transport = new HttpTransport({ isTestnet: !isMainnet() });
49
+ this.info = new InfoClient({ transport: this.transport });
50
+ this.exchange = new ExchangeClient({
51
+ transport: this.transport,
52
+ wallet: this.account,
53
+ });
54
+ this.log('Client init:', JSON.stringify({
55
+ network: isMainnet() ? 'mainnet' : 'testnet',
56
+ apiUrl: this.config.baseUrl,
57
+ accountAddress: this.config.accountAddress,
58
+ walletAddress: this.config.walletAddress,
59
+ isApiWallet: this.config.isApiWallet,
60
+ isReadOnly: this.config.isReadOnly,
61
+ }));
62
+ }
63
+ log(...args) {
64
+ if (this.verbose) {
65
+ console.log('[DEBUG]', ...args);
66
+ }
67
+ }
68
+ describeError(error) {
69
+ if (!(error instanceof Error))
70
+ return String(error);
71
+ const response = error.response;
72
+ const body = error.body;
73
+ const cause = error.cause;
74
+ const parts = [error.message];
75
+ if (response) {
76
+ parts.push(`status=${response.status} ${response.statusText}`.trim());
77
+ }
78
+ if (body) {
79
+ parts.push(`body=${body.length > 300 ? `${body.slice(0, 300)}...` : body}`);
80
+ }
81
+ if (cause instanceof Error && cause.message && cause.message !== error.message) {
82
+ parts.push(`cause=${cause.message}`);
83
+ }
84
+ else if (cause && !(cause instanceof Error)) {
85
+ parts.push(`cause=${String(cause)}`);
86
+ }
87
+ return parts.join(' | ');
88
+ }
89
+ /** Retry an async operation on transient failures (fetch failed, ECONNRESET, etc.) */
90
+ async withRetry(fn, label, maxRetries = 3) {
91
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
92
+ try {
93
+ return await fn();
94
+ }
95
+ catch (error) {
96
+ const message = error instanceof Error ? error.message : String(error);
97
+ const isTransient = /fetch failed|ECONNRESET|ETIMEDOUT|ENOTFOUND|socket hang up/i.test(message);
98
+ if (!isTransient || attempt === maxRetries)
99
+ throw error;
100
+ const delay = attempt * 1000; // 1s, 2s, 3s
101
+ this.log(`${label} attempt ${attempt}/${maxRetries} failed (${message}), retrying in ${delay}ms...`);
102
+ await new Promise(r => setTimeout(r, delay));
103
+ }
104
+ }
105
+ throw new Error('unreachable');
106
+ }
107
+ getTransportContext(label) {
108
+ return JSON.stringify({
109
+ label,
110
+ network: isMainnet() ? 'mainnet' : 'testnet',
111
+ apiUrl: this.config.baseUrl,
112
+ accountAddress: this.config.accountAddress,
113
+ walletAddress: this.config.walletAddress,
114
+ isApiWallet: this.config.isApiWallet,
115
+ isReadOnly: this.config.isReadOnly,
116
+ verbose: this.verbose,
117
+ });
118
+ }
119
+ async postInfo(payload, label) {
120
+ const baseUrl = isMainnet()
121
+ ? 'https://api.hyperliquid.xyz'
122
+ : 'https://api.hyperliquid-testnet.xyz';
123
+ const response = await this.withRetry(async () => {
124
+ try {
125
+ return await fetch(baseUrl + '/info', {
126
+ method: 'POST',
127
+ headers: { 'Content-Type': 'application/json' },
128
+ body: JSON.stringify(payload),
129
+ });
130
+ }
131
+ catch (error) {
132
+ const message = error instanceof Error ? error.message : String(error);
133
+ throw new Error(`${label} request failed before response: ${message}`);
134
+ }
135
+ }, label);
136
+ const text = await response.text();
137
+ if (!response.ok) {
138
+ const snippet = text.length > 300 ? `${text.slice(0, 300)}...` : text;
139
+ throw new Error(`${label} failed: HTTP ${response.status} ${response.statusText}${snippet ? ` | body=${snippet}` : ''}`);
140
+ }
141
+ try {
142
+ return JSON.parse(text);
143
+ }
144
+ catch (error) {
145
+ const message = error instanceof Error ? error.message : String(error);
146
+ throw new Error(`${label} returned invalid JSON: ${message}`);
147
+ }
148
+ }
149
+ /** The address we're trading on behalf of (may be different from wallet if using API wallet) */
150
+ get address() {
151
+ return this.config.accountAddress;
152
+ }
153
+ /** The address of the signing wallet (derived from private key) */
154
+ get walletAddress() {
155
+ return this.config.walletAddress;
156
+ }
157
+ /** Whether we're using an API wallet (signing wallet differs from trading account) */
158
+ get isApiWallet() {
159
+ return this.config.isApiWallet;
160
+ }
161
+ get builderInfo() {
162
+ return {
163
+ b: this.config.builderAddress.toLowerCase(),
164
+ f: this.config.builderFee,
165
+ };
166
+ }
167
+ get builderAddress() {
168
+ return this.config.builderAddress;
169
+ }
170
+ get builderFeeBps() {
171
+ return this.config.builderFee / 10; // Convert from tenths of bps to bps
172
+ }
173
+ /** Whether client is in read-only mode (no trading capability) */
174
+ get isReadOnly() {
175
+ return this.config.isReadOnly;
176
+ }
177
+ /** Whether connected to testnet (HIP-3 dexes not auto-loaded) */
178
+ get isTestnet() {
179
+ return !isMainnet();
180
+ }
181
+ /**
182
+ * Returns vaultAddress param for SDK exchange calls.
183
+ * Only used for vault trading (HYPERLIQUID_VAULT_ADDRESS set explicitly).
184
+ * Standard API wallets (agents) do NOT need this — the API maps agent → master automatically.
185
+ */
186
+ get vaultParam() {
187
+ if (this.config.vaultAddress) {
188
+ return { vaultAddress: this.config.vaultAddress };
189
+ }
190
+ return {};
191
+ }
192
+ /** Throw error if trying to trade in read-only mode. Validates API wallet on first call. */
193
+ async requireTrading() {
194
+ if (this.config.isReadOnly) {
195
+ throw new Error('Trading not available. Run "openbroker setup" to configure your wallet.');
196
+ }
197
+ // One-time API wallet validation on first trade attempt
198
+ if (this.config.isApiWallet && !this.apiWalletValidated) {
199
+ this.apiWalletValidated = true;
200
+ await this.validateApiWalletSetup();
201
+ }
202
+ }
203
+ // ============ Market Data ============
204
+ async getMetaAndAssetCtxs() {
205
+ if (this.meta)
206
+ return this.meta;
207
+ this.log('Fetching metaAndAssetCtxs...');
208
+ let response;
209
+ try {
210
+ response = await this.withRetry(() => this.info.metaAndAssetCtxs(), 'metaAndAssetCtxs');
211
+ }
212
+ catch (error) {
213
+ this.log('metaAndAssetCtxs failure context:', this.getTransportContext('metaAndAssetCtxs'));
214
+ if (error instanceof Error && error.stack) {
215
+ this.log('metaAndAssetCtxs stack:', error.stack);
216
+ }
217
+ throw new Error(`metaAndAssetCtxs failed: ${this.describeError(error)}`);
218
+ }
219
+ this.log('metaAndAssetCtxs response:', JSON.stringify(response, null, 2).slice(0, 500) + '...');
220
+ // Build the narrowed value locally so neither narrowing nor the `| null`
221
+ // field type can be lost across `await` / method-call boundaries below.
222
+ const meta = {
223
+ meta: { universe: response[0].universe },
224
+ assetCtxs: response[1],
225
+ };
226
+ this.meta = meta;
227
+ // Build lookup maps for main dex
228
+ meta.meta.universe.forEach((asset, index) => {
229
+ this.assetMap.set(asset.name, index);
230
+ this.szDecimalsMap.set(asset.name, asset.szDecimals);
231
+ this.coinDexMap.set(asset.name, { dexName: null, dexIdx: 0, localName: asset.name });
232
+ });
233
+ // Load HIP-3 dex assets (only once - maps persist across meta cache invalidation)
234
+ if (!this.hip3Loaded) {
235
+ await this.loadHip3Assets();
236
+ this.hip3Loaded = true;
237
+ }
238
+ return meta;
239
+ }
240
+ /**
241
+ * Load HIP-3 perp dex assets into the asset/szDecimals maps.
242
+ * Asset index formula: 100000 + dexIdx * 10000 + assetIdx
243
+ * Coins are keyed as "dexName:COIN" (e.g., "xyz:CL")
244
+ */
245
+ /** Max concurrent HIP-3 API requests to avoid rate limiting */
246
+ static HIP3_CONCURRENCY = 5;
247
+ /**
248
+ * Like Promise.allSettled but with a concurrency limit.
249
+ * Processes tasks in batches to avoid hitting API rate limits.
250
+ */
251
+ async batchSettled(tasks) {
252
+ const results = [];
253
+ const concurrency = HyperliquidClient.HIP3_CONCURRENCY;
254
+ for (let i = 0; i < tasks.length; i += concurrency) {
255
+ const batch = tasks.slice(i, i + concurrency);
256
+ const batchResults = await Promise.allSettled(batch.map(fn => fn()));
257
+ results.push(...batchResults);
258
+ }
259
+ return results;
260
+ }
261
+ /**
262
+ * Load HIP-3 perp dex assets into the asset/szDecimals maps.
263
+ * On testnet: skips auto-loading (too many junk dexes). Use loadSingleHip3Dex() on demand.
264
+ * On mainnet: loads all dexes with concurrency limit.
265
+ */
266
+ async loadHip3Assets() {
267
+ try {
268
+ const dexs = await this.getPerpDexs();
269
+ // On testnet, skip auto-loading — too many junk dexes cause rate limiting.
270
+ // Users can reference specific dexes (e.g., "felix:BTC") which triggers on-demand loading.
271
+ if (!isMainnet()) {
272
+ const dexCount = dexs.filter(d => d != null).length - 1; // exclude null at index 0
273
+ if (dexCount > 0) {
274
+ this.log(`Testnet: skipping auto-load of ${dexCount} HIP-3 dexes. Use "dexName:COIN" to load a specific dex on demand.`);
275
+ }
276
+ return;
277
+ }
278
+ // Mainnet: load all dexes
279
+ const dexEntries = [];
280
+ for (let dexIdx = 1; dexIdx < dexs.length; dexIdx++) {
281
+ const dex = dexs[dexIdx];
282
+ if (dex)
283
+ dexEntries.push({ dex, dexIdx });
284
+ }
285
+ const results = await this.batchSettled(dexEntries.map(({ dex, dexIdx }) => async () => {
286
+ const data = await this.postInfo({ type: 'metaAndAssetCtxs', dex: dex.name }, `metaAndAssetCtxs(${dex.name})`);
287
+ return { dex, dexIdx, data };
288
+ }));
289
+ for (const result of results) {
290
+ if (result.status === 'rejected') {
291
+ this.log(`Failed to load HIP-3 dex:`, result.reason);
292
+ continue;
293
+ }
294
+ const { dex, dexIdx, data: dexData } = result.value;
295
+ this.registerHip3Dex(dex.name, dexIdx, dexData);
296
+ }
297
+ }
298
+ catch (e) {
299
+ this.log('Failed to load HIP-3 assets:', e);
300
+ }
301
+ }
302
+ /**
303
+ * Load a single HIP-3 dex by name (on-demand, e.g. when user references "felix:BTC").
304
+ * No-op if already loaded.
305
+ */
306
+ async loadSingleHip3Dex(dexName) {
307
+ if (this.loadedHip3Dexes.has(dexName))
308
+ return true;
309
+ const dexs = await this.getPerpDexs();
310
+ const dexIdx = dexs.findIndex(d => d?.name === dexName);
311
+ if (dexIdx < 1) {
312
+ this.log(`HIP-3 dex "${dexName}" not found in perpDexs list`);
313
+ return false;
314
+ }
315
+ try {
316
+ this.log(`On-demand loading HIP-3 dex: ${dexName}`);
317
+ const data = await this.postInfo({ type: 'metaAndAssetCtxs', dex: dexName }, `metaAndAssetCtxs(${dexName})`);
318
+ this.registerHip3Dex(dexName, dexIdx, data);
319
+ return true;
320
+ }
321
+ catch (e) {
322
+ this.log(`Failed to load HIP-3 dex ${dexName}:`, e);
323
+ return false;
324
+ }
325
+ }
326
+ /**
327
+ * Get HIP-3 dexes to iterate over for bulk queries.
328
+ * Mainnet: all dexes. Testnet: only explicitly loaded dexes.
329
+ */
330
+ async getIterableHip3Dexs() {
331
+ const dexs = await this.getPerpDexs();
332
+ const all = dexs.slice(1).filter((d) => d != null);
333
+ if (isMainnet())
334
+ return all;
335
+ // Testnet: only return dexes that have been explicitly loaded
336
+ return all.filter(d => this.loadedHip3Dexes.has(d.name));
337
+ }
338
+ /** Register a fetched HIP-3 dex's assets into lookup maps */
339
+ registerHip3Dex(dexName, dexIdx, dexData) {
340
+ if (dexData && dexData[0]?.universe) {
341
+ const universe = dexData[0].universe;
342
+ this.log(`Loading HIP-3 dex: ${dexName} with ${universe.length} markets`);
343
+ universe.forEach((asset, assetIdx) => {
344
+ const coinName = asset.name;
345
+ const localName = coinName.startsWith(dexName + ':') ? coinName.slice(dexName.length + 1) : coinName;
346
+ const globalIndex = 100000 + dexIdx * 10000 + assetIdx;
347
+ this.assetMap.set(coinName, globalIndex);
348
+ this.szDecimalsMap.set(coinName, asset.szDecimals);
349
+ this.coinDexMap.set(coinName, { dexName, dexIdx, localName });
350
+ if (asset.maxLeverage)
351
+ this.hip3MaxLeverageMap.set(coinName, asset.maxLeverage);
352
+ });
353
+ }
354
+ this.loadedHip3Dexes.add(dexName);
355
+ }
356
+ async getAllMids() {
357
+ this.log('Fetching allMids...');
358
+ let response;
359
+ try {
360
+ response = await this.withRetry(() => this.info.allMids(), 'allMids');
361
+ }
362
+ catch (error) {
363
+ this.log('allMids failure context:', this.getTransportContext('allMids'));
364
+ if (error instanceof Error && error.stack) {
365
+ this.log('allMids stack:', error.stack);
366
+ }
367
+ throw new Error(`allMids failed: ${this.describeError(error)}`);
368
+ }
369
+ // Also fetch HIP-3 dex mids (in parallel; testnet: only loaded dexes)
370
+ try {
371
+ const validDexs = await this.getIterableHip3Dexs();
372
+ const results = await this.batchSettled(validDexs.map((dex) => async () => {
373
+ const mids = await this.postInfo({ type: 'allMids', dex: dex.name }, `allMids(${dex.name})`);
374
+ return { dex, mids };
375
+ }));
376
+ for (const result of results) {
377
+ if (result.status === 'rejected') {
378
+ this.log('Failed to fetch HIP-3 dex mids:', result.reason);
379
+ continue;
380
+ }
381
+ for (const [coin, mid] of Object.entries(result.value.mids)) {
382
+ response[coin] = mid;
383
+ }
384
+ }
385
+ }
386
+ catch (e) {
387
+ this.log('Failed to fetch HIP-3 mids:', e);
388
+ }
389
+ return response;
390
+ }
391
+ /**
392
+ * Get all perpetual DEXs (including HIP-3 builder-deployed markets)
393
+ * Returns array where index 0 is null (main dex), others are HIP-3 dexs
394
+ */
395
+ async getPerpDexs() {
396
+ if (this.perpDexsCache)
397
+ return this.perpDexsCache;
398
+ this.log('Fetching perpDexs...');
399
+ const data = await this.postInfo({ type: 'perpDexs' }, 'perpDexs');
400
+ this.log('perpDexs response:', JSON.stringify(data).slice(0, 500));
401
+ this.perpDexsCache = data;
402
+ return data;
403
+ }
404
+ /**
405
+ * Get all perp markets including HIP-3 dexs
406
+ * Returns array of [meta, assetCtxs] for each dex
407
+ */
408
+ async getAllPerpMetas() {
409
+ this.log('Fetching all perp markets...');
410
+ const results = [];
411
+ // Get main dex data (no dex parameter)
412
+ const mainData = await this.postInfo({ type: 'metaAndAssetCtxs' }, 'metaAndAssetCtxs(main)');
413
+ this.log('Main dex data fetched');
414
+ results.push({
415
+ dexName: null,
416
+ meta: { universe: mainData[0].universe },
417
+ assetCtxs: mainData[1],
418
+ });
419
+ // Get HIP-3 dex names and fetch all in parallel (testnet: only loaded dexes)
420
+ const validDexs = await this.getIterableHip3Dexs();
421
+ const hip3Results = await this.batchSettled(validDexs.map((dex) => async () => {
422
+ const data = await this.postInfo({ type: 'metaAndAssetCtxs', dex: dex.name }, `metaAndAssetCtxs(${dex.name})`);
423
+ return { dex, data };
424
+ }));
425
+ for (const result of hip3Results) {
426
+ if (result.status === 'rejected') {
427
+ this.log('Failed to fetch HIP-3 dex:', result.reason);
428
+ continue;
429
+ }
430
+ const { dex, data: dexData } = result.value;
431
+ if (dexData && dexData[0]?.universe) {
432
+ this.log(`Fetched HIP-3 dex: ${dex.name} with ${dexData[0].universe.length} markets`);
433
+ results.push({
434
+ dexName: dex.name,
435
+ meta: { universe: dexData[0].universe },
436
+ assetCtxs: dexData[1] || [],
437
+ });
438
+ }
439
+ }
440
+ return results;
441
+ }
442
+ /**
443
+ * Get spot market metadata
444
+ */
445
+ async getSpotMeta() {
446
+ this.log('Fetching spotMeta...');
447
+ const data = await this.postInfo({ type: 'spotMeta' }, 'spotMeta');
448
+ this.log('spotMeta response:', JSON.stringify(data).slice(0, 500));
449
+ return data;
450
+ }
451
+ /**
452
+ * Get spot metadata with asset contexts (prices, volumes)
453
+ */
454
+ async getSpotMetaAndAssetCtxs() {
455
+ this.log('Fetching spotMetaAndAssetCtxs...');
456
+ const data = await this.postInfo({ type: 'spotMetaAndAssetCtxs' }, 'spotMetaAndAssetCtxs');
457
+ this.log('spotMetaAndAssetCtxs response:', JSON.stringify(data).slice(0, 500));
458
+ if (!Array.isArray(data) || !data[0] || !data[1]) {
459
+ this.log('spotMetaAndAssetCtxs returned null/malformed data, falling back to spotMeta + allMids');
460
+ const [meta, mids] = await Promise.all([
461
+ this.getSpotMeta(),
462
+ this.getAllMids(),
463
+ ]);
464
+ return {
465
+ meta,
466
+ assetCtxs: meta.universe.map((pair) => {
467
+ const price = mids[pair.name] ?? '0';
468
+ return {
469
+ coin: pair.name,
470
+ dayNtlVlm: '0',
471
+ markPx: price,
472
+ midPx: price,
473
+ prevDayPx: price,
474
+ };
475
+ }),
476
+ };
477
+ }
478
+ return {
479
+ meta: data[0],
480
+ assetCtxs: data[1],
481
+ };
482
+ }
483
+ // ============ HIP-4 Outcomes ============
484
+ parseOutcomeDescription(description) {
485
+ const parsed = {};
486
+ for (const part of description.split('|')) {
487
+ const rawSegment = part.trim();
488
+ const metadataIdx = rawSegment.indexOf('metadata=');
489
+ const segments = metadataIdx >= 0
490
+ ? [rawSegment, rawSegment.slice(metadataIdx + 'metadata='.length)]
491
+ : [rawSegment];
492
+ for (const segment of segments) {
493
+ const idx = segment.indexOf(':');
494
+ if (idx <= 0)
495
+ continue;
496
+ const key = segment.slice(0, idx).trim();
497
+ if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(key))
498
+ continue;
499
+ parsed[key] = segment.slice(idx + 1).trim();
500
+ }
501
+ }
502
+ return parsed;
503
+ }
504
+ normalizeOutcomeSide(side) {
505
+ if (typeof side === 'number') {
506
+ if (side === 0 || side === 1)
507
+ return side;
508
+ }
509
+ else {
510
+ const normalized = side.trim().toLowerCase();
511
+ if (normalized === '0' || normalized === 'yes' || normalized === 'y')
512
+ return 0;
513
+ if (normalized === '1' || normalized === 'no' || normalized === 'n')
514
+ return 1;
515
+ }
516
+ throw new Error(`Invalid outcome side "${side}". Use yes/no or 0/1.`);
517
+ }
518
+ getOutcomeEncoding(outcome, side) {
519
+ return 10 * outcome + side;
520
+ }
521
+ getOutcomeCoin(outcome, side) {
522
+ return `#${this.getOutcomeEncoding(outcome, side)}`;
523
+ }
524
+ getOutcomeAssetId(outcome, side) {
525
+ return 100_000_000 + this.getOutcomeEncoding(outcome, side);
526
+ }
527
+ resolveOutcomeRef(ref, side) {
528
+ if (typeof ref === 'number') {
529
+ const resolvedSide = this.normalizeOutcomeSide(side ?? 0);
530
+ const encoding = this.getOutcomeEncoding(ref, resolvedSide);
531
+ return {
532
+ outcome: ref,
533
+ side: resolvedSide,
534
+ encoding,
535
+ coin: `#${encoding}`,
536
+ tokenName: `+${encoding}`,
537
+ assetId: 100_000_000 + encoding,
538
+ };
539
+ }
540
+ const trimmed = ref.trim();
541
+ const encoded = trimmed.startsWith('#') || trimmed.startsWith('+')
542
+ ? parseInt(trimmed.slice(1), 10)
543
+ : NaN;
544
+ if (!Number.isNaN(encoded)) {
545
+ const resolvedSide = encoded % 10;
546
+ if (resolvedSide !== 0 && resolvedSide !== 1) {
547
+ throw new Error(`Invalid outcome encoding "${ref}". Outcome side must encode to 0 or 1.`);
548
+ }
549
+ const outcome = Math.floor(encoded / 10);
550
+ return {
551
+ outcome,
552
+ side: resolvedSide,
553
+ encoding: encoded,
554
+ coin: `#${encoded}`,
555
+ tokenName: `+${encoded}`,
556
+ assetId: 100_000_000 + encoded,
557
+ };
558
+ }
559
+ const outcome = parseInt(trimmed, 10);
560
+ if (!Number.isFinite(outcome) || outcome < 0) {
561
+ throw new Error(`Invalid outcome reference "${ref}". Use an outcome id, #<encoding>, or +<encoding>.`);
562
+ }
563
+ const resolvedSide = this.normalizeOutcomeSide(side ?? 0);
564
+ const encoding = this.getOutcomeEncoding(outcome, resolvedSide);
565
+ return {
566
+ outcome,
567
+ side: resolvedSide,
568
+ encoding,
569
+ coin: `#${encoding}`,
570
+ tokenName: `+${encoding}`,
571
+ assetId: 100_000_000 + encoding,
572
+ };
573
+ }
574
+ async getOutcomeMeta() {
575
+ if (this.outcomeMeta)
576
+ return this.outcomeMeta;
577
+ this.log('Fetching outcomeMeta...');
578
+ const data = await this.postInfo({ type: 'outcomeMeta' }, 'outcomeMeta');
579
+ if (!data || !Array.isArray(data.outcomes)) {
580
+ throw new Error('outcomeMeta returned empty/malformed payload.');
581
+ }
582
+ this.outcomeMeta = {
583
+ outcomes: data.outcomes,
584
+ questions: data.questions ?? [],
585
+ };
586
+ this.log(`Loaded ${this.outcomeMeta.outcomes.length} outcome markets`);
587
+ return this.outcomeMeta;
588
+ }
589
+ async getOutcomeCtxMap() {
590
+ const ctxMap = new Map();
591
+ try {
592
+ const spotData = await this.getSpotMetaAndAssetCtxs();
593
+ for (const ctx of spotData.assetCtxs) {
594
+ if (ctx.coin)
595
+ ctxMap.set(ctx.coin, ctx);
596
+ }
597
+ }
598
+ catch (e) {
599
+ this.log('Unable to load spot/outcome contexts:', e);
600
+ }
601
+ try {
602
+ const mids = await this.getAllMids();
603
+ for (const [coin, midPx] of Object.entries(mids)) {
604
+ if (!coin.startsWith('#'))
605
+ continue;
606
+ const existing = ctxMap.get(coin) ?? { coin };
607
+ existing.midPx = existing.midPx ?? midPx;
608
+ existing.markPx = existing.markPx ?? midPx;
609
+ ctxMap.set(coin, existing);
610
+ }
611
+ }
612
+ catch (e) {
613
+ this.log('Unable to load allMids for outcomes:', e);
614
+ }
615
+ return ctxMap;
616
+ }
617
+ async getOutcomeMarkets() {
618
+ const [meta, spotMeta, ctxMap] = await Promise.all([
619
+ this.getOutcomeMeta(),
620
+ this.getSpotMeta().catch(() => null),
621
+ this.getOutcomeCtxMap(),
622
+ ]);
623
+ const tokenDecimals = new Map();
624
+ if (spotMeta) {
625
+ for (const token of spotMeta.tokens) {
626
+ tokenDecimals.set(token.index, token.szDecimals);
627
+ }
628
+ }
629
+ const questions = new Map();
630
+ for (const question of meta.questions ?? []) {
631
+ for (const outcome of question.namedOutcomes) {
632
+ questions.set(outcome, question);
633
+ }
634
+ questions.set(question.fallbackOutcome, question);
635
+ }
636
+ return meta.outcomes.map((outcome) => {
637
+ const sides = outcome.sideSpecs.map((sideSpec, idx) => {
638
+ const side = idx;
639
+ const encoding = this.getOutcomeEncoding(outcome.outcome, side);
640
+ const coin = `#${encoding}`;
641
+ const ctx = ctxMap.get(coin);
642
+ return {
643
+ side,
644
+ name: sideSpec.name,
645
+ encoding,
646
+ coin,
647
+ tokenName: `+${encoding}`,
648
+ assetId: 100_000_000 + encoding,
649
+ token: sideSpec.token,
650
+ szDecimals: sideSpec.token !== undefined ? tokenDecimals.get(sideSpec.token) : undefined,
651
+ midPx: ctx?.midPx ?? undefined,
652
+ markPx: ctx?.markPx,
653
+ prevDayPx: ctx?.prevDayPx,
654
+ dayNtlVlm: ctx?.dayNtlVlm,
655
+ };
656
+ });
657
+ return {
658
+ outcome: outcome.outcome,
659
+ name: outcome.name,
660
+ description: outcome.description,
661
+ parsedDescription: this.parseOutcomeDescription(outcome.description),
662
+ sides,
663
+ question: questions.get(outcome.outcome),
664
+ };
665
+ });
666
+ }
667
+ async getOutcomeMarket(outcomeId) {
668
+ const markets = await this.getOutcomeMarkets();
669
+ return markets.find((market) => market.outcome === outcomeId) ?? null;
670
+ }
671
+ async getOutcomeSzDecimals(outcome, side) {
672
+ const market = await this.getOutcomeMarket(outcome);
673
+ return market?.sides.find((s) => s.side === side)?.szDecimals ?? 0;
674
+ }
675
+ async getOutcomeMidPrice(outcome, side) {
676
+ const coin = this.getOutcomeCoin(outcome, side);
677
+ const markets = await this.getOutcomeMarkets();
678
+ const marketSide = markets
679
+ .find((market) => market.outcome === outcome)
680
+ ?.sides.find((s) => s.side === side);
681
+ const fromMeta = marketSide?.midPx ?? marketSide?.markPx;
682
+ if (fromMeta) {
683
+ const mid = parseFloat(fromMeta);
684
+ if (mid > 0)
685
+ return mid;
686
+ }
687
+ const mids = await this.getAllMids();
688
+ const mid = parseFloat(mids[coin] || '0');
689
+ if (mid > 0)
690
+ return mid;
691
+ try {
692
+ const book = await this.getL2Book(coin);
693
+ if (book.midPrice > 0)
694
+ return book.midPrice;
695
+ }
696
+ catch (e) {
697
+ this.log(`Unable to fetch outcome L2 book for ${coin}:`, e);
698
+ }
699
+ throw new Error(`No outcome price for ${coin}. The market may not be open or may have no liquidity.`);
700
+ }
701
+ /**
702
+ * Load spot metadata into lookup maps.
703
+ * Spot asset index for orders = 10000 + universe[i].index
704
+ * Uses the base token's szDecimals for size rounding.
705
+ */
706
+ async loadSpotMeta() {
707
+ if (this.spotMetaLoaded)
708
+ return;
709
+ try {
710
+ const spotData = await this.getSpotMeta();
711
+ // Build token lookup for szDecimals
712
+ const tokenMap = new Map();
713
+ for (const token of spotData.tokens) {
714
+ tokenMap.set(token.index, { name: token.name, szDecimals: token.szDecimals });
715
+ }
716
+ for (const pair of spotData.universe) {
717
+ // pair.name is the market name (e.g., "PURR/USDC", "@107")
718
+ // pair.tokens = [baseTokenIndex, quoteTokenIndex]
719
+ // pair.index is the spot universe index
720
+ const baseToken = tokenMap.get(pair.tokens[0]);
721
+ if (!baseToken)
722
+ continue;
723
+ const spotAssetIndex = 10000 + pair.index;
724
+ const quoteTokenIdx = pair.tokens[1];
725
+ // A token can appear in multiple pairs (e.g., HYPE/USDC, HYPE/USDE, HYPE/USDH).
726
+ // Prefer the USDC pair (quote token index 0) for the primary mapping.
727
+ const existing = this.spotAssetMap.get(baseToken.name);
728
+ if (existing !== undefined && quoteTokenIdx !== 0) {
729
+ // Already have a mapping — skip non-USDC pairs
730
+ continue;
731
+ }
732
+ this.spotAssetMap.set(baseToken.name, spotAssetIndex);
733
+ this.spotPairNameMap.set(baseToken.name, pair.name);
734
+ this.spotSzDecimalsMap.set(baseToken.name, baseToken.szDecimals);
735
+ this.log(`Spot: ${baseToken.name} → asset ${spotAssetIndex}, market ${pair.name} (szDecimals: ${baseToken.szDecimals})`);
736
+ }
737
+ this.spotMetaLoaded = true;
738
+ this.log(`Loaded ${this.spotAssetMap.size} spot markets`);
739
+ }
740
+ catch (e) {
741
+ this.log('Failed to load spot metadata:', e);
742
+ }
743
+ }
744
+ /** Get the spot asset index for a coin, or undefined if not a spot asset */
745
+ getSpotAssetIndex(coin) {
746
+ return this.spotAssetMap.get(coin);
747
+ }
748
+ /** Get the preferred spot market key for a coin (e.g. "@230", "PURR/USDC") */
749
+ getSpotMarketKey(coin) {
750
+ return this.spotPairNameMap.get(coin);
751
+ }
752
+ /** Get spot szDecimals for a coin */
753
+ getSpotSzDecimals(coin) {
754
+ return this.spotSzDecimalsMap.get(coin);
755
+ }
756
+ /** Get all loaded spot asset names */
757
+ getSpotAssetNames() {
758
+ return Array.from(this.spotAssetMap.keys());
759
+ }
760
+ /**
761
+ * Get user's spot token balances
762
+ */
763
+ async getSpotBalances(user) {
764
+ this.log('Fetching spotClearinghouseState for:', user ?? this.address);
765
+ const data = await this.postInfo({
766
+ type: 'spotClearinghouseState',
767
+ user: user ?? this.address,
768
+ }, 'spotClearinghouseState');
769
+ this.log('spotClearinghouseState response:', JSON.stringify(data).slice(0, 500));
770
+ return data;
771
+ }
772
+ /**
773
+ * Get token details by token ID
774
+ */
775
+ async getTokenDetails(tokenId) {
776
+ this.log('Fetching tokenDetails for:', tokenId);
777
+ const baseUrl = isMainnet()
778
+ ? 'https://api.hyperliquid.xyz'
779
+ : 'https://api.hyperliquid-testnet.xyz';
780
+ try {
781
+ const response = await fetch(baseUrl + '/info', {
782
+ method: 'POST',
783
+ headers: { 'Content-Type': 'application/json' },
784
+ body: JSON.stringify({
785
+ type: 'tokenDetails',
786
+ tokenId,
787
+ }),
788
+ });
789
+ const data = await response.json();
790
+ this.log('tokenDetails response:', JSON.stringify(data).slice(0, 500));
791
+ return data;
792
+ }
793
+ catch {
794
+ return null;
795
+ }
796
+ }
797
+ /**
798
+ * Get predicted funding rates across venues
799
+ */
800
+ async getPredictedFundings() {
801
+ this.log('Fetching predictedFundings...');
802
+ const baseUrl = isMainnet()
803
+ ? 'https://api.hyperliquid.xyz'
804
+ : 'https://api.hyperliquid-testnet.xyz';
805
+ const response = await fetch(baseUrl + '/info', {
806
+ method: 'POST',
807
+ headers: {
808
+ 'Content-Type': 'application/json',
809
+ 'Cache-Control': 'no-cache, no-store, max-age=0',
810
+ Pragma: 'no-cache',
811
+ },
812
+ body: JSON.stringify({ type: 'predictedFundings' }),
813
+ });
814
+ const data = await response.json();
815
+ this.log('predictedFundings response length:', data?.length);
816
+ return data;
817
+ }
818
+ /**
819
+ * Get L2 order book for an asset
820
+ * Returns best bid/ask and depth
821
+ */
822
+ async getL2Book(coin) {
823
+ this.log('Fetching l2Book for:', coin);
824
+ // API accepts prefixed names directly (e.g., "xyz:CL")
825
+ let response;
826
+ try {
827
+ response = await this.info.l2Book({ coin });
828
+ }
829
+ catch (error) {
830
+ this.log('l2Book failure context:', this.getTransportContext(`l2Book(${coin})`));
831
+ if (error instanceof Error && error.stack) {
832
+ this.log('l2Book stack:', error.stack);
833
+ }
834
+ throw new Error(`l2Book(${coin}) failed: ${this.describeError(error)}`);
835
+ }
836
+ if (!response || !Array.isArray(response.levels)) {
837
+ throw new Error(`l2Book(${coin}) returned empty/malformed payload.`);
838
+ }
839
+ const bids = (response.levels[0] ?? []);
840
+ const asks = (response.levels[1] ?? []);
841
+ const bestBid = bids.length > 0 ? parseFloat(bids[0].px) : 0;
842
+ const bestAsk = asks.length > 0 ? parseFloat(asks[0].px) : 0;
843
+ const midPrice = (bestBid + bestAsk) / 2;
844
+ const spread = bestAsk - bestBid;
845
+ const spreadBps = midPrice > 0 ? (spread / midPrice) * 10000 : 0;
846
+ return {
847
+ bids,
848
+ asks,
849
+ bestBid,
850
+ bestAsk,
851
+ midPrice,
852
+ spread,
853
+ spreadBps,
854
+ };
855
+ }
856
+ async getAssetIndexAsync(coin) {
857
+ let index = this.assetMap.get(coin);
858
+ if (index === undefined && coin.includes(':')) {
859
+ // Try on-demand loading the dex (e.g., "felix:BTC" → load "felix")
860
+ const dexName = coin.split(':')[0];
861
+ await this.loadSingleHip3Dex(dexName);
862
+ index = this.assetMap.get(coin);
863
+ }
864
+ if (index === undefined) {
865
+ const hip3Matches = this.findHip3Matches(coin);
866
+ if (hip3Matches.length > 0) {
867
+ const suggestions = hip3Matches.map(m => `${m}`).join(', ');
868
+ throw new Error(`Unknown asset: ${coin}. Did you mean one of these HIP-3 assets? ${suggestions}\n` +
869
+ `Use "openbroker search --query ${coin}" to find the full ticker.`);
870
+ }
871
+ throw new Error(`Unknown asset: ${coin}. Available: ${Array.from(this.assetMap.keys()).slice(0, 10).join(', ')}...`);
872
+ }
873
+ return index;
874
+ }
875
+ getAssetIndex(coin) {
876
+ const index = this.assetMap.get(coin);
877
+ if (index === undefined) {
878
+ // Check if bare name exists in HIP-3 dexes and suggest prefixed version
879
+ const hip3Matches = this.findHip3Matches(coin);
880
+ if (hip3Matches.length > 0) {
881
+ const suggestions = hip3Matches.map(m => `${m}`).join(', ');
882
+ throw new Error(`Unknown asset: ${coin}. Did you mean one of these HIP-3 assets? ${suggestions}\n` +
883
+ `Use "openbroker search --query ${coin}" to find the full ticker.`);
884
+ }
885
+ throw new Error(`Unknown asset: ${coin}. Available: ${Array.from(this.assetMap.keys()).slice(0, 10).join(', ')}...`);
886
+ }
887
+ return index;
888
+ }
889
+ async getSzDecimalsAsync(coin) {
890
+ let decimals = this.szDecimalsMap.get(coin);
891
+ if (decimals === undefined && coin.includes(':')) {
892
+ const dexName = coin.split(':')[0];
893
+ await this.loadSingleHip3Dex(dexName);
894
+ decimals = this.szDecimalsMap.get(coin);
895
+ }
896
+ if (decimals === undefined) {
897
+ const hip3Matches = this.findHip3Matches(coin);
898
+ if (hip3Matches.length > 0) {
899
+ throw new Error(`Unknown asset: ${coin}. Did you mean: ${hip3Matches.join(', ')}?`);
900
+ }
901
+ throw new Error(`Unknown asset: ${coin}`);
902
+ }
903
+ return decimals;
904
+ }
905
+ getSzDecimals(coin) {
906
+ const decimals = this.szDecimalsMap.get(coin);
907
+ if (decimals === undefined) {
908
+ const hip3Matches = this.findHip3Matches(coin);
909
+ if (hip3Matches.length > 0) {
910
+ throw new Error(`Unknown asset: ${coin}. Did you mean: ${hip3Matches.join(', ')}?`);
911
+ }
912
+ throw new Error(`Unknown asset: ${coin}`);
913
+ }
914
+ return decimals;
915
+ }
916
+ /**
917
+ * Find HIP-3 assets matching a bare coin name (without dex prefix)
918
+ */
919
+ findHip3Matches(bareName) {
920
+ const matches = [];
921
+ const upperName = bareName.toUpperCase();
922
+ for (const [key, info] of this.coinDexMap.entries()) {
923
+ if (info.dexName && info.localName.toUpperCase() === upperName) {
924
+ matches.push(key);
925
+ }
926
+ }
927
+ return matches;
928
+ }
929
+ /**
930
+ * Get the dex name for a coin (null for main dex assets)
931
+ */
932
+ getCoinDex(coin) {
933
+ return this.coinDexMap.get(coin)?.dexName ?? null;
934
+ }
935
+ /**
936
+ * Get the local (unprefixed) coin name for API calls that need it
937
+ * e.g., "xyz:CL" → "CL", "ETH" → "ETH"
938
+ */
939
+ getCoinLocalName(coin) {
940
+ return this.coinDexMap.get(coin)?.localName ?? coin;
941
+ }
942
+ /**
943
+ * Check if a coin is a HIP-3 asset
944
+ */
945
+ isHip3(coin) {
946
+ return this.coinDexMap.get(coin)?.dexName != null;
947
+ }
948
+ /**
949
+ * Invalidate cached metadata so next call fetches fresh data.
950
+ * Useful for long-running strategies that need updated funding rates.
951
+ */
952
+ invalidateMetaCache() {
953
+ this.meta = null;
954
+ // Keep the asset/szDecimals/coinDex maps - they don't change
955
+ }
956
+ /**
957
+ * Get all loaded asset names (main + HIP-3)
958
+ */
959
+ getAllAssetNames() {
960
+ return Array.from(this.assetMap.keys());
961
+ }
962
+ /**
963
+ * Get all HIP-3 asset names
964
+ */
965
+ getHip3AssetNames() {
966
+ return Array.from(this.coinDexMap.entries())
967
+ .filter(([_, info]) => info.dexName !== null)
968
+ .map(([name]) => name);
969
+ }
970
+ // ============ Account Info ============
971
+ /**
972
+ * Get the account's abstraction mode.
973
+ * Returns: 'standard' | 'unified' | 'portfolio' | 'dexAbstraction'
974
+ * Unified accounts have a single USDC balance shared across all dexes.
975
+ * Standard accounts have separate balances per dex (need sendAsset transfers).
976
+ */
977
+ async getAccountMode(user) {
978
+ if (this.accountMode)
979
+ return this.accountMode;
980
+ const baseUrl = isMainnet()
981
+ ? 'https://api.hyperliquid.xyz'
982
+ : 'https://api.hyperliquid-testnet.xyz';
983
+ try {
984
+ const response = await fetch(baseUrl + '/info', {
985
+ method: 'POST',
986
+ headers: { 'Content-Type': 'application/json' },
987
+ body: JSON.stringify({
988
+ type: 'userAbstraction',
989
+ user: user ?? this.address,
990
+ }),
991
+ });
992
+ const data = await response.json();
993
+ this.log('userAbstraction response:', JSON.stringify(data));
994
+ // API may return a bare string or an object. Normalize to string for matching.
995
+ const mode = typeof data === 'string' ? data : (data?.abstraction ?? data?.mode ?? String(data));
996
+ const modeLower = mode.toLowerCase();
997
+ if (modeLower.includes('unified')) {
998
+ this.accountMode = 'unified';
999
+ }
1000
+ else if (modeLower.includes('portfolio')) {
1001
+ this.accountMode = 'portfolio';
1002
+ }
1003
+ else if (modeLower.includes('dex')) {
1004
+ this.accountMode = 'dexAbstraction';
1005
+ }
1006
+ else {
1007
+ // "default" or "disabled" both mean standard mode
1008
+ this.accountMode = 'standard';
1009
+ }
1010
+ }
1011
+ catch (err) {
1012
+ this.log('Failed to fetch account abstraction mode:', err instanceof Error ? err.message : String(err));
1013
+ this.accountMode = 'standard'; // Safe fallback
1014
+ }
1015
+ this.log('Account mode:', this.accountMode);
1016
+ return this.accountMode;
1017
+ }
1018
+ /** Whether the account uses unified balances (unified or portfolio margin) */
1019
+ async isUnifiedAccount(user) {
1020
+ const mode = await this.getAccountMode(user);
1021
+ return mode === 'unified' || mode === 'portfolio';
1022
+ }
1023
+ /**
1024
+ * Query the role of an address on HyperCore L1.
1025
+ * Returns: "user" | "agent" | "vault" | "subAccount" | "missing"
1026
+ * Useful for verifying API wallet (agent) registration.
1027
+ */
1028
+ async getUserRole(address) {
1029
+ const target = address ?? this.address;
1030
+ this.log('Fetching userRole for:', target);
1031
+ try {
1032
+ const response = await this.postInfo({ type: 'userRole', user: target }, 'userRole');
1033
+ this.log('userRole response:', JSON.stringify(response));
1034
+ return response ?? { role: 'missing' };
1035
+ }
1036
+ catch (e) {
1037
+ this.log('userRole query failed:', e);
1038
+ return { role: 'unknown' };
1039
+ }
1040
+ }
1041
+ /**
1042
+ * Validate API wallet setup: check that the signing wallet is recognized
1043
+ * as an "agent" on HyperCore and the account address exists.
1044
+ * Logs warnings if misconfigured.
1045
+ */
1046
+ async validateApiWalletSetup() {
1047
+ const walletResult = await this.getUserRole(this.walletAddress);
1048
+ const accountResult = await this.getUserRole(this.address);
1049
+ const walletRole = walletResult.role;
1050
+ const accountRole = accountResult.role;
1051
+ this.log(`API wallet validation: wallet ${this.walletAddress} role=${walletRole}, account ${this.address} role=${accountRole}`);
1052
+ if (walletRole === 'agent') {
1053
+ const masterAddress = walletResult.data?.user;
1054
+ if (masterAddress && masterAddress.toLowerCase() !== this.address.toLowerCase()) {
1055
+ console.warn(`\x1b[33m⚠️ API wallet ${this.walletAddress} is an agent for ${masterAddress}, but HYPERLIQUID_ACCOUNT_ADDRESS is ${this.address}.\n` +
1056
+ ` These should match.\x1b[0m`);
1057
+ }
1058
+ else {
1059
+ this.log(`API wallet confirmed as agent for ${masterAddress ?? this.address}`);
1060
+ }
1061
+ }
1062
+ else {
1063
+ console.warn(`\x1b[33m⚠️ API wallet ${this.walletAddress} has role "${walletRole}" on HyperCore (expected "agent").\n` +
1064
+ ` Make sure the agent is registered via CoreWriter.registerAgent() on the correct network (${isMainnet() ? 'mainnet' : 'testnet'}).\x1b[0m`);
1065
+ }
1066
+ if (accountRole === 'missing') {
1067
+ console.warn(`\x1b[33m⚠️ Account ${this.address} has role "missing" on HyperCore.\n` +
1068
+ ` The account may not exist on ${isMainnet() ? 'mainnet' : 'testnet'} yet. Ensure the contract is deployed and has interacted with HyperCore.\x1b[0m`);
1069
+ }
1070
+ return { valid: walletRole === 'agent', walletRole, accountRole };
1071
+ }
1072
+ /**
1073
+ * Check if an address has sub-accounts (is a master account)
1074
+ * Sub-accounts cannot approve builder fees - only master accounts can
1075
+ */
1076
+ async getSubAccounts(user) {
1077
+ this.log('Fetching subAccounts for:', user ?? this.address);
1078
+ try {
1079
+ const response = await this.info.subAccounts({ user: user ?? this.address });
1080
+ if (!response)
1081
+ return [];
1082
+ // Response is an array of sub-account objects
1083
+ return response.map((sub) => ({
1084
+ subAccountUser: sub.subAccountUser,
1085
+ name: sub.name,
1086
+ }));
1087
+ }
1088
+ catch {
1089
+ return [];
1090
+ }
1091
+ }
1092
+ /**
1093
+ * Check the maximum builder fee approved for a user/builder pair
1094
+ * Returns the max fee rate as a string (e.g., "0.1%") or null if not approved
1095
+ */
1096
+ async getMaxBuilderFee(user, builder) {
1097
+ // IMPORTANT: Hyperliquid API requires lowercase addresses
1098
+ const targetUser = (user ?? this.address).toLowerCase();
1099
+ const targetBuilder = (builder ?? this.config.builderAddress).toLowerCase();
1100
+ this.log('Fetching maxBuilderFee for:', targetUser, 'builder:', targetBuilder);
1101
+ try {
1102
+ const baseUrl = isMainnet()
1103
+ ? 'https://api.hyperliquid.xyz'
1104
+ : 'https://api.hyperliquid-testnet.xyz';
1105
+ const response = await fetch(baseUrl + '/info', {
1106
+ method: 'POST',
1107
+ headers: { 'Content-Type': 'application/json' },
1108
+ body: JSON.stringify({
1109
+ type: 'maxBuilderFee',
1110
+ user: targetUser,
1111
+ builder: targetBuilder,
1112
+ }),
1113
+ });
1114
+ const data = await response.json();
1115
+ this.log('maxBuilderFee response:', data);
1116
+ // API returns a number (fee in tenths of bps) or 0/null if not approved
1117
+ // e.g., 100 = 10 bps = 0.1%
1118
+ if (data !== null && data !== undefined && data !== 0) {
1119
+ // Convert from tenths of bps to percentage string
1120
+ const bps = Number(data) / 10;
1121
+ const pct = bps / 100;
1122
+ return `${pct}%`;
1123
+ }
1124
+ return null;
1125
+ }
1126
+ catch (error) {
1127
+ this.log('maxBuilderFee error:', error);
1128
+ return null;
1129
+ }
1130
+ }
1131
+ /**
1132
+ * Approve a builder fee for the open-broker builder
1133
+ * IMPORTANT: This must be signed by a MAIN wallet, not an API wallet or sub-account
1134
+ *
1135
+ * @param maxFeeRate - Max fee rate to approve (e.g., "0.01%" for 1 bps)
1136
+ * @param builder - Builder address (defaults to open-broker builder)
1137
+ */
1138
+ async approveBuilderFee(maxFeeRate = '0.1%', builder) {
1139
+ const targetBuilder = builder ?? this.config.builderAddress;
1140
+ this.log('Approving builder fee:', maxFeeRate, 'for builder:', targetBuilder);
1141
+ // Check if using API wallet - this won't work
1142
+ if (this.isApiWallet) {
1143
+ return {
1144
+ status: 'err',
1145
+ response: 'Cannot approve builder fee with API wallet. Must use main wallet private key.',
1146
+ };
1147
+ }
1148
+ try {
1149
+ // approveBuilderFee is a wallet-level authorization (not a vault action)
1150
+ // and the SDK's `opts` parameter only carries an AbortSignal. Do not pass
1151
+ // vaultParam here — the signature is { signal? }, not a vault wrapper.
1152
+ const response = await this.exchange.approveBuilderFee({
1153
+ builder: targetBuilder,
1154
+ maxFeeRate,
1155
+ });
1156
+ this.log('approveBuilderFee response:', response);
1157
+ return { status: 'ok', response };
1158
+ }
1159
+ catch (error) {
1160
+ this.log('approveBuilderFee error:', error);
1161
+ return {
1162
+ status: 'err',
1163
+ response: error instanceof Error ? error.message : String(error),
1164
+ };
1165
+ }
1166
+ }
1167
+ /**
1168
+ * Get user funding ledger updates
1169
+ * Returns array of funding payments received/paid per position
1170
+ */
1171
+ async getUserFunding(user, startTime, endTime) {
1172
+ this.log('Fetching userFunding for:', user ?? this.address);
1173
+ const baseUrl = isMainnet()
1174
+ ? 'https://api.hyperliquid.xyz'
1175
+ : 'https://api.hyperliquid-testnet.xyz';
1176
+ const body = {
1177
+ type: 'userFunding',
1178
+ user: user ?? this.address,
1179
+ };
1180
+ if (startTime !== undefined)
1181
+ body.startTime = startTime;
1182
+ if (endTime !== undefined)
1183
+ body.endTime = endTime;
1184
+ const response = await fetch(baseUrl + '/info', {
1185
+ method: 'POST',
1186
+ headers: { 'Content-Type': 'application/json' },
1187
+ body: JSON.stringify(body),
1188
+ });
1189
+ const data = await response.json();
1190
+ this.log('userFunding response length:', data?.length);
1191
+ return data;
1192
+ }
1193
+ /**
1194
+ * Get user trade fills
1195
+ */
1196
+ async getUserFills(user, aggregateByTime) {
1197
+ this.log('Fetching userFills for:', user ?? this.address);
1198
+ const baseUrl = isMainnet()
1199
+ ? 'https://api.hyperliquid.xyz'
1200
+ : 'https://api.hyperliquid-testnet.xyz';
1201
+ const body = {
1202
+ type: 'userFills',
1203
+ user: user ?? this.address,
1204
+ };
1205
+ if (aggregateByTime !== undefined)
1206
+ body.aggregateByTime = aggregateByTime;
1207
+ const response = await fetch(baseUrl + '/info', {
1208
+ method: 'POST',
1209
+ headers: { 'Content-Type': 'application/json' },
1210
+ body: JSON.stringify(body),
1211
+ });
1212
+ const data = await response.json();
1213
+ this.log('userFills response length:', data?.length);
1214
+ return data;
1215
+ }
1216
+ /**
1217
+ * Get historical orders (all statuses)
1218
+ */
1219
+ async getHistoricalOrders(user) {
1220
+ this.log('Fetching historicalOrders for:', user ?? this.address);
1221
+ const baseUrl = isMainnet()
1222
+ ? 'https://api.hyperliquid.xyz'
1223
+ : 'https://api.hyperliquid-testnet.xyz';
1224
+ const response = await fetch(baseUrl + '/info', {
1225
+ method: 'POST',
1226
+ headers: { 'Content-Type': 'application/json' },
1227
+ body: JSON.stringify({
1228
+ type: 'historicalOrders',
1229
+ user: user ?? this.address,
1230
+ }),
1231
+ });
1232
+ const data = await response.json();
1233
+ this.log('historicalOrders response length:', data?.length);
1234
+ return data;
1235
+ }
1236
+ /**
1237
+ * Get status of a specific order by OID or CLOID
1238
+ */
1239
+ async getOrderStatus(oid, user) {
1240
+ this.log('Fetching orderStatus for oid:', oid);
1241
+ const baseUrl = isMainnet()
1242
+ ? 'https://api.hyperliquid.xyz'
1243
+ : 'https://api.hyperliquid-testnet.xyz';
1244
+ const response = await fetch(baseUrl + '/info', {
1245
+ method: 'POST',
1246
+ headers: { 'Content-Type': 'application/json' },
1247
+ body: JSON.stringify({
1248
+ type: 'orderStatus',
1249
+ user: user ?? this.address,
1250
+ oid: typeof oid === 'string' ? oid : oid,
1251
+ }),
1252
+ });
1253
+ const data = await response.json();
1254
+ this.log('orderStatus response:', JSON.stringify(data).slice(0, 500));
1255
+ return data;
1256
+ }
1257
+ /**
1258
+ * Get user fee schedule and volume info
1259
+ */
1260
+ async getUserFees(user) {
1261
+ this.log('Fetching userFees for:', user ?? this.address);
1262
+ const baseUrl = isMainnet()
1263
+ ? 'https://api.hyperliquid.xyz'
1264
+ : 'https://api.hyperliquid-testnet.xyz';
1265
+ const response = await fetch(baseUrl + '/info', {
1266
+ method: 'POST',
1267
+ headers: { 'Content-Type': 'application/json' },
1268
+ body: JSON.stringify({
1269
+ type: 'userFees',
1270
+ user: user ?? this.address,
1271
+ }),
1272
+ });
1273
+ const data = await response.json();
1274
+ this.log('userFees response:', JSON.stringify(data).slice(0, 500));
1275
+ return data;
1276
+ }
1277
+ /**
1278
+ * Get OHLCV candle data for an asset
1279
+ */
1280
+ async getCandleSnapshot(coin, interval, startTime, endTime) {
1281
+ this.log('Fetching candleSnapshot for:', coin, interval);
1282
+ const baseUrl = isMainnet()
1283
+ ? 'https://api.hyperliquid.xyz'
1284
+ : 'https://api.hyperliquid-testnet.xyz';
1285
+ // API accepts prefixed names directly (e.g., "xyz:CL")
1286
+ const req = { coin, interval, startTime };
1287
+ if (endTime !== undefined)
1288
+ req.endTime = endTime;
1289
+ const response = await fetch(baseUrl + '/info', {
1290
+ method: 'POST',
1291
+ headers: { 'Content-Type': 'application/json' },
1292
+ body: JSON.stringify({ type: 'candleSnapshot', req }),
1293
+ });
1294
+ const data = await response.json();
1295
+ this.log('candleSnapshot response length:', data?.length);
1296
+ return data;
1297
+ }
1298
+ /**
1299
+ * Get historical funding rates for an asset
1300
+ */
1301
+ async getFundingHistory(coin, startTime, endTime) {
1302
+ this.log('Fetching fundingHistory for:', coin);
1303
+ const baseUrl = isMainnet()
1304
+ ? 'https://api.hyperliquid.xyz'
1305
+ : 'https://api.hyperliquid-testnet.xyz';
1306
+ // API accepts prefixed names directly (e.g., "xyz:CL")
1307
+ const body = { type: 'fundingHistory', coin, startTime };
1308
+ if (endTime !== undefined)
1309
+ body.endTime = endTime;
1310
+ const response = await fetch(baseUrl + '/info', {
1311
+ method: 'POST',
1312
+ headers: { 'Content-Type': 'application/json' },
1313
+ body: JSON.stringify(body),
1314
+ });
1315
+ const data = await response.json();
1316
+ this.log('fundingHistory response length:', data?.length);
1317
+ return data;
1318
+ }
1319
+ /**
1320
+ * Get recent trades for an asset
1321
+ */
1322
+ async getRecentTrades(coin) {
1323
+ this.log('Fetching recentTrades for:', coin);
1324
+ const baseUrl = isMainnet()
1325
+ ? 'https://api.hyperliquid.xyz'
1326
+ : 'https://api.hyperliquid-testnet.xyz';
1327
+ // API accepts prefixed names directly (e.g., "xyz:CL")
1328
+ const body = { type: 'recentTrades', coin };
1329
+ const response = await fetch(baseUrl + '/info', {
1330
+ method: 'POST',
1331
+ headers: { 'Content-Type': 'application/json' },
1332
+ body: JSON.stringify(body),
1333
+ });
1334
+ const data = await response.json();
1335
+ this.log('recentTrades response length:', data?.length);
1336
+ return data;
1337
+ }
1338
+ /**
1339
+ * Get user API rate limit status
1340
+ */
1341
+ async getUserRateLimit(user) {
1342
+ this.log('Fetching userRateLimit for:', user ?? this.address);
1343
+ const baseUrl = isMainnet()
1344
+ ? 'https://api.hyperliquid.xyz'
1345
+ : 'https://api.hyperliquid-testnet.xyz';
1346
+ const response = await fetch(baseUrl + '/info', {
1347
+ method: 'POST',
1348
+ headers: { 'Content-Type': 'application/json' },
1349
+ body: JSON.stringify({
1350
+ type: 'userRateLimit',
1351
+ user: user ?? this.address,
1352
+ }),
1353
+ });
1354
+ const data = await response.json();
1355
+ this.log('userRateLimit response:', JSON.stringify(data));
1356
+ return data;
1357
+ }
1358
+ async getUserState(user, dex) {
1359
+ this.log('Fetching clearinghouseState for:', user ?? this.address, dex ? `dex: ${dex}` : '');
1360
+ const params = { user: user ?? this.address };
1361
+ if (dex !== undefined)
1362
+ params.dex = dex;
1363
+ const label = dex ? `clearinghouseState(${dex})` : 'clearinghouseState(main)';
1364
+ let response;
1365
+ try {
1366
+ response = await this.withRetry(() => this.info.clearinghouseState(params), label);
1367
+ }
1368
+ catch (error) {
1369
+ this.log(`${label} failure context:`, this.getTransportContext(label));
1370
+ if (error instanceof Error && error.stack) {
1371
+ this.log(`${label} stack:`, error.stack);
1372
+ }
1373
+ throw new Error(`${label} failed: ${this.describeError(error)}`);
1374
+ }
1375
+ // The SDK response has `withdrawable` as a top-level field, not inside
1376
+ // marginSummary/crossMarginSummary. Copy it into our MarginSummary shape.
1377
+ const state = response;
1378
+ const withdrawable = response.withdrawable ?? '0';
1379
+ if (state.marginSummary) {
1380
+ state.marginSummary.withdrawable = withdrawable;
1381
+ }
1382
+ if (state.crossMarginSummary) {
1383
+ state.crossMarginSummary.withdrawable = withdrawable;
1384
+ }
1385
+ return state;
1386
+ }
1387
+ /**
1388
+ * Get user state across all dexes (main + HIP-3).
1389
+ * For unified accounts: equity comes from spotClearinghouseState (single USDC balance).
1390
+ * For standard accounts: aggregates margin summaries from each dex.
1391
+ */
1392
+ async getUserStateAll(user) {
1393
+ await this.getMetaAndAssetCtxs(); // Ensure HIP-3 dex list is loaded
1394
+ const unified = await this.isUnifiedAccount(user);
1395
+ const mainState = await this.getUserState(user);
1396
+ // Collect positions from all HIP-3 dexes (in parallel; testnet: only loaded dexes)
1397
+ const validDexs = await this.getIterableHip3Dexs();
1398
+ const dexResults = await this.batchSettled(validDexs.map((dex) => async () => {
1399
+ const dexState = await this.getUserState(user, dex.name);
1400
+ return { dex, dexState };
1401
+ }));
1402
+ let hip3Errors = 0;
1403
+ const safeAdd = (a, b) => {
1404
+ const va = parseFloat(a ?? '0') || 0;
1405
+ const vb = parseFloat(b ?? '0') || 0;
1406
+ return String(va + vb);
1407
+ };
1408
+ for (const result of dexResults) {
1409
+ if (result.status === 'rejected') {
1410
+ hip3Errors++;
1411
+ this.log(`Failed to fetch state for HIP-3 dex:`, result.reason instanceof Error ? result.reason.message : String(result.reason));
1412
+ continue;
1413
+ }
1414
+ const { dexState } = result.value;
1415
+ if (dexState.assetPositions?.length > 0) {
1416
+ mainState.assetPositions.push(...dexState.assetPositions);
1417
+ }
1418
+ // For standard accounts, aggregate margin from each dex
1419
+ if (!unified) {
1420
+ const dexMargin = dexState.marginSummary;
1421
+ if (dexMargin) {
1422
+ const addToSummary = (summary) => {
1423
+ summary.accountValue = safeAdd(summary.accountValue, dexMargin.accountValue);
1424
+ summary.totalNtlPos = safeAdd(summary.totalNtlPos, dexMargin.totalNtlPos);
1425
+ summary.totalRawUsd = safeAdd(summary.totalRawUsd, dexMargin.totalRawUsd);
1426
+ summary.totalMarginUsed = safeAdd(summary.totalMarginUsed, dexMargin.totalMarginUsed);
1427
+ summary.withdrawable = safeAdd(summary.withdrawable, dexMargin.withdrawable);
1428
+ };
1429
+ addToSummary(mainState.marginSummary);
1430
+ addToSummary(mainState.crossMarginSummary);
1431
+ }
1432
+ }
1433
+ }
1434
+ if (hip3Errors > 0) {
1435
+ this.log(`Warning: ${hip3Errors} HIP-3 dex queries failed — some positions may be missing. Use --verbose for details.`);
1436
+ }
1437
+ // For unified accounts: equity is the USDC balance from spot clearinghouse
1438
+ if (unified) {
1439
+ try {
1440
+ const spotState = await this.getSpotBalances(user);
1441
+ this.log('Unified spot balances:', JSON.stringify(spotState));
1442
+ // Find USDC balance (case-insensitive, handles variations)
1443
+ const balances = spotState?.balances ?? [];
1444
+ const usdcBalance = balances.find(b => b.coin?.toUpperCase() === 'USDC');
1445
+ if (usdcBalance) {
1446
+ const totalUsdc = usdcBalance.total;
1447
+ const holdUsdc = usdcBalance.hold;
1448
+ const withdrawable = String(parseFloat(totalUsdc) - parseFloat(holdUsdc));
1449
+ // Compute total margin used and notional from all positions
1450
+ let totalMarginUsed = 0;
1451
+ let totalNtlPos = 0;
1452
+ for (const ap of mainState.assetPositions) {
1453
+ const pos = ap.position;
1454
+ if (parseFloat(pos.szi) === 0)
1455
+ continue;
1456
+ totalMarginUsed += parseFloat(pos.marginUsed);
1457
+ totalNtlPos += Math.abs(parseFloat(pos.positionValue));
1458
+ }
1459
+ const summary = {
1460
+ accountValue: totalUsdc,
1461
+ totalNtlPos: String(totalNtlPos),
1462
+ totalRawUsd: totalUsdc,
1463
+ totalMarginUsed: String(totalMarginUsed),
1464
+ withdrawable,
1465
+ };
1466
+ mainState.marginSummary = summary;
1467
+ mainState.crossMarginSummary = { ...summary };
1468
+ this.log(`Unified account: USDC balance $${parseFloat(totalUsdc).toFixed(2)}, margin used $${totalMarginUsed.toFixed(2)}`);
1469
+ }
1470
+ else {
1471
+ this.log('Unified account: no USDC balance found in spot state. Balances:', balances.map(b => b.coin));
1472
+ }
1473
+ }
1474
+ catch (err) {
1475
+ this.log('Failed to fetch spot balances for unified account:', err instanceof Error ? err.message : String(err));
1476
+ }
1477
+ }
1478
+ return mainState;
1479
+ }
1480
+ async getOpenOrders(user) {
1481
+ this.log('Fetching openOrders for:', user ?? this.address);
1482
+ await this.getMetaAndAssetCtxs(); // Ensure HIP-3 dex list is loaded
1483
+ // Fetch main dex orders
1484
+ const orders = await this.withRetry(() => this.info.openOrders({ user: user ?? this.address }), 'openOrders');
1485
+ // Fetch HIP-3 dex orders (in parallel; testnet: only loaded dexes)
1486
+ const validDexs = await this.getIterableHip3Dexs();
1487
+ const dexResults = await this.batchSettled(validDexs.map((dex) => async () => {
1488
+ const dexOrders = await this.info.openOrders({ user: user ?? this.address, dex: dex.name });
1489
+ return { dex, dexOrders };
1490
+ }));
1491
+ for (const result of dexResults) {
1492
+ if (result.status === 'rejected') {
1493
+ this.log('Failed to fetch open orders for HIP-3 dex:', result.reason instanceof Error ? result.reason.message : String(result.reason));
1494
+ continue;
1495
+ }
1496
+ const { dex, dexOrders } = result.value;
1497
+ if (dexOrders.length > 0) {
1498
+ this.log(`Found ${dexOrders.length} open orders on HIP-3 dex ${dex.name}`);
1499
+ orders.push(...dexOrders);
1500
+ }
1501
+ }
1502
+ return orders;
1503
+ }
1504
+ // ============ Trading ============
1505
+ /**
1506
+ * HIP-3 perps: prepare for trading.
1507
+ * 1. Set isolated margin mode (required for HIP-3)
1508
+ * 2. For standard accounts only: transfer USDC from main perp to HIP-3 dex
1509
+ * (unified accounts share USDC across all dexes automatically)
1510
+ */
1511
+ async ensureHip3Ready(coin, notional, leverage) {
1512
+ if (!this.isHip3(coin))
1513
+ return;
1514
+ const dexInfo = this.coinDexMap.get(coin);
1515
+ if (!dexInfo?.dexName)
1516
+ return;
1517
+ const maxLev = this.hip3MaxLeverageMap.get(coin) ?? 10;
1518
+ const effectiveLev = Math.min(leverage ?? maxLev, maxLev);
1519
+ // Set isolated margin on first order per asset, or when leverage changes
1520
+ if (!this.hip3IsolatedSet.has(coin) || leverage) {
1521
+ this.log(`HIP-3 asset ${coin} (dex: ${dexInfo.dexName}) — setting isolated margin at ${effectiveLev}x`);
1522
+ try {
1523
+ await this.updateLeverage(coin, effectiveLev, false); // false = isolated
1524
+ this.hip3IsolatedSet.add(coin);
1525
+ }
1526
+ catch (err) {
1527
+ this.log(`Failed to set isolated margin for ${coin}:`, err instanceof Error ? err.message : String(err));
1528
+ this.hip3IsolatedSet.add(coin);
1529
+ }
1530
+ }
1531
+ // Unified accounts share USDC across all dexes — no transfer needed
1532
+ const unified = await this.isUnifiedAccount();
1533
+ if (unified) {
1534
+ this.log(`Unified account — skipping USDC transfer for ${coin} (shared balance)`);
1535
+ return;
1536
+ }
1537
+ // Standard accounts: transfer USDC to the HIP-3 dex to cover margin
1538
+ const requiredMargin = notional / effectiveLev;
1539
+ // Add 20% buffer for fees and slippage
1540
+ const transferAmount = Math.ceil(requiredMargin * 1.2 * 100) / 100;
1541
+ this.log(`HIP-3 margin transfer: ${transferAmount} USDC from main → ${dexInfo.dexName} (notional: ${notional}, leverage: ${effectiveLev}x)`);
1542
+ try {
1543
+ await this.exchange.sendAsset({
1544
+ destination: this.address,
1545
+ sourceDex: '', // main perp dex
1546
+ destinationDex: dexInfo.dexName,
1547
+ token: 'USDC:0x6d1e7cde53ba9467b783cb7c530ce054',
1548
+ amount: String(transferAmount),
1549
+ }, this.vaultParam);
1550
+ this.log(`Transferred ${transferAmount} USDC to ${dexInfo.dexName} dex`);
1551
+ }
1552
+ catch (err) {
1553
+ // Log but don't block — dex may already have sufficient balance
1554
+ this.log(`Margin transfer to ${dexInfo.dexName} failed:`, err instanceof Error ? err.message : String(err));
1555
+ }
1556
+ }
1557
+ async order(coin, isBuy, size, price, orderType, reduceOnly = false, includeBuilder = true, leverage) {
1558
+ await this.requireTrading();
1559
+ await this.getMetaAndAssetCtxs();
1560
+ // Set leverage if specified (for main perps, cross margin; for HIP-3, handled in ensureHip3Ready)
1561
+ if (leverage && !this.isHip3(coin)) {
1562
+ this.log(`Setting leverage for ${coin} to ${leverage}x cross`);
1563
+ await this.updateLeverage(coin, leverage, true);
1564
+ }
1565
+ // HIP-3 perps: set isolated margin + transfer USDC to dex
1566
+ await this.ensureHip3Ready(coin, size * price, leverage);
1567
+ const assetIndex = this.getAssetIndex(coin);
1568
+ const szDecimals = this.getSzDecimals(coin);
1569
+ const orderWire = {
1570
+ a: assetIndex,
1571
+ b: isBuy,
1572
+ p: roundPrice(price, szDecimals),
1573
+ s: roundSize(size, szDecimals),
1574
+ r: reduceOnly,
1575
+ t: orderType,
1576
+ };
1577
+ this.log('Placing order:', JSON.stringify(orderWire, null, 2));
1578
+ const orderRequest = {
1579
+ orders: [orderWire],
1580
+ grouping: 'na',
1581
+ };
1582
+ // Add builder fee if configured (skip on testnet — builder may not be approved)
1583
+ if (includeBuilder && !this.isTestnet && this.config.builderAddress !== '0x0000000000000000000000000000000000000000') {
1584
+ orderRequest.builder = this.builderInfo;
1585
+ this.log('Including builder fee:', this.builderInfo);
1586
+ }
1587
+ try {
1588
+ const response = await this.exchange.order(orderRequest, this.vaultParam);
1589
+ this.log('Order response:', JSON.stringify(response, null, 2));
1590
+ return response;
1591
+ }
1592
+ catch (error) {
1593
+ this.log('Order error:', error);
1594
+ // Return error in our format
1595
+ return {
1596
+ status: 'err',
1597
+ response: error instanceof Error ? error.message : String(error),
1598
+ };
1599
+ }
1600
+ }
1601
+ async marketOrder(coin, isBuy, size, slippageBps, leverage) {
1602
+ await this.getMetaAndAssetCtxs();
1603
+ // Get current mid price
1604
+ const mids = await this.getAllMids();
1605
+ const midPrice = parseFloat(mids[coin]);
1606
+ if (!midPrice) {
1607
+ throw new Error(`No mid price for ${coin}. Check if the asset exists.`);
1608
+ }
1609
+ // Calculate slippage price
1610
+ const slippage = (slippageBps ?? this.config.slippageBps) / 10000;
1611
+ const limitPrice = isBuy
1612
+ ? midPrice * (1 + slippage)
1613
+ : midPrice * (1 - slippage);
1614
+ this.log(`Market order: ${coin} ${isBuy ? 'BUY' : 'SELL'} ${size} @ ${limitPrice} (mid: ${midPrice}, slippage: ${slippage * 100}%)`);
1615
+ return this.order(coin, isBuy, size, limitPrice, { limit: { tif: 'Ioc' } }, false, true, leverage);
1616
+ }
1617
+ async limitOrder(coin, isBuy, size, price, tif = 'Gtc', reduceOnly = false, leverage) {
1618
+ return this.order(coin, isBuy, size, price, { limit: { tif } }, reduceOnly, true, leverage);
1619
+ }
1620
+ /**
1621
+ * Place a trigger order (stop loss or take profit)
1622
+ * @param coin - Asset to trade
1623
+ * @param isBuy - True for buy, false for sell
1624
+ * @param size - Order size
1625
+ * @param triggerPrice - Price at which the order triggers
1626
+ * @param limitPrice - Limit price for the order (use triggerPrice for market-like execution)
1627
+ * @param tpsl - 'tp' for take profit, 'sl' for stop loss
1628
+ * @param reduceOnly - Whether order is reduce-only (should be true for TP/SL)
1629
+ */
1630
+ async triggerOrder(coin, isBuy, size, triggerPrice, limitPrice, tpsl, reduceOnly = true, leverage) {
1631
+ await this.requireTrading();
1632
+ await this.getMetaAndAssetCtxs();
1633
+ // Set leverage if specified (for main perps)
1634
+ if (leverage && !this.isHip3(coin)) {
1635
+ this.log(`Setting leverage for ${coin} to ${leverage}x cross`);
1636
+ await this.updateLeverage(coin, leverage, true);
1637
+ }
1638
+ // HIP-3 perps: set isolated margin + transfer USDC to dex
1639
+ await this.ensureHip3Ready(coin, size * limitPrice, leverage);
1640
+ const assetIndex = this.getAssetIndex(coin);
1641
+ const szDecimals = this.getSzDecimals(coin);
1642
+ // For trigger orders, we use the trigger order type
1643
+ // isMarket: false means it becomes a limit order at limitPrice when triggered
1644
+ // For stop loss, we typically want some slippage protection
1645
+ const orderWire = {
1646
+ a: assetIndex,
1647
+ b: isBuy,
1648
+ p: roundPrice(limitPrice, szDecimals),
1649
+ s: roundSize(size, szDecimals),
1650
+ r: reduceOnly,
1651
+ t: {
1652
+ trigger: {
1653
+ triggerPx: roundPrice(triggerPrice, szDecimals),
1654
+ isMarket: false,
1655
+ tpsl,
1656
+ },
1657
+ },
1658
+ };
1659
+ this.log('Placing trigger order:', JSON.stringify(orderWire, null, 2));
1660
+ const orderRequest = {
1661
+ orders: [orderWire],
1662
+ grouping: 'na',
1663
+ };
1664
+ // Add builder fee if configured (skip on testnet — builder may not be approved)
1665
+ if (!this.isTestnet && this.config.builderAddress !== '0x0000000000000000000000000000000000000000') {
1666
+ orderRequest.builder = this.builderInfo;
1667
+ this.log('Including builder fee:', this.builderInfo);
1668
+ }
1669
+ try {
1670
+ const response = await this.exchange.order(orderRequest, this.vaultParam);
1671
+ this.log('Trigger order response:', JSON.stringify(response, null, 2));
1672
+ return response;
1673
+ }
1674
+ catch (error) {
1675
+ this.log('Trigger order error:', error);
1676
+ return {
1677
+ status: 'err',
1678
+ response: error instanceof Error ? error.message : String(error),
1679
+ };
1680
+ }
1681
+ }
1682
+ /**
1683
+ * Place a stop loss order
1684
+ */
1685
+ async stopLoss(coin, isBuy, size, triggerPrice, slippageBps = 100 // 1% slippage for SL execution
1686
+ ) {
1687
+ // For stop loss, limit price should be worse than trigger to ensure fill
1688
+ // Buy SL: limit above trigger, Sell SL: limit below trigger
1689
+ const slippageMult = slippageBps / 10000;
1690
+ const limitPrice = isBuy
1691
+ ? triggerPrice * (1 + slippageMult)
1692
+ : triggerPrice * (1 - slippageMult);
1693
+ return this.triggerOrder(coin, isBuy, size, triggerPrice, limitPrice, 'sl', true);
1694
+ }
1695
+ /**
1696
+ * Place a take profit order
1697
+ */
1698
+ async takeProfit(coin, isBuy, size, triggerPrice) {
1699
+ // For take profit, we can use the same price as trigger (it's a favorable price)
1700
+ return this.triggerOrder(coin, isBuy, size, triggerPrice, triggerPrice, 'tp', true);
1701
+ }
1702
+ async cancel(coin, oid) {
1703
+ await this.requireTrading();
1704
+ await this.getMetaAndAssetCtxs();
1705
+ const assetIndex = this.getAssetIndex(coin);
1706
+ this.log(`Cancelling order: ${coin} (asset ${assetIndex}) oid ${oid}`);
1707
+ try {
1708
+ const response = await this.exchange.cancel({
1709
+ cancels: [{ a: assetIndex, o: oid }],
1710
+ }, this.vaultParam);
1711
+ this.log('Cancel response:', JSON.stringify(response, null, 2));
1712
+ return response;
1713
+ }
1714
+ catch (error) {
1715
+ this.log('Cancel error:', error);
1716
+ return {
1717
+ status: 'err',
1718
+ response: { type: 'cancel', data: { statuses: [error instanceof Error ? error.message : String(error)] } },
1719
+ };
1720
+ }
1721
+ }
1722
+ async cancelAll(coin) {
1723
+ const orders = await this.getOpenOrders();
1724
+ const results = [];
1725
+ for (const order of orders) {
1726
+ if (coin && order.coin !== coin)
1727
+ continue;
1728
+ const result = await this.cancel(order.coin, order.oid);
1729
+ results.push(result);
1730
+ }
1731
+ return results;
1732
+ }
1733
+ // ============ Spot Trading ============
1734
+ /**
1735
+ * Place a spot order.
1736
+ * Uses the same exchange.order() endpoint but with spot asset indices (10000 + spotIndex).
1737
+ * Spot orders have no leverage, no reduce-only, and builder fee max is 1000 (vs 100 for perps).
1738
+ *
1739
+ * @param coin - Base token symbol (e.g. "PURR", "HYPE")
1740
+ * @param isBuy - True to buy base token, false to sell
1741
+ * @param size - Size in base token units
1742
+ * @param price - Limit price in quote token (usually USDC)
1743
+ * @param orderType - Order type with time-in-force
1744
+ * @param includeBuilder - Whether to include builder fee (default: true)
1745
+ */
1746
+ async spotOrder(coin, isBuy, size, price, orderType, includeBuilder = true) {
1747
+ await this.requireTrading();
1748
+ await this.loadSpotMeta();
1749
+ const assetIndex = this.spotAssetMap.get(coin);
1750
+ if (assetIndex === undefined) {
1751
+ throw new Error(`Unknown spot asset: ${coin}. Available: ${Array.from(this.spotAssetMap.keys()).slice(0, 15).join(', ')}...\n` +
1752
+ `Use "openbroker spot" to see all spot markets.`);
1753
+ }
1754
+ const szDecimals = this.spotSzDecimalsMap.get(coin);
1755
+ const orderWire = {
1756
+ a: assetIndex,
1757
+ b: isBuy,
1758
+ p: roundPrice(price, szDecimals, true),
1759
+ s: roundSize(size, szDecimals),
1760
+ r: false, // reduce-only not applicable for spot
1761
+ t: orderType,
1762
+ };
1763
+ this.log('Placing spot order:', JSON.stringify(orderWire, null, 2));
1764
+ const orderRequest = {
1765
+ orders: [orderWire],
1766
+ grouping: 'na',
1767
+ };
1768
+ // Add builder fee if configured (skip on testnet — builder may not be approved)
1769
+ if (includeBuilder && !this.isTestnet && this.config.builderAddress !== '0x0000000000000000000000000000000000000000') {
1770
+ orderRequest.builder = this.builderInfo;
1771
+ this.log('Including builder fee:', this.builderInfo);
1772
+ }
1773
+ try {
1774
+ const response = await this.exchange.order(orderRequest, this.vaultParam);
1775
+ this.log('Spot order response:', JSON.stringify(response, null, 2));
1776
+ return response;
1777
+ }
1778
+ catch (error) {
1779
+ this.log('Spot order error:', error);
1780
+ return {
1781
+ status: 'err',
1782
+ response: error instanceof Error ? error.message : String(error),
1783
+ };
1784
+ }
1785
+ }
1786
+ /**
1787
+ * Place a spot market order (IOC at slippage price).
1788
+ * @param coin - Base token symbol (e.g. "PURR", "HYPE")
1789
+ * @param isBuy - True to buy, false to sell
1790
+ * @param size - Size in base token units
1791
+ * @param slippageBps - Slippage tolerance in basis points (default: config value)
1792
+ */
1793
+ async spotMarketOrder(coin, isBuy, size, slippageBps) {
1794
+ await this.loadSpotMeta();
1795
+ const assetIndex = this.spotAssetMap.get(coin);
1796
+ const spotCoinKey = this.spotPairNameMap.get(coin);
1797
+ if (assetIndex === undefined || !spotCoinKey) {
1798
+ throw new Error(`Unknown spot asset: ${coin}. Use "openbroker spot" to see available markets.`);
1799
+ }
1800
+ // Use the exact spot market key from spotMeta (e.g. "@230", "PURR/USDC").
1801
+ // On testnet the tradable asset id and displayed market key can diverge.
1802
+ const mids = await this.getAllMids();
1803
+ // Record<string, string> lookup returns `string` under TS defaults but
1804
+ // is runtime-undefined when the key is absent — hence the explicit union.
1805
+ let midStr = mids[spotCoinKey];
1806
+ // Fallback: allMids may omit spot pairs (especially on testnet).
1807
+ // Try spotMetaAndAssetCtxs which returns markPx directly.
1808
+ if (!midStr) {
1809
+ this.log(`allMids missing spot key "${spotCoinKey}", falling back to spotMetaAndAssetCtxs`);
1810
+ try {
1811
+ const spotData = await this.getSpotMetaAndAssetCtxs();
1812
+ const ctxMap = new Map();
1813
+ for (const ctx of spotData.assetCtxs) {
1814
+ if (ctx.coin)
1815
+ ctxMap.set(ctx.coin, ctx.midPx || ctx.markPx);
1816
+ }
1817
+ midStr = ctxMap.get(spotCoinKey);
1818
+ }
1819
+ catch (e) {
1820
+ this.log(`spotMetaAndAssetCtxs fallback failed:`, e);
1821
+ }
1822
+ }
1823
+ const midPrice = midStr ? parseFloat(midStr) : 0;
1824
+ if (!midPrice || midPrice === 0) {
1825
+ throw new Error(`No spot price for ${coin} (${spotCoinKey}). Check if the spot market exists with "openbroker spot --coin ${coin}".`);
1826
+ }
1827
+ // Calculate slippage price
1828
+ const slippage = (slippageBps ?? this.config.slippageBps) / 10000;
1829
+ const limitPrice = isBuy
1830
+ ? midPrice * (1 + slippage)
1831
+ : midPrice * (1 - slippage);
1832
+ this.log(`Spot market order: ${coin} ${isBuy ? 'BUY' : 'SELL'} ${size} @ ${limitPrice} (mid: ${midPrice}, slippage: ${slippage * 100}%)`);
1833
+ return this.spotOrder(coin, isBuy, size, limitPrice, { limit: { tif: 'Ioc' } });
1834
+ }
1835
+ /**
1836
+ * Place a spot limit order.
1837
+ * @param coin - Base token symbol (e.g. "PURR", "HYPE")
1838
+ * @param isBuy - True to buy, false to sell
1839
+ * @param size - Size in base token units
1840
+ * @param price - Limit price in quote token (usually USDC)
1841
+ * @param tif - Time-in-force (default: Gtc)
1842
+ */
1843
+ async spotLimitOrder(coin, isBuy, size, price, tif = 'Gtc') {
1844
+ return this.spotOrder(coin, isBuy, size, price, { limit: { tif } });
1845
+ }
1846
+ /**
1847
+ * Place a HIP-4 outcome order.
1848
+ * Outcome assets are spot-like, but encoded as:
1849
+ * encoding = 10 * outcome + side
1850
+ * assetId = 100_000_000 + encoding
1851
+ * coin = #<encoding>
1852
+ *
1853
+ * Side 0 is usually YES and side 1 is usually NO, per outcomeMeta.sideSpecs.
1854
+ */
1855
+ async outcomeOrder(outcomeRef, outcomeSide, isBuy, size, price, orderType, includeBuilder = true, szDecimalsOverride) {
1856
+ await this.requireTrading();
1857
+ const resolved = this.resolveOutcomeRef(outcomeRef, outcomeSide);
1858
+ const szDecimals = szDecimalsOverride ?? await this.getOutcomeSzDecimals(resolved.outcome, resolved.side);
1859
+ const orderWire = {
1860
+ a: resolved.assetId,
1861
+ b: isBuy,
1862
+ p: roundPrice(price, szDecimals, true),
1863
+ s: roundSize(size, szDecimals),
1864
+ r: false,
1865
+ t: orderType,
1866
+ };
1867
+ this.log('Placing outcome order:', JSON.stringify({ resolved, orderWire }, null, 2));
1868
+ const orderRequest = {
1869
+ orders: [orderWire],
1870
+ grouping: 'na',
1871
+ };
1872
+ if (includeBuilder && !this.isTestnet && this.config.builderAddress !== '0x0000000000000000000000000000000000000000') {
1873
+ orderRequest.builder = this.builderInfo;
1874
+ this.log('Including builder fee:', this.builderInfo);
1875
+ }
1876
+ try {
1877
+ const response = await this.exchange.order(orderRequest, this.vaultParam);
1878
+ this.log('Outcome order response:', JSON.stringify(response, null, 2));
1879
+ return response;
1880
+ }
1881
+ catch (error) {
1882
+ this.log('Outcome order error:', error);
1883
+ return {
1884
+ status: 'err',
1885
+ response: error instanceof Error ? error.message : String(error),
1886
+ };
1887
+ }
1888
+ }
1889
+ async outcomeMarketOrder(outcomeRef, outcomeSide, isBuy, size, slippageBps, szDecimalsOverride) {
1890
+ const resolved = this.resolveOutcomeRef(outcomeRef, outcomeSide);
1891
+ const midPrice = await this.getOutcomeMidPrice(resolved.outcome, resolved.side);
1892
+ const slippage = (slippageBps ?? this.config.slippageBps) / 10000;
1893
+ const limitPrice = isBuy
1894
+ ? midPrice * (1 + slippage)
1895
+ : midPrice * (1 - slippage);
1896
+ this.log(`Outcome market order: ${resolved.coin} ${isBuy ? 'BUY' : 'SELL'} ${size} @ ${limitPrice} (mid: ${midPrice})`);
1897
+ return this.outcomeOrder(outcomeRef, outcomeSide, isBuy, size, limitPrice, { limit: { tif: 'Ioc' } }, true, szDecimalsOverride);
1898
+ }
1899
+ async outcomeLimitOrder(outcomeRef, outcomeSide, isBuy, size, price, tif = 'Gtc', szDecimalsOverride) {
1900
+ return this.outcomeOrder(outcomeRef, outcomeSide, isBuy, size, price, { limit: { tif } }, true, szDecimalsOverride);
1901
+ }
1902
+ /**
1903
+ * Cancel a spot order by coin and order ID.
1904
+ */
1905
+ async spotCancel(coin, oid) {
1906
+ await this.requireTrading();
1907
+ await this.loadSpotMeta();
1908
+ const assetIndex = this.spotAssetMap.get(coin);
1909
+ if (assetIndex === undefined) {
1910
+ throw new Error(`Unknown spot asset: ${coin}`);
1911
+ }
1912
+ this.log(`Cancelling spot order: ${coin} (asset ${assetIndex}) oid ${oid}`);
1913
+ try {
1914
+ const response = await this.exchange.cancel({
1915
+ cancels: [{ a: assetIndex, o: oid }],
1916
+ }, this.vaultParam);
1917
+ this.log('Spot cancel response:', JSON.stringify(response, null, 2));
1918
+ return response;
1919
+ }
1920
+ catch (error) {
1921
+ this.log('Spot cancel error:', error);
1922
+ return {
1923
+ status: 'err',
1924
+ response: { type: 'cancel', data: { statuses: [error instanceof Error ? error.message : String(error)] } },
1925
+ };
1926
+ }
1927
+ }
1928
+ // ============ Leverage ============
1929
+ async updateLeverage(coin, leverage, isCross = true) {
1930
+ await this.requireTrading();
1931
+ await this.getMetaAndAssetCtxs();
1932
+ // HIP-3 perps only support isolated margin — override isCross and clamp leverage
1933
+ if (this.isHip3(coin)) {
1934
+ if (isCross) {
1935
+ this.log(`HIP-3 asset ${coin} does not support cross margin — forcing isolated`);
1936
+ }
1937
+ isCross = false;
1938
+ const maxLev = this.hip3MaxLeverageMap.get(coin) ?? 10;
1939
+ if (leverage > maxLev) {
1940
+ this.log(`HIP-3 asset ${coin} max leverage is ${maxLev}x — clamping from ${leverage}x`);
1941
+ leverage = maxLev;
1942
+ }
1943
+ }
1944
+ const assetIndex = this.getAssetIndex(coin);
1945
+ this.log(`Updating leverage: ${coin} (asset ${assetIndex}) to ${leverage}x ${isCross ? 'cross' : 'isolated'}`);
1946
+ try {
1947
+ const response = await this.exchange.updateLeverage({
1948
+ asset: assetIndex,
1949
+ isCross,
1950
+ leverage,
1951
+ }, this.vaultParam);
1952
+ this.log('Leverage response:', JSON.stringify(response, null, 2));
1953
+ return response;
1954
+ }
1955
+ catch (error) {
1956
+ this.log('Leverage error:', error);
1957
+ throw error;
1958
+ }
1959
+ }
1960
+ /**
1961
+ * Place a native Hyperliquid TWAP order.
1962
+ * The exchange handles slicing and timing server-side.
1963
+ * @param coin Asset symbol (e.g. "ETH")
1964
+ * @param isBuy true for long, false for short
1965
+ * @param size Total size in base currency
1966
+ * @param durationMinutes Duration in minutes (5–1440)
1967
+ * @param randomize Enable random order timing
1968
+ * @param reduceOnly Reduce-only flag
1969
+ * @param leverage Optional leverage to set before placing the TWAP
1970
+ */
1971
+ async twapOrder(coin, isBuy, size, durationMinutes, randomize = true, reduceOnly = false, leverage) {
1972
+ await this.getMetaAndAssetCtxs();
1973
+ if (leverage) {
1974
+ await this.updateLeverage(coin, leverage);
1975
+ }
1976
+ const assetIndex = this.getAssetIndex(coin);
1977
+ const roundedSize = roundSize(size, this.getSzDecimals(coin));
1978
+ this.log(`TWAP order: ${coin} (asset ${assetIndex}) ${isBuy ? 'BUY' : 'SELL'} ${roundedSize} over ${durationMinutes}m, randomize=${randomize}, reduceOnly=${reduceOnly}`);
1979
+ try {
1980
+ const response = await this.exchange.twapOrder({
1981
+ twap: {
1982
+ a: assetIndex,
1983
+ b: isBuy,
1984
+ s: String(roundedSize),
1985
+ r: reduceOnly,
1986
+ m: durationMinutes,
1987
+ t: randomize,
1988
+ },
1989
+ }, this.vaultParam);
1990
+ this.log('TWAP order response:', JSON.stringify(response, null, 2));
1991
+ return response;
1992
+ }
1993
+ catch (error) {
1994
+ this.log('TWAP order error:', error);
1995
+ throw error;
1996
+ }
1997
+ }
1998
+ /**
1999
+ * Cancel a running TWAP order.
2000
+ * @param coin Asset symbol (e.g. "ETH")
2001
+ * @param twapId The TWAP order ID to cancel
2002
+ */
2003
+ async twapCancel(coin, twapId) {
2004
+ await this.getMetaAndAssetCtxs();
2005
+ const assetIndex = this.getAssetIndex(coin);
2006
+ this.log(`TWAP cancel: ${coin} (asset ${assetIndex}) twapId=${twapId}`);
2007
+ try {
2008
+ const response = await this.exchange.twapCancel({
2009
+ a: assetIndex,
2010
+ t: twapId,
2011
+ }, this.vaultParam);
2012
+ this.log('TWAP cancel response:', JSON.stringify(response, null, 2));
2013
+ return response;
2014
+ }
2015
+ catch (error) {
2016
+ this.log('TWAP cancel error:', error);
2017
+ throw error;
2018
+ }
2019
+ }
2020
+ /**
2021
+ * Get TWAP order history for the current user.
2022
+ */
2023
+ async twapHistory() {
2024
+ const response = await this.info.twapHistory({ user: this.address });
2025
+ this.log('TWAP history:', JSON.stringify(response, null, 2));
2026
+ return response;
2027
+ }
2028
+ }
2029
+ // Singleton instance
2030
+ let clientInstance = null;
2031
+ export function getClient(config) {
2032
+ if (!clientInstance) {
2033
+ clientInstance = new HyperliquidClient(config);
2034
+ }
2035
+ return clientInstance;
2036
+ }
2037
+ // Reset client (useful for testing)
2038
+ export function resetClient() {
2039
+ clientInstance = null;
2040
+ }