@pear-protocol/exchanges-sdk 0.3.0 → 0.5.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 (57) hide show
  1. package/README.md +78 -12
  2. package/dist/client/binance/rest.js +4 -3
  3. package/dist/client/bybit/rest.js +2 -1
  4. package/dist/client/hyperliquid/client.test.js +41 -0
  5. package/dist/client/hyperliquid/exchange-ws.js +3 -2
  6. package/dist/client/hyperliquid/rest.js +28 -5
  7. package/dist/client/hyperliquid/ws.js +16 -4
  8. package/dist/client/index.js +2 -2
  9. package/dist/client/lighter/constants.js +9 -1
  10. package/dist/client/lighter/constants.test.js +22 -0
  11. package/dist/client/lighter/exchange-ws.js +2 -2
  12. package/dist/client/lighter/rest.js +110 -37
  13. package/dist/client/lighter/rest.test.js +152 -0
  14. package/dist/client/lighter/ws.js +7 -2
  15. package/dist/client/okx/rest.js +20 -10
  16. package/dist/helper/factory.js +2 -2
  17. package/dist/helper/hyperliquid/helper.js +28 -13
  18. package/dist/helper/hyperliquid/helper.test.js +45 -0
  19. package/dist/helper/lighter/helper.js +77 -16
  20. package/dist/helper/lighter/helper.test.js +289 -0
  21. package/dist/helper/lighter/types.js +9 -8
  22. package/dist/index.d.ts +189 -38
  23. package/dist/sdk.js +22 -16
  24. package/dist/sdk.test.js +101 -0
  25. package/dist/status/hyperliquid/status.js +18 -5
  26. package/dist/status/hyperliquid/status.test.js +81 -0
  27. package/dist/status/lighter/status.js +18 -11
  28. package/dist/status/lighter/status.test.js +75 -0
  29. package/dist/syncer/base.js +0 -2
  30. package/dist/syncer/base.test.js +0 -1
  31. package/dist/syncer/hyperliquid/syncer.js +96 -43
  32. package/dist/syncer/hyperliquid/syncer.test.js +274 -0
  33. package/dist/syncer/lighter/funding.js +25 -0
  34. package/dist/syncer/lighter/funding.test.js +48 -0
  35. package/dist/syncer/lighter/syncer.js +30 -6
  36. package/dist/syncer/lighter/syncer.test.js +132 -6
  37. package/dist/tracker/base.js +23 -6
  38. package/dist/tracker/base.test.js +132 -0
  39. package/dist/tracker/binance/tracker.js +48 -18
  40. package/dist/tracker/binance/tracker.test.js +138 -0
  41. package/dist/tracker/bybit/tracker.js +78 -23
  42. package/dist/tracker/bybit/tracker.test.js +145 -0
  43. package/dist/tracker/hyperliquid/mapper.js +244 -44
  44. package/dist/tracker/hyperliquid/mapper.test.js +255 -0
  45. package/dist/tracker/hyperliquid/tracker.js +241 -74
  46. package/dist/tracker/hyperliquid/tracker.test.js +587 -0
  47. package/dist/tracker/index.js +15 -37
  48. package/dist/tracker/lighter/mapper.js +163 -33
  49. package/dist/tracker/lighter/mapper.test.js +199 -0
  50. package/dist/tracker/lighter/tracker.js +219 -45
  51. package/dist/tracker/lighter/tracker.test.js +515 -0
  52. package/dist/tracker/lighter/types.js +10 -2
  53. package/dist/tracker/okx/tracker.js +50 -16
  54. package/dist/tracker/okx/tracker.test.js +126 -0
  55. package/dist/tracker/types.js +2 -1
  56. package/dist/tracker/utils.test.js +8 -0
  57. package/package.json +6 -5
@@ -0,0 +1,101 @@
1
+ import { vi, describe, beforeEach, it, expect } from 'vitest';
2
+ import { ExchangesSDK } from './sdk';
3
+
4
+ const { checkAccountStatusMock, createAccountSyncerMock, createAccountTrackerMock, createExchangeWsMock } = vi.hoisted(
5
+ () => ({
6
+ checkAccountStatusMock: vi.fn(),
7
+ createAccountSyncerMock: vi.fn(),
8
+ createAccountTrackerMock: vi.fn(),
9
+ createExchangeWsMock: vi.fn()
10
+ })
11
+ );
12
+ vi.mock("./status", () => ({
13
+ checkAccountStatus: checkAccountStatusMock
14
+ }));
15
+ vi.mock("./syncer", () => ({
16
+ createAccountSyncer: createAccountSyncerMock
17
+ }));
18
+ vi.mock("./client", () => ({
19
+ createExchangeWs: createExchangeWsMock
20
+ }));
21
+ vi.mock("./tracker", () => ({
22
+ createAccountTracker: createAccountTrackerMock
23
+ }));
24
+ const connection = {
25
+ connector: "okx",
26
+ account: { id: "account-1" },
27
+ credentials: { api_key: "k", api_secret: "s", api_pass: "p" }
28
+ };
29
+ function buildSdk(credentials = vi.fn().mockResolvedValue(connection), demo = true) {
30
+ const sdk = {
31
+ core: { accounts: { credentials } }
32
+ };
33
+ return { exchangesSdk: new ExchangesSDK({ sdk, demo }), credentials, sdk };
34
+ }
35
+ describe("ExchangesSDK.snapshot", () => {
36
+ beforeEach(() => {
37
+ checkAccountStatusMock.mockReset().mockResolvedValue({ status: "active", reasons: [] });
38
+ createAccountSyncerMock.mockReset().mockReturnValue({});
39
+ createExchangeWsMock.mockReset();
40
+ createAccountTrackerMock.mockReset();
41
+ });
42
+ it("fetches credentials, builds a ws-free tracker, and returns tracker.snapshot() without opening a socket", async () => {
43
+ const fakeSnapshot = { balance: null, positions: [] };
44
+ const trackerSnapshot = vi.fn().mockResolvedValue(fakeSnapshot);
45
+ createAccountTrackerMock.mockReturnValue({ snapshot: trackerSnapshot });
46
+ const { exchangesSdk, credentials, sdk } = buildSdk();
47
+ const result = await exchangesSdk.snapshot("trade-account-1");
48
+ expect(credentials).toHaveBeenCalledWith("trade-account-1");
49
+ expect(createAccountTrackerMock).toHaveBeenCalledWith(
50
+ "okx",
51
+ expect.objectContaining({
52
+ demo: true,
53
+ account: connection.account,
54
+ credentials: connection.credentials,
55
+ sdk
56
+ })
57
+ );
58
+ expect(trackerSnapshot).toHaveBeenCalledTimes(1);
59
+ expect(result).toBe(fakeSnapshot);
60
+ expect(createExchangeWsMock).not.toHaveBeenCalled();
61
+ });
62
+ it("snapshotAssets() delegates the coin list to a ws-free tracker", async () => {
63
+ const fakeAssets = { BTC: { coin: "BTC", leverage: "10", marginType: "cross" } };
64
+ const trackerSnapshotAssets = vi.fn().mockResolvedValue(fakeAssets);
65
+ createAccountTrackerMock.mockReturnValue({ snapshotAssets: trackerSnapshotAssets });
66
+ const { exchangesSdk, credentials } = buildSdk();
67
+ const result = await exchangesSdk.snapshotAssets("trade-account-1", ["BTC", "ETH"]);
68
+ expect(credentials).toHaveBeenCalledWith("trade-account-1");
69
+ expect(trackerSnapshotAssets).toHaveBeenCalledWith(["BTC", "ETH"]);
70
+ expect(result).toBe(fakeAssets);
71
+ expect(createExchangeWsMock).not.toHaveBeenCalled();
72
+ });
73
+ it("propagates credential fetch failures", async () => {
74
+ const { exchangesSdk } = buildSdk(vi.fn().mockRejectedValue(new Error("no access")));
75
+ await expect(exchangesSdk.snapshot("trade-account-1")).rejects.toThrow("no access");
76
+ expect(createAccountTrackerMock).not.toHaveBeenCalled();
77
+ expect(createExchangeWsMock).not.toHaveBeenCalled();
78
+ });
79
+ it("connect() is credential-only and opens no socket", async () => {
80
+ const { exchangesSdk } = buildSdk();
81
+ const result = await exchangesSdk.connect("trade-account-1");
82
+ expect(result).toEqual(connection);
83
+ expect(createExchangeWsMock).not.toHaveBeenCalled();
84
+ });
85
+ });
86
+ describe("ExchangesSDK configured network routing", () => {
87
+ beforeEach(() => {
88
+ checkAccountStatusMock.mockReset().mockResolvedValue({ status: "active", reasons: [] });
89
+ createAccountSyncerMock.mockReset().mockReturnValue({});
90
+ createAccountTrackerMock.mockReset().mockReturnValue({});
91
+ });
92
+ it("uses the SDK demo option for account-bound operations", async () => {
93
+ const { exchangesSdk } = buildSdk(void 0, true);
94
+ await exchangesSdk.status(connection);
95
+ exchangesSdk.createTracker(connection);
96
+ exchangesSdk.createSyncer(connection);
97
+ expect(checkAccountStatusMock).toHaveBeenCalledWith("okx", expect.objectContaining({ demo: true }));
98
+ expect(createAccountTrackerMock).toHaveBeenCalledWith("okx", expect.objectContaining({ demo: true }));
99
+ expect(createAccountSyncerMock).toHaveBeenCalledWith("okx", expect.objectContaining({ demo: true }));
100
+ });
101
+ });
@@ -1,20 +1,32 @@
1
+ import { fetchUserAbstraction } from '../../client/hyperliquid/rest';
1
2
  import { createExchangeHelper } from '../../helper/factory';
3
+ import { HyperliquidAccountType } from '../../tracker/hyperliquid/types';
2
4
  import { warningStatus } from '../base';
3
5
 
4
6
  async function checkHyperliquidAccountStatus(config) {
5
7
  const reasons = [];
6
8
  const options = config.statusOptions;
7
- if (!options?.builderAddress) {
9
+ if (!options?.builderAddress || !options.builderFeeTenthsBps) {
8
10
  throw new Error("Missing required hyperliquid connect options");
9
11
  }
10
- const helper = createExchangeHelper({ exchange: "hyperliquid" });
12
+ const helper = createExchangeHelper({ exchange: "hyperliquid", demo: config.demo });
11
13
  const metadata = config.account.metadata;
14
+ const approvalAddress = metadata && "isSubaccount" in metadata && metadata.isSubaccount ? metadata.mainAccountAddress : config.account.exchangeIdentifier;
15
+ try {
16
+ const abstraction = await fetchUserAbstraction(config.account.exchangeIdentifier, config.demo);
17
+ const supportedAccountMode = abstraction === HyperliquidAccountType.DEFAULT || abstraction === HyperliquidAccountType.DISABLED || abstraction === HyperliquidAccountType.UNIFIED_ACCOUNT;
18
+ if (!supportedAccountMode) {
19
+ reasons.push("hyperliquid_account_mode_unsupported");
20
+ }
21
+ } catch {
22
+ reasons.push("hyperliquid_account_mode_check_failed");
23
+ }
12
24
  if (!metadata || !("agentWalletAddress" in metadata)) {
13
25
  reasons.push("hyperliquid_api_wallet_invalid");
14
26
  } else {
15
27
  try {
16
28
  const apiWalletValid = await helper.checkApiWallet({
17
- userAddress: config.account.exchangeIdentifier,
29
+ userAddress: approvalAddress,
18
30
  agentWalletAddress: metadata.agentWalletAddress
19
31
  });
20
32
  if (!apiWalletValid) reasons.push("hyperliquid_api_wallet_invalid");
@@ -24,8 +36,9 @@ async function checkHyperliquidAccountStatus(config) {
24
36
  }
25
37
  try {
26
38
  const approved = await helper.checkBuilderFeeApproval(
27
- config.account.exchangeIdentifier,
28
- options.builderAddress
39
+ approvalAddress,
40
+ options.builderAddress,
41
+ options.builderFeeTenthsBps
29
42
  );
30
43
  if (!approved) reasons.push("hyperliquid_builder_fee_not_approved");
31
44
  } catch {
@@ -0,0 +1,81 @@
1
+ import { vi, describe, beforeEach, it, expect } from 'vitest';
2
+ import { checkHyperliquidAccountStatus } from './status';
3
+
4
+ const { checkApiWalletMock, checkBuilderFeeApprovalMock, fetchUserAbstractionMock } = vi.hoisted(() => ({
5
+ checkApiWalletMock: vi.fn(),
6
+ checkBuilderFeeApprovalMock: vi.fn(),
7
+ fetchUserAbstractionMock: vi.fn()
8
+ }));
9
+ vi.mock("../../helper/factory", () => ({
10
+ createExchangeHelper: () => ({
11
+ checkApiWallet: checkApiWalletMock,
12
+ checkBuilderFeeApproval: checkBuilderFeeApprovalMock
13
+ })
14
+ }));
15
+ vi.mock("../../client/hyperliquid/rest", () => ({
16
+ fetchUserAbstraction: fetchUserAbstractionMock
17
+ }));
18
+ const subaccountAddress = "0x1111111111111111111111111111111111111111";
19
+ const mainAccountAddress = "0x2222222222222222222222222222222222222222";
20
+ const agentWalletAddress = "0x3333333333333333333333333333333333333333";
21
+ const builderAddress = "0x4444444444444444444444444444444444444444";
22
+ function statusConfig() {
23
+ return {
24
+ sdk: {},
25
+ connector: "hyperliquid",
26
+ account: {
27
+ id: crypto.randomUUID(),
28
+ alias: "HL",
29
+ connector: "hyperliquid",
30
+ exchangeIdentifier: subaccountAddress,
31
+ metadata: {
32
+ agentWalletAddress,
33
+ isSubaccount: true,
34
+ mainAccountAddress
35
+ },
36
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
37
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
38
+ },
39
+ credentials: { signer_key: "sk" },
40
+ statusOptions: { builderAddress, builderFeeTenthsBps: 10 },
41
+ demo: true
42
+ };
43
+ }
44
+ describe("checkHyperliquidAccountStatus", () => {
45
+ beforeEach(() => {
46
+ checkApiWalletMock.mockReset().mockResolvedValue(true);
47
+ checkBuilderFeeApprovalMock.mockReset().mockResolvedValue(true);
48
+ fetchUserAbstractionMock.mockReset().mockResolvedValue("disabled");
49
+ });
50
+ it("checks subaccount approvals against the main account and mode against the trading account", async () => {
51
+ const status = await checkHyperliquidAccountStatus(statusConfig());
52
+ expect(status).toEqual({ status: "active", reasons: [] });
53
+ expect(fetchUserAbstractionMock).toHaveBeenCalledWith(subaccountAddress, true);
54
+ expect(checkApiWalletMock).toHaveBeenCalledWith({ userAddress: mainAccountAddress, agentWalletAddress });
55
+ expect(checkBuilderFeeApprovalMock).toHaveBeenCalledWith(mainAccountAddress, builderAddress, 10);
56
+ });
57
+ it.each(["portfolioMargin", "dexAbstraction", "somethingNew"])(
58
+ "warns when account mode %s cannot be represented safely",
59
+ async (abstraction) => {
60
+ fetchUserAbstractionMock.mockResolvedValue(abstraction);
61
+ const status = await checkHyperliquidAccountStatus(statusConfig());
62
+ expect(status).toEqual({
63
+ status: "warning",
64
+ reasons: ["hyperliquid_account_mode_unsupported"]
65
+ });
66
+ }
67
+ );
68
+ it("distinguishes an account-mode API failure from an unsupported mode", async () => {
69
+ fetchUserAbstractionMock.mockRejectedValue(new Error("503"));
70
+ const status = await checkHyperliquidAccountStatus(statusConfig());
71
+ expect(status).toEqual({
72
+ status: "warning",
73
+ reasons: ["hyperliquid_account_mode_check_failed"]
74
+ });
75
+ });
76
+ it("fails closed when the required builder fee is missing", async () => {
77
+ const config = statusConfig();
78
+ config.statusOptions = { builderAddress };
79
+ await expect(checkHyperliquidAccountStatus(config)).rejects.toThrow("Missing required hyperliquid connect options");
80
+ });
81
+ });
@@ -5,24 +5,31 @@ import { warningStatus } from '../base';
5
5
  async function checkLighterAccountStatus(config) {
6
6
  const reasons = [];
7
7
  const options = config.statusOptions;
8
- if (options?.integratorAccountIndex === void 0) {
8
+ if (options?.integratorAccountIndex === void 0 || options.integratorMinPerpsMakerFee === void 0 || options.integratorMinPerpsTakerFee === void 0) {
9
9
  throw new Error("Missing required lighter connect options");
10
10
  }
11
11
  const credentials = config.credentials;
12
12
  const accountIndex = Number(config.account.exchangeIdentifier);
13
- try {
14
- const { token } = await config.sdk.core.misc.lighter.createAuthToken({
15
- accountIndex,
16
- apiKeyIndex: credentials.api_key_index,
17
- apiPrivateKey: credentials.api_private_key
18
- });
19
- await fetchAccountMetadata(accountIndex, token);
20
- } catch {
13
+ if (config.demo) {
21
14
  reasons.push("lighter_api_key_invalid");
15
+ } else {
16
+ try {
17
+ const { token } = await config.sdk.core.misc.lighter.createAuthToken({
18
+ accountIndex,
19
+ apiKeyIndex: credentials.api_key_index,
20
+ apiPrivateKey: credentials.api_private_key
21
+ });
22
+ await fetchAccountMetadata(accountIndex, token, config.demo);
23
+ } catch {
24
+ reasons.push("lighter_api_key_invalid");
25
+ }
22
26
  }
23
27
  try {
24
- const helper = createExchangeHelper({ exchange: "lighter", sdk: config.sdk });
25
- const approved = await helper.checkIntegratorApproval(accountIndex, options.integratorAccountIndex);
28
+ const helper = createExchangeHelper({ exchange: "lighter", sdk: config.sdk, demo: config.demo });
29
+ const approved = await helper.checkIntegratorApproval(accountIndex, options.integratorAccountIndex, {
30
+ minPerpsMakerFee: options.integratorMinPerpsMakerFee,
31
+ minPerpsTakerFee: options.integratorMinPerpsTakerFee
32
+ });
26
33
  if (!approved) reasons.push("lighter_integrator_not_approved");
27
34
  } catch {
28
35
  reasons.push("lighter_integrator_not_approved");
@@ -0,0 +1,75 @@
1
+ import { vi, describe, beforeEach, it, expect } from 'vitest';
2
+ import { checkLighterAccountStatus } from './status';
3
+
4
+ const { checkIntegratorApprovalMock, createAuthTokenMock, createExchangeHelperMock, fetchAccountMetadataMock } = vi.hoisted(() => ({
5
+ checkIntegratorApprovalMock: vi.fn(),
6
+ createAuthTokenMock: vi.fn(),
7
+ createExchangeHelperMock: vi.fn(),
8
+ fetchAccountMetadataMock: vi.fn()
9
+ }));
10
+ vi.mock("../../client/lighter/rest", () => ({ fetchAccountMetadata: fetchAccountMetadataMock }));
11
+ vi.mock("../../helper/factory", () => ({ createExchangeHelper: createExchangeHelperMock }));
12
+ function config() {
13
+ return {
14
+ sdk: {
15
+ core: {
16
+ misc: {
17
+ lighter: {
18
+ createAuthToken: createAuthTokenMock
19
+ }
20
+ }
21
+ }
22
+ },
23
+ connector: "lighter",
24
+ account: { exchangeIdentifier: "42" },
25
+ credentials: { api_key_index: 4, api_private_key: "private" },
26
+ statusOptions: {
27
+ integratorAccountIndex: 77,
28
+ integratorMinPerpsMakerFee: 500,
29
+ integratorMinPerpsTakerFee: 600
30
+ },
31
+ demo: true
32
+ };
33
+ }
34
+ describe("checkLighterAccountStatus", () => {
35
+ beforeEach(() => {
36
+ createAuthTokenMock.mockReset().mockResolvedValue({ token: "token" });
37
+ fetchAccountMetadataMock.mockReset().mockResolvedValue(void 0);
38
+ checkIntegratorApprovalMock.mockReset().mockResolvedValue(true);
39
+ createExchangeHelperMock.mockReset().mockReturnValue({
40
+ checkIntegratorApproval: checkIntegratorApprovalMock
41
+ });
42
+ });
43
+ it("fails demo auth closed without minting a mainnet token and still checks testnet approval", async () => {
44
+ await expect(checkLighterAccountStatus(config())).resolves.toEqual({
45
+ status: "warning",
46
+ reasons: ["lighter_api_key_invalid"]
47
+ });
48
+ expect(createAuthTokenMock).not.toHaveBeenCalled();
49
+ expect(fetchAccountMetadataMock).not.toHaveBeenCalled();
50
+ expect(createExchangeHelperMock).toHaveBeenCalledWith(expect.objectContaining({ demo: true }));
51
+ expect(checkIntegratorApprovalMock).toHaveBeenCalledWith(42, 77, {
52
+ minPerpsMakerFee: 500,
53
+ minPerpsTakerFee: 600
54
+ });
55
+ });
56
+ it("authenticates mainnet status without unsupported payload fields", async () => {
57
+ const mainnetConfig = config();
58
+ mainnetConfig.demo = false;
59
+ await expect(checkLighterAccountStatus(mainnetConfig)).resolves.toEqual({ status: "active", reasons: [] });
60
+ expect(createAuthTokenMock).toHaveBeenCalledWith({
61
+ accountIndex: 42,
62
+ apiKeyIndex: 4,
63
+ apiPrivateKey: "private"
64
+ });
65
+ expect(fetchAccountMetadataMock).toHaveBeenCalledWith(42, "token", false);
66
+ });
67
+ it("fails closed when required approval thresholds are absent", async () => {
68
+ const missingThresholds = config();
69
+ missingThresholds.statusOptions = { integratorAccountIndex: 77 };
70
+ await expect(checkLighterAccountStatus(missingThresholds)).rejects.toThrow(
71
+ "Missing required lighter connect options"
72
+ );
73
+ expect(fetchAccountMetadataMock).not.toHaveBeenCalled();
74
+ });
75
+ });
@@ -6,14 +6,12 @@ class BaseAccountSyncer {
6
6
  connector;
7
7
  account;
8
8
  credentials;
9
- ws;
10
9
  demo;
11
10
  constructor(config) {
12
11
  this.sdk = config.sdk;
13
12
  this.connector = config.connector;
14
13
  this.account = config.account;
15
14
  this.credentials = config.credentials;
16
- this.ws = config.ws;
17
15
  this.demo = config.demo;
18
16
  }
19
17
  // Phase A: Load current Pear state (positions, fills, instruments, watermark).
@@ -59,7 +59,6 @@ function makeSyncer(args) {
59
59
  connector: "hyperliquid",
60
60
  account: { id: "acct-1" },
61
61
  credentials: {},
62
- ws: {},
63
62
  demo: false
64
63
  };
65
64
  return { syncer: new FakeSyncer(config), sync };
@@ -3,6 +3,8 @@ import { fetchInfo } from '../../client/hyperliquid/rest';
3
3
  import { BaseAccountSyncer } from '../base';
4
4
  import { toFundingPaymentEntries } from './funding';
5
5
 
6
+ const HYPERLIQUID_FILL_PAGE_LIMIT = 2e3;
7
+ const HYPERLIQUID_FILL_RETENTION_LIMIT = 1e4;
6
8
  class HyperliquidAccountSyncer extends BaseAccountSyncer {
7
9
  address;
8
10
  constructor(config) {
@@ -18,15 +20,18 @@ class HyperliquidAccountSyncer extends BaseAccountSyncer {
18
20
  { id: instrument.id, contractSize: instrument.precision.quantity.contractSize }
19
21
  ])
20
22
  );
21
- const perpDexs = await fetchInfo({ type: "perpDexs" });
22
- const dexNames = perpDexs.map((perpDex) => perpDex?.name ?? "");
23
+ const perpDexs = await fetchInfo({ type: "perpDexs" }, this.demo);
24
+ const dexNames = [...new Set(perpDexs.map((perpDex) => perpDex?.name ?? ""))];
23
25
  const states = await Promise.all(
24
26
  dexNames.map(
25
- (dex) => fetchInfo({
26
- type: "clearinghouseState",
27
- user: this.address,
28
- dex
29
- })
27
+ (dex) => fetchInfo(
28
+ {
29
+ type: "clearinghouseState",
30
+ user: this.address,
31
+ dex
32
+ },
33
+ this.demo
34
+ )
30
35
  )
31
36
  );
32
37
  const assetPositions = states.flatMap((state) => state.assetPositions);
@@ -40,12 +45,13 @@ class HyperliquidAccountSyncer extends BaseAccountSyncer {
40
45
  console.warn(`Instrument for coin ${rawPosition.coin} not found.`);
41
46
  return positions;
42
47
  }
48
+ const size = szi.abs().times(instrument.contractSize).toFixed();
43
49
  positions.push({
44
50
  symbol: instrument.id,
45
- size: szi.abs().times(instrument.contractSize).toFixed(),
51
+ size,
46
52
  side: szi.isPositive() ? "BUY" : "SELL",
47
53
  entryPrice: rawPosition.entryPx,
48
- markPrice: rawPosition.entryPx
54
+ markPrice: new BigNumber(rawPosition.positionValue).abs().div(size).toFixed()
49
55
  });
50
56
  return positions;
51
57
  }, []);
@@ -58,48 +64,73 @@ class HyperliquidAccountSyncer extends BaseAccountSyncer {
58
64
  acc[instrument.base] = instrument.id;
59
65
  return acc;
60
66
  }, {});
61
- const PAGE_LIMIT = 2e3;
62
67
  const allFills = [];
63
- const seenTids = /* @__PURE__ */ new Set();
68
+ const seenFillIds = /* @__PURE__ */ new Set();
69
+ let oldestReachableFillTime = null;
64
70
  const addFill = (fill) => {
65
- if (!seenTids.has(fill.tid)) {
66
- seenTids.add(fill.tid);
71
+ const fillId = hyperliquidFillId(fill);
72
+ if (!seenFillIds.has(fillId)) {
73
+ seenFillIds.add(fillId);
67
74
  allFills.push(fill);
68
75
  }
69
76
  };
70
77
  let cursor = since.getTime();
71
78
  let batchSize;
72
79
  do {
73
- const batch = await fetchInfo({
74
- type: "userFillsByTime",
75
- user: this.address,
76
- startTime: cursor,
77
- aggregateByTime: true
78
- });
80
+ const batch = await fetchInfo(
81
+ {
82
+ type: "userFillsByTime",
83
+ user: this.address,
84
+ startTime: cursor,
85
+ aggregateByTime: false
86
+ },
87
+ this.demo
88
+ );
79
89
  batchSize = batch.length;
90
+ if (batch.length > 0 && oldestReachableFillTime === null) {
91
+ oldestReachableFillTime = Math.min(...batch.map((fill) => fill.time));
92
+ }
80
93
  for (const rawFill of batch) {
81
94
  addFill(rawFill);
82
95
  }
83
- if (batchSize === PAGE_LIMIT) {
84
- cursor = batch[batch.length - 1]?.time ?? cursor;
96
+ if (seenFillIds.size >= HYPERLIQUID_FILL_RETENTION_LIMIT && oldestReachableFillTime !== null && since.getTime() < oldestReachableFillTime) {
97
+ throw new Error(
98
+ `Hyperliquid fill history is incomplete: requested since ${since.getTime()} predates oldest reachable fill ${oldestReachableFillTime} within the ${HYPERLIQUID_FILL_RETENTION_LIMIT}-fill retention limit.`
99
+ );
85
100
  }
86
- } while (batchSize === PAGE_LIMIT);
101
+ if (batchSize === HYPERLIQUID_FILL_PAGE_LIMIT) {
102
+ const nextCursor = batch[batch.length - 1]?.time ?? cursor;
103
+ if (nextCursor <= cursor) {
104
+ warnStalledPagination("fills", cursor);
105
+ break;
106
+ }
107
+ cursor = nextCursor;
108
+ }
109
+ } while (batchSize === HYPERLIQUID_FILL_PAGE_LIMIT);
87
110
  cursor = since.getTime();
88
111
  do {
89
- const twapBatch = await fetchInfo({
90
- type: "userTwapSliceFillsByTime",
91
- user: this.address,
92
- startTime: cursor,
93
- aggregateByTime: true
94
- });
112
+ const twapBatch = await fetchInfo(
113
+ {
114
+ type: "userTwapSliceFillsByTime",
115
+ user: this.address,
116
+ startTime: cursor,
117
+ aggregateByTime: false
118
+ },
119
+ this.demo
120
+ );
95
121
  batchSize = twapBatch.length;
96
122
  for (const item of twapBatch) {
97
123
  addFill(item.fill);
98
124
  }
99
- if (batchSize === PAGE_LIMIT) {
100
- cursor = twapBatch[twapBatch.length - 1]?.fill.time ?? cursor;
125
+ if (batchSize === HYPERLIQUID_FILL_PAGE_LIMIT) {
126
+ const nextCursor = twapBatch[twapBatch.length - 1]?.fill.time ?? cursor;
127
+ if (nextCursor <= cursor) {
128
+ warnStalledPagination("TWAP fills", cursor);
129
+ break;
130
+ }
131
+ cursor = nextCursor;
101
132
  }
102
- } while (batchSize === PAGE_LIMIT);
133
+ } while (batchSize === HYPERLIQUID_FILL_PAGE_LIMIT);
103
134
  allFills.sort((a, b) => a.time - b.time);
104
135
  return allFills.reduce((fills, rawFill) => {
105
136
  const instrumentId = instrumentSymbolToIdMap[rawFill.coin];
@@ -107,9 +138,15 @@ class HyperliquidAccountSyncer extends BaseAccountSyncer {
107
138
  console.warn(`Instrument symbol ${rawFill.coin} not found in instrument symbol to id map.`);
108
139
  return fills;
109
140
  }
141
+ const tradeFee = new BigNumber(rawFill.fee).minus(rawFill.builderFee ?? "0");
142
+ if (tradeFee.isNegative()) {
143
+ throw new Error(
144
+ `Hyperliquid fill ${hyperliquidFillId(rawFill)} has a negative net trade fee (${tradeFee.toFixed()}); the backend external-fill contract does not accept Hyperliquid rebates.`
145
+ );
146
+ }
110
147
  fills.push({
111
148
  connector: "hyperliquid",
112
- exchangeFillId: rawFill.tid.toFixed(),
149
+ exchangeFillId: hyperliquidFillId(rawFill),
113
150
  cloid: rawFill.cloid ?? null,
114
151
  symbol: instrumentId,
115
152
  price: rawFill.px,
@@ -117,8 +154,8 @@ class HyperliquidAccountSyncer extends BaseAccountSyncer {
117
154
  side: rawFill.side === "B" ? "BUY" : "SELL",
118
155
  reduceOnly: this.isReduceOnly(rawFill),
119
156
  timestamp: rawFill.time,
120
- tradeFee: rawFill.fee,
121
- builderFee: rawFill.builderFee ?? "0",
157
+ tradeFee: tradeFee.toFixed(),
158
+ builderFee: new BigNumber(rawFill.builderFee ?? "0").abs().toFixed(),
122
159
  liquidation: rawFill.liquidation ? { mark: rawFill.liquidation.markPx } : null
123
160
  });
124
161
  return fills;
@@ -133,26 +170,42 @@ class HyperliquidAccountSyncer extends BaseAccountSyncer {
133
170
  return acc;
134
171
  }, {});
135
172
  const PAGE_LIMIT = 500;
136
- const allEvents = [];
173
+ const allEvents = /* @__PURE__ */ new Map();
137
174
  let cursor = since.getTime();
138
175
  let batchSize;
139
176
  do {
140
- const batch = await fetchInfo({
141
- type: "userFunding",
142
- user: this.address,
143
- startTime: cursor
144
- });
177
+ const batch = await fetchInfo(
178
+ {
179
+ type: "userFunding",
180
+ user: this.address,
181
+ startTime: cursor
182
+ },
183
+ this.demo
184
+ );
145
185
  batchSize = batch.length;
146
- allEvents.push(...batch);
186
+ for (const event of batch) {
187
+ allEvents.set(`${event.time}:${event.delta.coin}:${event.hash}`, event);
188
+ }
147
189
  if (batchSize === PAGE_LIMIT) {
148
- cursor = batch[batch.length - 1].time;
190
+ const nextCursor = batch[batch.length - 1]?.time ?? cursor;
191
+ if (nextCursor <= cursor) {
192
+ warnStalledPagination("funding", cursor);
193
+ break;
194
+ }
195
+ cursor = nextCursor;
149
196
  }
150
197
  } while (batchSize === PAGE_LIMIT);
151
- return toFundingPaymentEntries(allEvents, instrumentSymbolToIdMap);
198
+ return toFundingPaymentEntries([...allEvents.values()], instrumentSymbolToIdMap);
152
199
  }
153
200
  isReduceOnly(rawFill) {
154
201
  return rawFill.dir.includes("Close");
155
202
  }
156
203
  }
204
+ function hyperliquidFillId(fill) {
205
+ return `${fill.time}:${fill.coin}:${fill.tid}`;
206
+ }
207
+ function warnStalledPagination(stream, cursor) {
208
+ console.warn(`Hyperliquid ${stream} pagination stalled at timestamp ${cursor}; returned history may be incomplete.`);
209
+ }
157
210
 
158
211
  export { HyperliquidAccountSyncer };