@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.
- package/README.md +78 -12
- package/dist/client/hyperliquid/client.test.js +41 -0
- package/dist/client/hyperliquid/exchange-ws.js +3 -2
- package/dist/client/hyperliquid/rest.js +26 -5
- package/dist/client/hyperliquid/ws.js +16 -4
- package/dist/client/index.js +2 -2
- package/dist/client/lighter/constants.js +9 -1
- package/dist/client/lighter/constants.test.js +22 -0
- package/dist/client/lighter/exchange-ws.js +2 -2
- package/dist/client/lighter/rest.js +101 -32
- package/dist/client/lighter/rest.test.js +152 -0
- package/dist/client/lighter/ws.js +7 -2
- package/dist/client/okx/rest.js +18 -9
- package/dist/helper/factory.js +2 -2
- package/dist/helper/hyperliquid/helper.js +28 -13
- package/dist/helper/hyperliquid/helper.test.js +45 -0
- package/dist/helper/lighter/helper.js +77 -16
- package/dist/helper/lighter/helper.test.js +289 -0
- package/dist/helper/lighter/types.js +9 -8
- package/dist/index.d.ts +182 -38
- package/dist/sdk.js +20 -16
- package/dist/sdk.test.js +101 -0
- package/dist/status/hyperliquid/status.js +18 -5
- package/dist/status/hyperliquid/status.test.js +81 -0
- package/dist/status/lighter/status.js +18 -11
- package/dist/status/lighter/status.test.js +75 -0
- package/dist/syncer/base.js +0 -2
- package/dist/syncer/base.test.js +0 -1
- package/dist/syncer/hyperliquid/syncer.js +96 -43
- package/dist/syncer/hyperliquid/syncer.test.js +274 -0
- package/dist/syncer/lighter/funding.js +25 -0
- package/dist/syncer/lighter/funding.test.js +48 -0
- package/dist/syncer/lighter/syncer.js +30 -6
- package/dist/syncer/lighter/syncer.test.js +132 -6
- package/dist/tracker/base.js +23 -6
- package/dist/tracker/base.test.js +132 -0
- package/dist/tracker/binance/tracker.js +48 -18
- package/dist/tracker/binance/tracker.test.js +138 -0
- package/dist/tracker/bybit/tracker.js +78 -23
- package/dist/tracker/bybit/tracker.test.js +145 -0
- package/dist/tracker/hyperliquid/mapper.js +244 -44
- package/dist/tracker/hyperliquid/mapper.test.js +255 -0
- package/dist/tracker/hyperliquid/tracker.js +241 -74
- package/dist/tracker/hyperliquid/tracker.test.js +587 -0
- package/dist/tracker/index.js +15 -37
- package/dist/tracker/lighter/mapper.js +163 -33
- package/dist/tracker/lighter/mapper.test.js +199 -0
- package/dist/tracker/lighter/tracker.js +219 -45
- package/dist/tracker/lighter/tracker.test.js +515 -0
- package/dist/tracker/lighter/types.js +10 -2
- package/dist/tracker/okx/tracker.js +50 -16
- package/dist/tracker/okx/tracker.test.js +126 -0
- package/dist/tracker/types.js +2 -1
- package/dist/tracker/utils.test.js +8 -0
- package/package.json +3 -2
package/dist/sdk.test.js
ADDED
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/dist/syncer/base.js
CHANGED
|
@@ -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).
|
package/dist/syncer/base.test.js
CHANGED
|
@@ -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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
51
|
+
size,
|
|
46
52
|
side: szi.isPositive() ? "BUY" : "SELL",
|
|
47
53
|
entryPrice: rawPosition.entryPx,
|
|
48
|
-
markPrice: rawPosition.
|
|
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
|
|
68
|
+
const seenFillIds = /* @__PURE__ */ new Set();
|
|
69
|
+
let oldestReachableFillTime = null;
|
|
64
70
|
const addFill = (fill) => {
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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 (
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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 ===
|
|
100
|
-
|
|
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 ===
|
|
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
|
|
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:
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|