@pear-protocol/exchanges-sdk 0.2.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 (60) 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 +239 -47
  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 +107 -25
  28. package/dist/syncer/base.test.js +131 -0
  29. package/dist/syncer/binance/syncer.js +48 -20
  30. package/dist/syncer/bybit/syncer.js +39 -15
  31. package/dist/syncer/hyperliquid/funding.js +24 -0
  32. package/dist/syncer/hyperliquid/funding.test.js +63 -0
  33. package/dist/syncer/hyperliquid/syncer.js +183 -24
  34. package/dist/syncer/hyperliquid/syncer.test.js +274 -0
  35. package/dist/syncer/lighter/funding.js +25 -0
  36. package/dist/syncer/lighter/funding.test.js +48 -0
  37. package/dist/syncer/lighter/syncer.js +93 -41
  38. package/dist/syncer/lighter/syncer.test.js +132 -6
  39. package/dist/syncer/okx/syncer.js +41 -17
  40. package/dist/tracker/base.js +23 -6
  41. package/dist/tracker/base.test.js +132 -0
  42. package/dist/tracker/binance/tracker.js +48 -18
  43. package/dist/tracker/binance/tracker.test.js +138 -0
  44. package/dist/tracker/bybit/tracker.js +78 -23
  45. package/dist/tracker/bybit/tracker.test.js +145 -0
  46. package/dist/tracker/hyperliquid/mapper.js +244 -44
  47. package/dist/tracker/hyperliquid/mapper.test.js +255 -0
  48. package/dist/tracker/hyperliquid/tracker.js +241 -74
  49. package/dist/tracker/hyperliquid/tracker.test.js +587 -0
  50. package/dist/tracker/index.js +15 -37
  51. package/dist/tracker/lighter/mapper.js +163 -33
  52. package/dist/tracker/lighter/mapper.test.js +199 -0
  53. package/dist/tracker/lighter/tracker.js +219 -45
  54. package/dist/tracker/lighter/tracker.test.js +515 -0
  55. package/dist/tracker/lighter/types.js +10 -2
  56. package/dist/tracker/okx/tracker.js +50 -16
  57. package/dist/tracker/okx/tracker.test.js +126 -0
  58. package/dist/tracker/types.js +2 -1
  59. package/dist/tracker/utils.test.js +8 -0
  60. package/package.json +6 -5
@@ -0,0 +1,587 @@
1
+ import { vi, describe, beforeEach, it, expect } from 'vitest';
2
+ import { HyperliquidAccountTracker } from './tracker';
3
+ import { HyperliquidAccountType } from './types';
4
+
5
+ const {
6
+ createExchangeWsMock,
7
+ fetchInfoMock,
8
+ fetchPerpDexsMock,
9
+ fetchClearinghouseStateMock,
10
+ fetchUserAbstractionMock,
11
+ fetchSpotClearinghouseStateMock,
12
+ fetchActiveAssetDataMock
13
+ } = vi.hoisted(() => ({
14
+ createExchangeWsMock: vi.fn(),
15
+ fetchInfoMock: vi.fn(),
16
+ fetchPerpDexsMock: vi.fn(),
17
+ fetchClearinghouseStateMock: vi.fn(),
18
+ fetchUserAbstractionMock: vi.fn(),
19
+ fetchSpotClearinghouseStateMock: vi.fn(),
20
+ fetchActiveAssetDataMock: vi.fn()
21
+ }));
22
+ vi.mock("../../client", () => ({
23
+ createExchangeWs: createExchangeWsMock
24
+ }));
25
+ vi.mock("../../client/hyperliquid/rest", () => ({
26
+ fetchInfo: fetchInfoMock,
27
+ fetchPerpDexs: fetchPerpDexsMock,
28
+ fetchClearinghouseState: fetchClearinghouseStateMock,
29
+ fetchUserAbstraction: fetchUserAbstractionMock,
30
+ fetchSpotClearinghouseState: fetchSpotClearinghouseStateMock,
31
+ fetchActiveAssetData: fetchActiveAssetDataMock
32
+ }));
33
+ const address = "0xabcdef0000000000000000000000000000000000";
34
+ const account = { exchangeIdentifier: address.toUpperCase() };
35
+ function buildTracker() {
36
+ return new HyperliquidAccountTracker({
37
+ demo: true,
38
+ account,
39
+ credentials: { signer_key: "sk" }
40
+ });
41
+ }
42
+ function exchangeWsMock() {
43
+ return {
44
+ connected: true,
45
+ addMessageListener: vi.fn().mockReturnValue("message-listener"),
46
+ addOpenListener: vi.fn().mockReturnValue("open-listener"),
47
+ removeMessageListener: vi.fn(),
48
+ removeOpenListener: vi.fn(),
49
+ send: vi.fn(),
50
+ destroy: vi.fn()
51
+ };
52
+ }
53
+ function dexState(coin, accountValue, unrealizedPnl) {
54
+ return {
55
+ marginSummary: {
56
+ accountValue,
57
+ totalNtlPos: "0",
58
+ totalRawUsd: accountValue,
59
+ totalMarginUsed: "10"
60
+ },
61
+ crossMarginSummary: {
62
+ accountValue,
63
+ totalNtlPos: "0",
64
+ totalRawUsd: accountValue,
65
+ totalMarginUsed: "10"
66
+ },
67
+ crossMaintenanceMarginUsed: "5",
68
+ withdrawable: accountValue,
69
+ assetPositions: [
70
+ {
71
+ type: "oneWay",
72
+ position: {
73
+ coin,
74
+ szi: "1",
75
+ leverage: { type: "cross", value: 10 },
76
+ entryPx: "50000",
77
+ positionValue: accountValue,
78
+ unrealizedPnl,
79
+ returnOnEquity: "0.1",
80
+ liquidationPx: "40000",
81
+ marginUsed: "10",
82
+ maxLeverage: 50,
83
+ cumFunding: { allTime: "0", sinceOpen: "0", sinceChange: "0" }
84
+ }
85
+ }
86
+ ],
87
+ time: 0
88
+ };
89
+ }
90
+ function allPerpMetas() {
91
+ return [
92
+ { collateralToken: 0, universe: [], marginTables: [] },
93
+ { collateralToken: 360, universe: [], marginTables: [] }
94
+ ];
95
+ }
96
+ function webData(abstraction) {
97
+ return {
98
+ userState: {
99
+ user: address,
100
+ abstraction,
101
+ agentAddress: null,
102
+ agentValidUntil: null,
103
+ cumLedger: "0",
104
+ serverTime: 1,
105
+ isVault: false
106
+ },
107
+ perpDexStates: []
108
+ };
109
+ }
110
+ describe("HyperliquidAccountTracker.snapshot", () => {
111
+ beforeEach(() => {
112
+ fetchInfoMock.mockReset().mockImplementation(async (body) => {
113
+ if (body.type === "allPerpMetas") return allPerpMetas();
114
+ if (body.type === "spotMeta") {
115
+ return {
116
+ tokens: [
117
+ { index: 0, name: "USDC" },
118
+ { index: 360, name: "USDH" }
119
+ ]
120
+ };
121
+ }
122
+ throw new Error(`Unexpected info request: ${body.type}`);
123
+ });
124
+ fetchPerpDexsMock.mockReset();
125
+ fetchClearinghouseStateMock.mockReset();
126
+ fetchUserAbstractionMock.mockReset();
127
+ fetchSpotClearinghouseStateMock.mockReset().mockResolvedValue({ balances: [] });
128
+ });
129
+ it("uses spot collateral for Unified balances while retaining positions from every DEX", async () => {
130
+ fetchPerpDexsMock.mockResolvedValue([null, { name: "builder-dex" }]);
131
+ fetchClearinghouseStateMock.mockImplementation(
132
+ async (_user, dex) => dex === "" ? dexState("BTC", "100", "10") : dexState("ETH", "50", "-5")
133
+ );
134
+ fetchUserAbstractionMock.mockResolvedValue("unifiedAccount");
135
+ fetchSpotClearinghouseStateMock.mockResolvedValue({
136
+ balances: [
137
+ { coin: "USDC", token: 0, total: "100", hold: "10", entryNtl: "0" },
138
+ { coin: "USDH", token: 360, total: "50", hold: "5", entryNtl: "0" }
139
+ ],
140
+ tokenToAvailableAfterMaintenance: [
141
+ [0, "85"],
142
+ [360, "42"]
143
+ ]
144
+ });
145
+ const snapshot = await buildTracker().snapshot();
146
+ expect(fetchClearinghouseStateMock).toHaveBeenCalledTimes(2);
147
+ expect(fetchClearinghouseStateMock).toHaveBeenCalledWith(address, "", true);
148
+ expect(fetchClearinghouseStateMock).toHaveBeenCalledWith(address, "builder-dex", true);
149
+ expect(fetchSpotClearinghouseStateMock).toHaveBeenCalledWith(address, true);
150
+ expect(snapshot.balance).toMatchObject({
151
+ accountType: "unifiedAccount",
152
+ walletBalance: "145",
153
+ totalEquity: "150",
154
+ availableToTrade: "127",
155
+ free: { USDC: "90", USDH: "45" }
156
+ });
157
+ expect(snapshot.positions.map((position) => position.symbol).sort()).toEqual(["BTC", "ETH"]);
158
+ });
159
+ it("fails closed for an unrecognized future abstraction value", async () => {
160
+ fetchPerpDexsMock.mockResolvedValue([null]);
161
+ fetchClearinghouseStateMock.mockResolvedValue(dexState("BTC", "100", "10"));
162
+ fetchUserAbstractionMock.mockResolvedValue("somethingNew");
163
+ const snapshot = await buildTracker().snapshot();
164
+ expect(snapshot.balance).toBeNull();
165
+ });
166
+ it("de-duplicates repeated DEX descriptors without double-counting Standard equity", async () => {
167
+ fetchPerpDexsMock.mockResolvedValue([null, null]);
168
+ fetchClearinghouseStateMock.mockResolvedValue(dexState("BTC", "100", "10"));
169
+ fetchUserAbstractionMock.mockResolvedValue("disabled");
170
+ const snapshot = await buildTracker().snapshot();
171
+ expect(fetchClearinghouseStateMock).toHaveBeenCalledTimes(1);
172
+ expect(snapshot.balance?.totalEquity).toBe("100");
173
+ });
174
+ it("uses selected-network spot metadata for Standard collateral assets", async () => {
175
+ fetchInfoMock.mockImplementation(async (body) => {
176
+ if (body.type === "allPerpMetas") {
177
+ return [{ collateralToken: 1452, universe: [], marginTables: [] }];
178
+ }
179
+ if (body.type === "spotMeta") {
180
+ return { tokens: [{ index: 1452, name: "USDH" }] };
181
+ }
182
+ throw new Error(`Unexpected info request: ${body.type}`);
183
+ });
184
+ fetchPerpDexsMock.mockResolvedValue([null]);
185
+ fetchClearinghouseStateMock.mockResolvedValue(dexState("BTC", "100", "10"));
186
+ fetchUserAbstractionMock.mockResolvedValue("disabled");
187
+ const snapshot = await buildTracker().snapshot();
188
+ expect(fetchInfoMock).toHaveBeenCalledWith({ type: "spotMeta" }, true);
189
+ expect(snapshot.balance).toMatchObject({
190
+ free: { USDH: "100" },
191
+ total: { USDH: "100" },
192
+ assets: [{ asset: "USDH" }]
193
+ });
194
+ });
195
+ it("does not expose a fabricated balance for deprecated DEX abstraction", async () => {
196
+ fetchPerpDexsMock.mockResolvedValue([null, { name: "builder-dex" }]);
197
+ fetchClearinghouseStateMock.mockImplementation(
198
+ async (_user, dex) => dex === "" ? dexState("BTC", "100", "10") : dexState("builder-dex:ETH", "100", "10")
199
+ );
200
+ fetchUserAbstractionMock.mockResolvedValue("dexAbstraction");
201
+ const snapshot = await buildTracker().snapshot();
202
+ expect(snapshot.balance).toBeNull();
203
+ expect(snapshot.positions).toHaveLength(2);
204
+ });
205
+ it("fails the complete snapshot when any DEX read fails", async () => {
206
+ fetchPerpDexsMock.mockResolvedValue([null, { name: "flaky-dex" }]);
207
+ fetchClearinghouseStateMock.mockImplementation(async (_user, dex) => {
208
+ if (dex === "flaky-dex") {
209
+ throw new Error("503");
210
+ }
211
+ return dexState("BTC", "100", "10");
212
+ });
213
+ fetchUserAbstractionMock.mockResolvedValue("disabled");
214
+ await expect(buildTracker().snapshot()).rejects.toThrow("503");
215
+ });
216
+ it("throws when every clearinghouse state read fails", async () => {
217
+ fetchPerpDexsMock.mockResolvedValue([null]);
218
+ fetchClearinghouseStateMock.mockRejectedValue(new Error("503"));
219
+ fetchUserAbstractionMock.mockResolvedValue("unifiedAccount");
220
+ await expect(buildTracker().snapshot()).rejects.toThrow("503");
221
+ });
222
+ });
223
+ describe("HyperliquidAccountTracker.snapshotAssets", () => {
224
+ beforeEach(() => {
225
+ fetchActiveAssetDataMock.mockReset();
226
+ });
227
+ it("reads leverage and margin type from testnet for each coin", async () => {
228
+ fetchActiveAssetDataMock.mockImplementation(
229
+ async (_user, coin) => coin === "BTC" ? { coin: "BTC", leverage: { type: "cross", value: 20 } } : { coin: "ETH", leverage: { type: "isolated", value: 5 } }
230
+ );
231
+ const result = await buildTracker().snapshotAssets(["BTC", "ETH"]);
232
+ expect(fetchActiveAssetDataMock).toHaveBeenCalledTimes(2);
233
+ expect(fetchActiveAssetDataMock).toHaveBeenCalledWith(address, "BTC", true);
234
+ expect(result).toEqual({
235
+ BTC: { coin: "BTC", leverage: "20", marginType: "cross" },
236
+ ETH: { coin: "ETH", leverage: "5", marginType: "isolated" }
237
+ });
238
+ });
239
+ it("maps one failed coin read to null while retaining successful settings", async () => {
240
+ fetchActiveAssetDataMock.mockImplementation(async (_user, coin) => {
241
+ if (coin === "DOGE") {
242
+ throw new Error("422");
243
+ }
244
+ return { coin, leverage: { type: "cross", value: 10 } };
245
+ });
246
+ const result = await buildTracker().snapshotAssets(["BTC", "DOGE"]);
247
+ expect(result.BTC).toMatchObject({ leverage: "10", marginType: "cross" });
248
+ expect(result.DOGE).toBeNull();
249
+ });
250
+ it("throws when every active-asset read fails", async () => {
251
+ fetchActiveAssetDataMock.mockRejectedValue(new Error("503"));
252
+ await expect(buildTracker().snapshotAssets(["BTC", "ETH"])).rejects.toThrow("every activeAssetData read failed");
253
+ });
254
+ });
255
+ describe("HyperliquidAccountTracker startup lifecycle", () => {
256
+ beforeEach(() => {
257
+ fetchInfoMock.mockReset().mockImplementation(async (body) => {
258
+ if (body.type === "allPerpMetas") return [allPerpMetas()[0]];
259
+ if (body.type === "spotMeta") return { tokens: [{ index: 0, name: "USDC" }] };
260
+ throw new Error(`Unexpected info request: ${body.type}`);
261
+ });
262
+ fetchPerpDexsMock.mockReset().mockResolvedValue([null]);
263
+ fetchUserAbstractionMock.mockReset().mockResolvedValue("disabled");
264
+ createExchangeWsMock.mockReset();
265
+ });
266
+ it("abandons startup when disconnect occurs during metadata loading", async () => {
267
+ const metadata = deferred();
268
+ fetchInfoMock.mockImplementation(async (body) => {
269
+ if (body.type === "allPerpMetas") return metadata.promise;
270
+ if (body.type === "spotMeta") return { tokens: [{ index: 0, name: "USDC" }] };
271
+ throw new Error(`Unexpected info request: ${body.type}`);
272
+ });
273
+ const tracker = buildTracker();
274
+ const start = tracker.start();
275
+ await tracker.disconnect();
276
+ metadata.resolve(allPerpMetas());
277
+ await start;
278
+ expect(createExchangeWsMock).not.toHaveBeenCalled();
279
+ expect(tracker.isConnected).toBe(false);
280
+ });
281
+ it("destroys a socket that resolves after disconnect without installing listeners", async () => {
282
+ const socketCreation = deferred();
283
+ const ws = exchangeWsMock();
284
+ createExchangeWsMock.mockReturnValue(socketCreation.promise);
285
+ const tracker = buildTracker();
286
+ const start = tracker.start();
287
+ await vi.waitFor(() => expect(createExchangeWsMock).toHaveBeenCalledTimes(1));
288
+ await tracker.disconnect();
289
+ socketCreation.resolve({ main: ws });
290
+ await start;
291
+ expect(ws.destroy).toHaveBeenCalledTimes(1);
292
+ expect(ws.addMessageListener).not.toHaveBeenCalled();
293
+ expect(ws.addOpenListener).not.toHaveBeenCalled();
294
+ expect(tracker.isConnected).toBe(false);
295
+ });
296
+ it("tears down the active socket before a repeated start installs its replacement", async () => {
297
+ const firstWs = exchangeWsMock();
298
+ const secondWs = exchangeWsMock();
299
+ createExchangeWsMock.mockResolvedValueOnce({ main: firstWs }).mockResolvedValueOnce({ main: secondWs });
300
+ const tracker = buildTracker();
301
+ await tracker.start();
302
+ await tracker.start();
303
+ expect(firstWs.removeMessageListener).toHaveBeenCalledWith("message-listener");
304
+ expect(firstWs.removeOpenListener).toHaveBeenCalledWith("open-listener");
305
+ expect(firstWs.destroy).toHaveBeenCalledTimes(1);
306
+ expect(secondWs.destroy).not.toHaveBeenCalled();
307
+ expect(tracker.isConnected).toBe(true);
308
+ });
309
+ it("destroys a superseded pending socket without disturbing the newer start generation", async () => {
310
+ const firstSocketCreation = deferred();
311
+ const firstWs = exchangeWsMock();
312
+ const secondWs = exchangeWsMock();
313
+ createExchangeWsMock.mockImplementationOnce(() => firstSocketCreation.promise).mockResolvedValueOnce({ main: secondWs });
314
+ const tracker = buildTracker();
315
+ const firstStart = tracker.start();
316
+ await vi.waitFor(() => expect(createExchangeWsMock).toHaveBeenCalledTimes(1));
317
+ const secondStart = tracker.start();
318
+ await secondStart;
319
+ firstSocketCreation.resolve({ main: firstWs });
320
+ await firstStart;
321
+ expect(firstWs.destroy).toHaveBeenCalledTimes(1);
322
+ expect(firstWs.addMessageListener).not.toHaveBeenCalled();
323
+ expect(secondWs.destroy).not.toHaveBeenCalled();
324
+ expect(tracker.isConnected).toBe(true);
325
+ });
326
+ });
327
+ describe("HyperliquidAccountTracker live account-mode handling", () => {
328
+ it("loads selected-network collateral metadata before mapping live Standard balances", async () => {
329
+ fetchInfoMock.mockReset().mockImplementation(async (body) => {
330
+ if (body.type === "allPerpMetas") {
331
+ return [{ collateralToken: 1452, universe: [], marginTables: [] }];
332
+ }
333
+ if (body.type === "spotMeta") {
334
+ return { tokens: [{ index: 1452, name: "USDH" }] };
335
+ }
336
+ throw new Error(`Unexpected info request: ${body.type}`);
337
+ });
338
+ fetchPerpDexsMock.mockReset().mockResolvedValue([null]);
339
+ fetchUserAbstractionMock.mockReset().mockResolvedValue("disabled");
340
+ const ws = {
341
+ connected: true,
342
+ addMessageListener: vi.fn().mockReturnValue("message-listener"),
343
+ addOpenListener: vi.fn().mockReturnValue("open-listener"),
344
+ removeMessageListener: vi.fn(),
345
+ removeOpenListener: vi.fn(),
346
+ send: vi.fn(),
347
+ destroy: vi.fn()
348
+ };
349
+ createExchangeWsMock.mockReset().mockResolvedValue({ main: ws });
350
+ const tracker = buildTracker();
351
+ await tracker.start();
352
+ const internals = tracker;
353
+ internals.onClearinghouseState({
354
+ user: address,
355
+ clearinghouseStates: [["", dexState("BTC", "100", "10")]]
356
+ });
357
+ expect(fetchInfoMock).toHaveBeenCalledWith({ type: "spotMeta" }, true);
358
+ expect(tracker.getBalance()).toMatchObject({
359
+ free: { USDH: "100" },
360
+ total: { USDH: "100" },
361
+ assets: [{ asset: "USDH" }]
362
+ });
363
+ await tracker.disconnect();
364
+ });
365
+ it("waits for spot state before emitting a Unified live balance", () => {
366
+ const tracker = buildTracker();
367
+ const updates = [];
368
+ tracker.trackBalance((balance) => updates.push(balance));
369
+ const internals = tracker;
370
+ internals.accountType = HyperliquidAccountType.UNIFIED_ACCOUNT;
371
+ internals.hasSpotState = false;
372
+ internals.hasClearinghouseState = false;
373
+ internals.onClearinghouseState({
374
+ user: address,
375
+ clearinghouseStates: [["", dexState("BTC", "100", "10")]]
376
+ });
377
+ expect(tracker.getBalance()).toBeNull();
378
+ expect(updates).toEqual([]);
379
+ internals.onSpotState({
380
+ user: address,
381
+ spotState: {
382
+ balances: [{ coin: "USDC", token: 0, total: "100", hold: "5", entryNtl: "0" }],
383
+ tokenToAvailableAfterMaintenance: [[0, "90"]]
384
+ }
385
+ });
386
+ expect(tracker.getBalance()).toMatchObject({
387
+ accountType: "unifiedAccount",
388
+ walletBalance: "90",
389
+ availableToTrade: "90"
390
+ });
391
+ expect(updates).toHaveLength(1);
392
+ });
393
+ it("waits for clearinghouse state when Unified spot state arrives first", () => {
394
+ const tracker = buildTracker();
395
+ const updates = [];
396
+ tracker.trackBalance((balance) => updates.push(balance));
397
+ const internals = tracker;
398
+ internals.accountType = HyperliquidAccountType.UNIFIED_ACCOUNT;
399
+ internals.hasSpotState = false;
400
+ internals.hasClearinghouseState = false;
401
+ internals.onSpotState({
402
+ user: address,
403
+ spotState: {
404
+ balances: [{ coin: "USDC", token: 0, total: "100", hold: "5", entryNtl: "0" }],
405
+ tokenToAvailableAfterMaintenance: [[0, "90"]]
406
+ }
407
+ });
408
+ expect(tracker.getBalance()).toBeNull();
409
+ expect(updates).toEqual([]);
410
+ internals.onClearinghouseState({
411
+ user: address,
412
+ clearinghouseStates: [["", dexState("BTC", "100", "10")]]
413
+ });
414
+ expect(tracker.getBalance()).toMatchObject({
415
+ accountType: "unifiedAccount",
416
+ walletBalance: "90",
417
+ availableToTrade: "90"
418
+ });
419
+ expect(updates).toHaveLength(1);
420
+ });
421
+ it("discards both cached snapshots when switching into Unified mode", () => {
422
+ const tracker = buildTracker();
423
+ const updates = [];
424
+ tracker.trackBalance((balance) => updates.push(balance));
425
+ const internals = tracker;
426
+ internals.accountType = HyperliquidAccountType.DEFAULT;
427
+ internals.hasSpotState = false;
428
+ internals.hasClearinghouseState = false;
429
+ internals.onClearinghouseState({
430
+ user: address,
431
+ clearinghouseStates: [["", dexState("BTC", "100", "10")]]
432
+ });
433
+ expect(updates).toHaveLength(1);
434
+ internals.onWebData3(webData("unifiedAccount"));
435
+ expect(tracker.getBalance()).toBeNull();
436
+ expect(updates).toHaveLength(1);
437
+ internals.onSpotState({
438
+ user: address,
439
+ spotState: {
440
+ balances: [{ coin: "USDC", token: 0, total: "120", hold: "5", entryNtl: "0" }],
441
+ tokenToAvailableAfterMaintenance: [[0, "100"]]
442
+ }
443
+ });
444
+ expect(updates).toHaveLength(1);
445
+ internals.onClearinghouseState({
446
+ user: address,
447
+ clearinghouseStates: [["", dexState("BTC", "80", "-20")]]
448
+ });
449
+ expect(updates).toHaveLength(2);
450
+ expect(updates[1]).toMatchObject({
451
+ accountType: "unifiedAccount",
452
+ availableToTrade: "100",
453
+ totalEquity: "120"
454
+ });
455
+ });
456
+ it("discards both cached snapshots before resubscribing a reconnected socket", () => {
457
+ const tracker = buildTracker();
458
+ const updates = [];
459
+ tracker.trackBalance((balance) => updates.push(balance));
460
+ const send = vi.fn();
461
+ const internals = tracker;
462
+ internals.accountType = HyperliquidAccountType.UNIFIED_ACCOUNT;
463
+ internals.hasSpotState = false;
464
+ internals.hasClearinghouseState = false;
465
+ internals.ws = { send };
466
+ internals.onClearinghouseState({
467
+ user: address,
468
+ clearinghouseStates: [["", dexState("BTC", "100", "10")]]
469
+ });
470
+ internals.onSpotState({
471
+ user: address,
472
+ spotState: {
473
+ balances: [{ coin: "USDC", token: 0, total: "100", hold: "5", entryNtl: "0" }],
474
+ tokenToAvailableAfterMaintenance: [[0, "90"]]
475
+ }
476
+ });
477
+ expect(updates).toHaveLength(1);
478
+ internals.resubscribeAll();
479
+ expect(tracker.getBalance()).toBeNull();
480
+ expect(updates).toHaveLength(1);
481
+ internals.onClearinghouseState({
482
+ user: address,
483
+ clearinghouseStates: [["", dexState("BTC", "90", "-10")]]
484
+ });
485
+ expect(updates).toHaveLength(1);
486
+ internals.onSpotState({
487
+ user: address,
488
+ spotState: {
489
+ balances: [{ coin: "USDC", token: 0, total: "90", hold: "0", entryNtl: "0" }],
490
+ tokenToAvailableAfterMaintenance: [[0, "80"]]
491
+ }
492
+ });
493
+ expect(updates).toHaveLength(2);
494
+ expect(updates[1]).toMatchObject({ totalEquity: "90", availableToTrade: "80" });
495
+ });
496
+ it("fails closed and clears listeners for an unknown live mode", () => {
497
+ const tracker = buildTracker();
498
+ const updates = [];
499
+ tracker.trackBalance((balance) => updates.push(balance));
500
+ const internals = tracker;
501
+ internals.accountType = HyperliquidAccountType.DEFAULT;
502
+ internals.hasClearinghouseState = false;
503
+ internals.onClearinghouseState({
504
+ user: address,
505
+ clearinghouseStates: [["", dexState("BTC", "100", "10")]]
506
+ });
507
+ expect(updates).toHaveLength(1);
508
+ const unknownModeEvent = webData("default");
509
+ unknownModeEvent.userState.abstraction = "somethingNew";
510
+ internals.onWebData3(unknownModeEvent);
511
+ expect(tracker.getBalance()).toBeNull();
512
+ expect(updates).toHaveLength(2);
513
+ expect(updates[1]).toBeNull();
514
+ });
515
+ it("broadcasts explicit null when a live mode becomes known unsupported", () => {
516
+ const tracker = buildTracker();
517
+ const updates = [];
518
+ tracker.trackBalance((balance) => updates.push(balance));
519
+ const send = vi.fn();
520
+ const internals = tracker;
521
+ internals.accountType = HyperliquidAccountType.DEFAULT;
522
+ internals.hasClearinghouseState = false;
523
+ internals.ws = { send };
524
+ internals.onClearinghouseState({
525
+ user: address,
526
+ clearinghouseStates: [["", dexState("BTC", "100", "10")]]
527
+ });
528
+ internals.onWebData3(webData("portfolioMargin"));
529
+ expect(tracker.getBalance()).toBeNull();
530
+ expect(updates).toHaveLength(2);
531
+ expect(updates[1]).toBeNull();
532
+ });
533
+ it("retains locked BTC active-asset data even without a user listener", () => {
534
+ const tracker = buildTracker();
535
+ const internals = tracker;
536
+ const event = {
537
+ user: address,
538
+ coin: "BTC",
539
+ leverage: { type: "cross", value: 20 },
540
+ maxTradeSzs: ["1", "1"],
541
+ availableToTrade: ["1", "1"],
542
+ markPx: "100000"
543
+ };
544
+ internals.subscribedCoins.add("BTC");
545
+ internals.onActiveAssetData(event);
546
+ expect(tracker.getTrackedAsset("BTC")).toEqual({
547
+ coin: "BTC",
548
+ leverage: "20",
549
+ marginType: "cross"
550
+ });
551
+ });
552
+ it("switches to the portfolio-specific spot subscription when the mode changes", () => {
553
+ const tracker = buildTracker();
554
+ const send = vi.fn();
555
+ const internals = tracker;
556
+ const event = {
557
+ userState: {
558
+ user: address,
559
+ abstraction: "portfolioMargin",
560
+ agentAddress: null,
561
+ agentValidUntil: null,
562
+ cumLedger: "0",
563
+ serverTime: 1,
564
+ isVault: false
565
+ },
566
+ perpDexStates: []
567
+ };
568
+ internals.accountType = HyperliquidAccountType.DEFAULT;
569
+ internals.ws = { send };
570
+ internals.onWebData3(event);
571
+ expect(send).toHaveBeenNthCalledWith(1, {
572
+ method: "unsubscribe",
573
+ subscription: { type: "spotState", user: address }
574
+ });
575
+ expect(send).toHaveBeenNthCalledWith(2, {
576
+ method: "subscribe",
577
+ subscription: { type: "spotState", user: address, isPortfolioMargin: true }
578
+ });
579
+ });
580
+ });
581
+ function deferred() {
582
+ let resolve;
583
+ const promise = new Promise((resolvePromise) => {
584
+ resolve = resolvePromise;
585
+ });
586
+ return { promise, resolve };
587
+ }
@@ -17,67 +17,45 @@ export { OkxAccountType } from './okx/types';
17
17
  export * from './types';
18
18
 
19
19
  function createAccountTracker(exchange, config) {
20
- let tracker;
21
20
  switch (exchange) {
22
- case "binance": {
23
- const ws = config.ws;
24
- tracker = new BinanceAccountTracker({
21
+ case "binance":
22
+ return new BinanceAccountTracker({
25
23
  demo: config.demo,
26
24
  onError: config.onError,
27
- credentials: config.credentials,
28
- userDataWs: ws.userData,
29
- wsApiWs: ws.wsApi
25
+ credentials: config.credentials
30
26
  });
31
- break;
32
- }
33
- case "bybit": {
34
- const ws = config.ws;
35
- tracker = new BybitAccountTracker({
27
+ case "bybit":
28
+ return new BybitAccountTracker({
36
29
  demo: config.demo,
37
30
  onError: config.onError,
38
- credentials: config.credentials,
39
- ws
31
+ credentials: config.credentials
40
32
  });
41
- break;
42
- }
43
- case "okx": {
44
- const ws = config.ws;
45
- tracker = new OkxAccountTracker({
33
+ case "okx":
34
+ return new OkxAccountTracker({
46
35
  demo: config.demo,
47
36
  onError: config.onError,
48
- credentials: config.credentials,
49
- ws
37
+ credentials: config.credentials
50
38
  });
51
- break;
52
- }
53
- case "hyperliquid": {
54
- const ws = config.ws;
55
- tracker = new HyperliquidAccountTracker({
39
+ case "hyperliquid":
40
+ return new HyperliquidAccountTracker({
56
41
  demo: config.demo,
57
42
  onError: config.onError,
58
43
  account: config.account,
59
- credentials: config.credentials,
60
- ws: ws.main
44
+ credentials: config.credentials
61
45
  });
62
- break;
63
- }
64
- case "lighter": {
65
- const ws = config.ws;
66
- tracker = new LighterAccountTracker({
46
+ case "lighter":
47
+ return new LighterAccountTracker({
67
48
  demo: config.demo,
68
49
  onError: config.onError,
69
50
  account: config.account,
70
51
  credentials: config.credentials,
71
- ws: ws.main
52
+ sdk: config.sdk
72
53
  });
73
- break;
74
- }
75
54
  default: {
76
55
  const _exhaustive = exchange;
77
56
  throw new Error(`Unsupported exchange: ${String(_exhaustive)}`);
78
57
  }
79
58
  }
80
- return tracker;
81
59
  }
82
60
 
83
61
  export { createAccountTracker };