@pear-protocol/exchanges-sdk 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +78 -12
  2. package/dist/client/hyperliquid/client.test.js +41 -0
  3. package/dist/client/hyperliquid/exchange-ws.js +3 -2
  4. package/dist/client/hyperliquid/rest.js +26 -5
  5. package/dist/client/hyperliquid/ws.js +16 -4
  6. package/dist/client/index.js +2 -2
  7. package/dist/client/lighter/constants.js +9 -1
  8. package/dist/client/lighter/constants.test.js +22 -0
  9. package/dist/client/lighter/exchange-ws.js +2 -2
  10. package/dist/client/lighter/rest.js +101 -32
  11. package/dist/client/lighter/rest.test.js +152 -0
  12. package/dist/client/lighter/ws.js +7 -2
  13. package/dist/client/okx/rest.js +18 -9
  14. package/dist/helper/factory.js +2 -2
  15. package/dist/helper/hyperliquid/helper.js +28 -13
  16. package/dist/helper/hyperliquid/helper.test.js +45 -0
  17. package/dist/helper/lighter/helper.js +77 -16
  18. package/dist/helper/lighter/helper.test.js +289 -0
  19. package/dist/helper/lighter/types.js +9 -8
  20. package/dist/index.d.ts +182 -38
  21. package/dist/sdk.js +20 -16
  22. package/dist/sdk.test.js +101 -0
  23. package/dist/status/hyperliquid/status.js +18 -5
  24. package/dist/status/hyperliquid/status.test.js +81 -0
  25. package/dist/status/lighter/status.js +18 -11
  26. package/dist/status/lighter/status.test.js +75 -0
  27. package/dist/syncer/base.js +0 -2
  28. package/dist/syncer/base.test.js +0 -1
  29. package/dist/syncer/hyperliquid/syncer.js +96 -43
  30. package/dist/syncer/hyperliquid/syncer.test.js +274 -0
  31. package/dist/syncer/lighter/funding.js +25 -0
  32. package/dist/syncer/lighter/funding.test.js +48 -0
  33. package/dist/syncer/lighter/syncer.js +30 -6
  34. package/dist/syncer/lighter/syncer.test.js +132 -6
  35. package/dist/tracker/base.js +23 -6
  36. package/dist/tracker/base.test.js +132 -0
  37. package/dist/tracker/binance/tracker.js +48 -18
  38. package/dist/tracker/binance/tracker.test.js +138 -0
  39. package/dist/tracker/bybit/tracker.js +78 -23
  40. package/dist/tracker/bybit/tracker.test.js +145 -0
  41. package/dist/tracker/hyperliquid/mapper.js +244 -44
  42. package/dist/tracker/hyperliquid/mapper.test.js +255 -0
  43. package/dist/tracker/hyperliquid/tracker.js +241 -74
  44. package/dist/tracker/hyperliquid/tracker.test.js +587 -0
  45. package/dist/tracker/index.js +15 -37
  46. package/dist/tracker/lighter/mapper.js +163 -33
  47. package/dist/tracker/lighter/mapper.test.js +199 -0
  48. package/dist/tracker/lighter/tracker.js +219 -45
  49. package/dist/tracker/lighter/tracker.test.js +515 -0
  50. package/dist/tracker/lighter/types.js +10 -2
  51. package/dist/tracker/okx/tracker.js +50 -16
  52. package/dist/tracker/okx/tracker.test.js +126 -0
  53. package/dist/tracker/types.js +2 -1
  54. package/dist/tracker/utils.test.js +8 -0
  55. package/package.json +3 -2
@@ -0,0 +1,255 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { aggregateClearinghouseStates, mapBalance } from './mapper';
3
+ import { HyperliquidAccountType } from './types';
4
+
5
+ function buildState(overrides) {
6
+ const leverage = overrides.leverage === "isolated" ? { type: "isolated", value: 10, rawUsd: overrides.marginUsed ?? "0" } : { type: "cross", value: 10 };
7
+ return {
8
+ marginSummary: {
9
+ accountValue: overrides.accountValue,
10
+ totalNtlPos: "0",
11
+ totalRawUsd: overrides.accountValue,
12
+ totalMarginUsed: overrides.totalMarginUsed
13
+ },
14
+ crossMarginSummary: {
15
+ accountValue: overrides.crossAccountValue ?? overrides.accountValue,
16
+ totalNtlPos: "0",
17
+ totalRawUsd: overrides.crossAccountValue ?? overrides.accountValue,
18
+ totalMarginUsed: overrides.totalMarginUsed
19
+ },
20
+ crossMaintenanceMarginUsed: overrides.crossMaintenanceMarginUsed,
21
+ withdrawable: overrides.withdrawable,
22
+ assetPositions: [
23
+ {
24
+ type: "oneWay",
25
+ position: {
26
+ coin: overrides.coin,
27
+ szi: "1",
28
+ leverage,
29
+ entryPx: "1000",
30
+ positionValue: overrides.positionValue ?? "1000",
31
+ unrealizedPnl: overrides.unrealizedPnl,
32
+ returnOnEquity: "0.1",
33
+ liquidationPx: "800",
34
+ marginUsed: overrides.marginUsed ?? "100",
35
+ maxLeverage: overrides.maxLeverage ?? 10,
36
+ cumFunding: { allTime: "0", sinceOpen: "0", sinceChange: "0" }
37
+ }
38
+ }
39
+ ],
40
+ time: 0
41
+ };
42
+ }
43
+ function dexState(state, collateralToken, dex = "", meta = null) {
44
+ return {
45
+ dex,
46
+ collateralToken,
47
+ meta,
48
+ state
49
+ };
50
+ }
51
+ describe("Hyperliquid balance mapping", () => {
52
+ it("uses the maximum independent cross and isolated risk ratios for Standard accounts", () => {
53
+ const state = buildState({
54
+ accountValue: "120",
55
+ crossAccountValue: "80",
56
+ totalMarginUsed: "40",
57
+ crossMaintenanceMarginUsed: "5",
58
+ withdrawable: "70",
59
+ coin: "BTC",
60
+ unrealizedPnl: "20",
61
+ leverage: "isolated",
62
+ positionValue: "1000",
63
+ maxLeverage: 10
64
+ });
65
+ const { summary, assetPositions } = aggregateClearinghouseStates([dexState(state, 0)]);
66
+ const balance = mapBalance(summary, /* @__PURE__ */ new Map(), HyperliquidAccountType.DEFAULT);
67
+ expect(summary.accountValue).toBe("120");
68
+ expect(summary.isolatedMaintenanceMarginUsed).toBe("50");
69
+ expect(assetPositions).toHaveLength(1);
70
+ expect(balance).toMatchObject({
71
+ accountType: "default",
72
+ totalEquity: "120",
73
+ walletBalance: "100",
74
+ initialMarginUsed: "40",
75
+ maintenanceMargin: "55",
76
+ marginRatio: "0.5000",
77
+ availableToTrade: "70"
78
+ });
79
+ });
80
+ it("applies tiered maintenance rates and continuity deductions to isolated positions", () => {
81
+ const state = buildState({
82
+ accountValue: "5000",
83
+ totalMarginUsed: "100",
84
+ crossMaintenanceMarginUsed: "0",
85
+ withdrawable: "4900",
86
+ coin: "BTC",
87
+ unrealizedPnl: "0",
88
+ leverage: "isolated",
89
+ positionValue: "2000",
90
+ maxLeverage: 50
91
+ });
92
+ const meta = {
93
+ collateralToken: 0,
94
+ universe: [{ name: "BTC", szDecimals: 5, maxLeverage: 50, marginTableId: 1 }],
95
+ marginTables: [
96
+ [
97
+ 1,
98
+ {
99
+ description: "tiered",
100
+ marginTiers: [
101
+ { lowerBound: "0", maxLeverage: 50 },
102
+ { lowerBound: "1000", maxLeverage: 25 }
103
+ ]
104
+ }
105
+ ]
106
+ ]
107
+ };
108
+ const { summary } = aggregateClearinghouseStates([dexState(state, 0, "", meta)]);
109
+ expect(summary.isolatedMaintenanceMarginUsed).toBe("30");
110
+ });
111
+ it("uses the maximum per-DEX Standard margin ratio instead of a blended aggregate ratio", () => {
112
+ const higherRiskState = buildState({
113
+ accountValue: "100",
114
+ totalMarginUsed: "20",
115
+ crossMaintenanceMarginUsed: "10",
116
+ withdrawable: "80",
117
+ coin: "BTC",
118
+ unrealizedPnl: "0"
119
+ });
120
+ const lowerRiskState = buildState({
121
+ accountValue: "900",
122
+ totalMarginUsed: "90",
123
+ crossMaintenanceMarginUsed: "45",
124
+ withdrawable: "810",
125
+ coin: "xyz:ETH",
126
+ unrealizedPnl: "0"
127
+ });
128
+ const { summary } = aggregateClearinghouseStates([
129
+ dexState(higherRiskState, 0),
130
+ dexState(lowerRiskState, 360, "xyz")
131
+ ]);
132
+ const balance = mapBalance(summary, /* @__PURE__ */ new Map(), HyperliquidAccountType.DEFAULT);
133
+ expect(balance).toMatchObject({
134
+ totalEquity: "1000",
135
+ maintenanceMargin: "55",
136
+ marginRatio: "0.1000"
137
+ });
138
+ });
139
+ it("maps network-specific collateral token indexes through spot metadata", () => {
140
+ const state = buildState({
141
+ accountValue: "100",
142
+ totalMarginUsed: "10",
143
+ crossMaintenanceMarginUsed: "5",
144
+ withdrawable: "90",
145
+ coin: "test:ETH",
146
+ unrealizedPnl: "0"
147
+ });
148
+ const { summary } = aggregateClearinghouseStates([dexState(state, 1452, "test")]);
149
+ const balance = mapBalance(summary, /* @__PURE__ */ new Map(), HyperliquidAccountType.DEFAULT, /* @__PURE__ */ new Map([[1452, "USDH"]]));
150
+ expect(balance).toMatchObject({
151
+ free: { USDH: "90" },
152
+ used: { USDH: "10" },
153
+ total: { USDH: "100" },
154
+ assets: [{ asset: "USDH" }]
155
+ });
156
+ });
157
+ it("reports at least full utilization when Standard maintenance is positive without collateral", () => {
158
+ const state = buildState({
159
+ accountValue: "0",
160
+ crossAccountValue: "0",
161
+ totalMarginUsed: "10",
162
+ crossMaintenanceMarginUsed: "5",
163
+ withdrawable: "0",
164
+ coin: "BTC",
165
+ unrealizedPnl: "-100"
166
+ });
167
+ const { summary } = aggregateClearinghouseStates([dexState(state, 0)]);
168
+ expect(mapBalance(summary, /* @__PURE__ */ new Map(), HyperliquidAccountType.DEFAULT)?.marginRatio).toBe("1.0000");
169
+ });
170
+ it("uses authoritative spot holds and post-maintenance availability for Unified accounts", () => {
171
+ const usdcState = buildState({
172
+ accountValue: "999",
173
+ totalMarginUsed: "30",
174
+ crossMaintenanceMarginUsed: "10",
175
+ withdrawable: "999",
176
+ coin: "BTC",
177
+ unrealizedPnl: "5",
178
+ leverage: "isolated",
179
+ marginUsed: "20",
180
+ positionValue: "0"
181
+ });
182
+ const usdhState = buildState({
183
+ accountValue: "999",
184
+ totalMarginUsed: "40",
185
+ crossMaintenanceMarginUsed: "20",
186
+ withdrawable: "999",
187
+ coin: "xyz:ETH",
188
+ unrealizedPnl: "0"
189
+ });
190
+ const { summary } = aggregateClearinghouseStates([dexState(usdcState, 0), dexState(usdhState, 360, "xyz")]);
191
+ const spotBalances = /* @__PURE__ */ new Map([
192
+ ["USDC", { coin: "USDC", token: 0, total: "100", hold: "5", availableAfterMaintenance: "70" }],
193
+ ["USDH", { coin: "USDH", token: 360, total: "400", hold: "10", availableAfterMaintenance: "380" }]
194
+ ]);
195
+ const balance = mapBalance(summary, spotBalances, HyperliquidAccountType.UNIFIED_ACCOUNT);
196
+ expect(balance).toMatchObject({
197
+ accountType: "unifiedAccount",
198
+ walletBalance: "495",
199
+ totalEquity: "500",
200
+ availableToTrade: "450",
201
+ marginRatio: "0.1250"
202
+ });
203
+ expect(balance?.free).toEqual({ USDC: "95", USDH: "390" });
204
+ expect(balance?.used).toEqual({ USDC: "5", USDH: "10" });
205
+ expect(balance?.assets).toEqual([
206
+ expect.objectContaining({ asset: "USDC", walletBalance: "95", total: "100" }),
207
+ expect.objectContaining({ asset: "USDH", walletBalance: "400", total: "400" })
208
+ ]);
209
+ });
210
+ it("reports at least full utilization when Unified cross maintenance has no available collateral", () => {
211
+ const state = buildState({
212
+ accountValue: "0",
213
+ totalMarginUsed: "10",
214
+ crossMaintenanceMarginUsed: "5",
215
+ withdrawable: "0",
216
+ coin: "BTC",
217
+ unrealizedPnl: "-100"
218
+ });
219
+ const { summary } = aggregateClearinghouseStates([dexState(state, 0)]);
220
+ const spotBalances = /* @__PURE__ */ new Map([["USDC", { coin: "USDC", token: 0, total: "0", hold: "0" }]]);
221
+ expect(mapBalance(summary, spotBalances, HyperliquidAccountType.UNIFIED_ACCOUNT)?.marginRatio).toBe("1.0000");
222
+ });
223
+ it("does not fabricate Portfolio Margin or deprecated DEX-abstraction account totals", () => {
224
+ const sharedState = buildState({
225
+ accountValue: "100",
226
+ totalMarginUsed: "10",
227
+ crossMaintenanceMarginUsed: "5",
228
+ withdrawable: "90",
229
+ coin: "BTC",
230
+ unrealizedPnl: "0"
231
+ });
232
+ const { summary } = aggregateClearinghouseStates([
233
+ dexState(sharedState, 0),
234
+ dexState(sharedState, 0, "builder-dex")
235
+ ]);
236
+ expect(summary.accountValue).toBe("200");
237
+ expect(mapBalance(summary, /* @__PURE__ */ new Map(), HyperliquidAccountType.DEX_ABSTRACTION)).toBeNull();
238
+ expect(mapBalance(summary, /* @__PURE__ */ new Map(), HyperliquidAccountType.PORTFOLIO_MARGIN)).toBeNull();
239
+ });
240
+ it("returns a zeroed aggregate for an empty DEX list", () => {
241
+ const { summary, assetPositions } = aggregateClearinghouseStates([]);
242
+ expect(summary).toMatchObject({
243
+ accountValue: "0",
244
+ totalMarginUsed: "0",
245
+ totalRawUsd: "0",
246
+ crossMaintenanceMarginUsed: "0",
247
+ isolatedMaintenanceMarginUsed: "0",
248
+ maximumStandardMarginRatio: "0.0000",
249
+ withdrawable: "0",
250
+ totalUnrealizedPnl: "0"
251
+ });
252
+ expect(summary.byCollateralToken.size).toBe(0);
253
+ expect(assetPositions).toEqual([]);
254
+ });
255
+ });
@@ -1,63 +1,136 @@
1
1
  import BigNumber from 'bignumber.js';
2
- import { fetchInfo } from '../../client/hyperliquid/rest';
2
+ import { createExchangeWs } from '../../client';
3
+ import { fetchPerpDexs, fetchInfo, fetchUserAbstraction, fetchSpotClearinghouseState, fetchClearinghouseState, fetchActiveAssetData } from '../../client/hyperliquid/rest';
3
4
  import { BaseAccountTracker } from '../base';
4
5
  import { LOCKED_TRACKED_COINS } from './const';
5
- import { mapPositions, mapBalance } from './mapper';
6
- import { COLLATERAL_TOKEN_MAP } from './types';
6
+ import { aggregateClearinghouseStates, mapBalance, mapPositions } from './mapper';
7
+ import { HyperliquidAccountType, COLLATERAL_TOKEN_MAP } from './types';
7
8
 
8
9
  class HyperliquidAccountTracker extends BaseAccountTracker {
9
10
  address;
10
- ws;
11
+ ws = null;
11
12
  messageListenerId = null;
12
13
  openListenerId = null;
14
+ lifecycleVersion = 0;
13
15
  subscribedCoins = /* @__PURE__ */ new Set();
14
- lastSummary = {
15
- accountValue: "0",
16
- totalMarginUsed: "0",
17
- totalRawUsd: "0",
18
- crossMaintenanceMarginUsed: "0",
19
- withdrawable: "0",
20
- totalUnrealizedPnl: "0"
21
- };
16
+ lastSummary = emptySummary();
22
17
  spotCoinBalances = /* @__PURE__ */ new Map();
18
+ hasSpotState = false;
19
+ hasClearinghouseState = false;
23
20
  accountType = null;
24
- symbolToCollateralToken = /* @__PURE__ */ new Map();
25
- availableToTradeByCollateral = /* @__PURE__ */ new Map();
21
+ collateralTokenMap = legacyCollateralTokenMap();
22
+ dexToMeta = /* @__PURE__ */ new Map();
26
23
  trackedAssetData = /* @__PURE__ */ new Map();
27
24
  constructor(config) {
28
25
  super(config);
29
- this.credentials = config.credentials;
30
26
  this.address = config.account.exchangeIdentifier;
31
- this.ws = config.ws;
27
+ }
28
+ /**
29
+ * Reads account mode, collateral balances, per-DEX state, and positions without opening a socket.
30
+ */
31
+ async snapshot() {
32
+ const user = this.addrLower();
33
+ const [perpDexs, allMetas, spotMeta, abstraction, spotState] = await Promise.all([
34
+ fetchPerpDexs(this.demo),
35
+ fetchInfo({ type: "allPerpMetas" }, this.demo),
36
+ fetchInfo({ type: "spotMeta" }, this.demo),
37
+ fetchUserAbstraction(user, this.demo),
38
+ fetchSpotClearinghouseState(user, this.demo)
39
+ ]);
40
+ const descriptors = uniqueDexDescriptors(perpDexs, allMetas);
41
+ const clearinghouseStates = await Promise.all(
42
+ descriptors.map(
43
+ async ({ dex, meta }) => ({
44
+ dex,
45
+ collateralToken: meta?.collateralToken ?? null,
46
+ meta,
47
+ state: await fetchClearinghouseState(user, dex, this.demo)
48
+ })
49
+ )
50
+ );
51
+ const { summary, assetPositions } = aggregateClearinghouseStates(clearinghouseStates);
52
+ const accountType = resolveHyperliquidAccountType(abstraction);
53
+ const collateralTokenMap = buildCollateralTokenMap(allMetas, spotMeta);
54
+ const spotCoinBalances = mapSpotBalances(spotState);
55
+ const balance = mapBalance(summary, spotCoinBalances, accountType, collateralTokenMap);
56
+ const positions = Array.from(mapPositions(assetPositions).values());
57
+ return { balance, positions };
58
+ }
59
+ /**
60
+ * Stateless one-shot per-asset settings read via the `activeAssetData` info request (the REST
61
+ * twin of the live WS subscription), one request per coin fired in parallel — the info endpoint
62
+ * has no batch form. Per-coin reads are tolerant (a failed coin maps to `null`); the read only
63
+ * throws if every coin fetch fails.
64
+ */
65
+ async fetchAssetSnapshots(coins) {
66
+ const user = this.addrLower();
67
+ const settled = await Promise.allSettled(coins.map((coin) => fetchActiveAssetData(user, coin, this.demo)));
68
+ if (settled.every((r) => r.status === "rejected")) {
69
+ throw new Error("Hyperliquid snapshotAssets: every activeAssetData read failed");
70
+ }
71
+ const result = {};
72
+ coins.forEach((coin, i) => {
73
+ const r = settled[i];
74
+ result[coin] = r?.status === "fulfilled" && r.value.coin ? buildTrackedAssetInfo(r.value) : null;
75
+ });
76
+ return result;
32
77
  }
33
78
  async start() {
34
- const allMetas = await fetchInfo({ type: "allPerpMetas" });
35
- this.loadPerpMetas(allMetas);
79
+ const lifecycleVersion = ++this.lifecycleVersion;
80
+ const user = this.addrLower();
81
+ this.destroyCurrentSocket();
82
+ this.subscribedCoins.clear();
83
+ this.resetLiveAccountState();
84
+ this.dexToMeta.clear();
85
+ const [allMetas, perpDexs, spotMeta, abstraction] = await Promise.all([
86
+ fetchInfo({ type: "allPerpMetas" }, this.demo),
87
+ fetchPerpDexs(this.demo),
88
+ fetchInfo({ type: "spotMeta" }, this.demo),
89
+ fetchUserAbstraction(user, this.demo)
90
+ ]);
91
+ if (lifecycleVersion !== this.lifecycleVersion) return;
92
+ this.loadPerpMetas(allMetas, perpDexs);
93
+ this.collateralTokenMap = buildCollateralTokenMap(allMetas, spotMeta);
94
+ this.accountType = resolveHyperliquidAccountType(abstraction);
95
+ const ws = (await createExchangeWs("hyperliquid", { demo: this.demo, credentials: this.credentials })).main;
96
+ if (lifecycleVersion !== this.lifecycleVersion) {
97
+ ws.destroy();
98
+ return;
99
+ }
100
+ this.ws = ws;
36
101
  this.messageListenerId = this.ws.addMessageListener((raw) => this.routeRaw(raw));
37
102
  this.openListenerId = this.ws.addOpenListener(() => this.resubscribeAll());
38
103
  this.sendStaticSubs();
39
- for (const coin of LOCKED_TRACKED_COINS) {
104
+ const coinsToTrack = /* @__PURE__ */ new Set([...LOCKED_TRACKED_COINS, ...this.trackedCoins]);
105
+ for (const coin of coinsToTrack) {
40
106
  this.ensureAssetTracked(coin);
41
107
  }
42
108
  }
43
109
  async cleanupConnections() {
110
+ this.lifecycleVersion += 1;
111
+ this.destroyCurrentSocket();
112
+ this.subscribedCoins.clear();
113
+ }
114
+ /** Detaches and destroys the active socket without changing the current lifecycle generation. */
115
+ destroyCurrentSocket() {
44
116
  if (this.messageListenerId) {
45
- this.ws.removeMessageListener(this.messageListenerId);
117
+ this.ws?.removeMessageListener(this.messageListenerId);
46
118
  this.messageListenerId = null;
47
119
  }
48
120
  if (this.openListenerId) {
49
- this.ws.removeOpenListener(this.openListenerId);
121
+ this.ws?.removeOpenListener(this.openListenerId);
50
122
  this.openListenerId = null;
51
123
  }
52
- this.subscribedCoins.clear();
124
+ this.ws?.destroy();
125
+ this.ws = null;
53
126
  }
54
127
  get isConnected() {
55
- return this.ws.connected;
128
+ return this.ws?.connected ?? false;
56
129
  }
57
130
  ensureAssetTracked(coin) {
58
131
  if (this.subscribedCoins.has(coin)) return;
59
132
  this.subscribedCoins.add(coin);
60
- this.ws.send({
133
+ this.ws?.send({
61
134
  method: "subscribe",
62
135
  subscription: { type: "activeAssetData", coin, user: this.addrLower() }
63
136
  });
@@ -66,7 +139,7 @@ class HyperliquidAccountTracker extends BaseAccountTracker {
66
139
  if (this.isLocked(coin)) return;
67
140
  if (!this.subscribedCoins.has(coin)) return;
68
141
  this.subscribedCoins.delete(coin);
69
- this.ws.send({
142
+ this.ws?.send({
70
143
  method: "unsubscribe",
71
144
  subscription: { type: "activeAssetData", coin, user: this.addrLower() }
72
145
  });
@@ -81,15 +154,22 @@ class HyperliquidAccountTracker extends BaseAccountTracker {
81
154
  }
82
155
  sendStaticSubs() {
83
156
  const user = this.addrLower();
84
- this.ws.send({ method: "subscribe", subscription: { type: "allDexsClearinghouseState", user } });
85
- this.ws.send({ method: "subscribe", subscription: { type: "spotState", user } });
86
- this.ws.send({ method: "subscribe", subscription: { type: "webData3", user } });
157
+ this.ws?.send({ method: "subscribe", subscription: { type: "allDexsClearinghouseState", user } });
158
+ this.sendSpotStateSubscription("subscribe", this.accountType);
159
+ this.ws?.send({ method: "subscribe", subscription: { type: "webData3", user } });
160
+ }
161
+ /** Subscribes to the spot state variant required by the active account mode. */
162
+ sendSpotStateSubscription(method, accountType) {
163
+ const user = this.addrLower();
164
+ const subscription = accountType === HyperliquidAccountType.PORTFOLIO_MARGIN ? { type: "spotState", user, isPortfolioMargin: true } : { type: "spotState", user };
165
+ this.ws?.send({ method, subscription });
87
166
  }
88
167
  resubscribeAll() {
168
+ this.resetLiveAccountState();
89
169
  this.sendStaticSubs();
90
170
  const user = this.addrLower();
91
171
  for (const coin of this.subscribedCoins) {
92
- this.ws.send({
172
+ this.ws?.send({
93
173
  method: "subscribe",
94
174
  subscription: { type: "activeAssetData", coin, user }
95
175
  });
@@ -133,71 +213,92 @@ class HyperliquidAccountTracker extends BaseAccountTracker {
133
213
  break;
134
214
  }
135
215
  }
136
- loadPerpMetas(allMetas) {
137
- allMetas.forEach((dexMeta) => {
138
- const collateralToken = COLLATERAL_TOKEN_MAP[dexMeta.collateralToken];
139
- dexMeta.universe.forEach((asset) => {
140
- if (!asset.name) return;
141
- if (collateralToken && !this.symbolToCollateralToken.has(asset.name)) {
142
- this.symbolToCollateralToken.set(asset.name, collateralToken);
143
- }
144
- });
216
+ loadPerpMetas(allMetas, perpDexs) {
217
+ allMetas.forEach((dexMeta, index) => {
218
+ const dex = perpDexs[index]?.name ?? "";
219
+ this.dexToMeta.set(dex, dexMeta);
145
220
  });
146
221
  }
147
222
  onClearinghouseState(data) {
148
- const states = (data.clearinghouseStates || []).map(([, s]) => s).filter(Boolean);
149
- const sumStr = (values) => values.reduce((acc, v) => acc.plus(new BigNumber(v || "0")), new BigNumber(0)).toFixed();
150
- const allAssetPositions = states.flatMap((s) => s.assetPositions || []);
151
- const totalUnrealizedPnl = allAssetPositions.reduce((acc, ap) => acc.plus(new BigNumber(ap.position.unrealizedPnl || "0")), new BigNumber(0)).toFixed();
152
- this.lastSummary = {
153
- accountValue: sumStr(states.map((s) => s.crossMarginSummary.accountValue)),
154
- totalMarginUsed: sumStr(states.map((s) => s.crossMarginSummary.totalMarginUsed)),
155
- totalRawUsd: sumStr(states.map((s) => s.crossMarginSummary.totalRawUsd)),
156
- crossMaintenanceMarginUsed: sumStr(states.map((s) => s.crossMaintenanceMarginUsed)),
157
- withdrawable: sumStr(states.map((s) => s.withdrawable)),
158
- totalUnrealizedPnl
159
- };
223
+ const states = (data.clearinghouseStates || []).flatMap(
224
+ ([dex, state]) => state ? [
225
+ {
226
+ dex,
227
+ collateralToken: this.dexToMeta.get(dex)?.collateralToken ?? null,
228
+ meta: this.dexToMeta.get(dex) ?? null,
229
+ state
230
+ }
231
+ ] : []
232
+ );
233
+ const { summary, assetPositions } = aggregateClearinghouseStates(states);
234
+ this.lastSummary = summary;
235
+ this.hasClearinghouseState = true;
160
236
  const balance = this.buildBalance();
161
- const positions = mapPositions(allAssetPositions);
162
- this.onStateUpdate({ balance: balance ?? void 0, positions });
237
+ const positions = mapPositions(assetPositions);
238
+ this.onStateUpdate({
239
+ ...balance === void 0 ? {} : { balance },
240
+ positions,
241
+ positionsMode: "replace"
242
+ });
163
243
  }
164
244
  onActiveAssetData(data) {
165
245
  if (!data.coin) return;
166
- if (!this.trackedCoins.has(data.coin)) return;
246
+ if (!this.subscribedCoins.has(data.coin)) return;
167
247
  this.trackedAssetData.set(data.coin, data);
168
- const collateral = this.symbolToCollateralToken.get(data.coin);
169
- if (collateral && data.availableToTrade) {
170
- const long = new BigNumber(data.availableToTrade[0] || "0");
171
- const short = new BigNumber(data.availableToTrade[1] || "0");
172
- const minAvailable = BigNumber.min(long, short).toFixed();
173
- this.availableToTradeByCollateral.set(collateral, minAvailable);
174
- const balance = this.buildBalance();
175
- if (balance) this.onStateUpdate({ balance });
176
- }
177
248
  const trackedAssets = /* @__PURE__ */ new Map();
178
249
  trackedAssets.set(data.coin, buildTrackedAssetInfo(data));
179
250
  this.onStateUpdate({ trackedAssets });
180
251
  }
181
252
  onWebData3(data) {
182
- this.accountType = data.userState.abstraction ?? null;
253
+ const previousAccountType = this.accountType;
254
+ const nextAccountType = resolveHyperliquidAccountType(data.userState.abstraction ?? HyperliquidAccountType.DEFAULT);
255
+ const portfolioSubscriptionChanged = previousAccountType === HyperliquidAccountType.PORTFOLIO_MARGIN !== (nextAccountType === HyperliquidAccountType.PORTFOLIO_MARGIN);
256
+ if (portfolioSubscriptionChanged) {
257
+ this.sendSpotStateSubscription("unsubscribe", previousAccountType);
258
+ this.sendSpotStateSubscription("subscribe", nextAccountType);
259
+ }
260
+ if (previousAccountType !== nextAccountType) {
261
+ this.resetLiveAccountState();
262
+ }
263
+ this.accountType = nextAccountType;
264
+ if (!nextAccountType) {
265
+ this.onStateUpdate({ balance: null });
266
+ return;
267
+ }
268
+ const balance = this.buildBalance();
269
+ if (balance !== void 0) {
270
+ this.onStateUpdate({ balance });
271
+ }
183
272
  }
184
273
  onSpotState(data) {
185
274
  const balances = data?.spotState?.balances;
186
275
  if (!balances) return;
187
- for (const b of balances) {
188
- const totalBN = new BigNumber(b.total || "0");
189
- const holdBN = new BigNumber(b.hold || "0");
190
- if (!totalBN.isZero() || !holdBN.isZero()) {
191
- this.spotCoinBalances.set(b.coin, { coin: b.coin, total: totalBN.toFixed(), hold: holdBN.toFixed() });
192
- } else {
193
- this.spotCoinBalances.delete(b.coin);
194
- }
195
- }
276
+ this.spotCoinBalances = mapSpotBalances(data.spotState);
277
+ this.hasSpotState = true;
196
278
  const balance = this.buildBalance();
197
- if (balance) this.onStateUpdate({ balance });
279
+ if (balance !== void 0) {
280
+ this.onStateUpdate({ balance });
281
+ }
198
282
  }
199
283
  buildBalance() {
200
- return mapBalance(this.lastSummary, this.spotCoinBalances, this.accountType, this.availableToTradeByCollateral);
284
+ if (this.accountType === HyperliquidAccountType.DEX_ABSTRACTION || this.accountType === HyperliquidAccountType.PORTFOLIO_MARGIN) {
285
+ return null;
286
+ }
287
+ if (!this.hasClearinghouseState || !this.accountType) {
288
+ return void 0;
289
+ }
290
+ if (this.accountType === HyperliquidAccountType.UNIFIED_ACCOUNT && !this.hasSpotState) {
291
+ return void 0;
292
+ }
293
+ return mapBalance(this.lastSummary, this.spotCoinBalances, this.accountType, this.collateralTokenMap);
294
+ }
295
+ /** Clears both sides of the live balance join and the public account cache without emitting. */
296
+ resetLiveAccountState() {
297
+ this.hasSpotState = false;
298
+ this.hasClearinghouseState = false;
299
+ this.lastSummary = emptySummary();
300
+ this.spotCoinBalances.clear();
301
+ this.resetAccountState();
201
302
  }
202
303
  buildTrackedAssetsMap() {
203
304
  const result = /* @__PURE__ */ new Map();
@@ -214,5 +315,71 @@ function buildTrackedAssetInfo(data) {
214
315
  marginType: data.leverage?.type
215
316
  };
216
317
  }
318
+ const HYPERLIQUID_ACCOUNT_TYPES = Object.values(HyperliquidAccountType);
319
+ function toHyperliquidAccountType(abstraction) {
320
+ if (!abstraction) {
321
+ return null;
322
+ }
323
+ return HYPERLIQUID_ACCOUNT_TYPES.includes(abstraction) ? abstraction : null;
324
+ }
325
+ function resolveHyperliquidAccountType(abstraction) {
326
+ return toHyperliquidAccountType(abstraction);
327
+ }
328
+ function uniqueDexDescriptors(perpDexs, allMetas) {
329
+ const seen = /* @__PURE__ */ new Set();
330
+ return perpDexs.flatMap((descriptor, index) => {
331
+ const dex = descriptor?.name ?? "";
332
+ if (seen.has(dex)) {
333
+ return [];
334
+ }
335
+ seen.add(dex);
336
+ return [{ dex, meta: allMetas[index] ?? null }];
337
+ });
338
+ }
339
+ function mapSpotBalances(spotState) {
340
+ const balances = /* @__PURE__ */ new Map();
341
+ const availableAfterMaintenance = new Map(spotState.tokenToAvailableAfterMaintenance ?? []);
342
+ for (const balance of spotState.balances) {
343
+ if (new BigNumber(balance.total || "0").isZero() && new BigNumber(balance.hold || "0").isZero()) {
344
+ continue;
345
+ }
346
+ balances.set(balance.coin, {
347
+ coin: balance.coin,
348
+ token: balance.token,
349
+ total: balance.total,
350
+ hold: balance.hold,
351
+ availableAfterMaintenance: availableAfterMaintenance.get(balance.token)
352
+ });
353
+ }
354
+ return balances;
355
+ }
356
+ function emptySummary() {
357
+ return {
358
+ accountValue: "0",
359
+ totalMarginUsed: "0",
360
+ totalRawUsd: "0",
361
+ crossMaintenanceMarginUsed: "0",
362
+ isolatedMaintenanceMarginUsed: "0",
363
+ maximumStandardMarginRatio: "0.0000",
364
+ withdrawable: "0",
365
+ totalUnrealizedPnl: "0",
366
+ byCollateralToken: /* @__PURE__ */ new Map()
367
+ };
368
+ }
369
+ function buildCollateralTokenMap(allMetas, spotMeta) {
370
+ const spotTokenNames = new Map(spotMeta.tokens.map((token) => [token.index, token.name]));
371
+ const result = /* @__PURE__ */ new Map();
372
+ for (const meta of allMetas) {
373
+ const token = meta.collateralToken;
374
+ const coin = spotTokenNames.get(token) ?? COLLATERAL_TOKEN_MAP[token];
375
+ if (coin) {
376
+ result.set(token, coin);
377
+ }
378
+ }
379
+ return result;
380
+ }
381
+ function legacyCollateralTokenMap() {
382
+ return new Map(Object.entries(COLLATERAL_TOKEN_MAP).map(([token, coin]) => [Number(token), coin]));
383
+ }
217
384
 
218
385
  export { HyperliquidAccountTracker };