@pear-protocol/exchanges-sdk 0.6.0 → 0.6.2
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/dist/client/hyperliquid/rest.js +4 -1
- package/dist/helper/hyperliquid/helper.js +11 -1
- package/dist/helper/hyperliquid/helper.test.js +29 -2
- package/dist/index.d.ts +1 -0
- package/dist/syncer/base.js +11 -3
- package/dist/syncer/base.test.js +19 -2
- package/dist/syncer/hyperliquid/syncer.js +0 -10
- package/dist/syncer/hyperliquid/syncer.test.js +11 -14
- package/package.json +3 -3
|
@@ -25,6 +25,9 @@ async function fetchClearinghouseState(user, dex, demo = false) {
|
|
|
25
25
|
async function fetchUserAbstraction(user, demo = false) {
|
|
26
26
|
return fetchInfo({ type: "userAbstraction", user }, demo);
|
|
27
27
|
}
|
|
28
|
+
async function fetchUserRole(user, demo = false) {
|
|
29
|
+
return fetchInfo({ type: "userRole", user }, demo);
|
|
30
|
+
}
|
|
28
31
|
async function fetchSpotClearinghouseState(user, demo = false) {
|
|
29
32
|
return fetchInfo({ type: "spotClearinghouseState", user }, demo);
|
|
30
33
|
}
|
|
@@ -32,4 +35,4 @@ async function fetchActiveAssetData(user, coin, demo = false) {
|
|
|
32
35
|
return fetchInfo({ type: "activeAssetData", user, coin }, demo);
|
|
33
36
|
}
|
|
34
37
|
|
|
35
|
-
export { fetchActiveAssetData, fetchClearinghouseState, fetchInfo, fetchPerpDexs, fetchSpotClearinghouseState, fetchUserAbstraction };
|
|
38
|
+
export { fetchActiveAssetData, fetchClearinghouseState, fetchInfo, fetchPerpDexs, fetchSpotClearinghouseState, fetchUserAbstraction, fetchUserRole };
|
|
@@ -1,7 +1,17 @@
|
|
|
1
|
-
import { fetchInfo } from '../../client/hyperliquid/rest';
|
|
1
|
+
import { fetchInfo, fetchUserRole } from '../../client/hyperliquid/rest';
|
|
2
2
|
|
|
3
3
|
function createHyperliquidHelper(demo = false) {
|
|
4
4
|
return {
|
|
5
|
+
/**
|
|
6
|
+
* Checks that the address holds a Hyperliquid account, which the exchange only creates on the
|
|
7
|
+
* first deposit. This is account existence alone — builder-fee approval, Pear trade-account
|
|
8
|
+
* setup, and agent approval stay separate gates. A failed request throws rather than reporting
|
|
9
|
+
* an inactive account, so callers never mistake an outage for a user who has to deposit.
|
|
10
|
+
*/
|
|
11
|
+
async checkAccountActivation(userAddress) {
|
|
12
|
+
const role = await fetchUserRole(userAddress, demo);
|
|
13
|
+
return role.role !== "missing";
|
|
14
|
+
},
|
|
5
15
|
/** Checks that the requested API wallet exists and has not expired. */
|
|
6
16
|
async checkApiWallet(params) {
|
|
7
17
|
const agents = await fetchInfo(
|
|
@@ -1,14 +1,41 @@
|
|
|
1
1
|
import { vi, describe, beforeEach, it, expect } from 'vitest';
|
|
2
2
|
import { createHyperliquidHelper } from './helper';
|
|
3
3
|
|
|
4
|
-
const { fetchInfoMock } = vi.hoisted(() => ({
|
|
5
|
-
vi.
|
|
4
|
+
const { fetchInfoMock, fetchUserRoleMock } = vi.hoisted(() => ({
|
|
5
|
+
fetchInfoMock: vi.fn(),
|
|
6
|
+
fetchUserRoleMock: vi.fn()
|
|
7
|
+
}));
|
|
8
|
+
vi.mock("../../client/hyperliquid/rest", () => ({ fetchInfo: fetchInfoMock, fetchUserRole: fetchUserRoleMock }));
|
|
6
9
|
const userAddress = "0x1111111111111111111111111111111111111111";
|
|
7
10
|
const agentWalletAddress = "0x2222222222222222222222222222222222222222";
|
|
8
11
|
const builderAddress = "0x3333333333333333333333333333333333333333";
|
|
12
|
+
const masterAddress = "0x4444444444444444444444444444444444444444";
|
|
9
13
|
describe("createHyperliquidHelper", () => {
|
|
10
14
|
beforeEach(() => {
|
|
11
15
|
fetchInfoMock.mockReset();
|
|
16
|
+
fetchUserRoleMock.mockReset();
|
|
17
|
+
});
|
|
18
|
+
it("reports an address Hyperliquid does not know as not activated", async () => {
|
|
19
|
+
fetchUserRoleMock.mockResolvedValue({ role: "missing" });
|
|
20
|
+
const activated = await createHyperliquidHelper(true).checkAccountActivation(userAddress);
|
|
21
|
+
expect(activated).toBe(false);
|
|
22
|
+
expect(fetchUserRoleMock).toHaveBeenCalledWith(userAddress, true);
|
|
23
|
+
});
|
|
24
|
+
it.each([
|
|
25
|
+
["user", { role: "user" }],
|
|
26
|
+
["agent", { role: "agent", data: { user: masterAddress } }],
|
|
27
|
+
["vault", { role: "vault" }],
|
|
28
|
+
["subAccount", { role: "subAccount", data: { master: masterAddress } }]
|
|
29
|
+
])("reports the %s role as activated", async (_role, response) => {
|
|
30
|
+
fetchUserRoleMock.mockResolvedValue(response);
|
|
31
|
+
await expect(createHyperliquidHelper().checkAccountActivation(userAddress)).resolves.toBe(true);
|
|
32
|
+
expect(fetchUserRoleMock).toHaveBeenCalledWith(userAddress, false);
|
|
33
|
+
});
|
|
34
|
+
it("surfaces a failed role request as an error instead of an inactive account", async () => {
|
|
35
|
+
fetchUserRoleMock.mockRejectedValue(new Error("Hyperliquid info request failed: 429"));
|
|
36
|
+
await expect(createHyperliquidHelper().checkAccountActivation(userAddress)).rejects.toThrow(
|
|
37
|
+
"Hyperliquid info request failed: 429"
|
|
38
|
+
);
|
|
12
39
|
});
|
|
13
40
|
it("accepts unexpired and non-expiring API wallets on the selected network", async () => {
|
|
14
41
|
fetchInfoMock.mockResolvedValue([
|
package/dist/index.d.ts
CHANGED
|
@@ -211,6 +211,7 @@ type CreateExchangeWsConfig = {
|
|
|
211
211
|
declare function createExchangeWs<E extends Connector>(exchange: E, config: CreateExchangeWsConfig): Promise<ExchangeWsByConnector[E]>;
|
|
212
212
|
|
|
213
213
|
interface HyperliquidHelper {
|
|
214
|
+
checkAccountActivation(userAddress: `0x${string}`): Promise<boolean>;
|
|
214
215
|
checkApiWallet(params: CheckApiWalletParams): Promise<boolean>;
|
|
215
216
|
checkBuilderFeeApproval(userAddress: `0x${string}`, builderAddress: `0x${string}`, requiredFeeTenthsBps?: number): Promise<boolean>;
|
|
216
217
|
}
|
package/dist/syncer/base.js
CHANGED
|
@@ -49,6 +49,7 @@ class BaseAccountSyncer {
|
|
|
49
49
|
// Phase B: Snapshot the exchange — fetch positions and fills since the watermark.
|
|
50
50
|
async snapshotExchange(state) {
|
|
51
51
|
const { openPositions, provisionalFills, instruments, watermark } = state;
|
|
52
|
+
const { firstSyncStartedAt } = watermark;
|
|
52
53
|
const externalFills = await this.listExternalFills({
|
|
53
54
|
since: new Date(watermark.sinceMs),
|
|
54
55
|
instruments,
|
|
@@ -56,12 +57,16 @@ class BaseAccountSyncer {
|
|
|
56
57
|
provisionalFills
|
|
57
58
|
});
|
|
58
59
|
const exchangePositions = await this.listExternalPositions({ instruments });
|
|
59
|
-
return {
|
|
60
|
+
return {
|
|
61
|
+
exchangePositions,
|
|
62
|
+
externalFills,
|
|
63
|
+
firstSyncStartedAt
|
|
64
|
+
};
|
|
60
65
|
}
|
|
61
66
|
// Phase C: Compute the sync payload and post it across the three sync endpoints.
|
|
62
67
|
async computeAndPostSync(state, snapshot) {
|
|
63
68
|
const { openPositions, entryBySymbol, provisionalFills, watermark } = state;
|
|
64
|
-
const { exchangePositions, externalFills } = snapshot;
|
|
69
|
+
const { exchangePositions, externalFills, firstSyncStartedAt } = snapshot;
|
|
65
70
|
const payload = await computeSyncPayload({
|
|
66
71
|
tradeAccountId: this.account.id,
|
|
67
72
|
connector: this.connector,
|
|
@@ -83,7 +88,10 @@ class BaseAccountSyncer {
|
|
|
83
88
|
await this.sdk.core.sync.fills.confirm(payload.confirmables);
|
|
84
89
|
}
|
|
85
90
|
const { firstSyncTrim } = payload;
|
|
86
|
-
let meta =
|
|
91
|
+
let meta = {
|
|
92
|
+
...payload.meta,
|
|
93
|
+
...firstSyncStartedAt === void 0 ? {} : { firstSyncStartedAt }
|
|
94
|
+
};
|
|
87
95
|
if (firstSyncTrim) {
|
|
88
96
|
console.log(
|
|
89
97
|
`${this.connector}: first-sync trim \u2014 dropped ${firstSyncTrim.trimmedCount} pre-cutoff fills (cutoffTs=${firstSyncTrim.cutoffTs})`
|
package/dist/syncer/base.test.js
CHANGED
|
@@ -39,7 +39,12 @@ class FakeSyncer extends BaseAccountSyncer {
|
|
|
39
39
|
}
|
|
40
40
|
function makeSyncer(args) {
|
|
41
41
|
const fills = {
|
|
42
|
-
since: vi.fn().mockResolvedValue({
|
|
42
|
+
since: vi.fn().mockResolvedValue({
|
|
43
|
+
sinceMs: 0,
|
|
44
|
+
neverSynced: args.neverSynced,
|
|
45
|
+
firstSyncStartedAt: args.neverSynced ? FIRST_SYNC_STARTED_AT : void 0,
|
|
46
|
+
confirmedExchangeFillIds: []
|
|
47
|
+
}),
|
|
43
48
|
reconcile: vi.fn().mockResolvedValue({}),
|
|
44
49
|
confirm: vi.fn().mockResolvedValue({}),
|
|
45
50
|
create: vi.fn().mockResolvedValue({}),
|
|
@@ -64,6 +69,7 @@ function makeSyncer(args) {
|
|
|
64
69
|
return { syncer: new FakeSyncer(config), sync };
|
|
65
70
|
}
|
|
66
71
|
const NEWEST_TS = 105e4;
|
|
72
|
+
const FIRST_SYNC_STARTED_AT = 1e6;
|
|
67
73
|
function trimmableFills(btcEraTs) {
|
|
68
74
|
return [
|
|
69
75
|
extFill({ exchangeFillId: "a", symbol: "BTC", side: "BUY", quantity: "1", timestamp: btcEraTs.open }),
|
|
@@ -90,6 +96,17 @@ describe("BaseAccountSyncer \u2014 first-sync plumbing", () => {
|
|
|
90
96
|
expect(sync.fills.create).toHaveBeenCalledTimes(1);
|
|
91
97
|
expect(sync.fills.create.mock.calls[0]?.[0]?.exchangePositions).toBe(SOL_SNAPSHOT);
|
|
92
98
|
});
|
|
99
|
+
it("captures an empty first-sync checkpoint before fetching fills", async () => {
|
|
100
|
+
const { syncer, sync } = makeSyncer({ neverSynced: true });
|
|
101
|
+
syncer.positionsFn.mockResolvedValue([]);
|
|
102
|
+
syncer.fillsFn.mockResolvedValue([]);
|
|
103
|
+
await syncer.synchronizeFills();
|
|
104
|
+
expect(sync.fills.since.mock.invocationCallOrder[0]).toBeLessThan(syncer.fillsFn.mock.invocationCallOrder[0] ?? 0);
|
|
105
|
+
expect(sync.fills.create.mock.calls[0]?.[0]?.meta).toEqual({
|
|
106
|
+
externalMaxTs: null,
|
|
107
|
+
firstSyncStartedAt: FIRST_SYNC_STARTED_AT
|
|
108
|
+
});
|
|
109
|
+
});
|
|
93
110
|
it("bumps the posted watermark past the overlap window when trimmed fills fall inside it", async () => {
|
|
94
111
|
const { syncer, sync } = makeSyncer({ neverSynced: true });
|
|
95
112
|
syncer.positionsFn.mockResolvedValue(SOL_SNAPSHOT);
|
|
@@ -125,7 +142,7 @@ describe("BaseAccountSyncer \u2014 first-sync plumbing", () => {
|
|
|
125
142
|
]);
|
|
126
143
|
await syncer.synchronizeFills();
|
|
127
144
|
expect(syncer.positionsFn).toHaveBeenCalledTimes(1);
|
|
128
|
-
expect(sync.fills.create.mock.calls[0]?.[0]?.meta
|
|
145
|
+
expect(sync.fills.create.mock.calls[0]?.[0]?.meta).toEqual({ externalMaxTs: NEWEST_TS });
|
|
129
146
|
expect(logSpy).not.toHaveBeenCalled();
|
|
130
147
|
});
|
|
131
148
|
});
|
|
@@ -4,7 +4,6 @@ import { BaseAccountSyncer } from '../base';
|
|
|
4
4
|
import { toFundingPaymentEntries } from './funding';
|
|
5
5
|
|
|
6
6
|
const HYPERLIQUID_FILL_PAGE_LIMIT = 2e3;
|
|
7
|
-
const HYPERLIQUID_FILL_RETENTION_LIMIT = 1e4;
|
|
8
7
|
class HyperliquidAccountSyncer extends BaseAccountSyncer {
|
|
9
8
|
address;
|
|
10
9
|
constructor(config) {
|
|
@@ -66,7 +65,6 @@ class HyperliquidAccountSyncer extends BaseAccountSyncer {
|
|
|
66
65
|
}, {});
|
|
67
66
|
const allFills = [];
|
|
68
67
|
const seenFillIds = /* @__PURE__ */ new Set();
|
|
69
|
-
let oldestReachableFillTime = null;
|
|
70
68
|
const addFill = (fill) => {
|
|
71
69
|
const fillId = hyperliquidFillId(fill);
|
|
72
70
|
if (!seenFillIds.has(fillId)) {
|
|
@@ -87,17 +85,9 @@ class HyperliquidAccountSyncer extends BaseAccountSyncer {
|
|
|
87
85
|
this.demo
|
|
88
86
|
);
|
|
89
87
|
batchSize = batch.length;
|
|
90
|
-
if (batch.length > 0 && oldestReachableFillTime === null) {
|
|
91
|
-
oldestReachableFillTime = Math.min(...batch.map((fill) => fill.time));
|
|
92
|
-
}
|
|
93
88
|
for (const rawFill of batch) {
|
|
94
89
|
addFill(rawFill);
|
|
95
90
|
}
|
|
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
|
-
);
|
|
100
|
-
}
|
|
101
91
|
if (batchSize === HYPERLIQUID_FILL_PAGE_LIMIT) {
|
|
102
92
|
const nextCursor = batch[batch.length - 1]?.time ?? cursor;
|
|
103
93
|
if (nextCursor <= cursor) {
|
|
@@ -164,29 +164,26 @@ describe("HyperliquidAccountSyncer fill mapping", () => {
|
|
|
164
164
|
expect(fetchInfoMock).toHaveBeenCalledTimes(2);
|
|
165
165
|
consoleWarn.mockRestore();
|
|
166
166
|
});
|
|
167
|
-
it("
|
|
167
|
+
it("imports the reachable history of a never-synced account whose older fills fell out of retention", async () => {
|
|
168
168
|
let page = 0;
|
|
169
169
|
fetchInfoMock.mockImplementation(async (body) => {
|
|
170
|
-
if (body.type
|
|
170
|
+
if (body.type === "userTwapSliceFillsByTime") return [];
|
|
171
|
+
if (page >= 5) return [];
|
|
171
172
|
const firstTime = 1e3 + page * 2e3;
|
|
172
173
|
page += 1;
|
|
173
174
|
return Array.from({ length: 2e3 }, (_, index) => rawFill(firstTime + index));
|
|
174
175
|
});
|
|
175
176
|
const syncer = createSyncer();
|
|
176
|
-
await
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
).rejects.toThrow(
|
|
184
|
-
"Hyperliquid fill history is incomplete: requested since 0 predates oldest reachable fill 1000 within the 10000-fill retention limit."
|
|
185
|
-
);
|
|
186
|
-
expect(fetchInfoMock).toHaveBeenCalledTimes(5);
|
|
177
|
+
const fills = await syncer.listExternalFills({
|
|
178
|
+
since: /* @__PURE__ */ new Date(0),
|
|
179
|
+
instruments: [instrument],
|
|
180
|
+
openPositions: [],
|
|
181
|
+
provisionalFills: []
|
|
182
|
+
});
|
|
183
|
+
expect(fills).toHaveLength(1e4);
|
|
187
184
|
expect(fetchInfoMock).toHaveBeenCalledWith(expect.objectContaining({ aggregateByTime: false }), true);
|
|
188
185
|
});
|
|
189
|
-
it("
|
|
186
|
+
it("pages through a full retention window of fills", async () => {
|
|
190
187
|
let page = 0;
|
|
191
188
|
fetchInfoMock.mockImplementation(async (body) => {
|
|
192
189
|
if (body.type === "userTwapSliceFillsByTime") return [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pear-protocol/exchanges-sdk",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"description": "Pear Protocol Exchanges SDK",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@pear-protocol/core-sdk": "^1.12.0",
|
|
31
|
-
"@pear-protocol/types": "^1.15.
|
|
32
|
-
"@pear-protocol/utils": "^0.4.
|
|
31
|
+
"@pear-protocol/types": "^1.15.1",
|
|
32
|
+
"@pear-protocol/utils": "^0.4.1",
|
|
33
33
|
"bignumber.js": "^11.1.1",
|
|
34
34
|
"partysocket": "^1.0.3",
|
|
35
35
|
"viem": "^2.48.8"
|