@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.
Files changed (55) hide show
  1. package/README.md +78 -12
  2. package/dist/client/hyperliquid/client.test.js +41 -0
  3. package/dist/client/hyperliquid/exchange-ws.js +3 -2
  4. package/dist/client/hyperliquid/rest.js +26 -5
  5. package/dist/client/hyperliquid/ws.js +16 -4
  6. package/dist/client/index.js +2 -2
  7. package/dist/client/lighter/constants.js +9 -1
  8. package/dist/client/lighter/constants.test.js +22 -0
  9. package/dist/client/lighter/exchange-ws.js +2 -2
  10. package/dist/client/lighter/rest.js +101 -32
  11. package/dist/client/lighter/rest.test.js +152 -0
  12. package/dist/client/lighter/ws.js +7 -2
  13. package/dist/client/okx/rest.js +18 -9
  14. package/dist/helper/factory.js +2 -2
  15. package/dist/helper/hyperliquid/helper.js +28 -13
  16. package/dist/helper/hyperliquid/helper.test.js +45 -0
  17. package/dist/helper/lighter/helper.js +77 -16
  18. package/dist/helper/lighter/helper.test.js +289 -0
  19. package/dist/helper/lighter/types.js +9 -8
  20. package/dist/index.d.ts +182 -38
  21. package/dist/sdk.js +20 -16
  22. package/dist/sdk.test.js +101 -0
  23. package/dist/status/hyperliquid/status.js +18 -5
  24. package/dist/status/hyperliquid/status.test.js +81 -0
  25. package/dist/status/lighter/status.js +18 -11
  26. package/dist/status/lighter/status.test.js +75 -0
  27. package/dist/syncer/base.js +0 -2
  28. package/dist/syncer/base.test.js +0 -1
  29. package/dist/syncer/hyperliquid/syncer.js +96 -43
  30. package/dist/syncer/hyperliquid/syncer.test.js +274 -0
  31. package/dist/syncer/lighter/funding.js +25 -0
  32. package/dist/syncer/lighter/funding.test.js +48 -0
  33. package/dist/syncer/lighter/syncer.js +30 -6
  34. package/dist/syncer/lighter/syncer.test.js +132 -6
  35. package/dist/tracker/base.js +23 -6
  36. package/dist/tracker/base.test.js +132 -0
  37. package/dist/tracker/binance/tracker.js +48 -18
  38. package/dist/tracker/binance/tracker.test.js +138 -0
  39. package/dist/tracker/bybit/tracker.js +78 -23
  40. package/dist/tracker/bybit/tracker.test.js +145 -0
  41. package/dist/tracker/hyperliquid/mapper.js +244 -44
  42. package/dist/tracker/hyperliquid/mapper.test.js +255 -0
  43. package/dist/tracker/hyperliquid/tracker.js +241 -74
  44. package/dist/tracker/hyperliquid/tracker.test.js +587 -0
  45. package/dist/tracker/index.js +15 -37
  46. package/dist/tracker/lighter/mapper.js +163 -33
  47. package/dist/tracker/lighter/mapper.test.js +199 -0
  48. package/dist/tracker/lighter/tracker.js +219 -45
  49. package/dist/tracker/lighter/tracker.test.js +515 -0
  50. package/dist/tracker/lighter/types.js +10 -2
  51. package/dist/tracker/okx/tracker.js +50 -16
  52. package/dist/tracker/okx/tracker.test.js +126 -0
  53. package/dist/tracker/types.js +2 -1
  54. package/dist/tracker/utils.test.js +8 -0
  55. package/package.json +3 -2
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @pear-protocol/exchanges-sdk
2
2
 
3
- Unified SDK for real-time account state (balance, positions, per-asset leverage) across supported derivative exchanges.
3
+ Unified SDK for account state (balance, positions, per-asset leverage) across supported derivative exchanges — as a live WebSocket subscription or a stateless one-shot snapshot.
4
4
 
5
5
  ## Supported Exchanges
6
6
 
@@ -25,7 +25,16 @@ import PearSDK from '@pear-protocol/core-sdk';
25
25
  import { ExchangesSDK } from '@pear-protocol/exchanges-sdk';
26
26
 
27
27
  const sdk = new PearSDK({ /* ... */ });
28
- const exchanges = new ExchangesSDK({ sdk });
28
+ const exchanges = new ExchangesSDK({
29
+ sdk,
30
+ options: {
31
+ builderAddress: '0x...',
32
+ builderFeeTenthsBps: 60,
33
+ integratorAccountIndex: 1234,
34
+ integratorMinPerpsMakerFee: 600,
35
+ integratorMinPerpsTakerFee: 600,
36
+ },
37
+ });
29
38
 
30
39
  const connection = await exchanges.connect(tradeAccountId);
31
40
 
@@ -39,6 +48,7 @@ const syncer = exchanges.createSyncer(connection);
39
48
  await tracker.start();
40
49
 
41
50
  const offBalance = tracker.trackBalance((balance) => {
51
+ if (!balance) return; // unsupported/unrepresentable account mode
42
52
  console.log(balance.totalEquity, balance.unrealizedPnl);
43
53
  });
44
54
 
@@ -52,25 +62,26 @@ offPositions();
52
62
  await tracker.disconnect();
53
63
  ```
54
64
 
55
- Pass `demo: true` to target testnet:
65
+ Pass `demo: true` to target each exchange's test or simulated-trading environment:
56
66
 
57
67
  ```typescript
58
68
  const exchanges = new ExchangesSDK({ sdk, demo: true });
59
69
  ```
60
70
 
71
+ Hyperliquid and Lighter public REST/WebSocket reads are routed to testnet. Lighter operations that require a backend-generated auth token or signature currently fail closed in demo mode because the existing supporting API signs for mainnet only; public snapshots and tracking remain available, without authenticated account-tier enrichment.
72
+
61
73
  ## Connection
62
74
 
63
- `connect(tradeAccountId)` fetches decrypted credentials and the trade account from the core API, then opens the exchange websocket.
75
+ `connect(tradeAccountId)` fetches decrypted credentials and the trade account from the core API. It does **not** open a socket — the exchange WebSocket is created lazily when you call `tracker.start()` and torn down by `tracker.disconnect()`. Syncers perform one-shot REST reads. For an account snapshot with no socket, use [`snapshot()`](#one-shot-snapshot-no-websocket).
64
76
 
65
77
  The connector comes from the backend trade account.
66
78
 
67
79
  ```typescript
68
80
  const connection = await exchanges.connect(tradeAccountId);
69
81
 
70
- connection.exchange; // "hyperliquid"
82
+ connection.connector; // "hyperliquid"
71
83
  connection.account.exchangeIdentifier;
72
84
  connection.credentials;
73
- connection.ws;
74
85
  ```
75
86
 
76
87
  For exchanges that need account health checks, pass status options when creating the SDK:
@@ -80,12 +91,17 @@ const exchanges = new ExchangesSDK({
80
91
  sdk,
81
92
  options: {
82
93
  builderAddress: '0x...',
94
+ builderFeeTenthsBps: 60,
83
95
  integratorAccountIndex: 1234,
96
+ integratorMinPerpsMakerFee: 600,
97
+ integratorMinPerpsTakerFee: 600,
84
98
  },
85
99
  });
86
100
  ```
87
101
 
88
- `builderAddress` is used to check Hyperliquid builder fee approval. `integratorAccountIndex` is used to check Lighter integrator approval.
102
+ Hyperliquid status requires the builder address and the minimum approved fee in tenths of a basis point. Lighter status requires the integrator account and minimum maker/taker approval caps in Lighter fee ticks (parts per million). These thresholds must match the fees configured by the trading backend.
103
+
104
+ Historical Hyperliquid fill synchronization uses the exchange's globally unique `time:coin:tid` identity. Existing consumers that persisted bare `tid` identifiers must account for the transition overlap. Synchronization also fails closed when the exchange reports a maker rebate: the current core external-fill contract accepts only non-negative fees, so the SDK will not silently turn a rebate into a cost or advance the synchronization watermark past it.
89
105
 
90
106
  ## Account Status
91
107
 
@@ -108,9 +124,58 @@ Possible warning reasons:
108
124
  | `api_key_invalid` | API credentials failed the exchange account check |
109
125
  | `hyperliquid_api_wallet_invalid` | Hyperliquid agent wallet is missing or invalid |
110
126
  | `hyperliquid_builder_fee_not_approved` | Hyperliquid builder fee approval is missing |
127
+ | `hyperliquid_account_mode_check_failed` | Hyperliquid account mode could not be read |
128
+ | `hyperliquid_account_mode_unsupported` | Portfolio Margin or deprecated DEX Abstraction cannot be valued safely |
111
129
  | `lighter_api_key_invalid` | Lighter API key cannot create an auth token |
112
130
  | `lighter_integrator_not_approved` | Lighter integrator approval is missing |
113
131
 
132
+ ## One-Shot Snapshot (no WebSocket)
133
+
134
+ When you only need the current balance and positions once — a request/response read rather than a live subscription — use `snapshot()`. It fetches over REST, opens **no** WebSocket, and leaves nothing running.
135
+
136
+ ```typescript
137
+ const { balance, positions } = await exchanges.snapshot(tradeAccountId);
138
+
139
+ balance?.totalEquity;
140
+ balance?.availableToTrade;
141
+ for (const p of positions) {
142
+ console.log(p.symbol, p.side, p.leverage, p.liquidationPrice);
143
+ }
144
+ ```
145
+
146
+ `snapshot()` returns the same `AccountBalance` / `AccountPosition` shapes the live tracker produces — `{ balance: AccountBalance | null; positions: AccountPosition[] }` — so either path can feed the same consumer. `balance` is `null` when the account type cannot be represented safely. Hyperliquid currently supports Standard and Unified calculations; Portfolio Margin and deprecated DEX Abstraction fail closed and are surfaced by account status.
147
+
148
+ There is nothing to tear down: no `disconnect()`, no socket. This is the read a stateless caller (such as an MCP tool) wants.
149
+
150
+ > Hyperliquid Unified balances come from the spot clearinghouse state and use the documented maximum per-collateral maintenance ratio. Standard balances use per-DEX margin summaries, including isolated positions.
151
+
152
+ ### Per-Asset Settings: `snapshotAssets()`
153
+
154
+ `snapshot()` carries leverage only for **open** positions.
155
+ To read the currently configured leverage and margin mode for any asset — positioned or not — use `snapshotAssets()`.
156
+
157
+ ```typescript
158
+ const assets = await exchanges.snapshotAssets(tradeAccountId, ['BTC', 'ETH']);
159
+
160
+ assets.BTC?.leverage; // e.g. '20'
161
+ assets.BTC?.marginType; // 'cross' | 'isolated'
162
+ assets.ETH; // null when the exchange has no readable setting for that asset
163
+ ```
164
+
165
+ It returns `Record<string, TrackedAssetInfo | null>` keyed by the requested asset id — the same `TrackedAssetInfo` shape the live `trackAsset()` path emits, and the same per-exchange identifier it uses (Hyperliquid coin, Binance/Bybit symbol, OKX instId, Lighter market id).
166
+
167
+ The read is batch-first: each connector services the whole list with as few requests as its exchange allows.
168
+
169
+ | Exchange | Requests for N assets | How |
170
+ | --- | --- | --- |
171
+ | Binance | 1 | all-symbols `positionRisk` |
172
+ | Lighter | 2 | account read + market defaults |
173
+ | OKX | ceil(N/20) | `leverage-info` accepts 20 instIds per call |
174
+ | Bybit | 2 + k | settleCoin position lists; per-symbol fill-in for the k non-positioned symbols (direct per-symbol reads when N ≤ 2) |
175
+ | Hyperliquid | N (parallel) | `activeAssetData` info request is per-coin (unauthenticated, cheap) |
176
+
177
+ > For Lighter, a custom margin setting on a market with **no open position** is not exposed by the REST account read, so it reports the market default — the same figure the live path shows before a position exists.
178
+
114
179
  ## Tracking
115
180
 
116
181
  Each `track*` method returns an unsubscribe function. Callbacks fire with the current snapshot on subscribe (if available) and on every update.
@@ -119,6 +184,7 @@ Each `track*` method returns an unsubscribe function. Callbacks fire with the cu
119
184
 
120
185
  ```typescript
121
186
  tracker.trackBalance((balance) => {
187
+ if (!balance) return; // a live mode change can explicitly invalidate the cached balance
122
188
  balance.totalEquity;
123
189
  balance.walletBalance;
124
190
  balance.unrealizedPnl;
@@ -127,6 +193,7 @@ tracker.trackBalance((balance) => {
127
193
  balance.maintenanceMargin;
128
194
  balance.marginRatio;
129
195
  balance.accountType;
196
+ balance.accountTier; // Lighter account tier when reported
130
197
 
131
198
  for (const a of balance.assets) {
132
199
  a.asset; // "USDT", "USDC", ...
@@ -177,16 +244,16 @@ Asset symbol format per exchange:
177
244
  | Lighter | base coin | `BTC` |
178
245
  | OKX | `instId` | `BTC-USDT-SWAP` |
179
246
 
180
- ## Snapshot Reads
247
+ ## Cached Reads (live tracker)
181
248
 
182
- Synchronous getters for the latest cached state:
249
+ Synchronous getters for the latest cached state on a running tracker:
183
250
 
184
251
  ```typescript
185
252
  tracker.getBalance(); // AccountBalance | null
186
253
  tracker.getPositions(); // AccountPosition[]
187
254
  tracker.getTrackedAsset('ETH'); // TrackedAssetInfo | null
188
255
  tracker.isConnected;
189
- tracker.isInitialized; // true after first snapshot
256
+ tracker.isInitialized; // true after first balance/position state (asset metadata alone does not count)
190
257
  ```
191
258
 
192
259
  ## Account Type Labels
@@ -202,8 +269,7 @@ ACCOUNT_TYPE_LABELS[balance.accountType]; // "Cross Margin", "Portfolio Margin",
202
269
  ## Cleanup
203
270
 
204
271
  ```typescript
205
- await tracker.disconnect();
206
- connection.ws.destroy();
272
+ await tracker.disconnect(); // closes the WebSocket opened by start()
207
273
  ```
208
274
 
209
275
  Credentials and the trade account are fetched automatically on `connect`; Hyperliquid and Lighter account identifiers come from `connection.account.exchangeIdentifier`.
@@ -0,0 +1,41 @@
1
+ import { describe, afterEach, vi, it, expect } from 'vitest';
2
+ import { fetchInfo } from './rest';
3
+ import { HyperliquidWs } from './ws';
4
+
5
+ class InspectableHyperliquidWs extends HyperliquidWs {
6
+ /** Exposes the selected URL for a transport regression assertion. */
7
+ url() {
8
+ return this.getWsUrl();
9
+ }
10
+ /** Exposes heartbeat cadence for a transport regression assertion. */
11
+ pingInterval() {
12
+ return this.getPingIntervalMs();
13
+ }
14
+ /** Exposes the heartbeat payload for a transport regression assertion. */
15
+ pingMessage() {
16
+ return this.buildPingMessage();
17
+ }
18
+ }
19
+ describe("Hyperliquid network clients", () => {
20
+ afterEach(() => {
21
+ vi.unstubAllGlobals();
22
+ });
23
+ it.each([
24
+ [false, "https://api.hyperliquid.xyz/info"],
25
+ [true, "https://api.hyperliquid-testnet.xyz/info"]
26
+ ])("routes info requests to the selected network", async (demo, expectedUrl) => {
27
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ ok: true }) });
28
+ vi.stubGlobal("fetch", fetchMock);
29
+ await fetchInfo({ type: "perpDexs" }, demo);
30
+ expect(fetchMock).toHaveBeenCalledWith(expectedUrl, expect.objectContaining({ method: "POST" }));
31
+ });
32
+ it("uses testnet websocket routing and Hyperliquid heartbeats in demo mode", () => {
33
+ const ws = new InspectableHyperliquidWs(true);
34
+ expect(ws.url()).toBe("wss://api.hyperliquid-testnet.xyz/ws");
35
+ expect(ws.pingInterval()).toBe(3e4);
36
+ expect(ws.pingMessage()).toEqual({ method: "ping" });
37
+ });
38
+ it("keeps no-argument websocket construction on mainnet", () => {
39
+ expect(new InspectableHyperliquidWs().url()).toBe("wss://api.hyperliquid.xyz/ws");
40
+ });
41
+ });
@@ -11,8 +11,9 @@ class HyperliquidExchangeWs extends ExchangeWs {
11
11
  connections() {
12
12
  return [this.main];
13
13
  }
14
- static async create() {
15
- const main = new HyperliquidWs();
14
+ /** Opens the Hyperliquid socket on the requested network. */
15
+ static async create(config = { demo: false }) {
16
+ const main = new HyperliquidWs(config.demo);
16
17
  await main.connect();
17
18
  return new HyperliquidExchangeWs(main);
18
19
  }
@@ -1,12 +1,33 @@
1
- const HL_INFO_URL = "https://api.hyperliquid.xyz/info";
2
- async function fetchInfo(body) {
3
- const res = await fetch(HL_INFO_URL, {
1
+ const HL_INFO_URLS = {
2
+ mainnet: "https://api.hyperliquid.xyz/info",
3
+ testnet: "https://api.hyperliquid-testnet.xyz/info"
4
+ };
5
+ async function fetchInfo(body, demo = false) {
6
+ const url = demo ? HL_INFO_URLS.testnet : HL_INFO_URLS.mainnet;
7
+ const res = await fetch(url, {
4
8
  method: "POST",
5
9
  headers: { "Content-Type": "application/json" },
6
10
  body: JSON.stringify(body)
7
11
  });
8
- if (!res.ok) throw new Error(`Hyperliquid info request failed: ${res.status}`);
12
+ if (!res.ok) {
13
+ throw new Error(`Hyperliquid info request failed: ${res.status}`);
14
+ }
9
15
  return res.json();
10
16
  }
17
+ async function fetchPerpDexs(demo = false) {
18
+ return fetchInfo({ type: "perpDexs" }, demo);
19
+ }
20
+ async function fetchClearinghouseState(user, dex, demo = false) {
21
+ return fetchInfo({ type: "clearinghouseState", user, dex }, demo);
22
+ }
23
+ async function fetchUserAbstraction(user, demo = false) {
24
+ return fetchInfo({ type: "userAbstraction", user }, demo);
25
+ }
26
+ async function fetchSpotClearinghouseState(user, demo = false) {
27
+ return fetchInfo({ type: "spotClearinghouseState", user }, demo);
28
+ }
29
+ async function fetchActiveAssetData(user, coin, demo = false) {
30
+ return fetchInfo({ type: "activeAssetData", user, coin }, demo);
31
+ }
11
32
 
12
- export { fetchInfo };
33
+ export { fetchActiveAssetData, fetchClearinghouseState, fetchInfo, fetchPerpDexs, fetchSpotClearinghouseState, fetchUserAbstraction };
@@ -1,15 +1,27 @@
1
1
  import { BaseWs } from '../base-ws';
2
2
 
3
- const HL_WS_URL = "wss://api.hyperliquid.xyz/ws";
3
+ const HL_WS_URLS = {
4
+ mainnet: "wss://api.hyperliquid.xyz/ws",
5
+ testnet: "wss://api.hyperliquid-testnet.xyz/ws"
6
+ };
4
7
  class HyperliquidWs extends BaseWs {
8
+ /** Creates a Hyperliquid socket for the selected network. */
9
+ constructor(demo = false) {
10
+ super();
11
+ this.demo = demo;
12
+ }
13
+ demo;
14
+ /** Selects the mainnet or testnet websocket origin. */
5
15
  getWsUrl() {
6
- return HL_WS_URL;
16
+ return this.demo ? HL_WS_URLS.testnet : HL_WS_URLS.mainnet;
7
17
  }
18
+ /** Sends heartbeats before Hyperliquid's 60-second idle timeout. */
8
19
  getPingIntervalMs() {
9
- return 0;
20
+ return 3e4;
10
21
  }
22
+ /** Builds Hyperliquid's documented application-level heartbeat. */
11
23
  buildPingMessage() {
12
- return null;
24
+ return { method: "ping" };
13
25
  }
14
26
  }
15
27
 
@@ -34,9 +34,9 @@ async function createExchangeWs(exchange, config) {
34
34
  credentials: config.credentials
35
35
  });
36
36
  case "hyperliquid":
37
- return HyperliquidExchangeWs.create();
37
+ return HyperliquidExchangeWs.create({ demo: config.demo });
38
38
  case "lighter":
39
- return LighterExchangeWs.create();
39
+ return LighterExchangeWs.create({ demo: config.demo });
40
40
  default: {
41
41
  const _exhaustive = exchange;
42
42
  throw new Error(`Unsupported exchange: ${String(_exhaustive)}`);
@@ -1,4 +1,12 @@
1
1
  const LIGHTER_BASE_URL = "https://mainnet.zklighter.elliot.ai";
2
+ const LIGHTER_BASE_URL_TESTNET = "https://testnet.zklighter.elliot.ai";
2
3
  const LIGHTER_WS_URL = "wss://mainnet.zklighter.elliot.ai/stream";
4
+ const LIGHTER_WS_URL_TESTNET = "wss://testnet.zklighter.elliot.ai/stream";
5
+ function getLighterBaseUrl(demo) {
6
+ return demo ? LIGHTER_BASE_URL_TESTNET : LIGHTER_BASE_URL;
7
+ }
8
+ function getLighterWsUrl(demo) {
9
+ return demo ? LIGHTER_WS_URL_TESTNET : LIGHTER_WS_URL;
10
+ }
3
11
 
4
- export { LIGHTER_BASE_URL, LIGHTER_WS_URL };
12
+ export { LIGHTER_BASE_URL, LIGHTER_BASE_URL_TESTNET, LIGHTER_WS_URL, LIGHTER_WS_URL_TESTNET, getLighterBaseUrl, getLighterWsUrl };
@@ -0,0 +1,22 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { getLighterBaseUrl, getLighterWsUrl } from './constants';
3
+ import { LighterWs } from './ws';
4
+
5
+ class InspectableLighterWs extends LighterWs {
6
+ url() {
7
+ return this.getWsUrl();
8
+ }
9
+ }
10
+ describe("Lighter network endpoints", () => {
11
+ it("routes demo traffic to testnet", () => {
12
+ expect(getLighterBaseUrl(true)).toBe("https://testnet.zklighter.elliot.ai");
13
+ expect(getLighterWsUrl(true)).toBe("wss://testnet.zklighter.elliot.ai/stream");
14
+ });
15
+ it("routes non-demo traffic to mainnet", () => {
16
+ expect(getLighterBaseUrl(false)).toBe("https://mainnet.zklighter.elliot.ai");
17
+ expect(getLighterWsUrl(false)).toBe("wss://mainnet.zklighter.elliot.ai/stream");
18
+ });
19
+ it("keeps no-argument websocket construction on mainnet", () => {
20
+ expect(new InspectableLighterWs().url()).toBe("wss://mainnet.zklighter.elliot.ai/stream");
21
+ });
22
+ });
@@ -11,8 +11,8 @@ class LighterExchangeWs extends ExchangeWs {
11
11
  connections() {
12
12
  return [this.main];
13
13
  }
14
- static async create() {
15
- const main = new LighterWs();
14
+ static async create(config = { demo: false }) {
15
+ const main = new LighterWs(config.demo);
16
16
  await main.connect();
17
17
  return new LighterExchangeWs(main);
18
18
  }
@@ -1,46 +1,76 @@
1
- import { LIGHTER_BASE_URL } from './constants';
1
+ import { getLighterBaseUrl } from './constants';
2
2
 
3
- async function fetchApiKeys(accountIndex) {
4
- const url = `${LIGHTER_BASE_URL}/api/v1/apikeys?account_index=${accountIndex}`;
3
+ async function fetchApiKeys(accountIndex, demo = false) {
4
+ const url = `${getLighterBaseUrl(demo)}/api/v1/apikeys?account_index=${accountIndex}`;
5
5
  const response = await fetch(url);
6
- if (!response.ok) throw new Error(`Lighter /apikeys returned ${response.status}`);
7
- const data = await response.json();
6
+ const data = await readLighterResponse(response, "/apikeys");
8
7
  return data.api_keys;
9
8
  }
10
- async function fetchAccountsByL1Address(l1Address) {
11
- const res = await fetch(`${LIGHTER_BASE_URL}/api/v1/accountsByL1Address?l1_address=${l1Address}`);
12
- return res.json();
9
+ async function fetchAccountsByL1Address(l1Address, demo = false) {
10
+ const subAccounts = /* @__PURE__ */ new Map();
11
+ const seenCursors = /* @__PURE__ */ new Set();
12
+ let cursor;
13
+ let message;
14
+ do {
15
+ const params = new URLSearchParams({
16
+ l1_address: l1Address,
17
+ ...cursor ? { cursor } : {}
18
+ });
19
+ const response = await fetch(`${getLighterBaseUrl(demo)}/api/v1/accountsByL1Address?${params.toString()}`);
20
+ const data = await readLighterResponse(response, "/accountsByL1Address", [200, 21100]);
21
+ if (data.code === 21100) return data;
22
+ message ??= data.message;
23
+ for (const account of data.sub_accounts ?? []) {
24
+ subAccounts.set(account.index, account);
25
+ }
26
+ const nextCursor = data.next_cursor || void 0;
27
+ cursor = nextCursor && !seenCursors.has(nextCursor) ? nextCursor : void 0;
28
+ if (cursor) {
29
+ seenCursors.add(cursor);
30
+ }
31
+ } while (cursor);
32
+ return {
33
+ code: 200,
34
+ ...message ? { message } : {},
35
+ sub_accounts: Array.from(subAccounts.values())
36
+ };
37
+ }
38
+ async function fetchAccount(accountIndex, demo = false) {
39
+ const response = await fetch(`${getLighterBaseUrl(demo)}/api/v1/account?by=index&value=${accountIndex}`);
40
+ return readLighterResponse(response, "/account");
13
41
  }
14
- async function fetchAccount(accountIndex) {
15
- const res = await fetch(`${LIGHTER_BASE_URL}/api/v1/account?by=index&value=${accountIndex}`);
16
- if (!res.ok) throw new Error(`Lighter /account returned ${res.status}`);
17
- return res.json();
42
+ async function fetchAccountLimits(accountIndex, authToken, demo = false) {
43
+ const response = await fetch(`${getLighterBaseUrl(demo)}/api/v1/accountLimits?account_index=${accountIndex}`, {
44
+ headers: { authorization: authToken }
45
+ });
46
+ return readLighterResponse(response, "/accountLimits");
18
47
  }
19
- async function fetchAccountMetadata(accountIndex, authToken) {
20
- const res = await fetch(`${LIGHTER_BASE_URL}/api/v1/accountMetadata?by=index&value=${accountIndex}`, {
48
+ async function fetchAccountMetadata(accountIndex, authToken, demo = false) {
49
+ const response = await fetch(`${getLighterBaseUrl(demo)}/api/v1/accountMetadata?by=index&value=${accountIndex}`, {
21
50
  headers: {
22
51
  accept: "application/json",
23
52
  authorization: authToken
24
53
  }
25
54
  });
26
- if (!res.ok) throw new Error(`Lighter /accountMetadata returned ${res.status}`);
27
- const data = await res.json();
28
- if (data.code !== 200) {
29
- throw new Error(`Lighter /accountMetadata returned ${data.code}: ${data.message ?? "unknown error"}`);
30
- }
55
+ const data = await readLighterResponse(response, "/accountMetadata");
31
56
  const matchesAccount = data.account_metadatas?.some((metadata) => metadata.account_index === accountIndex);
32
57
  if (!matchesAccount) {
33
58
  throw new Error(`Lighter /accountMetadata did not return account ${accountIndex}`);
34
59
  }
35
60
  }
36
- async function fetchOrderBookDetails() {
37
- const res = await fetch(`${LIGHTER_BASE_URL}/api/v1/orderBookDetails?filter=perp`);
38
- if (!res.ok) throw new Error(`Lighter /orderBookDetails returned ${res.status}`);
39
- const data = await res.json();
61
+ async function fetchOrderBookDetails(demo = false) {
62
+ const response = await fetch(`${getLighterBaseUrl(demo)}/api/v1/orderBookDetails?filter=perp`);
63
+ const data = await readLighterResponse(response, "/orderBookDetails");
40
64
  return data.order_book_details;
41
65
  }
42
- async function fetchTradesSince(accountIndex, authToken, since) {
66
+ async function fetchAssetDetails(demo) {
67
+ const response = await fetch(`${getLighterBaseUrl(demo)}/api/v1/assetDetails`);
68
+ const data = await readLighterResponse(response, "/assetDetails");
69
+ return data.asset_details;
70
+ }
71
+ async function fetchTradesSince(accountIndex, authToken, since, demo = false) {
43
72
  const results = [];
73
+ const seenCursors = /* @__PURE__ */ new Set();
44
74
  let cursor;
45
75
  do {
46
76
  const params = new URLSearchParams({
@@ -55,18 +85,18 @@ async function fetchTradesSince(accountIndex, authToken, since) {
55
85
  aggregate: "false",
56
86
  ...cursor ? { cursor } : {}
57
87
  });
58
- const res = await fetch(`${LIGHTER_BASE_URL}/api/v1/trades?${params.toString()}`, {
88
+ const response = await fetch(`${getLighterBaseUrl(demo)}/api/v1/trades?${params.toString()}`, {
59
89
  headers: { authorization: authToken }
60
90
  });
61
- if (!res.ok) throw new Error(`Lighter /trades returned ${res.status}`);
62
- const data = await res.json();
63
- if (data.code !== 200) {
64
- throw new Error(`Lighter /trades returned ${data.code}: ${data.message ?? "unknown error"}`);
65
- }
91
+ const data = await readLighterResponse(response, "/trades");
66
92
  const page = data.trades ?? [];
67
93
  results.push(...page.filter((trade) => trade.timestamp >= since.getTime()));
68
94
  const reachedSince = page.some((trade) => trade.timestamp < since.getTime());
69
- cursor = reachedSince ? void 0 : data.next_cursor || void 0;
95
+ const nextCursor = data.next_cursor || void 0;
96
+ cursor = !reachedSince && nextCursor && !seenCursors.has(nextCursor) ? nextCursor : void 0;
97
+ if (cursor) {
98
+ seenCursors.add(cursor);
99
+ }
70
100
  } while (cursor);
71
101
  const byId = /* @__PURE__ */ new Map();
72
102
  for (const trade of results) {
@@ -74,5 +104,44 @@ async function fetchTradesSince(accountIndex, authToken, since) {
74
104
  }
75
105
  return Array.from(byId.values());
76
106
  }
107
+ async function fetchPositionFundingSince(accountIndex, authToken, since, demo = false) {
108
+ const results = /* @__PURE__ */ new Map();
109
+ const seenCursors = /* @__PURE__ */ new Set();
110
+ let cursor;
111
+ do {
112
+ const params = new URLSearchParams({
113
+ account_index: accountIndex.toString(),
114
+ limit: "100",
115
+ side: "all",
116
+ start_timestamp: since.getTime().toString(),
117
+ ...cursor ? { cursor } : {}
118
+ });
119
+ const response = await fetch(`${getLighterBaseUrl(demo)}/api/v1/positionFunding?${params.toString()}`, {
120
+ headers: { authorization: authToken }
121
+ });
122
+ const data = await readLighterResponse(response, "/positionFunding");
123
+ for (const funding of data.position_fundings ?? []) {
124
+ if (funding.timestamp >= since.getTime()) {
125
+ results.set(`${funding.market_id}:${funding.funding_id}`, funding);
126
+ }
127
+ }
128
+ const nextCursor = data.next_cursor || void 0;
129
+ cursor = nextCursor && !seenCursors.has(nextCursor) ? nextCursor : void 0;
130
+ if (cursor) {
131
+ seenCursors.add(cursor);
132
+ }
133
+ } while (cursor);
134
+ return Array.from(results.values());
135
+ }
136
+ async function readLighterResponse(response, endpoint, acceptedCodes = [200]) {
137
+ if (!response.ok) {
138
+ throw new Error(`Lighter ${endpoint} returned ${response.status}`);
139
+ }
140
+ const data = await response.json();
141
+ if (!acceptedCodes.includes(data.code)) {
142
+ throw new Error(`Lighter ${endpoint} returned ${data.code}: ${data.message ?? "unknown error"}`);
143
+ }
144
+ return data;
145
+ }
77
146
 
78
- export { fetchAccount, fetchAccountMetadata, fetchAccountsByL1Address, fetchApiKeys, fetchOrderBookDetails, fetchTradesSince };
147
+ export { fetchAccount, fetchAccountLimits, fetchAccountMetadata, fetchAccountsByL1Address, fetchApiKeys, fetchAssetDetails, fetchOrderBookDetails, fetchPositionFundingSince, fetchTradesSince };