@pear-protocol/exchanges-sdk 0.5.2 → 0.6.1

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.
@@ -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(() => ({ fetchInfoMock: vi.fn() }));
5
- vi.mock("../../client/hyperliquid/rest", () => ({ fetchInfo: fetchInfoMock }));
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
@@ -8,7 +8,6 @@ type SupportedCredentials = HttpTypes.ExchangeCredentialsResponse['credentials']
8
8
  type ExchangesSDKConfig = {
9
9
  sdk: PearSDK;
10
10
  demo?: boolean;
11
- options?: ConnectOptions;
12
11
  /**
13
12
  * Optional forward-proxy for the exchange REST calls. When enabled, requests are re-pointed at
14
13
  * `proxy.url` with the original venue host in `X-Proxy-Target`. Injected here because the SDK
@@ -212,6 +211,7 @@ type CreateExchangeWsConfig = {
212
211
  declare function createExchangeWs<E extends Connector>(exchange: E, config: CreateExchangeWsConfig): Promise<ExchangeWsByConnector[E]>;
213
212
 
214
213
  interface HyperliquidHelper {
214
+ checkAccountActivation(userAddress: `0x${string}`): Promise<boolean>;
215
215
  checkApiWallet(params: CheckApiWalletParams): Promise<boolean>;
216
216
  checkBuilderFeeApproval(userAddress: `0x${string}`, builderAddress: `0x${string}`, requiredFeeTenthsBps?: number): Promise<boolean>;
217
217
  }
@@ -368,16 +368,16 @@ interface AccountSyncer {
368
368
  }
369
369
  interface ListExternalFillsParams {
370
370
  since: Date;
371
- instruments: HttpTypes.Instrument[];
371
+ instruments: HttpTypes.InstrumentLite[];
372
372
  openPositions: HttpTypes.OpenPositionLite[];
373
373
  provisionalFills: HttpTypes.ProvisionalFill[];
374
374
  }
375
375
  interface ListExternalPositionsParams {
376
- instruments: HttpTypes.Instrument[];
376
+ instruments: HttpTypes.InstrumentLite[];
377
377
  }
378
378
  interface ListExternalFundingParams {
379
379
  since: Date;
380
- instruments: HttpTypes.Instrument[];
380
+ instruments: HttpTypes.InstrumentLite[];
381
381
  }
382
382
  declare abstract class BaseAccountSyncer<ExternalFill> implements AccountSyncer {
383
383
  protected sdk: PearSDK;
@@ -1007,10 +1007,14 @@ declare function createAccountTracker(exchange: Connector, config: CreateAccount
1007
1007
  declare class ExchangesSDK {
1008
1008
  private sdk;
1009
1009
  private demo;
1010
- private options?;
1011
1010
  constructor(config: ExchangesSDKConfig);
1012
1011
  connect(tradeAccountId: string): Promise<ExchangeConnection>;
1013
- status(connection: ExchangeConnection): Promise<AccountStatus>;
1012
+ /**
1013
+ * Options come in per call rather than at construction: the approval targets live in the
1014
+ * backend's fee-recipient source and can change, so the caller fetches (and caches) them and
1015
+ * passes the current values with every check.
1016
+ */
1017
+ status(connection: ExchangeConnection, options?: ConnectOptions): Promise<AccountStatus>;
1014
1018
  createTracker(connection: ExchangeConnection): AccountTracker;
1015
1019
  snapshot(tradeAccountId: string): Promise<AccountSnapshot>;
1016
1020
  snapshotAssets(tradeAccountId: string, coins: string[]): Promise<AssetSnapshotMap>;
package/dist/sdk.js CHANGED
@@ -8,23 +8,26 @@ import { createAccountTracker } from './tracker';
8
8
  class ExchangesSDK {
9
9
  sdk;
10
10
  demo;
11
- options;
12
11
  constructor(config) {
13
12
  this.sdk = config.sdk;
14
13
  this.demo = config.demo ?? false;
15
- this.options = config.options;
16
14
  if (config.proxy) configureProxy(config.proxy);
17
15
  }
18
16
  async connect(tradeAccountId) {
19
17
  return this.fetchCredentials(tradeAccountId);
20
18
  }
21
- async status(connection) {
19
+ /**
20
+ * Options come in per call rather than at construction: the approval targets live in the
21
+ * backend's fee-recipient source and can change, so the caller fetches (and caches) them and
22
+ * passes the current values with every check.
23
+ */
24
+ async status(connection, options) {
22
25
  return checkAccountStatus(connection.connector, {
23
26
  sdk: this.sdk,
24
27
  connector: connection.connector,
25
28
  account: connection.account,
26
29
  credentials: connection.credentials,
27
- statusOptions: this.options,
30
+ statusOptions: options,
28
31
  demo: this.demo
29
32
  });
30
33
  }
package/dist/sdk.test.js CHANGED
@@ -99,3 +99,19 @@ describe("ExchangesSDK configured network routing", () => {
99
99
  expect(createAccountSyncerMock).toHaveBeenCalledWith("okx", expect.objectContaining({ demo: true }));
100
100
  });
101
101
  });
102
+ describe("ExchangesSDK status options", () => {
103
+ beforeEach(() => {
104
+ checkAccountStatusMock.mockReset().mockResolvedValue({ status: "active", reasons: [] });
105
+ });
106
+ it("passes per-call options through to the status check unchanged", async () => {
107
+ const options = { builderAddress: "0xabc", builderFeeTenthsBps: 60 };
108
+ const { exchangesSdk } = buildSdk();
109
+ await exchangesSdk.status(connection, options);
110
+ expect(checkAccountStatusMock).toHaveBeenCalledWith("okx", expect.objectContaining({ statusOptions: options }));
111
+ });
112
+ it("leaves the options absent when the caller passes none", async () => {
113
+ const { exchangesSdk } = buildSdk();
114
+ await exchangesSdk.status(connection);
115
+ expect(checkAccountStatusMock).toHaveBeenCalledWith("okx", expect.objectContaining({ statusOptions: void 0 }));
116
+ });
117
+ });
@@ -6,7 +6,8 @@ import { warningStatus } from '../base';
6
6
  async function checkHyperliquidAccountStatus(config) {
7
7
  const reasons = [];
8
8
  const options = config.statusOptions;
9
- if (!options?.builderAddress || !options.builderFeeTenthsBps) {
9
+ const builderAddress = options?.builderAddress;
10
+ if (builderAddress && !options?.builderFeeTenthsBps) {
10
11
  throw new Error("Missing required hyperliquid connect options");
11
12
  }
12
13
  const helper = createExchangeHelper({ exchange: "hyperliquid", demo: config.demo });
@@ -34,15 +35,17 @@ async function checkHyperliquidAccountStatus(config) {
34
35
  reasons.push("hyperliquid_api_wallet_invalid");
35
36
  }
36
37
  }
37
- try {
38
- const approved = await helper.checkBuilderFeeApproval(
39
- approvalAddress,
40
- options.builderAddress,
41
- options.builderFeeTenthsBps
42
- );
43
- if (!approved) reasons.push("hyperliquid_builder_fee_not_approved");
44
- } catch {
45
- reasons.push("hyperliquid_builder_fee_not_approved");
38
+ if (builderAddress) {
39
+ try {
40
+ const approved = await helper.checkBuilderFeeApproval(
41
+ approvalAddress,
42
+ builderAddress,
43
+ options?.builderFeeTenthsBps
44
+ );
45
+ if (!approved) reasons.push("hyperliquid_builder_fee_not_approved");
46
+ } catch {
47
+ reasons.push("hyperliquid_builder_fee_not_approved");
48
+ }
46
49
  }
47
50
  return warningStatus(reasons);
48
51
  }
@@ -83,4 +83,12 @@ describe("checkHyperliquidAccountStatus", () => {
83
83
  config.statusOptions = { builderAddress };
84
84
  await expect(checkHyperliquidAccountStatus(config)).rejects.toThrow("Missing required hyperliquid connect options");
85
85
  });
86
+ it("skips the builder-fee check when no builder address is configured, keeping the other checks", async () => {
87
+ const config = statusConfig();
88
+ config.statusOptions = { builderFeeTenthsBps: 10 };
89
+ const status = await checkHyperliquidAccountStatus(config);
90
+ expect(status).toEqual({ status: "active", reasons: [] });
91
+ expect(checkBuilderFeeApprovalMock).not.toHaveBeenCalled();
92
+ expect(checkApiWalletMock).toHaveBeenCalled();
93
+ });
86
94
  });
@@ -5,7 +5,10 @@ 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 || options.integratorMinPerpsMakerFee === void 0 || options.integratorMinPerpsTakerFee === void 0) {
8
+ const integratorAccountIndex = options?.integratorAccountIndex;
9
+ const integratorMinPerpsMakerFee = options?.integratorMinPerpsMakerFee;
10
+ const integratorMinPerpsTakerFee = options?.integratorMinPerpsTakerFee;
11
+ if (integratorAccountIndex !== void 0 && (integratorMinPerpsMakerFee === void 0 || integratorMinPerpsTakerFee === void 0)) {
9
12
  throw new Error("Missing required lighter connect options");
10
13
  }
11
14
  const credentials = config.credentials;
@@ -24,15 +27,17 @@ async function checkLighterAccountStatus(config) {
24
27
  reasons.push("lighter_api_key_invalid");
25
28
  }
26
29
  }
27
- try {
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
- });
33
- if (!approved) reasons.push("lighter_integrator_not_approved");
34
- } catch {
35
- reasons.push("lighter_integrator_not_approved");
30
+ if (integratorAccountIndex !== void 0 && integratorMinPerpsMakerFee !== void 0 && integratorMinPerpsTakerFee !== void 0) {
31
+ try {
32
+ const helper = createExchangeHelper({ exchange: "lighter", sdk: config.sdk, demo: config.demo });
33
+ const approved = await helper.checkIntegratorApproval(accountIndex, integratorAccountIndex, {
34
+ minPerpsMakerFee: integratorMinPerpsMakerFee,
35
+ minPerpsTakerFee: integratorMinPerpsTakerFee
36
+ });
37
+ if (!approved) reasons.push("lighter_integrator_not_approved");
38
+ } catch {
39
+ reasons.push("lighter_integrator_not_approved");
40
+ }
36
41
  }
37
42
  return warningStatus(reasons);
38
43
  }
@@ -72,4 +72,15 @@ describe("checkLighterAccountStatus", () => {
72
72
  );
73
73
  expect(fetchAccountMetadataMock).not.toHaveBeenCalled();
74
74
  });
75
+ it("skips the integrator check when no integrator is configured, keeping the api-key check", async () => {
76
+ const noIntegrator = config();
77
+ noIntegrator.demo = false;
78
+ noIntegrator.statusOptions = {
79
+ integratorMinPerpsMakerFee: 500,
80
+ integratorMinPerpsTakerFee: 600
81
+ };
82
+ await expect(checkLighterAccountStatus(noIntegrator)).resolves.toEqual({ status: "active", reasons: [] });
83
+ expect(checkIntegratorApprovalMock).not.toHaveBeenCalled();
84
+ expect(fetchAccountMetadataMock).toHaveBeenCalled();
85
+ });
75
86
  });
@@ -19,7 +19,7 @@ class BaseAccountSyncer {
19
19
  const [{ positions: openPositions }, { fills: provisionalFills }, { instruments }, watermark] = await Promise.all([
20
20
  this.sdk.core.positions.openLite(),
21
21
  this.sdk.core.fills.provisionals(),
22
- this.sdk.core.instruments.list({ connector: this.connector }),
22
+ this.sdk.core.instruments.lite({ connector: this.connector }),
23
23
  this.sdk.core.sync.fills.since({ overlapMs: OVERLAP_MS })
24
24
  ]);
25
25
  const entryBySymbol = await this.loadEntryBySymbol(openPositions);
@@ -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 { exchangePositions, externalFills };
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 = payload.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})`
@@ -111,7 +119,7 @@ class BaseAccountSyncer {
111
119
  */
112
120
  async synchronizeFunding() {
113
121
  const [{ instruments }, watermark] = await Promise.all([
114
- this.sdk.core.instruments.list({ connector: this.connector }),
122
+ this.sdk.core.instruments.lite({ connector: this.connector }),
115
123
  this.sdk.core.sync.funding.since({ overlapMs: 6e4 })
116
124
  ]);
117
125
  const payments = await this.listExternalFunding({ since: new Date(watermark.sinceMs), instruments });
@@ -39,7 +39,12 @@ class FakeSyncer extends BaseAccountSyncer {
39
39
  }
40
40
  function makeSyncer(args) {
41
41
  const fills = {
42
- since: vi.fn().mockResolvedValue({ sinceMs: 0, neverSynced: args.neverSynced, confirmedExchangeFillIds: [] }),
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({}),
@@ -50,7 +55,7 @@ function makeSyncer(args) {
50
55
  core: {
51
56
  positions: { openLite: vi.fn().mockResolvedValue({ positions: [] }) },
52
57
  fills: { provisionals: vi.fn().mockResolvedValue({ fills: [] }), details: vi.fn() },
53
- instruments: { list: vi.fn().mockResolvedValue({ instruments: [] }) },
58
+ instruments: { lite: vi.fn().mockResolvedValue({ instruments: [] }) },
54
59
  sync
55
60
  }
56
61
  };
@@ -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.externalMaxTs).toBe(NEWEST_TS);
145
+ expect(sync.fills.create.mock.calls[0]?.[0]?.meta).toEqual({ externalMaxTs: NEWEST_TS });
129
146
  expect(logSpy).not.toHaveBeenCalled();
130
147
  });
131
148
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pear-protocol/exchanges-sdk",
3
- "version": "0.5.2",
3
+ "version": "0.6.1",
4
4
  "description": "Pear Protocol Exchanges SDK",
5
5
  "private": false,
6
6
  "type": "module",
@@ -27,9 +27,9 @@
27
27
  "test:watch": "vitest"
28
28
  },
29
29
  "dependencies": {
30
- "@pear-protocol/core-sdk": "^1.11.1",
31
- "@pear-protocol/types": "^1.14.1",
32
- "@pear-protocol/utils": "^0.3.0",
30
+ "@pear-protocol/core-sdk": "^1.12.0",
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"