@lifi/perps-sdk-provider-hyperliquid 20.0.0 → 20.1.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 (44) hide show
  1. package/README.md +45 -0
  2. package/dist/cjs/services/getActivity.d.ts.map +1 -1
  3. package/dist/cjs/services/getActivity.js +68 -44
  4. package/dist/cjs/services/getActivity.js.map +1 -1
  5. package/dist/cjs/services/getFills.d.ts.map +1 -1
  6. package/dist/cjs/services/getFills.js +30 -38
  7. package/dist/cjs/services/getFills.js.map +1 -1
  8. package/dist/cjs/services/historyPagination.d.ts +22 -0
  9. package/dist/cjs/services/historyPagination.d.ts.map +1 -0
  10. package/dist/cjs/services/historyPagination.js +125 -0
  11. package/dist/cjs/services/historyPagination.js.map +1 -0
  12. package/dist/cjs/websocket/HyperliquidWsProvider.d.ts +6 -0
  13. package/dist/cjs/websocket/HyperliquidWsProvider.d.ts.map +1 -1
  14. package/dist/cjs/websocket/HyperliquidWsProvider.js +129 -37
  15. package/dist/cjs/websocket/HyperliquidWsProvider.js.map +1 -1
  16. package/dist/esm/services/getActivity.d.ts +3 -13
  17. package/dist/esm/services/getActivity.d.ts.map +1 -1
  18. package/dist/esm/services/getActivity.js +74 -64
  19. package/dist/esm/services/getActivity.js.map +1 -1
  20. package/dist/esm/services/getFills.d.ts +2 -14
  21. package/dist/esm/services/getFills.d.ts.map +1 -1
  22. package/dist/esm/services/getFills.js +32 -58
  23. package/dist/esm/services/getFills.js.map +1 -1
  24. package/dist/esm/services/historyPagination.d.ts +22 -0
  25. package/dist/esm/services/historyPagination.d.ts.map +1 -0
  26. package/dist/esm/services/historyPagination.js +121 -0
  27. package/dist/esm/services/historyPagination.js.map +1 -0
  28. package/dist/esm/websocket/HyperliquidWsProvider.d.ts +6 -0
  29. package/dist/esm/websocket/HyperliquidWsProvider.d.ts.map +1 -1
  30. package/dist/esm/websocket/HyperliquidWsProvider.js +131 -49
  31. package/dist/esm/websocket/HyperliquidWsProvider.js.map +1 -1
  32. package/dist/types/services/getActivity.d.ts +3 -13
  33. package/dist/types/services/getActivity.d.ts.map +1 -1
  34. package/dist/types/services/getFills.d.ts +2 -14
  35. package/dist/types/services/getFills.d.ts.map +1 -1
  36. package/dist/types/services/historyPagination.d.ts +22 -0
  37. package/dist/types/services/historyPagination.d.ts.map +1 -0
  38. package/dist/types/websocket/HyperliquidWsProvider.d.ts +6 -0
  39. package/dist/types/websocket/HyperliquidWsProvider.d.ts.map +1 -1
  40. package/package.json +2 -2
  41. package/src/services/getActivity.ts +133 -104
  42. package/src/services/getFills.ts +42 -95
  43. package/src/services/historyPagination.ts +169 -0
  44. package/src/websocket/HyperliquidWsProvider.ts +141 -63
@@ -0,0 +1,169 @@
1
+ import { PerpsError } from '@lifi/perps-sdk'
2
+ import { PerpsErrorCode } from '@lifi/perps-types'
3
+ import { DEFAULT_HISTORY_LIMIT, MAX_HISTORY_LIMIT } from '../constants.js'
4
+
5
+ type HistoryKey = string | number
6
+ interface HistoryBoundary {
7
+ time: number
8
+ key: HistoryKey
9
+ startTime: number
10
+ endTime: number
11
+ }
12
+
13
+ const compareKeys = (a: HistoryKey, b: HistoryKey): number =>
14
+ typeof a === 'number' && typeof b === 'number'
15
+ ? b - a
16
+ : a < b
17
+ ? 1
18
+ : a > b
19
+ ? -1
20
+ : 0
21
+
22
+ export const getHistoryLimit = (limit = DEFAULT_HISTORY_LIMIT): number => {
23
+ if (!Number.isSafeInteger(limit) || limit < 1) {
24
+ throw new PerpsError(
25
+ PerpsErrorCode.ValidationError,
26
+ 'Hyperliquid history limit must be a positive safe integer'
27
+ )
28
+ }
29
+ return Math.min(limit, MAX_HISTORY_LIMIT)
30
+ }
31
+
32
+ const decodeCursor = (cursor: string): HistoryBoundary => {
33
+ let value: unknown
34
+ try {
35
+ value = JSON.parse(cursor)
36
+ } catch {
37
+ throw new PerpsError(
38
+ PerpsErrorCode.ValidationError,
39
+ 'Invalid Hyperliquid history cursor'
40
+ )
41
+ }
42
+ if (
43
+ !Array.isArray(value) ||
44
+ value.length !== 4 ||
45
+ !Number.isSafeInteger(value[0]) ||
46
+ (typeof value[1] !== 'string' && typeof value[1] !== 'number') ||
47
+ !Number.isSafeInteger(value[2]) ||
48
+ value[2] > value[0] ||
49
+ !Number.isSafeInteger(value[3]) ||
50
+ value[3] < value[0]
51
+ ) {
52
+ throw new PerpsError(
53
+ PerpsErrorCode.ValidationError,
54
+ 'Invalid Hyperliquid history cursor'
55
+ )
56
+ }
57
+ return {
58
+ time: value[0],
59
+ key: value[1],
60
+ startTime: value[2],
61
+ endTime: value[3],
62
+ }
63
+ }
64
+
65
+ export async function historyPage<T>(
66
+ params: { startTime?: number; endTime?: number; cursor?: string },
67
+ limit: number,
68
+ fetchWindow: (
69
+ startTime: number,
70
+ endTime: number,
71
+ snapshotEnd: number
72
+ ) => Promise<{ items: T[]; saturated: boolean }>,
73
+ identify: (item: T) => { time: number; key: HistoryKey },
74
+ signal?: AbortSignal
75
+ ): Promise<{
76
+ items: T[]
77
+ pagination: { limit: number; hasMore: boolean; cursor?: string }
78
+ }> {
79
+ const boundary =
80
+ params.cursor === undefined ? undefined : decodeCursor(params.cursor)
81
+ const lower = params.startTime ?? 0
82
+ const snapshotEnd = boundary?.endTime ?? params.endTime ?? Date.now()
83
+ const upper = Math.min(snapshotEnd, boundary?.time ?? Infinity)
84
+ const windows: { startTime: number; endTime: number }[] = []
85
+ if (boundary && boundary.startTime > lower) {
86
+ windows.push({ startTime: lower, endTime: boundary.startTime - 1 })
87
+ }
88
+ windows.push({
89
+ startTime: Math.max(lower, boundary?.startTime ?? lower),
90
+ endTime: upper,
91
+ })
92
+ const items: T[] = []
93
+ let last: HistoryBoundary | undefined
94
+ while (windows.length > 0) {
95
+ signal?.throwIfAborted()
96
+ const window = windows.pop()!
97
+ if (window.startTime > window.endTime) {
98
+ continue
99
+ }
100
+ const result = await fetchWindow(
101
+ window.startTime,
102
+ window.endTime,
103
+ snapshotEnd
104
+ )
105
+ signal?.throwIfAborted()
106
+ if (result.saturated) {
107
+ if (window.startTime === window.endTime) {
108
+ throw new PerpsError(
109
+ PerpsErrorCode.ThirdPartyError,
110
+ 'Hyperliquid history saturates a single millisecond; its time-only API cannot paginate this timestamp safely'
111
+ )
112
+ }
113
+ // Time APIs truncate from the start, so a capped response cannot supply a newest page.
114
+ const midpoint =
115
+ window.startTime + Math.floor((window.endTime - window.startTime) / 2)
116
+ windows.push({ startTime: window.startTime, endTime: midpoint })
117
+ windows.push({ startTime: midpoint + 1, endTime: window.endTime })
118
+ continue
119
+ }
120
+ const sorted = result.items.sort((a, b) => {
121
+ const left = identify(a)
122
+ const right = identify(b)
123
+ return right.time - left.time || compareKeys(left.key, right.key)
124
+ })
125
+ for (const item of sorted) {
126
+ const identity = identify(item)
127
+ if (identity.time < window.startTime || identity.time > window.endTime) {
128
+ continue
129
+ }
130
+ if (
131
+ boundary &&
132
+ (identity.time > boundary.time ||
133
+ (identity.time === boundary.time &&
134
+ compareKeys(identity.key, boundary.key) <= 0))
135
+ ) {
136
+ continue
137
+ }
138
+ if (items.length === limit) {
139
+ return {
140
+ items,
141
+ pagination: {
142
+ limit,
143
+ hasMore: true,
144
+ cursor:
145
+ last &&
146
+ JSON.stringify([
147
+ last.time,
148
+ last.key,
149
+ last.startTime,
150
+ last.endTime,
151
+ ]),
152
+ },
153
+ }
154
+ }
155
+ items.push(item)
156
+ last = { ...identity, startTime: window.startTime, endTime: snapshotEnd }
157
+ }
158
+ }
159
+ return {
160
+ items,
161
+ pagination: {
162
+ limit,
163
+ hasMore: false,
164
+ cursor:
165
+ last &&
166
+ JSON.stringify([last.time, last.key, last.startTime, last.endTime]),
167
+ },
168
+ }
169
+ }
@@ -137,20 +137,21 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
137
137
  private readonly clearinghouseRefs = new Map<string, number>()
138
138
  private readonly client: PerpsSDKClient | undefined
139
139
  private readonly registry: MarketRegistry | undefined
140
- // The clearinghouse stream covers perps equity only, so its summary is
141
- // honest solely for modes whose collateral lives per-dex. The abstraction
142
- // mode is read per subscribed user (null = never set = standard) and
143
- // selects the summary source: standard/dexAbstraction emit the equity
144
- // summary straight from clearinghouse frames, unified/portfolio run the
145
- // spot-fed pipeline below. Frames arriving before the first read are held
146
- // and released on resolution (delayed, never wrong). The read refreshes on
147
- // fresh summary subscribes and once older than the TTL, and drops with
148
- // the user's last clearinghouse subscription.
140
+ // Spot-funded modes need both spot valuation and clearinghouse margin state.
149
141
  private readonly abstractionByUser = new Map<
150
142
  string,
151
143
  { mode: HlAbstractionMode | null; readAt: number } | 'pending'
152
144
  >()
145
+ private readonly summaryUsers = new Set<string>()
146
+ private readonly abstractionReadsByUser = new Map<string, symbol>()
153
147
  private readonly spotRefs = new Map<string, number>()
148
+ private spotWireEpoch = 0
149
+ private readonly latestSpotByUser = new Map<string, HlWsSpotStateData>()
150
+ private readonly revalueSpotBalances = () => {
151
+ for (const user of this.latestSpotByUser.keys()) {
152
+ this.emitSpotBalances(user)
153
+ }
154
+ }
154
155
  // Unified/portfolio summary pipeline: collateral lives in spot, margin and
155
156
  // uPnL on the positions — the summary recomputes from the latest of both
156
157
  // envelopes with the same gross calculator the REST getAccountSummary uses.
@@ -310,16 +311,15 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
310
311
  // Markets context aggregates slower asset-context feeds with fast mid/mark
311
312
  // ticks from `fastAssetCtxs`.
312
313
  if (sub.channel === 'marketsContext') {
314
+ const epoch = this.spotWireEpoch
313
315
  const entries = this.getMarketsContextSubEntries()
314
-
315
- for (const { subKey, payload } of entries) {
316
- await this.registerSub(subKey, payload)
317
- }
318
-
319
- await this.rws.ready()
320
-
321
- return () => {
322
- for (const { subKey, payload } of entries) {
316
+ let acquired = 0
317
+ const release = () => {
318
+ if (epoch !== this.spotWireEpoch) {
319
+ return
320
+ }
321
+ for (let index = 0; index < acquired; index++) {
322
+ const { subKey, payload } = entries[index]
323
323
  this.unregisterSub(subKey)
324
324
  this.rws.send(
325
325
  JSON.stringify({ method: 'unsubscribe', subscription: payload })
@@ -329,15 +329,25 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
329
329
  this.spotCtxByMarketId = {}
330
330
  this.fastCtxByMarketId = {}
331
331
  this.marketsContextByMarketId = {}
332
+ acquired = 0
333
+ }
334
+ try {
335
+ for (const { subKey, payload } of entries) {
336
+ await this.registerSub(subKey, payload)
337
+ acquired++
338
+ }
339
+ await this.rws.ready()
340
+ return release
341
+ } catch (error) {
342
+ release()
343
+ throw error
332
344
  }
333
345
  }
334
346
 
335
347
  // The spot wire is shared between the public spotBalances channel and
336
348
  // the unified-summary pipeline; refcounted like the clearinghouse sub.
337
349
  if (sub.channel === 'spotBalances') {
338
- const release = await this.acquireSpotWire(sub.address)
339
- await this.rws.ready()
340
- return release
350
+ return this.acquireSpotWire(sub.address)
341
351
  }
342
352
 
343
353
  // `positions` and `accountSummary` are two views over the same wire
@@ -348,20 +358,25 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
348
358
  const payload = this.toHlPayload(sub)
349
359
  const count = this.clearinghouseRefs.get(user) ?? 0
350
360
  this.clearinghouseRefs.set(user, count + 1)
361
+ if (sub.channel === 'accountSummary') {
362
+ this.summaryUsers.add(user)
363
+ }
351
364
  if (count === 0) {
352
365
  await this.registerSub(wireKey, payload)
353
366
  }
354
367
  await this.rws.ready()
355
368
  return () => {
369
+ if (sub.channel === 'accountSummary') {
370
+ this.summaryUsers.delete(user)
371
+ this.abstractionReadsByUser.delete(user)
372
+ this.abstractionByUser.delete(user)
373
+ this.heldSummaryByUser.delete(user)
374
+ this.syncUnifiedPipeline(user, user, null)
375
+ }
356
376
  const remaining = (this.clearinghouseRefs.get(user) ?? 1) - 1
357
377
  if (remaining <= 0) {
358
378
  this.clearinghouseRefs.delete(user)
359
- // Drop the summary gate's mode read (and its pipeline) with the
360
- // subscription so a resubscribe reflects an account-mode change.
361
- this.abstractionByUser.delete(user)
362
- this.heldSummaryByUser.delete(user)
363
379
  this.latestPerpsByUser.delete(user)
364
- this.syncUnifiedPipeline(user, user, null)
365
380
  this.unregisterSub(wireKey)
366
381
  this.rws.send(
367
382
  JSON.stringify({ method: 'unsubscribe', subscription: payload })
@@ -489,9 +504,19 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
489
504
  }
490
505
 
491
506
  protected override onClose(): void {
507
+ this.spotWireEpoch++
492
508
  this.perpCtxBySubDex.clear()
493
509
  this.spotCtxByMarketId = {}
494
510
  this.fastCtxByMarketId = {}
511
+ this.latestSpotByUser.clear()
512
+ this.spotRefs.clear()
513
+ this.clearinghouseRefs.clear()
514
+ this.summaryUsers.clear()
515
+ this.abstractionReadsByUser.clear()
516
+ this.abstractionByUser.clear()
517
+ this.heldSummaryByUser.clear()
518
+ this.latestPerpsByUser.clear()
519
+ this.unifiedSummaryByUser.clear()
495
520
  this.marketsContextByMarketId = {}
496
521
  this.orderbookKeysByMarketId.clear()
497
522
  this.latestOrderbookByMarketId.clear()
@@ -1206,22 +1231,23 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
1206
1231
  // Lowercased in the payload too, so releases from either holder (public
1207
1232
  // channel or pipeline) unsubscribe with an identical payload.
1208
1233
  const user = address.toLowerCase()
1234
+ const epoch = this.spotWireEpoch
1209
1235
  const wireKey = `spot:${user}`
1210
1236
  const payload = { type: 'spotState', user }
1211
1237
  const count = this.spotRefs.get(user) ?? 0
1212
1238
  this.spotRefs.set(user, count + 1)
1213
- if (count === 0) {
1214
- await this.registerSub(wireKey, payload)
1215
- }
1239
+ let releasePrices: (() => void) | undefined
1216
1240
  let released = false
1217
- return () => {
1218
- if (released) {
1241
+ const release = () => {
1242
+ if (released || epoch !== this.spotWireEpoch) {
1219
1243
  return
1220
1244
  }
1221
1245
  released = true
1246
+ releasePrices?.()
1222
1247
  const remaining = (this.spotRefs.get(user) ?? 1) - 1
1223
1248
  if (remaining <= 0) {
1224
1249
  this.spotRefs.delete(user)
1250
+ this.latestSpotByUser.delete(user)
1225
1251
  this.unregisterSub(wireKey)
1226
1252
  this.rws.send(
1227
1253
  JSON.stringify({ method: 'unsubscribe', subscription: payload })
@@ -1230,6 +1256,21 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
1230
1256
  this.spotRefs.set(user, remaining)
1231
1257
  }
1232
1258
  }
1259
+ try {
1260
+ if (count === 0) {
1261
+ await this.registerSub(wireKey, payload)
1262
+ }
1263
+ if (epoch === this.spotWireEpoch) {
1264
+ releasePrices = await super.subscribe(
1265
+ { channel: 'marketsContext', dex: this.providerKey },
1266
+ this.revalueSpotBalances
1267
+ )
1268
+ }
1269
+ return release
1270
+ } catch (error) {
1271
+ release()
1272
+ throw error
1273
+ }
1233
1274
  }
1234
1275
 
1235
1276
  /** Start or stop the spot-fed unified pipeline to match the resolved mode. */
@@ -1241,12 +1282,22 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
1241
1282
  const unified = !HyperliquidWsProvider.summaryComputableFor(mode)
1242
1283
  const existing = this.unifiedSummaryByUser.get(key)
1243
1284
  if (unified && existing === undefined) {
1244
- this.unifiedSummaryByUser.set(key, {
1245
- releaseSpot: this.acquireSpotWire(address).catch((error) => {
1246
- wsLog.handlerFailure(this.providerKey, error)
1247
- return () => {}
1248
- }),
1285
+ const epoch = this.spotWireEpoch
1286
+ const releaseSpot = this.acquireSpotWire(address).catch((error) => {
1287
+ if (this.unifiedSummaryByUser.get(key)?.releaseSpot === releaseSpot) {
1288
+ this.unifiedSummaryByUser.delete(key)
1289
+ }
1290
+ wsLog.handlerFailure(this.providerKey, error)
1291
+ return () => {}
1249
1292
  })
1293
+ if (epoch !== this.spotWireEpoch) {
1294
+ return
1295
+ }
1296
+ this.unifiedSummaryByUser.set(key, { releaseSpot })
1297
+ const spot = this.latestSpotByUser.get(key)
1298
+ if (spot !== undefined) {
1299
+ this.handleSpotState(spot)
1300
+ }
1250
1301
  } else if (!unified && existing !== undefined) {
1251
1302
  this.unifiedSummaryByUser.delete(key)
1252
1303
  void existing.releaseSpot.then((release) => release())
@@ -1303,6 +1354,9 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
1303
1354
  }
1304
1355
  ) {
1305
1356
  const key = user.toLowerCase()
1357
+ if (!this.summaryUsers.has(key)) {
1358
+ return
1359
+ }
1306
1360
  // Without a client there is no way to read the mode; keep the historic
1307
1361
  // always-emit behavior rather than silencing every standalone consumer.
1308
1362
  if (this.client === undefined) {
@@ -1340,7 +1394,7 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
1340
1394
  refreshing?: { mode: HlAbstractionMode | null; readAt: number }
1341
1395
  ) {
1342
1396
  const client = this.client
1343
- if (client === undefined) {
1397
+ if (client === undefined || !this.summaryUsers.has(key)) {
1344
1398
  return
1345
1399
  }
1346
1400
  // A background refresh keeps the current entry live (stamped now so
@@ -1349,6 +1403,8 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
1349
1403
  key,
1350
1404
  refreshing ? { ...refreshing, readAt: Date.now() } : 'pending'
1351
1405
  )
1406
+ const read = Symbol()
1407
+ this.abstractionReadsByUser.set(key, read)
1352
1408
  // "Never set abstraction" is a successful 200 `null` body — only a fetch
1353
1409
  // failure clears a first read so the next frame retries it. A failed
1354
1410
  // refresh keeps the previous mode until the TTL passes again.
@@ -1358,6 +1414,10 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
1358
1414
  hlInfoOptions(client)
1359
1415
  ).then(
1360
1416
  (mode) => {
1417
+ if (this.abstractionReadsByUser.get(key) !== read) {
1418
+ return
1419
+ }
1420
+ this.abstractionReadsByUser.delete(key)
1361
1421
  this.abstractionByUser.set(key, { mode, readAt: Date.now() })
1362
1422
  this.syncUnifiedPipeline(key, user, mode)
1363
1423
  const held = this.heldSummaryByUser.get(key)
@@ -1370,6 +1430,10 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
1370
1430
  }
1371
1431
  },
1372
1432
  (error) => {
1433
+ if (this.abstractionReadsByUser.get(key) !== read) {
1434
+ return
1435
+ }
1436
+ this.abstractionReadsByUser.delete(key)
1373
1437
  if (!refreshing) {
1374
1438
  this.abstractionByUser.delete(key)
1375
1439
  }
@@ -1401,35 +1465,10 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
1401
1465
 
1402
1466
  private handleSpotState(data: HlWsSpotStateData) {
1403
1467
  const user = data.user.toLowerCase()
1468
+ this.latestSpotByUser.set(user, data)
1404
1469
  const markets = this.registry?.activeMarkets ?? []
1405
- const priceById = spotPriceById(markets, this.mergedMids())
1406
- const rows = data.spotState.balances
1407
- .filter(
1408
- (balance) =>
1409
- !assetIsOutcome(balance.coin) &&
1410
- toWireBig(balance.total, 'spotState.balances.total').gt(0)
1411
- )
1412
- .map((balance) => ({
1413
- balance: spotBalance(
1414
- spotAssetFromToken(balance),
1415
- balance.total,
1416
- priceById
1417
- ),
1418
- hold: balance.hold,
1419
- }))
1420
- this.emit(`spotState:${user}`, {
1421
- channel: 'spotBalances',
1422
- data: rows.map(({ balance, hold }) => ({ ...balance, locked: hold })),
1423
- })
1424
-
1425
1470
  const pipeline = this.unifiedSummaryByUser.get(user)
1426
1471
  if (pipeline !== undefined) {
1427
- const quoteAssetIds = new Set(markets.map((m) => m.quoteAsset.id))
1428
- // Same partition as getAccount so REST and WS agree on collateral.
1429
- pipeline.spot = partitionSpotBalances(
1430
- rows.map(({ balance }) => balance),
1431
- quoteAssetIds
1432
- )
1433
1472
  const settlementAssetId = markets.find(
1434
1473
  (m) => m.categoryId === this.providerKey
1435
1474
  )?.quoteAsset.id
@@ -1442,6 +1481,45 @@ export class HyperliquidWsProvider extends WsProviderBase<object> {
1442
1481
  } else {
1443
1482
  pipeline.availableMargin = available
1444
1483
  }
1484
+ }
1485
+ this.emitSpotBalances(user)
1486
+ }
1487
+
1488
+ private emitSpotBalances(user: string) {
1489
+ const data = this.latestSpotByUser.get(user)
1490
+ if (data === undefined) {
1491
+ return
1492
+ }
1493
+ const markets = this.registry?.activeMarkets ?? []
1494
+ const priceById = spotPriceById(markets, this.mergedMids())
1495
+ const balances = data.spotState.balances.filter(
1496
+ (balance) =>
1497
+ !assetIsOutcome(balance.coin) &&
1498
+ toWireBig(balance.total, 'spotState.balances.total').gt(0)
1499
+ )
1500
+ const pipeline = this.unifiedSummaryByUser.get(user)
1501
+ // Known spot markets await a price; unlisted tokens keep their unpriced balance.
1502
+ if (
1503
+ balances.some((balance) => priceById.get(String(balance.token)) === 0)
1504
+ ) {
1505
+ if (pipeline !== undefined) {
1506
+ pipeline.spot = undefined
1507
+ }
1508
+ return
1509
+ }
1510
+ const rows = balances.map((balance) => ({
1511
+ ...spotBalance(spotAssetFromToken(balance), balance.total, priceById),
1512
+ locked: balance.hold,
1513
+ }))
1514
+ this.emit(`spotState:${user}`, {
1515
+ channel: 'spotBalances',
1516
+ data: rows,
1517
+ })
1518
+ if (pipeline !== undefined) {
1519
+ pipeline.spot = partitionSpotBalances(
1520
+ rows,
1521
+ new Set(markets.map((market) => market.quoteAsset.id))
1522
+ )
1445
1523
  this.emitUnifiedSummary(user)
1446
1524
  }
1447
1525
  }