@lifi/perps-sdk-provider-hyperliquid 1.1.0 → 1.2.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 (41) hide show
  1. package/dist/cjs/types/ws.d.ts +33 -0
  2. package/dist/cjs/types/ws.d.ts.map +1 -1
  3. package/dist/cjs/utils/decodeFastAssetCtxs.d.ts +1 -0
  4. package/dist/cjs/utils/decodeFastAssetCtxs.d.ts.map +1 -1
  5. package/dist/cjs/utils/decodeFastAssetCtxs.js +5 -1
  6. package/dist/cjs/utils/decodeFastAssetCtxs.js.map +1 -1
  7. package/dist/cjs/utils/index.d.ts +1 -1
  8. package/dist/cjs/utils/index.d.ts.map +1 -1
  9. package/dist/cjs/utils/index.js +2 -1
  10. package/dist/cjs/utils/index.js.map +1 -1
  11. package/dist/cjs/websocket/HyperliquidWsProvider.d.ts +13 -0
  12. package/dist/cjs/websocket/HyperliquidWsProvider.d.ts.map +1 -1
  13. package/dist/cjs/websocket/HyperliquidWsProvider.js +206 -22
  14. package/dist/cjs/websocket/HyperliquidWsProvider.js.map +1 -1
  15. package/dist/esm/types/ws.d.ts +37 -0
  16. package/dist/esm/types/ws.d.ts.map +1 -1
  17. package/dist/esm/utils/decodeFastAssetCtxs.d.ts +1 -0
  18. package/dist/esm/utils/decodeFastAssetCtxs.d.ts.map +1 -1
  19. package/dist/esm/utils/decodeFastAssetCtxs.js +9 -6
  20. package/dist/esm/utils/decodeFastAssetCtxs.js.map +1 -1
  21. package/dist/esm/utils/index.d.ts +1 -1
  22. package/dist/esm/utils/index.d.ts.map +1 -1
  23. package/dist/esm/utils/index.js +1 -1
  24. package/dist/esm/utils/index.js.map +1 -1
  25. package/dist/esm/websocket/HyperliquidWsProvider.d.ts +13 -0
  26. package/dist/esm/websocket/HyperliquidWsProvider.d.ts.map +1 -1
  27. package/dist/esm/websocket/HyperliquidWsProvider.js +208 -27
  28. package/dist/esm/websocket/HyperliquidWsProvider.js.map +1 -1
  29. package/dist/types/types/ws.d.ts +37 -0
  30. package/dist/types/types/ws.d.ts.map +1 -1
  31. package/dist/types/utils/decodeFastAssetCtxs.d.ts +1 -0
  32. package/dist/types/utils/decodeFastAssetCtxs.d.ts.map +1 -1
  33. package/dist/types/utils/index.d.ts +1 -1
  34. package/dist/types/utils/index.d.ts.map +1 -1
  35. package/dist/types/websocket/HyperliquidWsProvider.d.ts +13 -0
  36. package/dist/types/websocket/HyperliquidWsProvider.d.ts.map +1 -1
  37. package/package.json +3 -3
  38. package/src/types/ws.ts +35 -0
  39. package/src/utils/decodeFastAssetCtxs.ts +10 -6
  40. package/src/utils/index.ts +4 -1
  41. package/src/websocket/HyperliquidWsProvider.ts +271 -29
@@ -14,6 +14,8 @@ import {
14
14
  import {
15
15
  type MarketContext,
16
16
  type OpenOrder,
17
+ type OrderbookLevel,
18
+ type OrderbookResponse,
17
19
  OrderSide,
18
20
  OrderType,
19
21
  type Subscription,
@@ -27,14 +29,18 @@ import type {
27
29
  HlWsAllDexsAssetCtxsData,
28
30
  HlWsAllDexsClearinghouseStateData,
29
31
  HlWsCandleData,
32
+ HlWsCompressedL2Data,
30
33
  HlWsFastAssetCtx,
31
34
  HlWsL2BookData,
35
+ HlWsL2Data,
32
36
  HlWsMessage,
33
37
  HlWsPerpAssetCtx,
34
38
  HlWsSpotStateData,
39
+ HlWsTrade,
35
40
  HlWsUserFillsData,
36
41
  } from '../types/index.js'
37
42
  import {
43
+ decodeCompressedJson,
38
44
  decodeFastAssetCtxs,
39
45
  isOpenAssetPosition,
40
46
  isTriggerOrder,
@@ -49,7 +55,7 @@ import {
49
55
  spotPriceById,
50
56
  } from '../utils/index.js'
51
57
 
52
- /** HL's `l2Book` returns at most this many levels per side (slow/default mode). */
58
+ /** HL's compact `l2` snapshot carries 20 levels per side. */
53
59
  const HL_L2_BOOK_MAX_LEVELS_PER_SIDE = 20
54
60
 
55
61
  /**
@@ -85,6 +91,8 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
85
91
  private readonly client: PerpsSDKClient | undefined
86
92
  private readonly registry: MarketRegistry | undefined
87
93
  private perpCtxBySubDex = new Map<string, Record<string, HlWsPerpAssetCtx>>()
94
+ private orderbookKeysByMarketId = new Map<string, Set<string>>()
95
+ private latestOrderbookByMarketId = new Map<string, OrderbookResponse>()
88
96
  // Latest mid/mark per `Market.id` from the high-frequency `fastAssetCtxs`
89
97
  // feed (all dexes, incl. builder/sub-dex coins). Merged incrementally —
90
98
  // frames carry only changed coins — and overlaid onto the slower
@@ -93,6 +101,7 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
93
101
  // `fastAssetCtxs` payloads are base64 + raw-DEFLATE; decode is async, so
94
102
  // chain decodes to apply incremental frames in arrival order.
95
103
  private fastDecodeChain: Promise<void> = Promise.resolve()
104
+ private orderbookDecodeChain: Promise<void> = Promise.resolve()
96
105
 
97
106
  constructor(wsUrl: string, providerKey: string, client?: PerpsSDKClient) {
98
107
  super(
@@ -159,8 +168,14 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
159
168
 
160
169
  // All other channels: single WS subscription per key
161
170
  const key = this.toKey(sub)
171
+ if (sub.channel === 'orderbook') {
172
+ this.claimOrderbookKey(sub.marketId, key)
173
+ }
162
174
  const payload = this.toHlPayload(sub)
163
175
  await this.registerSub(key, payload)
176
+ if (sub.channel === 'orderbook') {
177
+ this.registerOrderbook(sub.marketId, key)
178
+ }
164
179
 
165
180
  await this.rws.ready()
166
181
 
@@ -170,8 +185,14 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
170
185
  this.orderUpdatesKey = undefined
171
186
  }
172
187
  this.rws.send(
173
- JSON.stringify({ method: 'unsubscribe', subscription: payload })
188
+ JSON.stringify({
189
+ method: 'unsubscribe',
190
+ subscription: payload,
191
+ })
174
192
  )
193
+ if (sub.channel === 'orderbook') {
194
+ this.releaseOrderbook(sub.marketId, key)
195
+ }
175
196
  }
176
197
  }
177
198
 
@@ -198,6 +219,25 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
198
219
  this.orderUpdatesKey = key
199
220
  }
200
221
 
222
+ private claimOrderbookKey(marketId: string, key: string): void {
223
+ const activeKeys = this.orderbookKeysByMarketId.get(marketId)
224
+ if (activeKeys === undefined) {
225
+ return
226
+ }
227
+
228
+ for (const activeKey of [...activeKeys]) {
229
+ if (activeKey === key) {
230
+ continue
231
+ }
232
+ if (!this.closeChannelIfIdle(activeKey)) {
233
+ throw new Error(
234
+ `Hyperliquid supports one orderbook aggregation per market per ` +
235
+ `provider instance. Unsubscribe ${activeKey} before subscribing ${key}.`
236
+ )
237
+ }
238
+ }
239
+ }
240
+
201
241
  /**
202
242
  * Sub-key + payload pairs for the markets-context aggregation: the all-dexs
203
243
  * perp asset-context feed (oracle/funding/OI/metadata) plus the compressed
@@ -213,9 +253,35 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
213
253
  ]
214
254
  }
215
255
 
256
+ private registerOrderbook(marketId: string, orderbookKey: string): void {
257
+ let activeKeys = this.orderbookKeysByMarketId.get(marketId)
258
+ if (activeKeys === undefined) {
259
+ activeKeys = new Set()
260
+ this.orderbookKeysByMarketId.set(marketId, activeKeys)
261
+ }
262
+
263
+ activeKeys.add(orderbookKey)
264
+ }
265
+
266
+ private releaseOrderbook(marketId: string, orderbookKey: string): void {
267
+ const activeKeys = this.orderbookKeysByMarketId.get(marketId)
268
+ if (activeKeys === undefined) {
269
+ return
270
+ }
271
+
272
+ activeKeys.delete(orderbookKey)
273
+ if (activeKeys.size > 0) {
274
+ return
275
+ }
276
+
277
+ this.orderbookKeysByMarketId.delete(marketId)
278
+ }
279
+
216
280
  protected override onClose(): void {
217
281
  this.perpCtxBySubDex.clear()
218
282
  this.fastCtxByMarketId = {}
283
+ this.orderbookKeysByMarketId.clear()
284
+ this.latestOrderbookByMarketId.clear()
219
285
  this.orderUpdatesKey = undefined
220
286
  }
221
287
 
@@ -230,9 +296,14 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
230
296
  case 'marketsContext':
231
297
  return 'marketsContext'
232
298
  case 'orderbook':
233
- return `l2Book:${sub.marketId}`
299
+ return `l2:${sub.marketId}:${this.orderbookAggregationKey(
300
+ sub.marketId,
301
+ sub.priceStep
302
+ )}`
234
303
  case 'candle':
235
304
  return `candle:${sub.marketId}:${sub.interval}`
305
+ case 'trades':
306
+ return `trades:${sub.marketId}`
236
307
  case 'orderUpdates':
237
308
  return `orderUpdates:${sub.address.toLowerCase()}`
238
309
  case 'fills':
@@ -244,32 +315,61 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
244
315
  }
245
316
  }
246
317
 
318
+ private orderbookAggregation(
319
+ marketId: string,
320
+ priceStep: number | undefined
321
+ ): { nSigFigs?: number; mantissa?: number } {
322
+ if (priceStep === undefined) {
323
+ return {}
324
+ }
325
+ return priceStepToAggregation(
326
+ priceStep,
327
+ this.orderbookReferencePrice(marketId)
328
+ )
329
+ }
330
+
331
+ private orderbookAggregationKey(
332
+ marketId: string,
333
+ priceStep: number | undefined
334
+ ): string {
335
+ const aggregation = this.orderbookAggregation(marketId, priceStep)
336
+ const nSigFigs = aggregation.nSigFigs ?? null
337
+ const mantissa = aggregation.mantissa ?? null
338
+ if (nSigFigs === null && mantissa === null) {
339
+ return 'full'
340
+ }
341
+ return `s:${nSigFigs}:m:${mantissa}`
342
+ }
343
+
247
344
  private toHlPayload(sub: Subscription): object {
248
345
  switch (sub.channel) {
249
346
  case 'marketsContext':
250
347
  // Handled via getMarketsContextSubEntries in openChannel; never reaches
251
348
  // toHlPayload, but TS requires an exhaustive switch.
252
349
  return { type: 'allDexsAssetCtxs' }
253
- case 'orderbook':
350
+ case 'orderbook': {
351
+ const aggregation = this.orderbookAggregation(
352
+ sub.marketId,
353
+ sub.priceStep
354
+ )
254
355
  return {
255
- type: 'l2Book',
256
- coin: sub.marketId,
257
- // HL's l2Book ignores any level-count field; it returns up to 20
258
- // levels/side and controls granularity via nSigFigs (+ mantissa,
259
- // valid only when nSigFigs === 5).
260
- ...(sub.priceStep !== undefined
261
- ? priceStepToAggregation(
262
- sub.priceStep,
263
- this.mergedMids().get(sub.marketId) ?? Number.NaN
264
- )
265
- : {}),
356
+ type: 'l2',
357
+ c: sub.marketId,
358
+ s: aggregation.nSigFigs ?? null,
359
+ m: aggregation.mantissa ?? null,
266
360
  }
361
+ }
267
362
  case 'candle':
268
363
  return {
269
364
  type: 'candle',
270
365
  coin: sub.marketId,
271
366
  interval: sub.interval,
272
367
  }
368
+ case 'trades':
369
+ return {
370
+ type: 'trades',
371
+ coin: sub.marketId,
372
+ }
273
373
  case 'orderUpdates':
274
374
  return { type: 'orderUpdates', user: sub.address }
275
375
  case 'fills':
@@ -315,12 +415,18 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
315
415
  case 'fastAssetCtxs':
316
416
  this.handleFastAssetCtxs(msg.data as string)
317
417
  break
418
+ case 'l2':
419
+ this.handleL2(msg.data as HlWsL2Data)
420
+ break
318
421
  case 'l2Book':
319
422
  this.handleL2Book(msg.data as HlWsL2BookData)
320
423
  break
321
424
  case 'candle':
322
425
  this.handleCandle(msg.data as HlWsCandleData)
323
426
  break
427
+ case 'trades':
428
+ this.handleTrades(msg.data as HlWsTrade[])
429
+ break
324
430
  case 'orderUpdates':
325
431
  this.handleOrderUpdates(msg.data as HlOrderDetail[])
326
432
  break
@@ -427,21 +533,93 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
427
533
  return map
428
534
  }
429
535
 
536
+ private orderbookReferencePrice(marketId: string): number {
537
+ const mid = this.mergedMids().get(marketId)
538
+ if (mid !== undefined) {
539
+ return mid
540
+ }
541
+
542
+ const book = this.latestOrderbookByMarketId.get(marketId)
543
+ const bid = book?.bids[0]?.price
544
+ const ask = book?.asks[0]?.price
545
+ if (bid !== undefined && ask !== undefined) {
546
+ return (Number(bid) + Number(ask)) / 2
547
+ }
548
+
549
+ return Number.NaN
550
+ }
551
+
430
552
  private handleL2Book(data: HlWsL2BookData) {
431
- this.emit(`l2Book:${data.coin}`, {
432
- channel: 'orderbook',
433
- data: {
434
- provider: this.providerKey,
435
- marketId: data.coin,
436
- bids: data.levels[0]
437
- .slice(0, HL_L2_BOOK_MAX_LEVELS_PER_SIDE)
438
- .map((l) => ({ price: l.px, size: l.sz })),
439
- asks: data.levels[1]
440
- .slice(0, HL_L2_BOOK_MAX_LEVELS_PER_SIDE)
441
- .map((l) => ({ price: l.px, size: l.sz })),
442
- timestamp: data.time,
443
- },
444
- })
553
+ const book = {
554
+ provider: this.providerKey,
555
+ marketId: data.coin,
556
+ bids: data.levels[0]
557
+ .slice(0, HL_L2_BOOK_MAX_LEVELS_PER_SIDE)
558
+ .map((l) => ({ price: l.px, size: l.sz })),
559
+ asks: data.levels[1]
560
+ .slice(0, HL_L2_BOOK_MAX_LEVELS_PER_SIDE)
561
+ .map((l) => ({ price: l.px, size: l.sz })),
562
+ timestamp: data.time,
563
+ }
564
+ this.latestOrderbookByMarketId.set(data.coin, book)
565
+ this.emitOrderbook(data.coin, book)
566
+ }
567
+
568
+ private handleL2(data: HlWsL2Data) {
569
+ if (data.s !== undefined) {
570
+ this.handleL2Book(data.s)
571
+ }
572
+ if (data.u !== undefined) {
573
+ this.handleCompactL2Delta(data.u)
574
+ }
575
+ if (data.c === undefined) {
576
+ return
577
+ }
578
+ const compressed = data.c
579
+ this.orderbookDecodeChain = this.orderbookDecodeChain
580
+ .then(async () => {
581
+ this.handleCompactL2Delta(
582
+ await decodeCompressedJson<HlWsCompressedL2Data>(compressed)
583
+ )
584
+ })
585
+ .catch((error) => wsLog.handlerFailure(this.providerKey, error))
586
+ }
587
+
588
+ private handleCompactL2Delta(delta: HlWsCompressedL2Data) {
589
+ const previous = this.latestOrderbookByMarketId.get(delta.c)
590
+ if (previous === undefined) {
591
+ return
592
+ }
593
+
594
+ const book: OrderbookResponse = {
595
+ provider: this.providerKey,
596
+ marketId: delta.c,
597
+ bids: applyCompressedL2Side(
598
+ previous.bids,
599
+ delta.l[0],
600
+ delta.r?.[0] ?? [],
601
+ 'bid'
602
+ ),
603
+ asks: applyCompressedL2Side(
604
+ previous.asks,
605
+ delta.l[1],
606
+ delta.r?.[1] ?? [],
607
+ 'ask'
608
+ ),
609
+ timestamp: delta.t,
610
+ }
611
+ this.latestOrderbookByMarketId.set(delta.c, book)
612
+ this.emitOrderbook(delta.c, book)
613
+ }
614
+
615
+ private emitOrderbook(marketId: string, book: OrderbookResponse) {
616
+ const keys = this.orderbookKeysByMarketId.get(marketId)
617
+ if (keys === undefined) {
618
+ return
619
+ }
620
+ for (const key of keys) {
621
+ this.emit(key, { channel: 'orderbook', data: book })
622
+ }
445
623
  }
446
624
 
447
625
  private handleCandle(data: HlWsCandleData) {
@@ -458,6 +636,25 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
458
636
  })
459
637
  }
460
638
 
639
+ private handleTrades(data: HlWsTrade[]) {
640
+ for (const trade of data) {
641
+ this.emit(`trades:${trade.coin}`, {
642
+ channel: 'trades',
643
+ data: [
644
+ {
645
+ provider: this.providerKey,
646
+ marketId: trade.coin,
647
+ price: trade.px,
648
+ size: trade.sz,
649
+ timestamp: trade.time,
650
+ side: trade.side === 'B' ? 'buy' : 'sell',
651
+ id: trade.tid !== undefined ? String(trade.tid) : trade.hash,
652
+ },
653
+ ],
654
+ })
655
+ }
656
+ }
657
+
461
658
  private handleOrderUpdates(data: HlOrderDetail[]) {
462
659
  // Untagged frame: attributable only via the single-address invariant.
463
660
  const key = this.orderUpdatesKey
@@ -563,6 +760,47 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
563
760
  const isObject = (v: unknown): v is Record<string, unknown> =>
564
761
  typeof v === 'object' && v !== null
565
762
 
763
+ function compactRemovalPrice(
764
+ levels: OrderbookLevel[],
765
+ value: number | string | { p: string }
766
+ ): string | undefined {
767
+ if (typeof value === 'number') {
768
+ return levels[value]?.price
769
+ }
770
+ return typeof value === 'string' ? value : value.p
771
+ }
772
+
773
+ function applyCompressedL2Side(
774
+ previous: OrderbookLevel[],
775
+ updates: HlWsCompressedL2Data['l'][number],
776
+ removals: NonNullable<HlWsCompressedL2Data['r']>[number],
777
+ side: 'bid' | 'ask'
778
+ ): OrderbookLevel[] {
779
+ const byPrice = new Map(previous.map((level) => [level.price, level.size]))
780
+ for (const removal of removals) {
781
+ const price = compactRemovalPrice(previous, removal)
782
+ if (price !== undefined) {
783
+ byPrice.delete(price)
784
+ }
785
+ }
786
+ for (const update of updates) {
787
+ if (Number(update.s) === 0) {
788
+ byPrice.delete(update.p)
789
+ } else {
790
+ byPrice.set(update.p, update.s)
791
+ }
792
+ }
793
+
794
+ return [...byPrice.entries()]
795
+ .map(([price, size]) => ({ price, size }))
796
+ .sort((a, b) =>
797
+ side === 'bid'
798
+ ? Number(b.price) - Number(a.price)
799
+ : Number(a.price) - Number(b.price)
800
+ )
801
+ .slice(0, HL_L2_BOOK_MAX_LEVELS_PER_SIDE)
802
+ }
803
+
566
804
  /**
567
805
  * Minimal presence/type check of the channel-discriminating and required
568
806
  * fields the matching handler dereferences without its own guard. A frame
@@ -589,8 +827,12 @@ function isValidHlFrame(channel: string, data: unknown): boolean {
589
827
  Array.isArray(data.levels[1]) &&
590
828
  typeof data.time === 'number'
591
829
  )
830
+ case 'l2':
831
+ return typeof data.c === 'string' || isObject(data.s) || isObject(data.u)
592
832
  case 'candle':
593
833
  return typeof data.s === 'string' && typeof data.i === 'string'
834
+ case 'trades':
835
+ return Array.isArray(data)
594
836
  case 'orderUpdates':
595
837
  return Array.isArray(data)
596
838
  case 'userFills':