@lifi/perps-sdk-provider-hyperliquid 4.0.0 → 4.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 (61) hide show
  1. package/dist/cjs/HyperliquidProvider.d.ts.map +1 -1
  2. package/dist/cjs/HyperliquidProvider.js +1 -0
  3. package/dist/cjs/HyperliquidProvider.js.map +1 -1
  4. package/dist/cjs/services/getAccount.d.ts.map +1 -1
  5. package/dist/cjs/services/getAccount.js +1 -11
  6. package/dist/cjs/services/getAccount.js.map +1 -1
  7. package/dist/cjs/types/account.d.ts +1 -0
  8. package/dist/cjs/types/account.d.ts.map +1 -1
  9. package/dist/cjs/types/account.js +1 -0
  10. package/dist/cjs/types/account.js.map +1 -1
  11. package/dist/cjs/utils/index.d.ts +1 -0
  12. package/dist/cjs/utils/index.d.ts.map +1 -1
  13. package/dist/cjs/utils/index.js +3 -1
  14. package/dist/cjs/utils/index.js.map +1 -1
  15. package/dist/cjs/utils/spotCollateral.d.ts +7 -0
  16. package/dist/cjs/utils/spotCollateral.d.ts.map +1 -0
  17. package/dist/cjs/utils/spotCollateral.js +29 -0
  18. package/dist/cjs/utils/spotCollateral.js.map +1 -0
  19. package/dist/cjs/websocket/HyperliquidWsProvider.d.ts +15 -1
  20. package/dist/cjs/websocket/HyperliquidWsProvider.d.ts.map +1 -1
  21. package/dist/cjs/websocket/HyperliquidWsProvider.js +184 -27
  22. package/dist/cjs/websocket/HyperliquidWsProvider.js.map +1 -1
  23. package/dist/esm/HyperliquidProvider.d.ts.map +1 -1
  24. package/dist/esm/HyperliquidProvider.js +1 -0
  25. package/dist/esm/HyperliquidProvider.js.map +1 -1
  26. package/dist/esm/services/getAccount.d.ts.map +1 -1
  27. package/dist/esm/services/getAccount.js +2 -13
  28. package/dist/esm/services/getAccount.js.map +1 -1
  29. package/dist/esm/types/account.d.ts +3 -1
  30. package/dist/esm/types/account.d.ts.map +1 -1
  31. package/dist/esm/types/account.js +3 -1
  32. package/dist/esm/types/account.js.map +1 -1
  33. package/dist/esm/utils/index.d.ts +1 -0
  34. package/dist/esm/utils/index.d.ts.map +1 -1
  35. package/dist/esm/utils/index.js +1 -0
  36. package/dist/esm/utils/index.js.map +1 -1
  37. package/dist/esm/utils/spotCollateral.d.ts +14 -0
  38. package/dist/esm/utils/spotCollateral.d.ts.map +1 -0
  39. package/dist/esm/utils/spotCollateral.js +39 -0
  40. package/dist/esm/utils/spotCollateral.js.map +1 -0
  41. package/dist/esm/websocket/HyperliquidWsProvider.d.ts +23 -1
  42. package/dist/esm/websocket/HyperliquidWsProvider.d.ts.map +1 -1
  43. package/dist/esm/websocket/HyperliquidWsProvider.js +223 -17
  44. package/dist/esm/websocket/HyperliquidWsProvider.js.map +1 -1
  45. package/dist/types/HyperliquidProvider.d.ts.map +1 -1
  46. package/dist/types/services/getAccount.d.ts.map +1 -1
  47. package/dist/types/types/account.d.ts +3 -1
  48. package/dist/types/types/account.d.ts.map +1 -1
  49. package/dist/types/utils/index.d.ts +1 -0
  50. package/dist/types/utils/index.d.ts.map +1 -1
  51. package/dist/types/utils/spotCollateral.d.ts +14 -0
  52. package/dist/types/utils/spotCollateral.d.ts.map +1 -0
  53. package/dist/types/websocket/HyperliquidWsProvider.d.ts +23 -1
  54. package/dist/types/websocket/HyperliquidWsProvider.d.ts.map +1 -1
  55. package/package.json +3 -3
  56. package/src/HyperliquidProvider.ts +2 -0
  57. package/src/services/getAccount.ts +8 -12
  58. package/src/types/account.ts +3 -1
  59. package/src/utils/index.ts +1 -0
  60. package/src/utils/spotCollateral.ts +50 -0
  61. package/src/websocket/HyperliquidWsProvider.ts +304 -18
@@ -22,6 +22,7 @@ import {
22
22
  type HlUserFees,
23
23
  } from '../types/index.js'
24
24
  import {
25
+ partitionSpotBalances,
25
26
  perpsDexNames,
26
27
  spotAssetFromToken,
27
28
  spotBalance,
@@ -96,18 +97,13 @@ const buildBalances = (
96
97
  priceById: Map<string, number>,
97
98
  quoteAssetByCategory: Map<string, Asset>
98
99
  ): BalancePartition => {
99
- const balances: Balance[] = []
100
- const collateralBalances: Balance[] = []
101
-
102
- // Spot balances: collateral if and only if the token is a category quote asset.
103
- for (const b of spotState.balances) {
104
- const balance = spotBalance(spotAssetFromToken(b), b.total, priceById)
105
- if (quoteAssetIds.has(balance.asset.id)) {
106
- collateralBalances.push(balance)
107
- } else {
108
- balances.push(balance)
109
- }
110
- }
100
+ const { balances, collateralBalances } = partitionSpotBalances(
101
+ spotState.balances.map((b) =>
102
+ spotBalance(spotAssetFromToken(b), b.total, priceById)
103
+ ),
104
+ quoteAssetIds,
105
+ abstraction === HlAbstractionMode.PORTFOLIO_MARGIN
106
+ )
111
107
 
112
108
  // Unified/portfolio modes hold everything in spot — per-dex equity would
113
109
  // double-count. Only disabled/dexAbstraction carry separate venue collateral.
@@ -69,10 +69,12 @@ export type HlPreTransferCheck = {
69
69
 
70
70
  /**
71
71
  * Possible values returned by the `userAbstraction` info endpoint.
72
- * `null` means abstraction has never been set (standard mode).
72
+ * `null` means abstraction has never been set; `'default'`/`'disabled'` are
73
+ * the live and legacy spellings of standard (non-abstracted) mode.
73
74
  * @public
74
75
  */
75
76
  export const HlAbstractionMode = {
77
+ DEFAULT: 'default',
76
78
  DISABLED: 'disabled',
77
79
  UNIFIED_ACCOUNT: 'unifiedAccount',
78
80
  PORTFOLIO_MARGIN: 'portfolioMargin',
@@ -40,3 +40,4 @@ export {
40
40
  spotBalance,
41
41
  spotPriceById,
42
42
  } from './spotBalance.js'
43
+ export { partitionSpotBalances, type SpotPartition } from './spotCollateral.js'
@@ -0,0 +1,50 @@
1
+ import type { Balance } from '@lifi/perps-types'
2
+
3
+ /**
4
+ * Portfolio-margin collateral beyond the category quote assets, keyed by spot
5
+ * display symbol with its loan-to-value weight. Hyperliquid credits these
6
+ * toward buying power at the given fraction (0.5 for both today). Quote assets
7
+ * (USDC/USDT) are already full-value collateral via the category model, so
8
+ * they are not listed here.
9
+ * https://hyperliquid.gitbook.io/hyperliquid-docs/trading/portfolio-margin
10
+ */
11
+ const PORTFOLIO_MARGIN_LTV: Readonly<Record<string, number>> = {
12
+ HYPE: 0.5,
13
+ UBTC: 0.5,
14
+ }
15
+
16
+ /** @internal */
17
+ export interface SpotPartition {
18
+ collateralBalances: Balance[]
19
+ balances: Balance[]
20
+ }
21
+
22
+ /**
23
+ * Split spot balances into margin collateral and flat holdings. A balance is
24
+ * collateral when its token is a category quote asset (full value) or, under
25
+ * portfolio margin, an LTV-weighted asset (HYPE/BTC); everything else is a
26
+ * flat holding.
27
+ */
28
+ export const partitionSpotBalances = (
29
+ spotBalances: readonly Balance[],
30
+ quoteAssetIds: ReadonlySet<string>,
31
+ portfolioMargin: boolean
32
+ ): SpotPartition => {
33
+ const collateralBalances: Balance[] = []
34
+ const balances: Balance[] = []
35
+ for (const balance of spotBalances) {
36
+ if (quoteAssetIds.has(balance.asset.id)) {
37
+ collateralBalances.push(balance)
38
+ continue
39
+ }
40
+ const ltv = portfolioMargin
41
+ ? PORTFOLIO_MARGIN_LTV[balance.asset.displaySymbol]
42
+ : undefined
43
+ if (ltv !== undefined) {
44
+ collateralBalances.push({ ...balance, collateralWeight: ltv })
45
+ } else {
46
+ balances.push(balance)
47
+ }
48
+ }
49
+ return { collateralBalances, balances }
50
+ }
@@ -7,22 +7,32 @@ import {
7
7
  type QuoteListener,
8
8
  ReconnectingWebSocket,
9
9
  resolveSubscribeQuote,
10
+ type SubscriptionListener,
11
+ summarizeAccount,
10
12
  WsProviderBase,
11
13
  type WsProviderFactory,
14
+ type WsStatusListener,
12
15
  wsLog,
13
16
  } from '@lifi/perps-sdk'
14
17
  import {
18
+ type AccountResponse,
19
+ type Balance,
15
20
  type MarketContext,
16
21
  type OpenOrder,
17
22
  type OrderbookLevel,
18
23
  type OrderbookResponse,
19
24
  OrderSide,
20
25
  OrderType,
26
+ type Position,
21
27
  type Subscription,
22
28
  type TriggerOrder,
23
29
  } from '@lifi/perps-types'
24
30
  import Big from 'big.js'
25
- import { HYPERLIQUID_FEE_TIER_FALLBACK, SPOT_MARKET_ID } from '../constants.js'
31
+ import {
32
+ DEFAULT_HYPERLIQUID_API_URL,
33
+ HYPERLIQUID_FEE_TIER_FALLBACK,
34
+ SPOT_MARKET_ID,
35
+ } from '../constants.js'
26
36
  import type {
27
37
  HlAssetPosition,
28
38
  HlOrderDetail,
@@ -46,9 +56,12 @@ import type {
46
56
  HlWsTrade,
47
57
  HlWsUserFillsData,
48
58
  } from '../types/index.js'
59
+ import { HlAbstractionMode } from '../types/index.js'
49
60
  import {
50
61
  decodeCompressedJson,
51
62
  decodeFastAssetCtxs,
63
+ hlInfoOptions,
64
+ infoRequest,
52
65
  isOpenAssetPosition,
53
66
  isTriggerOrder,
54
67
  mapFill,
@@ -56,6 +69,7 @@ import {
56
69
  mapOrderStatus,
57
70
  mapOrderType,
58
71
  mapPosition,
72
+ partitionSpotBalances,
59
73
  priceStepToAggregation,
60
74
  spotAssetFromToken,
61
75
  spotBalance,
@@ -99,6 +113,42 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
99
113
  private readonly clearinghouseRefs = new Map<string, number>()
100
114
  private readonly client: PerpsSDKClient | undefined
101
115
  private readonly registry: MarketRegistry | undefined
116
+ // The clearinghouse stream covers perps equity only, so its summary is
117
+ // honest solely for modes whose collateral lives per-dex. The abstraction
118
+ // mode is read per subscribed user (null = never set = standard) and
119
+ // selects the summary source: standard/dexAbstraction emit the equity
120
+ // summary straight from clearinghouse frames, unified/portfolio run the
121
+ // spot-fed pipeline below. Frames arriving before the first read are held
122
+ // and released on resolution (delayed, never wrong). The read refreshes on
123
+ // fresh summary subscribes and once older than the TTL, and drops with
124
+ // the user's last clearinghouse subscription.
125
+ private readonly abstractionByUser = new Map<
126
+ string,
127
+ { mode: HlAbstractionMode | null; readAt: number } | 'pending'
128
+ >()
129
+ private readonly spotRefs = new Map<string, number>()
130
+ // Unified/portfolio summary pipeline: collateral lives in spot, margin and
131
+ // uPnL on the positions — the summary recomputes from the latest of both
132
+ // envelopes with the same gross calculator the REST getAccountSummary uses.
133
+ private readonly unifiedSummaryByUser = new Map<
134
+ string,
135
+ {
136
+ releaseSpot: Promise<() => void>
137
+ spot?: { collateralBalances: Balance[]; balances: Balance[] }
138
+ }
139
+ >()
140
+ // Kept outside the pipeline entry: the first clearinghouse frame lands
141
+ // while the mode read is still pending, before the pipeline exists.
142
+ private readonly latestPositionsByUser = new Map<string, Position[]>()
143
+ private readonly heldSummaryByUser = new Map<
144
+ string,
145
+ {
146
+ portfolioValue: string
147
+ availableMargin: string
148
+ marginUsed: string
149
+ unrealizedPnl: string
150
+ }
151
+ >()
102
152
  private perpCtxBySubDex = new Map<string, Record<string, HlWsPerpAssetCtx>>()
103
153
  private spotCtxByMarketId: Record<string, HlWsSpotAssetCtx> = {}
104
154
  private marketsContextByMarketId: Record<string, MarketContext> = {}
@@ -142,6 +192,33 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
142
192
  this.registry = client && getMarketRegistry(client, providerKey)
143
193
  }
144
194
 
195
+ override async subscribe(
196
+ sub: Subscription,
197
+ listener: SubscriptionListener,
198
+ onStatus?: WsStatusListener
199
+ ): Promise<() => void> {
200
+ const unsubscribe = await super.subscribe(sub, listener, onStatus)
201
+ // The summary source is decided by the abstraction mode, so every
202
+ // summary subscribe (re-)reads it — consumers resubscribe when they
203
+ // observe an account-config change, and the wire channel is usually
204
+ // reused (multiplexed listeners, teardown linger), so openChannel cannot
205
+ // carry this trigger. Coalesced so one React commit's burst of
206
+ // resubscribing hooks fires a single read.
207
+ if (sub.channel === 'accountSummary') {
208
+ const user = sub.address.toLowerCase()
209
+ const entry = this.abstractionByUser.get(user)
210
+ if (entry === undefined) {
211
+ this.fetchAbstractionMode(sub.address, user)
212
+ } else if (
213
+ entry !== 'pending' &&
214
+ Date.now() - entry.readAt > HyperliquidWsProvider.REFRESH_COALESCE_MS
215
+ ) {
216
+ this.fetchAbstractionMode(sub.address, user, entry)
217
+ }
218
+ }
219
+ return unsubscribe
220
+ }
221
+
145
222
  async subscribeQuote(
146
223
  params: ProviderGetQuoteParams,
147
224
  onQuote: QuoteListener
@@ -197,6 +274,14 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
197
274
  }
198
275
  }
199
276
 
277
+ // The spot wire is shared between the public spotBalances channel and
278
+ // the unified-summary pipeline; refcounted like the clearinghouse sub.
279
+ if (sub.channel === 'spotBalances') {
280
+ const release = await this.acquireSpotWire(sub.address)
281
+ await this.rws.ready()
282
+ return release
283
+ }
284
+
200
285
  // `positions` and `accountSummary` are two views over the same wire
201
286
  // subscription; refcount it so neither's teardown starves the other.
202
287
  if (sub.channel === 'positions' || sub.channel === 'accountSummary') {
@@ -213,6 +298,12 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
213
298
  const remaining = (this.clearinghouseRefs.get(user) ?? 1) - 1
214
299
  if (remaining <= 0) {
215
300
  this.clearinghouseRefs.delete(user)
301
+ // Drop the summary gate's mode read (and its pipeline) with the
302
+ // subscription so a resubscribe reflects an account-mode change.
303
+ this.abstractionByUser.delete(user)
304
+ this.heldSummaryByUser.delete(user)
305
+ this.latestPositionsByUser.delete(user)
306
+ this.syncUnifiedPipeline(user, user, null)
216
307
  this.unregisterSub(wireKey)
217
308
  this.rws.send(
218
309
  JSON.stringify({ method: 'unsubscribe', subscription: payload })
@@ -955,6 +1046,9 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
955
1046
  data: positions,
956
1047
  })
957
1048
 
1049
+ this.latestPositionsByUser.set(data.user.toLowerCase(), positions)
1050
+ this.emitUnifiedSummary(data.user.toLowerCase())
1051
+
958
1052
  let accountValue = 0
959
1053
  let marginUsed = 0
960
1054
  for (const [, state] of data.clearinghouseStates) {
@@ -969,30 +1063,222 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
969
1063
  )
970
1064
  // Equity semantics, matching the REST summary: `accountValue` already
971
1065
  // carries locked margin and unrealized PnL. Spot balances stream apart,
972
- // so this portfolio value covers perps equity only.
973
- this.emit(`accountSummary:${data.user.toLowerCase()}`, {
1066
+ // so this portfolio value covers perps equity only — which is why the
1067
+ // frame is gated on the abstraction mode below.
1068
+ this.emitSummaryIfModeAllows(data.user, {
1069
+ portfolioValue: accountValue.toString(),
1070
+ availableMargin: (accountValue - marginUsed).toString(),
1071
+ marginUsed: marginUsed.toString(),
1072
+ unrealizedPnl: unrealizedPnl.toString(),
1073
+ })
1074
+ }
1075
+
1076
+ /** Whether the mode's collateral lives per-dex, making the perps-only
1077
+ * summary frame honest. Unified/portfolio hold collateral in spot. */
1078
+ private static summaryComputableFor(mode: HlAbstractionMode | null): boolean {
1079
+ return (
1080
+ mode !== HlAbstractionMode.UNIFIED_ACCOUNT &&
1081
+ mode !== HlAbstractionMode.PORTFOLIO_MARGIN
1082
+ )
1083
+ }
1084
+
1085
+ private async acquireSpotWire(address: string): Promise<() => void> {
1086
+ // Lowercased in the payload too, so releases from either holder (public
1087
+ // channel or pipeline) unsubscribe with an identical payload.
1088
+ const user = address.toLowerCase()
1089
+ const wireKey = `spot:${user}`
1090
+ const payload = { type: 'spotState', user }
1091
+ const count = this.spotRefs.get(user) ?? 0
1092
+ this.spotRefs.set(user, count + 1)
1093
+ if (count === 0) {
1094
+ await this.registerSub(wireKey, payload)
1095
+ }
1096
+ let released = false
1097
+ return () => {
1098
+ if (released) {
1099
+ return
1100
+ }
1101
+ released = true
1102
+ const remaining = (this.spotRefs.get(user) ?? 1) - 1
1103
+ if (remaining <= 0) {
1104
+ this.spotRefs.delete(user)
1105
+ this.unregisterSub(wireKey)
1106
+ this.rws.send(
1107
+ JSON.stringify({ method: 'unsubscribe', subscription: payload })
1108
+ )
1109
+ } else {
1110
+ this.spotRefs.set(user, remaining)
1111
+ }
1112
+ }
1113
+ }
1114
+
1115
+ /** Start or stop the spot-fed unified pipeline to match the resolved mode. */
1116
+ private syncUnifiedPipeline(
1117
+ key: string,
1118
+ address: string,
1119
+ mode: HlAbstractionMode | null
1120
+ ) {
1121
+ const unified = !HyperliquidWsProvider.summaryComputableFor(mode)
1122
+ const existing = this.unifiedSummaryByUser.get(key)
1123
+ if (unified && existing === undefined) {
1124
+ this.unifiedSummaryByUser.set(key, {
1125
+ releaseSpot: this.acquireSpotWire(address).catch((error) => {
1126
+ wsLog.handlerFailure(this.providerKey, error)
1127
+ return () => {}
1128
+ }),
1129
+ })
1130
+ } else if (!unified && existing !== undefined) {
1131
+ this.unifiedSummaryByUser.delete(key)
1132
+ void existing.releaseSpot.then((release) => release())
1133
+ }
1134
+ }
1135
+
1136
+ private emitUnifiedSummary(key: string) {
1137
+ const pipeline = this.unifiedSummaryByUser.get(key)
1138
+ const positions = this.latestPositionsByUser.get(key)
1139
+ // Both envelopes must have arrived: a spot-only summary would report
1140
+ // zero margin used against real positions.
1141
+ if (pipeline?.spot === undefined || positions === undefined) {
1142
+ return
1143
+ }
1144
+ const summary = summarizeAccount(
1145
+ // summarizeAccount reads only the two balance lists.
1146
+ {
1147
+ collateralBalances: pipeline.spot.collateralBalances,
1148
+ balances: pipeline.spot.balances,
1149
+ } as AccountResponse,
1150
+ positions,
1151
+ 'gross'
1152
+ )
1153
+ this.emit(`accountSummary:${key}`, {
974
1154
  channel: 'accountSummary',
975
- data: {
976
- portfolioValue: accountValue.toString(),
977
- availableMargin: (accountValue - marginUsed).toString(),
978
- marginUsed: marginUsed.toString(),
979
- unrealizedPnl: unrealizedPnl.toString(),
980
- },
1155
+ data: summary,
981
1156
  })
982
1157
  }
983
1158
 
984
- private handleSpotState(data: HlWsSpotStateData) {
985
- const priceById = spotPriceById(
986
- this.registry?.markets ?? [],
987
- this.mergedMids()
1159
+ /** Re-read the abstraction mode this long after the previous read, so an
1160
+ * in-session account-mode switch flips the gate within one refresh. */
1161
+ private static readonly ABSTRACTION_TTL_MS = 15_000
1162
+
1163
+ /** Subscribe-triggered re-reads younger than this are skipped: a config
1164
+ * change resubscribes every consumer hook in one React commit, and one
1165
+ * read serves them all. */
1166
+ private static readonly REFRESH_COALESCE_MS = 200
1167
+
1168
+ private emitSummaryIfModeAllows(
1169
+ user: string,
1170
+ summary: {
1171
+ portfolioValue: string
1172
+ availableMargin: string
1173
+ marginUsed: string
1174
+ unrealizedPnl: string
1175
+ }
1176
+ ) {
1177
+ const key = user.toLowerCase()
1178
+ // Without a client there is no way to read the mode; keep the historic
1179
+ // always-emit behavior rather than silencing every standalone consumer.
1180
+ if (this.client === undefined) {
1181
+ this.emit(`accountSummary:${key}`, {
1182
+ channel: 'accountSummary',
1183
+ data: summary,
1184
+ })
1185
+ return
1186
+ }
1187
+ const entry = this.abstractionByUser.get(key)
1188
+ if (entry === undefined || entry === 'pending') {
1189
+ this.heldSummaryByUser.set(key, summary)
1190
+ if (entry === undefined) {
1191
+ this.fetchAbstractionMode(user, key)
1192
+ }
1193
+ return
1194
+ }
1195
+ if (Date.now() - entry.readAt > HyperliquidWsProvider.ABSTRACTION_TTL_MS) {
1196
+ // Refresh in the background; the current mode keeps gating meanwhile,
1197
+ // so frames neither stall nor mis-emit while the read is in flight.
1198
+ this.fetchAbstractionMode(user, key, entry)
1199
+ }
1200
+ if (!HyperliquidWsProvider.summaryComputableFor(entry.mode)) {
1201
+ return
1202
+ }
1203
+ this.emit(`accountSummary:${key}`, {
1204
+ channel: 'accountSummary',
1205
+ data: summary,
1206
+ })
1207
+ }
1208
+
1209
+ private fetchAbstractionMode(
1210
+ user: string,
1211
+ key: string,
1212
+ refreshing?: { mode: HlAbstractionMode | null; readAt: number }
1213
+ ) {
1214
+ const client = this.client
1215
+ if (client === undefined) {
1216
+ return
1217
+ }
1218
+ // A background refresh keeps the current entry live (stamped now so
1219
+ // frames don't re-kick the read); the first read holds frames instead.
1220
+ this.abstractionByUser.set(
1221
+ key,
1222
+ refreshing ? { ...refreshing, readAt: Date.now() } : 'pending'
988
1223
  )
989
- this.emit(`spotState:${data.user.toLowerCase()}`, {
1224
+ // "Never set abstraction" is a successful 200 `null` body — only a fetch
1225
+ // failure clears a first read so the next frame retries it. A failed
1226
+ // refresh keeps the previous mode until the TTL passes again.
1227
+ infoRequest<HlAbstractionMode | null>(
1228
+ DEFAULT_HYPERLIQUID_API_URL,
1229
+ { type: 'userAbstraction', user },
1230
+ hlInfoOptions(client)
1231
+ ).then(
1232
+ (mode) => {
1233
+ this.abstractionByUser.set(key, { mode, readAt: Date.now() })
1234
+ this.syncUnifiedPipeline(key, user, mode)
1235
+ const held = this.heldSummaryByUser.get(key)
1236
+ this.heldSummaryByUser.delete(key)
1237
+ if (held && HyperliquidWsProvider.summaryComputableFor(mode)) {
1238
+ this.emit(`accountSummary:${key}`, {
1239
+ channel: 'accountSummary',
1240
+ data: held,
1241
+ })
1242
+ }
1243
+ },
1244
+ (error) => {
1245
+ if (!refreshing) {
1246
+ this.abstractionByUser.delete(key)
1247
+ }
1248
+ wsLog.handlerFailure(this.providerKey, error)
1249
+ }
1250
+ )
1251
+ }
1252
+
1253
+ private handleSpotState(data: HlWsSpotStateData) {
1254
+ const user = data.user.toLowerCase()
1255
+ const markets = this.registry?.markets ?? []
1256
+ const priceById = spotPriceById(markets, this.mergedMids())
1257
+ const rows = data.spotState.balances.map((b) => ({
1258
+ balance: spotBalance(spotAssetFromToken(b), b.total, priceById),
1259
+ hold: b.hold,
1260
+ }))
1261
+ this.emit(`spotState:${user}`, {
990
1262
  channel: 'spotBalances',
991
- data: data.spotState.balances.map((b) => ({
992
- ...spotBalance(spotAssetFromToken(b), b.total, priceById),
993
- locked: b.hold,
994
- })),
1263
+ data: rows.map(({ balance, hold }) => ({ ...balance, locked: hold })),
995
1264
  })
1265
+
1266
+ const pipeline = this.unifiedSummaryByUser.get(user)
1267
+ if (pipeline !== undefined) {
1268
+ const quoteAssetIds = new Set(markets.map((m) => m.quoteAsset.id))
1269
+ const entry = this.abstractionByUser.get(user)
1270
+ const portfolioMargin =
1271
+ entry !== undefined &&
1272
+ entry !== 'pending' &&
1273
+ entry.mode === HlAbstractionMode.PORTFOLIO_MARGIN
1274
+ // Same partition as getAccount so REST and WS agree on collateral.
1275
+ pipeline.spot = partitionSpotBalances(
1276
+ rows.map(({ balance }) => balance),
1277
+ quoteAssetIds,
1278
+ portfolioMargin
1279
+ )
1280
+ this.emitUnifiedSummary(user)
1281
+ }
996
1282
  }
997
1283
  }
998
1284