@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.
- 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 +182 -38
- 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 +0 -2
- package/dist/syncer/base.test.js +0 -1
- package/dist/syncer/hyperliquid/syncer.js +96 -43
- 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 +30 -6
- package/dist/syncer/lighter/syncer.test.js +132 -6
- 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 +3 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @pear-protocol/exchanges-sdk
|
|
2
2
|
|
|
3
|
-
Unified SDK for
|
|
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({
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
|
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
|
-
|
|
15
|
-
|
|
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
|
|
2
|
-
|
|
3
|
-
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
20
|
+
return 3e4;
|
|
10
21
|
}
|
|
22
|
+
/** Builds Hyperliquid's documented application-level heartbeat. */
|
|
11
23
|
buildPingMessage() {
|
|
12
|
-
return
|
|
24
|
+
return { method: "ping" };
|
|
13
25
|
}
|
|
14
26
|
}
|
|
15
27
|
|
package/dist/client/index.js
CHANGED
|
@@ -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 {
|
|
1
|
+
import { getLighterBaseUrl } from './constants';
|
|
2
2
|
|
|
3
|
-
async function fetchApiKeys(accountIndex) {
|
|
4
|
-
const url = `${
|
|
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
|
-
|
|
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
|
|
12
|
-
|
|
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
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
38
|
-
|
|
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
|
|
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
|
|
88
|
+
const response = await fetch(`${getLighterBaseUrl(demo)}/api/v1/trades?${params.toString()}`, {
|
|
59
89
|
headers: { authorization: authToken }
|
|
60
90
|
});
|
|
61
|
-
|
|
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
|
-
|
|
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 };
|