@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.
- 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 +239 -47
- 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 +107 -25
- package/dist/syncer/base.test.js +131 -0
- package/dist/syncer/binance/syncer.js +48 -20
- package/dist/syncer/bybit/syncer.js +39 -15
- package/dist/syncer/hyperliquid/funding.js +24 -0
- package/dist/syncer/hyperliquid/funding.test.js +63 -0
- package/dist/syncer/hyperliquid/syncer.js +183 -24
- 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 +93 -41
- package/dist/syncer/lighter/syncer.test.js +132 -6
- package/dist/syncer/okx/syncer.js +41 -17
- 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 +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
|
-
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
}
|
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
|
@@ -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
|
-
|
|
19
|
-
|
|
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(
|
|
53
|
+
since: new Date(watermark.sinceMs),
|
|
36
54
|
instruments,
|
|
37
|
-
openPositions
|
|
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
|
-
|
|
66
|
+
tradeAccountId: this.account.id,
|
|
67
|
+
connector: this.connector,
|
|
68
|
+
openPositions,
|
|
69
|
+
entryBySymbol,
|
|
42
70
|
provisionalFills,
|
|
43
|
-
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
|
-
|
|
56
|
-
|
|
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
|
|