@pear-protocol/exchanges-sdk 0.8.0 → 0.9.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.
@@ -138,6 +138,22 @@ function toLighterAccountType(accountTradingMode) {
138
138
  return accountTradingMode === 1 ? LighterAccountType.UNIFIED : LighterAccountType.CLASSIC;
139
139
  }
140
140
  __name(toLighterAccountType, "toLighterAccountType");
141
+ function toLighterAccountTier(userTier) {
142
+ switch (userTier?.trim().toLowerCase()) {
143
+ case "std":
144
+ case "standard": {
145
+ return "standard";
146
+ }
147
+ case "plus": {
148
+ return "plus";
149
+ }
150
+ case "premium": {
151
+ return "premium";
152
+ }
153
+ }
154
+ return null;
155
+ }
156
+ __name(toLighterAccountTier, "toLighterAccountTier");
141
157
  function toLighterBalance(figures) {
142
158
  const spotEquity = figures.equity.spot;
143
159
  const perpetualsEquity = figures.equity.perpetuals;
@@ -393,4 +409,4 @@ function signed(value) {
393
409
  }
394
410
  __name(signed, "signed");
395
411
 
396
- export { buildSpotPriceIndex, leverageFromImf, mapLiveBalance, mapMarketDefaultAssetInfo, mapPositions, mapRestBalance, toLighterAccountType };
412
+ export { buildSpotPriceIndex, leverageFromImf, mapLiveBalance, mapMarketDefaultAssetInfo, mapPositions, mapRestBalance, toLighterAccountTier, toLighterAccountType };
@@ -1,14 +1,17 @@
1
1
  import BigNumber from 'bignumber.js';
2
2
  import { createExchangeWs } from '../../client';
3
- import { fetchOrderBookDetails, fetchAccount } from '../../client/lighter/rest';
3
+ import { fetchAccount, fetchPublicAccount, fetchOrderBookDetails, fetchPublicOrderBookDetails, fetchAccountLimits } from '../../client/lighter/rest';
4
+ import { retrieveLighterAuthToken } from '../../helper/lighter/auth-token.helper';
4
5
  import { BaseAccountTracker } from '../base';
5
6
  import { computeMaxBasketSize } from '../basket-size';
6
7
  import { toLighterBasketCalculation } from './basket-size';
7
- import { buildSpotPriceIndex, mapPositions, mapRestBalance, mapMarketDefaultAssetInfo, toLighterAccountType, mapLiveBalance } from './mapper';
8
+ import { buildSpotPriceIndex, mapPositions, mapRestBalance, mapMarketDefaultAssetInfo, toLighterAccountType, mapLiveBalance, toLighterAccountTier } from './mapper';
8
9
 
9
10
  var __defProp = Object.defineProperty;
10
11
  var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
11
12
  const POSITION_POLL_INTERVAL_MS = 2e3;
13
+ const SUBSCRIBE_RETRY_BASE_DELAY_MS = 1e3;
14
+ const SUBSCRIBE_RETRY_MAX_DELAY_MS = 3e4;
12
15
  const MARKET_REFRESH_INTERVAL_MS = 6e4;
13
16
  const DECIMAL_PATTERN = /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/;
14
17
  function isZeroSize(size) {
@@ -30,7 +33,10 @@ class LighterAccountTracker extends BaseAccountTracker {
30
33
  static {
31
34
  __name(this, "LighterAccountTracker");
32
35
  }
36
+ sdk;
33
37
  accountIdentifier;
38
+ apiKeyIndex;
39
+ apiPrivateKey;
34
40
  ws = null;
35
41
  messageListenerId = null;
36
42
  openListenerId = null;
@@ -38,8 +44,10 @@ class LighterAccountTracker extends BaseAccountTracker {
38
44
  positionPollInFlight = null;
39
45
  marketRefreshTimer = null;
40
46
  marketRefreshInFlight = null;
47
+ subscribeRetryTimer = null;
41
48
  startInFlight = null;
42
49
  lifecycleVersion = 0;
50
+ wsConnectionVersion = 0;
43
51
  stateVersion = 0;
44
52
  assetsByAssetId = /* @__PURE__ */ new Map();
45
53
  // Written only by `account_all`. The live balance reads its unrealized PnL and maintenance
@@ -51,16 +59,59 @@ class LighterAccountTracker extends BaseAccountTracker {
51
59
  // market data arrives, and a balance the index cannot price would otherwise read as unknown.
52
60
  spotPriceBySymbol = buildSpotPriceIndex([]);
53
61
  stats = null;
62
+ // Null until the authenticated read lands, and null for good where nothing can authenticate it.
63
+ accountTier = null;
64
+ accountTierListeners = /* @__PURE__ */ new Set();
65
+ accountTierInFlight = null;
54
66
  hasAssets = false;
55
67
  constructor(config) {
56
68
  super(config);
69
+ const credentials = config.credentials;
70
+ this.sdk = config.sdk;
57
71
  this.accountIdentifier = config.account.exchangeIdentifier;
72
+ this.apiKeyIndex = credentials.api_key_index;
73
+ this.apiPrivateKey = credentials.api_private_key;
58
74
  }
59
- /** Stateless one-shot account read — public endpoints only, and no websocket. */
75
+ /**
76
+ * The tier Lighter has the account on, and every later change to it.
77
+ *
78
+ * A separate channel from the balance on purpose: tier is account configuration, so it neither
79
+ * moves with the account's figures nor belongs to a trading mode. No websocket channel carries
80
+ * it — `/accountLimits` is the only source — so this reports what the last read said, and
81
+ * `refreshAccountTier` is what asks again.
82
+ */
83
+ trackAccountTier(callback) {
84
+ this.accountTierListeners.add(callback);
85
+ callback(this.accountTier);
86
+ return () => this.accountTierListeners.delete(callback);
87
+ }
88
+ /** The tier as the last read left it, without asking the venue again. */
89
+ getAccountTier() {
90
+ return this.accountTier;
91
+ }
92
+ /**
93
+ * Re-reads the tier and tells every subscriber what changed.
94
+ *
95
+ * Called after something moves the tier — a change this account submitted — because nothing on
96
+ * the socket announces one. Repeated callers share one in-flight read.
97
+ */
98
+ refreshAccountTier() {
99
+ if (this.accountTierInFlight) {
100
+ return this.accountTierInFlight;
101
+ }
102
+ const refresh = this.performAccountTierRead(this.lifecycleVersion).finally(() => {
103
+ if (this.accountTierInFlight === refresh) {
104
+ this.accountTierInFlight = null;
105
+ }
106
+ });
107
+ this.accountTierInFlight = refresh;
108
+ return refresh;
109
+ }
110
+ /** Stateless one-shot account read — authenticated for connected accounts, and no websocket. */
60
111
  async snapshot() {
61
112
  const [account, markets] = await Promise.all([
62
113
  this.fetchPrimaryAccount(),
63
- fetchOrderBookDetails(this.demo)
114
+ this.fetchMarkets()
64
115
  ]);
65
116
  if (!account) return {
66
117
  balance: null,
@@ -81,7 +132,7 @@ class LighterAccountTracker extends BaseAccountTracker {
81
132
  async fetchAssetSnapshots(marketIds) {
82
133
  const [account, markets] = await Promise.all([
83
134
  this.fetchPrimaryAccount(),
84
- fetchOrderBookDetails(this.demo)
135
+ this.fetchMarkets()
85
136
  ]);
86
137
  const settings = toAccountSettings(markets.perp, account?.positions ?? []);
87
138
  const result = {};
@@ -105,7 +156,7 @@ class LighterAccountTracker extends BaseAccountTracker {
105
156
  async snapshotMaxBasketSize(input) {
106
157
  const [account, markets] = await Promise.all([
107
158
  this.fetchPrimaryAccount(),
108
- fetchOrderBookDetails(this.demo)
159
+ this.fetchMarkets()
109
160
  ]);
110
161
  if (!account) {
111
162
  return computeMaxBasketSize({
@@ -138,7 +189,7 @@ class LighterAccountTracker extends BaseAccountTracker {
138
189
  /** Creates one tracker lifecycle while repeated callers share the same pending start. */
139
190
  async performStart() {
140
191
  const lifecycleVersion = ++this.lifecycleVersion;
141
- const markets = await fetchOrderBookDetails(this.demo);
192
+ const markets = await this.fetchMarkets();
142
193
  if (lifecycleVersion !== this.lifecycleVersion) return;
143
194
  this.spotPriceBySymbol = buildSpotPriceIndex(markets.spot);
144
195
  const trackedAssets = /* @__PURE__ */ new Map();
@@ -165,10 +216,14 @@ class LighterAccountTracker extends BaseAccountTracker {
165
216
  void this.pollPositions();
166
217
  this.positionPollTimer = setInterval(() => void this.pollPositions(), POSITION_POLL_INTERVAL_MS);
167
218
  this.marketRefreshTimer = setInterval(() => void this.refreshMarkets(), MARKET_REFRESH_INTERVAL_MS);
219
+ void this.refreshAccountTier();
168
220
  }
169
221
  async cleanupConnections() {
170
222
  this.lifecycleVersion += 1;
223
+ this.wsConnectionVersion += 1;
171
224
  this.startInFlight = null;
225
+ this.accountTierInFlight = null;
226
+ this.clearSubscribeRetry();
172
227
  if (this.positionPollTimer) {
173
228
  clearInterval(this.positionPollTimer);
174
229
  this.positionPollTimer = null;
@@ -197,6 +252,8 @@ class LighterAccountTracker extends BaseAccountTracker {
197
252
  }
198
253
  /** Starts a clean account-data epoch before subscribing on each websocket connection. */
199
254
  handleWsOpen() {
255
+ const wsConnectionVersion = ++this.wsConnectionVersion;
256
+ this.clearSubscribeRetry();
200
257
  this.stateVersion += 1;
201
258
  this.assetsByAssetId.clear();
202
259
  this.wsPositionsByMarketId.clear();
@@ -210,18 +267,47 @@ class LighterAccountTracker extends BaseAccountTracker {
210
267
  this.onStateUpdate({
211
268
  trackedAssets
212
269
  });
213
- this.sendStaticSubs();
270
+ this.subscribeAccountChannels(wsConnectionVersion, 0);
214
271
  }
215
- sendStaticSubs() {
216
- this.ws?.send({
217
- type: "subscribe",
218
- channel: `account_all/${this.accountIdentifier}`
219
- });
220
- this.ws?.send({
221
- type: "subscribe",
222
- channel: `user_stats/${this.accountIdentifier}`
272
+ /**
273
+ * Subscribes this socket epoch's account channels, and keeps trying until one attempt lands.
274
+ *
275
+ * This is the sole subscriber of `account_all` and `user_stats`, and the live balance is built
276
+ * from those two channels alone — see `buildBalance`. A mint that failed once and was never
277
+ * retried therefore left the tracker connected but permanently unsubscribed: the 2s REST poll
278
+ * kept positions moving while the balance silently froze until some incidental reconnect.
279
+ *
280
+ * The wait doubles up to a ceiling, and only a new connection epoch or teardown ends it.
281
+ */
282
+ subscribeAccountChannels(wsConnectionVersion, attempt) {
283
+ void this.sendAccountSubscriptions(wsConnectionVersion).catch((error) => {
284
+ if (wsConnectionVersion !== this.wsConnectionVersion) {
285
+ return;
286
+ }
287
+ this.onError(error instanceof Error ? error : new Error(String(error)));
288
+ const delayMs = Math.min(SUBSCRIBE_RETRY_BASE_DELAY_MS * 2 ** attempt, SUBSCRIBE_RETRY_MAX_DELAY_MS);
289
+ this.subscribeRetryTimer = setTimeout(() => {
290
+ this.subscribeRetryTimer = null;
291
+ this.subscribeAccountChannels(wsConnectionVersion, attempt + 1);
292
+ }, delayMs);
223
293
  });
224
294
  }
295
+ /** Mints a live token for this socket epoch and authenticates every account subscription. */
296
+ async sendAccountSubscriptions(wsConnectionVersion) {
297
+ const authToken = await this.retrieveRequiredAuthToken();
298
+ if (wsConnectionVersion !== this.wsConnectionVersion || !this.ws?.connected) {
299
+ return;
300
+ }
301
+ this.ws.subscribe(`account_all/${this.accountIdentifier}`, authToken);
302
+ this.ws.subscribe(`user_stats/${this.accountIdentifier}`, authToken);
303
+ }
304
+ /** Drops a pending subscription retry, so nothing outlives the connection that scheduled it. */
305
+ clearSubscribeRetry() {
306
+ if (this.subscribeRetryTimer) {
307
+ clearTimeout(this.subscribeRetryTimer);
308
+ this.subscribeRetryTimer = null;
309
+ }
310
+ }
225
311
  routeRaw(raw) {
226
312
  try {
227
313
  const msg = JSON.parse(raw);
@@ -339,10 +425,40 @@ class LighterAccountTracker extends BaseAccountTracker {
339
425
  }
340
426
  return settings;
341
427
  }
428
+ /** Reads the account with credentials when present, or explicitly uses the read-only verifier path. */
342
429
  async fetchPrimaryAccount() {
343
- const data = await fetchAccount(Number(this.accountIdentifier), this.demo);
430
+ const authToken = await this.retrieveAuthToken();
431
+ const accountIndex = Number(this.accountIdentifier);
432
+ const data = authToken ? await fetchAccount(accountIndex, authToken, this.demo) : await fetchPublicAccount(accountIndex, this.demo);
344
433
  return data.accounts[0];
345
434
  }
435
+ /** Reads market context under the same account identity as the snapshot that consumes it. */
436
+ async fetchMarkets() {
437
+ const authToken = await this.retrieveAuthToken();
438
+ return authToken ? fetchOrderBookDetails(authToken, this.demo) : fetchPublicOrderBookDetails(this.demo);
439
+ }
440
+ /** Returns this account's cached auth token, or no token for the explicit empty-key verifier. */
441
+ async retrieveAuthToken() {
442
+ if (!this.apiPrivateKey) {
443
+ return void 0;
444
+ }
445
+ if (!this.sdk) {
446
+ throw new Error("Lighter authenticated account reads require a Pear SDK client");
447
+ }
448
+ return retrieveLighterAuthToken(this.sdk, {
449
+ accountIndex: Number(this.accountIdentifier),
450
+ apiKeyIndex: this.apiKeyIndex,
451
+ apiPrivateKey: this.apiPrivateKey
452
+ }, this.demo);
453
+ }
454
+ /** Requires credentials for private WebSocket channels even when REST is in verifier mode. */
455
+ async retrieveRequiredAuthToken() {
456
+ const authToken = await this.retrieveAuthToken();
457
+ if (!authToken) {
458
+ throw new Error("Lighter WebSocket account channels require API credentials");
459
+ }
460
+ return authToken;
461
+ }
346
462
  pollPositions() {
347
463
  if (this.positionPollInFlight) return this.positionPollInFlight;
348
464
  const lifecycleVersion = this.lifecycleVersion;
@@ -384,6 +500,42 @@ class LighterAccountTracker extends BaseAccountTracker {
384
500
  }
385
501
  }
386
502
  }
503
+ /**
504
+ * Reads the tier Lighter currently has the account on, and publishes it when it moved.
505
+ *
506
+ * `/accountLimits` is authenticated, so a tracker with no SDK to mint a token through — and demo,
507
+ * where no such account exists — reports no tier rather than a wrong one. A failed read does the
508
+ * same and leaves the last known tier standing: the endpoint failing is not the account losing
509
+ * its tier, and blanking it would announce a change nobody made.
510
+ */
511
+ async performAccountTierRead(lifecycleVersion) {
512
+ if (!this.sdk || this.demo) {
513
+ return;
514
+ }
515
+ try {
516
+ const credentials = this.credentials;
517
+ const accountIndex = Number(this.accountIdentifier);
518
+ const authToken = await retrieveLighterAuthToken(this.sdk, {
519
+ accountIndex,
520
+ apiKeyIndex: credentials.api_key_index,
521
+ apiPrivateKey: credentials.api_private_key
522
+ }, this.demo);
523
+ const limits = await fetchAccountLimits(accountIndex, authToken, this.demo);
524
+ const accountTier = toLighterAccountTier(limits.user_tier);
525
+ if (lifecycleVersion !== this.lifecycleVersion) {
526
+ return;
527
+ }
528
+ if (accountTier === null || accountTier === this.accountTier) {
529
+ return;
530
+ }
531
+ this.accountTier = accountTier;
532
+ for (const listener of this.accountTierListeners) {
533
+ listener(accountTier);
534
+ }
535
+ } catch (err) {
536
+ this.onError(err instanceof Error ? err : new Error(String(err)));
537
+ }
538
+ }
387
539
  /** Shares one in-flight market read, so a slow response cannot stack requests behind the WAF. */
388
540
  refreshMarkets() {
389
541
  if (this.marketRefreshInFlight) return this.marketRefreshInFlight;
@@ -422,7 +574,7 @@ class LighterAccountTracker extends BaseAccountTracker {
422
574
  */
423
575
  async performMarketRefresh(lifecycleVersion) {
424
576
  try {
425
- const markets = await fetchOrderBookDetails(this.demo);
577
+ const markets = await this.fetchMarkets();
426
578
  if (lifecycleVersion !== this.lifecycleVersion) return;
427
579
  for (const market of markets.perp) {
428
580
  this.marketsByMarketId.set(market.market_id, market);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pear-protocol/exchanges-sdk",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Pear Protocol Exchanges SDK",
5
5
  "private": false,
6
6
  "type": "module",
@@ -22,12 +22,14 @@
22
22
  "scripts": {
23
23
  "build": "tsup",
24
24
  "clean": "rm -rf dist .turbo",
25
- "typecheck": "tsc --noEmit"
25
+ "typecheck": "tsc --noEmit",
26
+ "test": "vitest run",
27
+ "test:watch": "vitest"
26
28
  },
27
29
  "dependencies": {
28
- "@pear-protocol/core-sdk": "^1.18.0",
29
- "@pear-protocol/types": "^1.27.0",
30
- "@pear-protocol/utils": "^0.5.1",
30
+ "@pear-protocol/core-sdk": "^1.19.0",
31
+ "@pear-protocol/types": "^1.28.0",
32
+ "@pear-protocol/utils": "^0.5.3",
31
33
  "bignumber.js": "^11.1.1",
32
34
  "partysocket": "^1.0.3",
33
35
  "viem": "^2.48.8",
@@ -36,6 +38,7 @@
36
38
  "devDependencies": {
37
39
  "@backend/typescript-config": "workspace:*",
38
40
  "tsup": "8.5.1",
39
- "typescript": "5.9.3"
41
+ "typescript": "5.9.3",
42
+ "vitest": "4.1.2"
40
43
  }
41
44
  }