openbroker 1.0.80 → 1.0.85

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.
@@ -31,6 +31,10 @@ export class HyperliquidClient {
31
31
  private perpDexsCache: Array<{ name: string; fullName: string; deployer: string } | null> | null = null;
32
32
  /** Whether HIP-3 assets have been loaded into maps */
33
33
  private hip3Loaded: boolean = false;
34
+ /** Whether API wallet setup has been validated */
35
+ private apiWalletValidated: boolean = false;
36
+ /** Set of HIP-3 dex names that have been loaded (for testnet on-demand loading) */
37
+ private loadedHip3Dexes: Set<string> = new Set();
34
38
  /** HIP-3 assets that have had isolated margin set this session */
35
39
  private hip3IsolatedSet: Set<string> = new Set();
36
40
  /** Cached maxLeverage for HIP-3 assets */
@@ -39,6 +43,8 @@ export class HyperliquidClient {
39
43
  private accountMode: string | null = null;
40
44
  /** Spot asset index map: coin name → 10000 + spotMeta.universe[i].index */
41
45
  private spotAssetMap: Map<string, number> = new Map();
46
+ /** Spot market key map: coin name → pair.name (e.g. "@230", "PURR/USDC") */
47
+ private spotPairNameMap: Map<string, string> = new Map();
42
48
  /** Spot szDecimals map: coin name → base token szDecimals */
43
49
  private spotSzDecimalsMap: Map<string, number> = new Map();
44
50
  /** Whether spot metadata has been loaded */
@@ -51,13 +57,24 @@ export class HyperliquidClient {
51
57
  this.verbose = process.env.VERBOSE === '1' || process.env.VERBOSE === 'true';
52
58
 
53
59
  // Initialize SDK clients
54
- this.transport = new HttpTransport({ isMainnet: isMainnet() });
60
+ this.transport = new HttpTransport({ isTestnet: !isMainnet() });
55
61
  this.info = new InfoClient({ transport: this.transport });
56
62
  this.exchange = new ExchangeClient({
57
63
  transport: this.transport,
58
64
  wallet: this.account,
59
- isMainnet: isMainnet(),
60
65
  });
66
+
67
+ this.log(
68
+ 'Client init:',
69
+ JSON.stringify({
70
+ network: isMainnet() ? 'mainnet' : 'testnet',
71
+ apiUrl: this.config.baseUrl,
72
+ accountAddress: this.config.accountAddress,
73
+ walletAddress: this.config.walletAddress,
74
+ isApiWallet: this.config.isApiWallet,
75
+ isReadOnly: this.config.isReadOnly,
76
+ })
77
+ );
61
78
  }
62
79
 
63
80
  private log(...args: unknown[]) {
@@ -66,6 +83,95 @@ export class HyperliquidClient {
66
83
  }
67
84
  }
68
85
 
86
+ private describeError(error: unknown): string {
87
+ if (!(error instanceof Error)) return String(error);
88
+
89
+ const response = (error as Error & { response?: Response }).response;
90
+ const body = (error as Error & { body?: string }).body;
91
+ const cause = (error as Error & { cause?: unknown }).cause;
92
+ const parts = [error.message];
93
+
94
+ if (response) {
95
+ parts.push(`status=${response.status} ${response.statusText}`.trim());
96
+ }
97
+
98
+ if (body) {
99
+ parts.push(`body=${body.length > 300 ? `${body.slice(0, 300)}...` : body}`);
100
+ }
101
+
102
+ if (cause instanceof Error && cause.message && cause.message !== error.message) {
103
+ parts.push(`cause=${cause.message}`);
104
+ } else if (cause && !(cause instanceof Error)) {
105
+ parts.push(`cause=${String(cause)}`);
106
+ }
107
+
108
+ return parts.join(' | ');
109
+ }
110
+
111
+ /** Retry an async operation on transient failures (fetch failed, ECONNRESET, etc.) */
112
+ private async withRetry<T>(fn: () => Promise<T>, label: string, maxRetries = 3): Promise<T> {
113
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
114
+ try {
115
+ return await fn();
116
+ } catch (error) {
117
+ const message = error instanceof Error ? error.message : String(error);
118
+ const isTransient = /fetch failed|ECONNRESET|ETIMEDOUT|ENOTFOUND|socket hang up/i.test(message);
119
+ if (!isTransient || attempt === maxRetries) throw error;
120
+ const delay = attempt * 1000; // 1s, 2s, 3s
121
+ this.log(`${label} attempt ${attempt}/${maxRetries} failed (${message}), retrying in ${delay}ms...`);
122
+ await new Promise(r => setTimeout(r, delay));
123
+ }
124
+ }
125
+ throw new Error('unreachable');
126
+ }
127
+
128
+ private getTransportContext(label: string): string {
129
+ return JSON.stringify({
130
+ label,
131
+ network: isMainnet() ? 'mainnet' : 'testnet',
132
+ apiUrl: this.config.baseUrl,
133
+ accountAddress: this.config.accountAddress,
134
+ walletAddress: this.config.walletAddress,
135
+ isApiWallet: this.config.isApiWallet,
136
+ isReadOnly: this.config.isReadOnly,
137
+ verbose: this.verbose,
138
+ });
139
+ }
140
+
141
+ private async postInfo<T>(payload: Record<string, unknown>, label: string): Promise<T> {
142
+ const baseUrl = isMainnet()
143
+ ? 'https://api.hyperliquid.xyz'
144
+ : 'https://api.hyperliquid-testnet.xyz';
145
+
146
+ const response = await this.withRetry(async () => {
147
+ try {
148
+ return await fetch(baseUrl + '/info', {
149
+ method: 'POST',
150
+ headers: { 'Content-Type': 'application/json' },
151
+ body: JSON.stringify(payload),
152
+ });
153
+ } catch (error) {
154
+ const message = error instanceof Error ? error.message : String(error);
155
+ throw new Error(`${label} request failed before response: ${message}`);
156
+ }
157
+ }, label);
158
+
159
+ const text = await response.text();
160
+ if (!response.ok) {
161
+ const snippet = text.length > 300 ? `${text.slice(0, 300)}...` : text;
162
+ throw new Error(
163
+ `${label} failed: HTTP ${response.status} ${response.statusText}${snippet ? ` | body=${snippet}` : ''}`
164
+ );
165
+ }
166
+
167
+ try {
168
+ return JSON.parse(text) as T;
169
+ } catch (error) {
170
+ const message = error instanceof Error ? error.message : String(error);
171
+ throw new Error(`${label} returned invalid JSON: ${message}`);
172
+ }
173
+ }
174
+
69
175
  /** The address we're trading on behalf of (may be different from wallet if using API wallet) */
70
176
  get address(): string {
71
177
  return this.config.accountAddress;
@@ -101,13 +207,35 @@ export class HyperliquidClient {
101
207
  return this.config.isReadOnly;
102
208
  }
103
209
 
104
- /** Throw error if trying to trade in read-only mode */
105
- private requireTrading(): void {
210
+ /** Whether connected to testnet (HIP-3 dexes not auto-loaded) */
211
+ get isTestnet(): boolean {
212
+ return !isMainnet();
213
+ }
214
+
215
+ /**
216
+ * Returns vaultAddress param for SDK exchange calls.
217
+ * Only used for vault trading (HYPERLIQUID_VAULT_ADDRESS set explicitly).
218
+ * Standard API wallets (agents) do NOT need this — the API maps agent → master automatically.
219
+ */
220
+ private get vaultParam(): { vaultAddress: `0x${string}` } | Record<string, never> {
221
+ if (this.config.vaultAddress) {
222
+ return { vaultAddress: this.config.vaultAddress as `0x${string}` };
223
+ }
224
+ return {};
225
+ }
226
+
227
+ /** Throw error if trying to trade in read-only mode. Validates API wallet on first call. */
228
+ private async requireTrading(): Promise<void> {
106
229
  if (this.config.isReadOnly) {
107
230
  throw new Error(
108
231
  'Trading not available. Run "openbroker setup" to configure your wallet.'
109
232
  );
110
233
  }
234
+ // One-time API wallet validation on first trade attempt
235
+ if (this.config.isApiWallet && !this.apiWalletValidated) {
236
+ this.apiWalletValidated = true;
237
+ await this.validateApiWalletSetup();
238
+ }
111
239
  }
112
240
 
113
241
  // ============ Market Data ============
@@ -116,7 +244,16 @@ export class HyperliquidClient {
116
244
  if (this.meta) return this.meta;
117
245
 
118
246
  this.log('Fetching metaAndAssetCtxs...');
119
- const response = await this.info.metaAndAssetCtxs();
247
+ let response;
248
+ try {
249
+ response = await this.withRetry(() => this.info.metaAndAssetCtxs(), 'metaAndAssetCtxs');
250
+ } catch (error) {
251
+ this.log('metaAndAssetCtxs failure context:', this.getTransportContext('metaAndAssetCtxs'));
252
+ if (error instanceof Error && error.stack) {
253
+ this.log('metaAndAssetCtxs stack:', error.stack);
254
+ }
255
+ throw new Error(`metaAndAssetCtxs failed: ${this.describeError(error)}`);
256
+ }
120
257
  this.log('metaAndAssetCtxs response:', JSON.stringify(response, null, 2).slice(0, 500) + '...');
121
258
 
122
259
  this.meta = {
@@ -145,80 +282,166 @@ export class HyperliquidClient {
145
282
  * Asset index formula: 100000 + dexIdx * 10000 + assetIdx
146
283
  * Coins are keyed as "dexName:COIN" (e.g., "xyz:CL")
147
284
  */
285
+ /** Max concurrent HIP-3 API requests to avoid rate limiting */
286
+ private static readonly HIP3_CONCURRENCY = 5;
287
+
288
+ /**
289
+ * Like Promise.allSettled but with a concurrency limit.
290
+ * Processes tasks in batches to avoid hitting API rate limits.
291
+ */
292
+ private async batchSettled<T>(tasks: Array<() => Promise<T>>): Promise<PromiseSettledResult<T>[]> {
293
+ const results: PromiseSettledResult<T>[] = [];
294
+ const concurrency = HyperliquidClient.HIP3_CONCURRENCY;
295
+ for (let i = 0; i < tasks.length; i += concurrency) {
296
+ const batch = tasks.slice(i, i + concurrency);
297
+ const batchResults = await Promise.allSettled(batch.map(fn => fn()));
298
+ results.push(...batchResults);
299
+ }
300
+ return results;
301
+ }
302
+
303
+ /**
304
+ * Load HIP-3 perp dex assets into the asset/szDecimals maps.
305
+ * On testnet: skips auto-loading (too many junk dexes). Use loadSingleHip3Dex() on demand.
306
+ * On mainnet: loads all dexes with concurrency limit.
307
+ */
148
308
  private async loadHip3Assets(): Promise<void> {
149
309
  try {
150
310
  const dexs = await this.getPerpDexs();
151
- const baseUrl = isMainnet()
152
- ? 'https://api.hyperliquid.xyz'
153
- : 'https://api.hyperliquid-testnet.xyz';
154
311
 
312
+ // On testnet, skip auto-loading — too many junk dexes cause rate limiting.
313
+ // Users can reference specific dexes (e.g., "felix:BTC") which triggers on-demand loading.
314
+ if (!isMainnet()) {
315
+ const dexCount = dexs.filter(d => d != null).length - 1; // exclude null at index 0
316
+ if (dexCount > 0) {
317
+ this.log(`Testnet: skipping auto-load of ${dexCount} HIP-3 dexes. Use "dexName:COIN" to load a specific dex on demand.`);
318
+ }
319
+ return;
320
+ }
321
+
322
+ // Mainnet: load all dexes
323
+ const dexEntries: Array<{ dex: { name: string }; dexIdx: number }> = [];
155
324
  for (let dexIdx = 1; dexIdx < dexs.length; dexIdx++) {
156
325
  const dex = dexs[dexIdx];
157
- if (!dex) continue;
158
-
159
- try {
160
- const dexResponse = await fetch(baseUrl + '/info', {
161
- method: 'POST',
162
- headers: { 'Content-Type': 'application/json' },
163
- body: JSON.stringify({ type: 'metaAndAssetCtxs', dex: dex.name }),
164
- });
165
- const dexData = await dexResponse.json();
166
-
167
- if (dexData && dexData[0]?.universe) {
168
- const universe = dexData[0].universe as Array<{ name: string; szDecimals: number; maxLeverage: number; onlyIsolated?: boolean }>;
169
- this.log(`Loading HIP-3 dex: ${dex.name} with ${universe.length} markets`);
170
-
171
- universe.forEach((asset, assetIdx) => {
172
- // API returns names already prefixed (e.g., "xyz:CL"), use as-is
173
- const coinName = asset.name;
174
- // Extract local name by stripping dex prefix if present
175
- const localName = coinName.startsWith(dex.name + ':') ? coinName.slice(dex.name.length + 1) : coinName;
176
- const globalIndex = 100000 + dexIdx * 10000 + assetIdx;
177
-
178
- this.assetMap.set(coinName, globalIndex);
179
- this.szDecimalsMap.set(coinName, asset.szDecimals);
180
- this.coinDexMap.set(coinName, { dexName: dex.name, dexIdx, localName });
181
- if (asset.maxLeverage) this.hip3MaxLeverageMap.set(coinName, asset.maxLeverage);
182
- });
183
- }
184
- } catch (e) {
185
- this.log(`Failed to load HIP-3 dex ${dex.name}:`, e);
326
+ if (dex) dexEntries.push({ dex, dexIdx });
327
+ }
328
+
329
+ const results = await this.batchSettled(
330
+ dexEntries.map(({ dex, dexIdx }) => async () => {
331
+ const data = await this.postInfo<unknown>(
332
+ { type: 'metaAndAssetCtxs', dex: dex.name },
333
+ `metaAndAssetCtxs(${dex.name})`
334
+ );
335
+ return { dex, dexIdx, data };
336
+ })
337
+ );
338
+
339
+ for (const result of results) {
340
+ if (result.status === 'rejected') {
341
+ this.log(`Failed to load HIP-3 dex:`, result.reason);
342
+ continue;
186
343
  }
344
+ const { dex, dexIdx, data: dexData } = result.value;
345
+ this.registerHip3Dex(dex.name, dexIdx, dexData);
187
346
  }
188
347
  } catch (e) {
189
348
  this.log('Failed to load HIP-3 assets:', e);
190
349
  }
191
350
  }
192
351
 
352
+ /**
353
+ * Load a single HIP-3 dex by name (on-demand, e.g. when user references "felix:BTC").
354
+ * No-op if already loaded.
355
+ */
356
+ async loadSingleHip3Dex(dexName: string): Promise<boolean> {
357
+ if (this.loadedHip3Dexes.has(dexName)) return true;
358
+
359
+ const dexs = await this.getPerpDexs();
360
+ const dexIdx = dexs.findIndex(d => d?.name === dexName);
361
+ if (dexIdx < 1) {
362
+ this.log(`HIP-3 dex "${dexName}" not found in perpDexs list`);
363
+ return false;
364
+ }
365
+
366
+ try {
367
+ this.log(`On-demand loading HIP-3 dex: ${dexName}`);
368
+ const data = await this.postInfo<unknown>(
369
+ { type: 'metaAndAssetCtxs', dex: dexName },
370
+ `metaAndAssetCtxs(${dexName})`
371
+ );
372
+ this.registerHip3Dex(dexName, dexIdx, data);
373
+ return true;
374
+ } catch (e) {
375
+ this.log(`Failed to load HIP-3 dex ${dexName}:`, e);
376
+ return false;
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Get HIP-3 dexes to iterate over for bulk queries.
382
+ * Mainnet: all dexes. Testnet: only explicitly loaded dexes.
383
+ */
384
+ private async getIterableHip3Dexs(): Promise<Array<{ name: string; fullName: string; deployer: string }>> {
385
+ const dexs = await this.getPerpDexs();
386
+ const all = dexs.slice(1).filter((d): d is NonNullable<typeof d> => d != null);
387
+ if (isMainnet()) return all;
388
+ // Testnet: only return dexes that have been explicitly loaded
389
+ return all.filter(d => this.loadedHip3Dexes.has(d.name));
390
+ }
391
+
392
+ /** Register a fetched HIP-3 dex's assets into lookup maps */
393
+ private registerHip3Dex(dexName: string, dexIdx: number, dexData: any): void {
394
+ if (dexData && dexData[0]?.universe) {
395
+ const universe = dexData[0].universe as Array<{ name: string; szDecimals: number; maxLeverage: number; onlyIsolated?: boolean }>;
396
+ this.log(`Loading HIP-3 dex: ${dexName} with ${universe.length} markets`);
397
+
398
+ universe.forEach((asset, assetIdx) => {
399
+ const coinName = asset.name;
400
+ const localName = coinName.startsWith(dexName + ':') ? coinName.slice(dexName.length + 1) : coinName;
401
+ const globalIndex = 100000 + dexIdx * 10000 + assetIdx;
402
+
403
+ this.assetMap.set(coinName, globalIndex);
404
+ this.szDecimalsMap.set(coinName, asset.szDecimals);
405
+ this.coinDexMap.set(coinName, { dexName, dexIdx, localName });
406
+ if (asset.maxLeverage) this.hip3MaxLeverageMap.set(coinName, asset.maxLeverage);
407
+ });
408
+ }
409
+ this.loadedHip3Dexes.add(dexName);
410
+ }
411
+
193
412
  async getAllMids(): Promise<Record<string, string>> {
194
413
  this.log('Fetching allMids...');
195
- const response = await this.info.allMids() as Record<string, string>;
414
+ let response: Record<string, string>;
415
+ try {
416
+ response = await this.withRetry(() => this.info.allMids(), 'allMids') as Record<string, string>;
417
+ } catch (error) {
418
+ this.log('allMids failure context:', this.getTransportContext('allMids'));
419
+ if (error instanceof Error && error.stack) {
420
+ this.log('allMids stack:', error.stack);
421
+ }
422
+ throw new Error(`allMids failed: ${this.describeError(error)}`);
423
+ }
196
424
 
197
- // Also fetch HIP-3 dex mids
425
+ // Also fetch HIP-3 dex mids (in parallel; testnet: only loaded dexes)
198
426
  try {
199
- const dexs = await this.getPerpDexs();
200
- const baseUrl = isMainnet()
201
- ? 'https://api.hyperliquid.xyz'
202
- : 'https://api.hyperliquid-testnet.xyz';
427
+ const validDexs = await this.getIterableHip3Dexs();
428
+ const results = await this.batchSettled(
429
+ validDexs.map((dex) => async () => {
430
+ const mids = await this.postInfo<Record<string, string>>(
431
+ { type: 'allMids', dex: dex.name },
432
+ `allMids(${dex.name})`
433
+ );
434
+ return { dex, mids };
435
+ })
436
+ );
203
437
 
204
- for (let i = 1; i < dexs.length; i++) {
205
- const dex = dexs[i];
206
- if (!dex) continue;
207
-
208
- try {
209
- const dexResponse = await fetch(baseUrl + '/info', {
210
- method: 'POST',
211
- headers: { 'Content-Type': 'application/json' },
212
- body: JSON.stringify({ type: 'allMids', dex: dex.name }),
213
- });
214
- const dexMids = await dexResponse.json() as Record<string, string>;
215
-
216
- // Merge directly — API already returns prefixed keys (e.g., "xyz:CL")
217
- for (const [coin, mid] of Object.entries(dexMids)) {
218
- response[coin] = mid;
219
- }
220
- } catch (e) {
221
- this.log(`Failed to fetch mids for HIP-3 dex ${dex.name}:`, e);
438
+ for (const result of results) {
439
+ if (result.status === 'rejected') {
440
+ this.log('Failed to fetch HIP-3 dex mids:', result.reason);
441
+ continue;
442
+ }
443
+ for (const [coin, mid] of Object.entries(result.value.mids)) {
444
+ response[coin] = mid;
222
445
  }
223
446
  }
224
447
  } catch (e) {
@@ -240,16 +463,11 @@ export class HyperliquidClient {
240
463
  if (this.perpDexsCache) return this.perpDexsCache;
241
464
 
242
465
  this.log('Fetching perpDexs...');
243
- const baseUrl = isMainnet()
244
- ? 'https://api.hyperliquid.xyz'
245
- : 'https://api.hyperliquid-testnet.xyz';
246
-
247
- const response = await fetch(baseUrl + '/info', {
248
- method: 'POST',
249
- headers: { 'Content-Type': 'application/json' },
250
- body: JSON.stringify({ type: 'perpDexs' }),
251
- });
252
- const data = await response.json();
466
+ const data = await this.postInfo<Array<{
467
+ name: string;
468
+ fullName: string;
469
+ deployer: string;
470
+ } | null>>({ type: 'perpDexs' }, 'perpDexs');
253
471
  this.log('perpDexs response:', JSON.stringify(data).slice(0, 500));
254
472
  this.perpDexsCache = data;
255
473
  return data;
@@ -273,10 +491,6 @@ export class HyperliquidClient {
273
491
  }>;
274
492
  }>> {
275
493
  this.log('Fetching all perp markets...');
276
- const baseUrl = isMainnet()
277
- ? 'https://api.hyperliquid.xyz'
278
- : 'https://api.hyperliquid-testnet.xyz';
279
-
280
494
  const results: Array<{
281
495
  dexName: string | null;
282
496
  meta: { universe: Array<{ name: string; szDecimals: number; maxLeverage: number; onlyIsolated?: boolean }> };
@@ -292,12 +506,7 @@ export class HyperliquidClient {
292
506
  }> = [];
293
507
 
294
508
  // Get main dex data (no dex parameter)
295
- const mainResponse = await fetch(baseUrl + '/info', {
296
- method: 'POST',
297
- headers: { 'Content-Type': 'application/json' },
298
- body: JSON.stringify({ type: 'metaAndAssetCtxs' }),
299
- });
300
- const mainData = await mainResponse.json();
509
+ const mainData = await this.postInfo<any>({ type: 'metaAndAssetCtxs' }, 'metaAndAssetCtxs(main)');
301
510
  this.log('Main dex data fetched');
302
511
 
303
512
  results.push({
@@ -306,32 +515,32 @@ export class HyperliquidClient {
306
515
  assetCtxs: mainData[1],
307
516
  });
308
517
 
309
- // Get HIP-3 dex names
310
- const dexs = await this.getPerpDexs();
518
+ // Get HIP-3 dex names and fetch all in parallel (testnet: only loaded dexes)
519
+ const validDexs = await this.getIterableHip3Dexs();
311
520
 
312
- // Fetch each HIP-3 dex by name
313
- for (let i = 1; i < dexs.length; i++) {
314
- const dex = dexs[i];
315
- if (!dex) continue;
521
+ const hip3Results = await this.batchSettled(
522
+ validDexs.map((dex) => async () => {
523
+ const data = await this.postInfo<any>(
524
+ { type: 'metaAndAssetCtxs', dex: dex.name },
525
+ `metaAndAssetCtxs(${dex.name})`
526
+ );
527
+ return { dex, data };
528
+ })
529
+ );
316
530
 
317
- try {
318
- const dexResponse = await fetch(baseUrl + '/info', {
319
- method: 'POST',
320
- headers: { 'Content-Type': 'application/json' },
321
- body: JSON.stringify({ type: 'metaAndAssetCtxs', dex: dex.name }),
531
+ for (const result of hip3Results) {
532
+ if (result.status === 'rejected') {
533
+ this.log('Failed to fetch HIP-3 dex:', result.reason);
534
+ continue;
535
+ }
536
+ const { dex, data: dexData } = result.value;
537
+ if (dexData && dexData[0]?.universe) {
538
+ this.log(`Fetched HIP-3 dex: ${dex.name} with ${dexData[0].universe.length} markets`);
539
+ results.push({
540
+ dexName: dex.name,
541
+ meta: { universe: dexData[0].universe },
542
+ assetCtxs: dexData[1] || [],
322
543
  });
323
- const dexData = await dexResponse.json();
324
-
325
- if (dexData && dexData[0]?.universe) {
326
- this.log(`Fetched HIP-3 dex: ${dex.name} with ${dexData[0].universe.length} markets`);
327
- results.push({
328
- dexName: dex.name,
329
- meta: { universe: dexData[0].universe },
330
- assetCtxs: dexData[1] || [],
331
- });
332
- }
333
- } catch (e) {
334
- this.log(`Failed to fetch HIP-3 dex ${dex.name}:`, e);
335
544
  }
336
545
  }
337
546
 
@@ -359,16 +568,23 @@ export class HyperliquidClient {
359
568
  }>;
360
569
  }> {
361
570
  this.log('Fetching spotMeta...');
362
- const baseUrl = isMainnet()
363
- ? 'https://api.hyperliquid.xyz'
364
- : 'https://api.hyperliquid-testnet.xyz';
365
-
366
- const response = await fetch(baseUrl + '/info', {
367
- method: 'POST',
368
- headers: { 'Content-Type': 'application/json' },
369
- body: JSON.stringify({ type: 'spotMeta' }),
370
- });
371
- const data = await response.json();
571
+ const data = await this.postInfo<{
572
+ tokens: Array<{
573
+ name: string;
574
+ szDecimals: number;
575
+ weiDecimals: number;
576
+ index: number;
577
+ tokenId: string;
578
+ isCanonical: boolean;
579
+ fullName: string | null;
580
+ }>;
581
+ universe: Array<{
582
+ name: string;
583
+ tokens: [number, number];
584
+ index: number;
585
+ isCanonical: boolean;
586
+ }>;
587
+ }>({ type: 'spotMeta' }, 'spotMeta');
372
588
  this.log('spotMeta response:', JSON.stringify(data).slice(0, 500));
373
589
  return data;
374
590
  }
@@ -394,6 +610,7 @@ export class HyperliquidClient {
394
610
  }>;
395
611
  };
396
612
  assetCtxs: Array<{
613
+ coin?: string;
397
614
  dayNtlVlm: string;
398
615
  markPx: string;
399
616
  midPx: string;
@@ -401,17 +618,35 @@ export class HyperliquidClient {
401
618
  }>;
402
619
  }> {
403
620
  this.log('Fetching spotMetaAndAssetCtxs...');
404
- const baseUrl = isMainnet()
405
- ? 'https://api.hyperliquid.xyz'
406
- : 'https://api.hyperliquid-testnet.xyz';
407
-
408
- const response = await fetch(baseUrl + '/info', {
409
- method: 'POST',
410
- headers: { 'Content-Type': 'application/json' },
411
- body: JSON.stringify({ type: 'spotMetaAndAssetCtxs' }),
412
- });
413
- const data = await response.json();
621
+ const data = await this.postInfo<unknown>(
622
+ { type: 'spotMetaAndAssetCtxs' },
623
+ 'spotMetaAndAssetCtxs'
624
+ );
414
625
  this.log('spotMetaAndAssetCtxs response:', JSON.stringify(data).slice(0, 500));
626
+
627
+ if (!Array.isArray(data) || !data[0] || !data[1]) {
628
+ this.log('spotMetaAndAssetCtxs returned null/malformed data, falling back to spotMeta + allMids');
629
+
630
+ const [meta, mids] = await Promise.all([
631
+ this.getSpotMeta(),
632
+ this.getAllMids(),
633
+ ]);
634
+
635
+ return {
636
+ meta,
637
+ assetCtxs: meta.universe.map((pair) => {
638
+ const price = mids[pair.name] ?? '0';
639
+ return {
640
+ coin: pair.name,
641
+ dayNtlVlm: '0',
642
+ markPx: price,
643
+ midPx: price,
644
+ prevDayPx: price,
645
+ };
646
+ }),
647
+ };
648
+ }
649
+
415
650
  return {
416
651
  meta: data[0],
417
652
  assetCtxs: data[1],
@@ -453,9 +688,12 @@ export class HyperliquidClient {
453
688
  }
454
689
 
455
690
  this.spotAssetMap.set(baseToken.name, spotAssetIndex);
691
+ this.spotPairNameMap.set(baseToken.name, pair.name);
456
692
  this.spotSzDecimalsMap.set(baseToken.name, baseToken.szDecimals);
457
693
 
458
- this.log(`Spot: ${baseToken.name} → asset ${spotAssetIndex} (szDecimals: ${baseToken.szDecimals})`);
694
+ this.log(
695
+ `Spot: ${baseToken.name} → asset ${spotAssetIndex}, market ${pair.name} (szDecimals: ${baseToken.szDecimals})`
696
+ );
459
697
  }
460
698
 
461
699
  this.spotMetaLoaded = true;
@@ -470,6 +708,11 @@ export class HyperliquidClient {
470
708
  return this.spotAssetMap.get(coin);
471
709
  }
472
710
 
711
+ /** Get the preferred spot market key for a coin (e.g. "@230", "PURR/USDC") */
712
+ getSpotMarketKey(coin: string): string | undefined {
713
+ return this.spotPairNameMap.get(coin);
714
+ }
715
+
473
716
  /** Get spot szDecimals for a coin */
474
717
  getSpotSzDecimals(coin: string): number | undefined {
475
718
  return this.spotSzDecimalsMap.get(coin);
@@ -493,19 +736,18 @@ export class HyperliquidClient {
493
736
  }>;
494
737
  }> {
495
738
  this.log('Fetching spotClearinghouseState for:', user ?? this.address);
496
- const baseUrl = isMainnet()
497
- ? 'https://api.hyperliquid.xyz'
498
- : 'https://api.hyperliquid-testnet.xyz';
499
-
500
- const response = await fetch(baseUrl + '/info', {
501
- method: 'POST',
502
- headers: { 'Content-Type': 'application/json' },
503
- body: JSON.stringify({
504
- type: 'spotClearinghouseState',
505
- user: user ?? this.address,
506
- }),
507
- });
508
- const data = await response.json();
739
+ const data = await this.postInfo<{
740
+ balances: Array<{
741
+ coin: string;
742
+ token: number;
743
+ hold: string;
744
+ total: string;
745
+ entryNtl: string;
746
+ }>;
747
+ }>({
748
+ type: 'spotClearinghouseState',
749
+ user: user ?? this.address,
750
+ }, 'spotClearinghouseState');
509
751
  this.log('spotClearinghouseState response:', JSON.stringify(data).slice(0, 500));
510
752
  return data;
511
753
  }
@@ -585,7 +827,16 @@ export class HyperliquidClient {
585
827
  }> {
586
828
  this.log('Fetching l2Book for:', coin);
587
829
  // API accepts prefixed names directly (e.g., "xyz:CL")
588
- const response = await this.info.l2Book({ coin });
830
+ let response;
831
+ try {
832
+ response = await this.info.l2Book({ coin });
833
+ } catch (error) {
834
+ this.log('l2Book failure context:', this.getTransportContext(`l2Book(${coin})`));
835
+ if (error instanceof Error && error.stack) {
836
+ this.log('l2Book stack:', error.stack);
837
+ }
838
+ throw new Error(`l2Book(${coin}) failed: ${this.describeError(error)}`);
839
+ }
589
840
 
590
841
  const bids = response.levels[0] as Array<{ px: string; sz: string; n: number }>;
591
842
  const asks = response.levels[1] as Array<{ px: string; sz: string; n: number }>;
@@ -607,6 +858,28 @@ export class HyperliquidClient {
607
858
  };
608
859
  }
609
860
 
861
+ async getAssetIndexAsync(coin: string): Promise<number> {
862
+ let index = this.assetMap.get(coin);
863
+ if (index === undefined && coin.includes(':')) {
864
+ // Try on-demand loading the dex (e.g., "felix:BTC" → load "felix")
865
+ const dexName = coin.split(':')[0];
866
+ await this.loadSingleHip3Dex(dexName);
867
+ index = this.assetMap.get(coin);
868
+ }
869
+ if (index === undefined) {
870
+ const hip3Matches = this.findHip3Matches(coin);
871
+ if (hip3Matches.length > 0) {
872
+ const suggestions = hip3Matches.map(m => `${m}`).join(', ');
873
+ throw new Error(
874
+ `Unknown asset: ${coin}. Did you mean one of these HIP-3 assets? ${suggestions}\n` +
875
+ `Use "openbroker search --query ${coin}" to find the full ticker.`
876
+ );
877
+ }
878
+ throw new Error(`Unknown asset: ${coin}. Available: ${Array.from(this.assetMap.keys()).slice(0, 10).join(', ')}...`);
879
+ }
880
+ return index;
881
+ }
882
+
610
883
  getAssetIndex(coin: string): number {
611
884
  const index = this.assetMap.get(coin);
612
885
  if (index === undefined) {
@@ -624,6 +897,25 @@ export class HyperliquidClient {
624
897
  return index;
625
898
  }
626
899
 
900
+ async getSzDecimalsAsync(coin: string): Promise<number> {
901
+ let decimals = this.szDecimalsMap.get(coin);
902
+ if (decimals === undefined && coin.includes(':')) {
903
+ const dexName = coin.split(':')[0];
904
+ await this.loadSingleHip3Dex(dexName);
905
+ decimals = this.szDecimalsMap.get(coin);
906
+ }
907
+ if (decimals === undefined) {
908
+ const hip3Matches = this.findHip3Matches(coin);
909
+ if (hip3Matches.length > 0) {
910
+ throw new Error(
911
+ `Unknown asset: ${coin}. Did you mean: ${hip3Matches.join(', ')}?`
912
+ );
913
+ }
914
+ throw new Error(`Unknown asset: ${coin}`);
915
+ }
916
+ return decimals;
917
+ }
918
+
627
919
  getSzDecimals(coin: string): number {
628
920
  const decimals = this.szDecimalsMap.get(coin);
629
921
  if (decimals === undefined) {
@@ -755,6 +1047,67 @@ export class HyperliquidClient {
755
1047
  return mode === 'unified' || mode === 'portfolio';
756
1048
  }
757
1049
 
1050
+ /**
1051
+ * Query the role of an address on HyperCore L1.
1052
+ * Returns: "user" | "agent" | "vault" | "subAccount" | "missing"
1053
+ * Useful for verifying API wallet (agent) registration.
1054
+ */
1055
+ async getUserRole(address?: string): Promise<{ role: string; data?: Record<string, string> }> {
1056
+ const target = address ?? this.address;
1057
+ this.log('Fetching userRole for:', target);
1058
+ try {
1059
+ const response = await this.postInfo<{ role: string; data?: Record<string, string> }>(
1060
+ { type: 'userRole', user: target },
1061
+ 'userRole'
1062
+ );
1063
+ this.log('userRole response:', JSON.stringify(response));
1064
+ return response ?? { role: 'missing' };
1065
+ } catch (e) {
1066
+ this.log('userRole query failed:', e);
1067
+ return { role: 'unknown' };
1068
+ }
1069
+ }
1070
+
1071
+ /**
1072
+ * Validate API wallet setup: check that the signing wallet is recognized
1073
+ * as an "agent" on HyperCore and the account address exists.
1074
+ * Logs warnings if misconfigured.
1075
+ */
1076
+ async validateApiWalletSetup(): Promise<{ valid: boolean; walletRole: string; accountRole: string }> {
1077
+ const walletResult = await this.getUserRole(this.walletAddress);
1078
+ const accountResult = await this.getUserRole(this.address);
1079
+ const walletRole = walletResult.role;
1080
+ const accountRole = accountResult.role;
1081
+
1082
+ this.log(`API wallet validation: wallet ${this.walletAddress} role=${walletRole}, account ${this.address} role=${accountRole}`);
1083
+
1084
+ if (walletRole === 'agent') {
1085
+ const masterAddress = walletResult.data?.user;
1086
+ if (masterAddress && masterAddress.toLowerCase() !== this.address.toLowerCase()) {
1087
+ console.warn(
1088
+ `\x1b[33m⚠️ API wallet ${this.walletAddress} is an agent for ${masterAddress}, but HYPERLIQUID_ACCOUNT_ADDRESS is ${this.address}.\n` +
1089
+ ` These should match.\x1b[0m`
1090
+ );
1091
+ } else {
1092
+ this.log(`API wallet confirmed as agent for ${masterAddress ?? this.address}`);
1093
+ }
1094
+ } else {
1095
+ console.warn(
1096
+ `\x1b[33m⚠️ API wallet ${this.walletAddress} has role "${walletRole}" on HyperCore (expected "agent").\n` +
1097
+ ` Make sure the agent is registered via CoreWriter.registerAgent() on the correct network (${isMainnet() ? 'mainnet' : 'testnet'}).\x1b[0m`
1098
+ );
1099
+ }
1100
+
1101
+ if (accountRole === 'missing') {
1102
+ console.warn(
1103
+ `\x1b[33m⚠️ Account ${this.address} has role "missing" on HyperCore.\n` +
1104
+ ` The account may not exist on ${isMainnet() ? 'mainnet' : 'testnet'} yet. Ensure the contract is deployed and has interacted with HyperCore.\x1b[0m`
1105
+ );
1106
+ }
1107
+
1108
+ return { valid: walletRole === 'agent', walletRole, accountRole };
1109
+ }
1110
+
758
1111
  /**
759
1112
  * Check if an address has sub-accounts (is a master account)
760
1113
  * Sub-accounts cannot approve builder fees - only master accounts can
@@ -844,7 +1197,7 @@ export class HyperliquidClient {
844
1197
  const response = await this.exchange.approveBuilderFee({
845
1198
  builder: targetBuilder as `0x${string}`,
846
1199
  maxFeeRate,
847
- });
1200
+ }, this.vaultParam);
848
1201
  this.log('approveBuilderFee response:', response);
849
1202
  return { status: 'ok', response };
850
1203
  } catch (error) {
@@ -1193,7 +1546,17 @@ export class HyperliquidClient {
1193
1546
  this.log('Fetching clearinghouseState for:', user ?? this.address, dex ? `dex: ${dex}` : '');
1194
1547
  const params: { user: string; dex?: string } = { user: user ?? this.address };
1195
1548
  if (dex !== undefined) params.dex = dex;
1196
- const response = await this.info.clearinghouseState(params as any);
1549
+ const label = dex ? `clearinghouseState(${dex})` : 'clearinghouseState(main)';
1550
+ let response;
1551
+ try {
1552
+ response = await this.withRetry(() => this.info.clearinghouseState(params as any), label);
1553
+ } catch (error) {
1554
+ this.log(`${label} failure context:`, this.getTransportContext(label));
1555
+ if (error instanceof Error && error.stack) {
1556
+ this.log(`${label} stack:`, error.stack);
1557
+ }
1558
+ throw new Error(`${label} failed: ${this.describeError(error)}`);
1559
+ }
1197
1560
 
1198
1561
  // The SDK response has `withdrawable` as a top-level field, not inside
1199
1562
  // marginSummary/crossMarginSummary. Copy it into our MarginSummary shape.
@@ -1218,43 +1581,48 @@ export class HyperliquidClient {
1218
1581
 
1219
1582
  const unified = await this.isUnifiedAccount(user);
1220
1583
  const mainState = await this.getUserState(user);
1221
- const dexs = await this.getPerpDexs();
1222
1584
 
1223
- // Collect positions from all HIP-3 dexes
1585
+ // Collect positions from all HIP-3 dexes (in parallel; testnet: only loaded dexes)
1586
+ const validDexs = await this.getIterableHip3Dexs();
1587
+ const dexResults = await this.batchSettled(
1588
+ validDexs.map((dex) => async () => {
1589
+ const dexState = await this.getUserState(user, dex.name);
1590
+ return { dex, dexState };
1591
+ })
1592
+ );
1593
+
1224
1594
  let hip3Errors = 0;
1225
- for (let i = 1; i < dexs.length; i++) {
1226
- const dex = dexs[i];
1227
- if (!dex) continue;
1595
+ const safeAdd = (a: string | undefined, b: string | undefined): string => {
1596
+ const va = parseFloat(a ?? '0') || 0;
1597
+ const vb = parseFloat(b ?? '0') || 0;
1598
+ return String(va + vb);
1599
+ };
1228
1600
 
1229
- try {
1230
- const dexState = await this.getUserState(user, dex.name);
1231
- if (dexState.assetPositions?.length > 0) {
1232
- mainState.assetPositions.push(...dexState.assetPositions);
1233
- }
1601
+ for (const result of dexResults) {
1602
+ if (result.status === 'rejected') {
1603
+ hip3Errors++;
1604
+ this.log(`Failed to fetch state for HIP-3 dex:`, result.reason instanceof Error ? result.reason.message : String(result.reason));
1605
+ continue;
1606
+ }
1607
+ const { dexState } = result.value;
1608
+ if (dexState.assetPositions?.length > 0) {
1609
+ mainState.assetPositions.push(...dexState.assetPositions);
1610
+ }
1234
1611
 
1235
- // For standard accounts, aggregate margin from each dex
1236
- if (!unified) {
1237
- const dexMargin = dexState.marginSummary;
1238
- if (dexMargin) {
1239
- const safeAdd = (a: string | undefined, b: string | undefined): string => {
1240
- const va = parseFloat(a ?? '0') || 0;
1241
- const vb = parseFloat(b ?? '0') || 0;
1242
- return String(va + vb);
1243
- };
1244
- const addToSummary = (summary: { accountValue: string; totalNtlPos: string; totalRawUsd: string; totalMarginUsed: string; withdrawable: string }) => {
1245
- summary.accountValue = safeAdd(summary.accountValue, dexMargin.accountValue);
1246
- summary.totalNtlPos = safeAdd(summary.totalNtlPos, dexMargin.totalNtlPos);
1247
- summary.totalRawUsd = safeAdd(summary.totalRawUsd, dexMargin.totalRawUsd);
1248
- summary.totalMarginUsed = safeAdd(summary.totalMarginUsed, dexMargin.totalMarginUsed);
1249
- summary.withdrawable = safeAdd(summary.withdrawable, dexMargin.withdrawable);
1250
- };
1251
- addToSummary(mainState.marginSummary);
1252
- addToSummary(mainState.crossMarginSummary);
1253
- }
1612
+ // For standard accounts, aggregate margin from each dex
1613
+ if (!unified) {
1614
+ const dexMargin = dexState.marginSummary;
1615
+ if (dexMargin) {
1616
+ const addToSummary = (summary: { accountValue: string; totalNtlPos: string; totalRawUsd: string; totalMarginUsed: string; withdrawable: string }) => {
1617
+ summary.accountValue = safeAdd(summary.accountValue, dexMargin.accountValue);
1618
+ summary.totalNtlPos = safeAdd(summary.totalNtlPos, dexMargin.totalNtlPos);
1619
+ summary.totalRawUsd = safeAdd(summary.totalRawUsd, dexMargin.totalRawUsd);
1620
+ summary.totalMarginUsed = safeAdd(summary.totalMarginUsed, dexMargin.totalMarginUsed);
1621
+ summary.withdrawable = safeAdd(summary.withdrawable, dexMargin.withdrawable);
1622
+ };
1623
+ addToSummary(mainState.marginSummary);
1624
+ addToSummary(mainState.crossMarginSummary);
1254
1625
  }
1255
- } catch (err) {
1256
- hip3Errors++;
1257
- this.log(`Failed to fetch state for dex ${dex.name}:`, err instanceof Error ? err.message : String(err));
1258
1626
  }
1259
1627
  }
1260
1628
 
@@ -1314,21 +1682,26 @@ export class HyperliquidClient {
1314
1682
  await this.getMetaAndAssetCtxs(); // Ensure HIP-3 dex list is loaded
1315
1683
 
1316
1684
  // Fetch main dex orders
1317
- const orders = await this.info.openOrders({ user: user ?? this.address }) as OpenOrder[];
1685
+ const orders = await this.withRetry(() => this.info.openOrders({ user: user ?? this.address }), 'openOrders') as OpenOrder[];
1318
1686
 
1319
- // Fetch HIP-3 dex orders
1320
- const dexs = await this.getPerpDexs();
1321
- for (let i = 1; i < dexs.length; i++) {
1322
- const dex = dexs[i];
1323
- if (!dex) continue;
1324
- try {
1687
+ // Fetch HIP-3 dex orders (in parallel; testnet: only loaded dexes)
1688
+ const validDexs = await this.getIterableHip3Dexs();
1689
+ const dexResults = await this.batchSettled(
1690
+ validDexs.map((dex) => async () => {
1325
1691
  const dexOrders = await this.info.openOrders({ user: user ?? this.address, dex: dex.name }) as OpenOrder[];
1326
- if (dexOrders.length > 0) {
1327
- this.log(`Found ${dexOrders.length} open orders on HIP-3 dex ${dex.name}`);
1328
- orders.push(...dexOrders);
1329
- }
1330
- } catch (err) {
1331
- this.log(`Failed to fetch open orders for dex ${dex.name}:`, err instanceof Error ? err.message : String(err));
1692
+ return { dex, dexOrders };
1693
+ })
1694
+ );
1695
+
1696
+ for (const result of dexResults) {
1697
+ if (result.status === 'rejected') {
1698
+ this.log('Failed to fetch open orders for HIP-3 dex:', result.reason instanceof Error ? result.reason.message : String(result.reason));
1699
+ continue;
1700
+ }
1701
+ const { dex, dexOrders } = result.value;
1702
+ if (dexOrders.length > 0) {
1703
+ this.log(`Found ${dexOrders.length} open orders on HIP-3 dex ${dex.name}`);
1704
+ orders.push(...dexOrders);
1332
1705
  }
1333
1706
  }
1334
1707
 
@@ -1384,7 +1757,7 @@ export class HyperliquidClient {
1384
1757
  destinationDex: dexInfo.dexName,
1385
1758
  token: 'USDC:0x6d1e7cde53ba9467b783cb7c530ce054',
1386
1759
  amount: String(transferAmount),
1387
- });
1760
+ }, this.vaultParam as any);
1388
1761
  this.log(`Transferred ${transferAmount} USDC to ${dexInfo.dexName} dex`);
1389
1762
  } catch (err) {
1390
1763
  // Log but don't block — dex may already have sufficient balance
@@ -1402,7 +1775,7 @@ export class HyperliquidClient {
1402
1775
  includeBuilder: boolean = true,
1403
1776
  leverage?: number
1404
1777
  ): Promise<OrderResponse> {
1405
- this.requireTrading();
1778
+ await this.requireTrading();
1406
1779
  await this.getMetaAndAssetCtxs();
1407
1780
 
1408
1781
  // Set leverage if specified (for main perps, cross margin; for HIP-3, handled in ensureHip3Ready)
@@ -1437,14 +1810,14 @@ export class HyperliquidClient {
1437
1810
  grouping: 'na',
1438
1811
  };
1439
1812
 
1440
- // Add builder fee if configured
1441
- if (includeBuilder && this.config.builderAddress !== '0x0000000000000000000000000000000000000000') {
1813
+ // Add builder fee if configured (skip on testnet — builder may not be approved)
1814
+ if (includeBuilder && !this.isTestnet && this.config.builderAddress !== '0x0000000000000000000000000000000000000000') {
1442
1815
  orderRequest.builder = this.builderInfo;
1443
1816
  this.log('Including builder fee:', this.builderInfo);
1444
1817
  }
1445
1818
 
1446
1819
  try {
1447
- const response = await this.exchange.order(orderRequest);
1820
+ const response = await this.exchange.order(orderRequest, this.vaultParam);
1448
1821
  this.log('Order response:', JSON.stringify(response, null, 2));
1449
1822
  return response as unknown as OrderResponse;
1450
1823
  } catch (error) {
@@ -1534,7 +1907,7 @@ export class HyperliquidClient {
1534
1907
  reduceOnly: boolean = true,
1535
1908
  leverage?: number
1536
1909
  ): Promise<OrderResponse> {
1537
- this.requireTrading();
1910
+ await this.requireTrading();
1538
1911
  await this.getMetaAndAssetCtxs();
1539
1912
 
1540
1913
  // Set leverage if specified (for main perps)
@@ -1578,14 +1951,14 @@ export class HyperliquidClient {
1578
1951
  grouping: 'na',
1579
1952
  };
1580
1953
 
1581
- // Add builder fee if configured
1582
- if (this.config.builderAddress !== '0x0000000000000000000000000000000000000000') {
1954
+ // Add builder fee if configured (skip on testnet — builder may not be approved)
1955
+ if (!this.isTestnet && this.config.builderAddress !== '0x0000000000000000000000000000000000000000') {
1583
1956
  orderRequest.builder = this.builderInfo;
1584
1957
  this.log('Including builder fee:', this.builderInfo);
1585
1958
  }
1586
1959
 
1587
1960
  try {
1588
- const response = await this.exchange.order(orderRequest);
1961
+ const response = await this.exchange.order(orderRequest, this.vaultParam);
1589
1962
  this.log('Trigger order response:', JSON.stringify(response, null, 2));
1590
1963
  return response as unknown as OrderResponse;
1591
1964
  } catch (error) {
@@ -1631,7 +2004,7 @@ export class HyperliquidClient {
1631
2004
  }
1632
2005
 
1633
2006
  async cancel(coin: string, oid: number): Promise<CancelResponse> {
1634
- this.requireTrading();
2007
+ await this.requireTrading();
1635
2008
  await this.getMetaAndAssetCtxs();
1636
2009
 
1637
2010
  const assetIndex = this.getAssetIndex(coin);
@@ -1641,7 +2014,7 @@ export class HyperliquidClient {
1641
2014
  try {
1642
2015
  const response = await this.exchange.cancel({
1643
2016
  cancels: [{ a: assetIndex, o: oid }],
1644
- });
2017
+ }, this.vaultParam);
1645
2018
  this.log('Cancel response:', JSON.stringify(response, null, 2));
1646
2019
  return response as unknown as CancelResponse;
1647
2020
  } catch (error) {
@@ -1688,7 +2061,7 @@ export class HyperliquidClient {
1688
2061
  orderType: { limit: { tif: 'Gtc' | 'Ioc' | 'Alo' } },
1689
2062
  includeBuilder: boolean = true,
1690
2063
  ): Promise<OrderResponse> {
1691
- this.requireTrading();
2064
+ await this.requireTrading();
1692
2065
  await this.loadSpotMeta();
1693
2066
 
1694
2067
  const assetIndex = this.spotAssetMap.get(coin);
@@ -1721,14 +2094,14 @@ export class HyperliquidClient {
1721
2094
  grouping: 'na',
1722
2095
  };
1723
2096
 
1724
- // Add builder fee if configured (spot max is 1000 vs 100 for perps)
1725
- if (includeBuilder && this.config.builderAddress !== '0x0000000000000000000000000000000000000000') {
2097
+ // Add builder fee if configured (skip on testnet builder may not be approved)
2098
+ if (includeBuilder && !this.isTestnet && this.config.builderAddress !== '0x0000000000000000000000000000000000000000') {
1726
2099
  orderRequest.builder = this.builderInfo;
1727
2100
  this.log('Including builder fee:', this.builderInfo);
1728
2101
  }
1729
2102
 
1730
2103
  try {
1731
- const response = await this.exchange.order(orderRequest);
2104
+ const response = await this.exchange.order(orderRequest, this.vaultParam);
1732
2105
  this.log('Spot order response:', JSON.stringify(response, null, 2));
1733
2106
  return response as unknown as OrderResponse;
1734
2107
  } catch (error) {
@@ -1755,18 +2128,33 @@ export class HyperliquidClient {
1755
2128
  ): Promise<OrderResponse> {
1756
2129
  await this.loadSpotMeta();
1757
2130
 
1758
- // Get the spot pair name (@index or PURR/USDC) for allMids lookup
1759
2131
  const assetIndex = this.spotAssetMap.get(coin);
1760
- if (assetIndex === undefined) {
2132
+ const spotCoinKey = this.spotPairNameMap.get(coin);
2133
+ if (assetIndex === undefined || !spotCoinKey) {
1761
2134
  throw new Error(`Unknown spot asset: ${coin}. Use "openbroker spot" to see available markets.`);
1762
2135
  }
1763
- const spotPairIndex = assetIndex - 10000;
1764
- // Canonical PURR/USDC is index 0, everything else uses @index
1765
- const spotCoinKey = spotPairIndex === 0 ? 'PURR/USDC' : `@${spotPairIndex}`;
1766
2136
 
1767
- // Use allMids for accurate live prices (spotMetaAndAssetCtxs contexts can be misaligned)
2137
+ // Use the exact spot market key from spotMeta (e.g. "@230", "PURR/USDC").
2138
+ // On testnet the tradable asset id and displayed market key can diverge.
1768
2139
  const mids = await this.getAllMids();
1769
- const midStr = mids[spotCoinKey];
2140
+ let midStr = mids[spotCoinKey];
2141
+
2142
+ // Fallback: allMids may omit spot pairs (especially on testnet).
2143
+ // Try spotMetaAndAssetCtxs which returns markPx directly.
2144
+ if (!midStr) {
2145
+ this.log(`allMids missing spot key "${spotCoinKey}", falling back to spotMetaAndAssetCtxs`);
2146
+ try {
2147
+ const spotData = await this.getSpotMetaAndAssetCtxs();
2148
+ const ctxMap = new Map<string, string>();
2149
+ for (const ctx of spotData.assetCtxs as Array<{ coin?: string; midPx?: string; markPx: string }>) {
2150
+ if (ctx.coin) ctxMap.set(ctx.coin, ctx.midPx || ctx.markPx);
2151
+ }
2152
+ midStr = ctxMap.get(spotCoinKey);
2153
+ } catch (e) {
2154
+ this.log(`spotMetaAndAssetCtxs fallback failed:`, e);
2155
+ }
2156
+ }
2157
+
1770
2158
  const midPrice = midStr ? parseFloat(midStr) : 0;
1771
2159
 
1772
2160
  if (!midPrice || midPrice === 0) {
@@ -1818,7 +2206,7 @@ export class HyperliquidClient {
1818
2206
  * Cancel a spot order by coin and order ID.
1819
2207
  */
1820
2208
  async spotCancel(coin: string, oid: number): Promise<CancelResponse> {
1821
- this.requireTrading();
2209
+ await this.requireTrading();
1822
2210
  await this.loadSpotMeta();
1823
2211
 
1824
2212
  const assetIndex = this.spotAssetMap.get(coin);
@@ -1831,7 +2219,7 @@ export class HyperliquidClient {
1831
2219
  try {
1832
2220
  const response = await this.exchange.cancel({
1833
2221
  cancels: [{ a: assetIndex, o: oid }],
1834
- });
2222
+ }, this.vaultParam);
1835
2223
  this.log('Spot cancel response:', JSON.stringify(response, null, 2));
1836
2224
  return response as unknown as CancelResponse;
1837
2225
  } catch (error) {
@@ -1850,7 +2238,7 @@ export class HyperliquidClient {
1850
2238
  leverage: number,
1851
2239
  isCross: boolean = true
1852
2240
  ): Promise<unknown> {
1853
- this.requireTrading();
2241
+ await this.requireTrading();
1854
2242
  await this.getMetaAndAssetCtxs();
1855
2243
 
1856
2244
  // HIP-3 perps only support isolated margin — override isCross and clamp leverage
@@ -1875,7 +2263,7 @@ export class HyperliquidClient {
1875
2263
  asset: assetIndex,
1876
2264
  isCross,
1877
2265
  leverage,
1878
- });
2266
+ }, this.vaultParam);
1879
2267
  this.log('Leverage response:', JSON.stringify(response, null, 2));
1880
2268
  return response;
1881
2269
  } catch (error) {
@@ -1925,7 +2313,8 @@ export class HyperliquidClient {
1925
2313
  m: durationMinutes,
1926
2314
  t: randomize,
1927
2315
  },
1928
- });
2316
+
2317
+ }, this.vaultParam);
1929
2318
  this.log('TWAP order response:', JSON.stringify(response, null, 2));
1930
2319
  return response;
1931
2320
  } catch (error) {
@@ -1950,7 +2339,7 @@ export class HyperliquidClient {
1950
2339
  const response = await this.exchange.twapCancel({
1951
2340
  a: assetIndex,
1952
2341
  t: twapId,
1953
- });
2342
+ }, this.vaultParam);
1954
2343
  this.log('TWAP cancel response:', JSON.stringify(response, null, 2));
1955
2344
  return response;
1956
2345
  } catch (error) {