@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
package/dist/sdk.js CHANGED
@@ -1,4 +1,3 @@
1
- import { createExchangeWs } from './client';
2
1
  import './helper';
3
2
  import { createExchangeHelper } from './helper/factory';
4
3
  import { checkAccountStatus } from './status';
@@ -15,12 +14,7 @@ class ExchangesSDK {
15
14
  this.options = config.options;
16
15
  }
17
16
  async connect(tradeAccountId) {
18
- const connection = await this.fetchCredentials(tradeAccountId);
19
- const ws = await createExchangeWs(connection.connector, {
20
- demo: this.demo,
21
- credentials: connection.credentials
22
- });
23
- return { ...connection, ws };
17
+ return this.fetchCredentials(tradeAccountId);
24
18
  }
25
19
  async status(connection) {
26
20
  return checkAccountStatus(connection.connector, {
@@ -33,19 +27,22 @@ class ExchangesSDK {
33
27
  });
34
28
  }
35
29
  createTracker(connection) {
36
- return createAccountTracker(connection.connector, {
37
- demo: this.demo,
38
- account: connection.account,
39
- credentials: connection.credentials,
40
- ws: connection.ws
41
- });
30
+ return this.buildTracker(connection);
31
+ }
32
+ async snapshot(tradeAccountId) {
33
+ const connection = await this.fetchCredentials(tradeAccountId);
34
+ return this.buildTracker(connection).snapshot();
35
+ }
36
+ async snapshotAssets(tradeAccountId, coins) {
37
+ const connection = await this.fetchCredentials(tradeAccountId);
38
+ return this.buildTracker(connection).snapshotAssets(coins);
42
39
  }
43
40
  createHelper(exchange) {
44
41
  switch (exchange) {
45
42
  case "hyperliquid":
46
- return createExchangeHelper({ exchange: "hyperliquid" });
43
+ return createExchangeHelper({ exchange: "hyperliquid", demo: this.demo });
47
44
  case "lighter":
48
- return createExchangeHelper({ exchange: "lighter", sdk: this.sdk });
45
+ return createExchangeHelper({ exchange: "lighter", sdk: this.sdk, demo: this.demo });
49
46
  }
50
47
  }
51
48
  createSyncer(connection) {
@@ -54,10 +51,17 @@ class ExchangesSDK {
54
51
  connector: connection.connector,
55
52
  account: connection.account,
56
53
  credentials: connection.credentials,
57
- ws: connection.ws,
58
54
  demo: this.demo
59
55
  });
60
56
  }
57
+ buildTracker(connection) {
58
+ return createAccountTracker(connection.connector, {
59
+ demo: this.demo,
60
+ account: connection.account,
61
+ credentials: connection.credentials,
62
+ sdk: this.sdk
63
+ });
64
+ }
61
65
  async fetchCredentials(tradeAccountId) {
62
66
  return await this.sdk.core.accounts.credentials(tradeAccountId);
63
67
  }
@@ -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
+ });
@@ -1,60 +1,142 @@
1
- import { computeSyncPayload } from '@pear-protocol/utils';
1
+ import { computeEntryPriceForAsset, computeSyncPayload } from '@pear-protocol/utils';
2
2
 
3
+ const OVERLAP_MS = 6e4;
3
4
  class BaseAccountSyncer {
4
5
  sdk;
5
6
  connector;
6
7
  account;
7
8
  credentials;
8
- ws;
9
9
  demo;
10
10
  constructor(config) {
11
11
  this.sdk = config.sdk;
12
12
  this.connector = config.connector;
13
13
  this.account = config.account;
14
14
  this.credentials = config.credentials;
15
- this.ws = config.ws;
16
15
  this.demo = config.demo;
17
16
  }
18
- async synchronize() {
19
- const [{ positions }, { fills: provisionalFills }] = await Promise.all([
17
+ // Phase A: Load current Pear state (positions, fills, instruments, watermark).
18
+ async loadPearState() {
19
+ const [{ positions: openPositions }, { fills: provisionalFills }, { instruments }, watermark] = await Promise.all([
20
20
  this.sdk.core.positions.openLite(),
21
- this.sdk.core.fills.provisionals()
22
- ]);
23
- if (!positions.length && !provisionalFills.length) {
24
- return;
25
- }
26
- const [{ instruments }, since] = await Promise.all([
21
+ this.sdk.core.fills.provisionals(),
27
22
  this.sdk.core.instruments.list({ connector: this.connector }),
28
- this.sdk.core.sync.since({
29
- overlapMs: 6e4,
30
- nowMs: Date.now(),
31
- bootstrapLookbackMs: 30 * 24 * 60 * 60 * 1e3
32
- })
23
+ this.sdk.core.sync.fills.since({ overlapMs: OVERLAP_MS })
33
24
  ]);
25
+ const entryBySymbol = await this.loadEntryBySymbol(openPositions);
26
+ return { openPositions, entryBySymbol, provisionalFills, instruments, watermark };
27
+ }
28
+ /**
29
+ * Global per-symbol cost-basis entry of existing open positions, derived by pulling each
30
+ * position's fills and folding them (mirrors example-ui). One call per open position today —
31
+ * can collapse to a single endpoint later.
32
+ */
33
+ async loadEntryBySymbol(openPositions) {
34
+ const responses = await Promise.all(openPositions.map((position) => this.sdk.core.fills.details(position.id)));
35
+ const fills = responses.flatMap((response) => response.fills).sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
36
+ const fillsBySymbol = /* @__PURE__ */ new Map();
37
+ for (const fill of fills) {
38
+ const list = fillsBySymbol.get(fill.symbol) ?? [];
39
+ list.push(fill);
40
+ fillsBySymbol.set(fill.symbol, list);
41
+ }
42
+ const entryBySymbol = {};
43
+ for (const [symbol, symbolFills] of fillsBySymbol) {
44
+ const entry = computeEntryPriceForAsset(symbolFills);
45
+ if (!entry.isZero()) entryBySymbol[symbol] = entry.toFixed();
46
+ }
47
+ return entryBySymbol;
48
+ }
49
+ // Phase B: Snapshot the exchange — fetch positions and fills since the watermark.
50
+ async snapshotExchange(state) {
51
+ const { openPositions, provisionalFills, instruments, watermark } = state;
34
52
  const externalFills = await this.listExternalFills({
35
- since: new Date(since.since),
53
+ since: new Date(watermark.sinceMs),
36
54
  instruments,
37
- openPositions: positions,
55
+ openPositions,
38
56
  provisionalFills
39
57
  });
58
+ const exchangePositions = await this.listExternalPositions({ instruments });
59
+ return { exchangePositions, externalFills };
60
+ }
61
+ // Phase C: Compute the sync payload and post it across the three sync endpoints.
62
+ async computeAndPostSync(state, snapshot) {
63
+ const { openPositions, entryBySymbol, provisionalFills, watermark } = state;
64
+ const { exchangePositions, externalFills } = snapshot;
40
65
  const payload = await computeSyncPayload({
41
- openPositions: positions,
66
+ tradeAccountId: this.account.id,
67
+ connector: this.connector,
68
+ openPositions,
69
+ entryBySymbol,
42
70
  provisionalFills,
43
- confirmedExchangeFillIds: since.confirmedExchangeFillIds ?? [],
44
- externalFills
71
+ confirmedExchangeFillIds: watermark.confirmedExchangeFillIds ?? [],
72
+ externalFills,
73
+ exchangePositions,
74
+ neverSynced: watermark.neverSynced
45
75
  });
46
76
  if (!payload) {
47
77
  return;
48
78
  }
49
79
  if (payload.reconcilables) {
50
- await this.sdk.core.sync.reconcile(payload.reconcilables);
80
+ await this.sdk.core.sync.fills.reconcile(payload.reconcilables);
51
81
  }
52
82
  if (payload.confirmables) {
53
- await this.sdk.core.sync.confirm(payload.confirmables);
83
+ await this.sdk.core.sync.fills.confirm(payload.confirmables);
84
+ }
85
+ const { firstSyncTrim } = payload;
86
+ let meta = payload.meta;
87
+ if (firstSyncTrim) {
88
+ console.log(
89
+ `${this.connector}: first-sync trim \u2014 dropped ${firstSyncTrim.trimmedCount} pre-cutoff fills (cutoffTs=${firstSyncTrim.cutoffTs})`
90
+ );
91
+ if (meta.externalMaxTs !== null && firstSyncTrim.trimmedMaxTs >= meta.externalMaxTs - OVERLAP_MS) {
92
+ meta = { ...meta, externalMaxTs: meta.externalMaxTs + OVERLAP_MS + 1 };
93
+ }
94
+ }
95
+ const createBody = {
96
+ applicables: payload.applicables,
97
+ exchangePositions: payload.exchangePositions,
98
+ meta
99
+ };
100
+ const bytes = new Blob([JSON.stringify(createBody)]).size;
101
+ if (bytes > 50 * 1024) {
102
+ await this.sdk.core.sync.fills.createBulk(createBody);
103
+ return;
104
+ }
105
+ await this.sdk.core.sync.fills.create(createBody);
106
+ }
107
+ /**
108
+ * Pull historical funding payments since the funding watermark and post them.
109
+ * Independent of the fill sync cycle (`synchronizeFills`) — funding rows are
110
+ * immutable and upsert idempotently, so this is driven on its own cadence.
111
+ */
112
+ async synchronizeFunding() {
113
+ const [{ instruments }, watermark] = await Promise.all([
114
+ this.sdk.core.instruments.list({ connector: this.connector }),
115
+ this.sdk.core.sync.funding.since({ overlapMs: 6e4 })
116
+ ]);
117
+ const payments = await this.listExternalFunding({ since: new Date(watermark.sinceMs), instruments });
118
+ if (payments.length === 0) {
119
+ return;
120
+ }
121
+ const externalMaxTs = Math.max(...payments.map((payment) => payment.timestamp));
122
+ const createBody = { payments, meta: { externalMaxTs } };
123
+ const bytes = new Blob([JSON.stringify(createBody)]).size;
124
+ if (bytes > 50 * 1024) {
125
+ await this.sdk.core.sync.funding.createBulk(createBody);
126
+ return;
54
127
  }
55
- if (payload.creatables) {
56
- await this.sdk.core.sync.create(payload.creatables);
128
+ await this.sdk.core.sync.funding.create(createBody);
129
+ }
130
+ async synchronizeFills() {
131
+ const state = await this.loadPearState();
132
+ let snapshot;
133
+ try {
134
+ snapshot = await this.snapshotExchange(state);
135
+ } catch (error) {
136
+ console.error(`${this.connector}: position/fill fetch failed \u2014 sync cycle aborted, retry next tick`, error);
137
+ return;
57
138
  }
139
+ await this.computeAndPostSync(state, snapshot);
58
140
  }
59
141
  }
60
142