@pear-protocol/exchanges-sdk 0.3.0 → 0.5.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 (57) hide show
  1. package/README.md +78 -12
  2. package/dist/client/binance/rest.js +4 -3
  3. package/dist/client/bybit/rest.js +2 -1
  4. package/dist/client/hyperliquid/client.test.js +41 -0
  5. package/dist/client/hyperliquid/exchange-ws.js +3 -2
  6. package/dist/client/hyperliquid/rest.js +28 -5
  7. package/dist/client/hyperliquid/ws.js +16 -4
  8. package/dist/client/index.js +2 -2
  9. package/dist/client/lighter/constants.js +9 -1
  10. package/dist/client/lighter/constants.test.js +22 -0
  11. package/dist/client/lighter/exchange-ws.js +2 -2
  12. package/dist/client/lighter/rest.js +110 -37
  13. package/dist/client/lighter/rest.test.js +152 -0
  14. package/dist/client/lighter/ws.js +7 -2
  15. package/dist/client/okx/rest.js +20 -10
  16. package/dist/helper/factory.js +2 -2
  17. package/dist/helper/hyperliquid/helper.js +28 -13
  18. package/dist/helper/hyperliquid/helper.test.js +45 -0
  19. package/dist/helper/lighter/helper.js +77 -16
  20. package/dist/helper/lighter/helper.test.js +289 -0
  21. package/dist/helper/lighter/types.js +9 -8
  22. package/dist/index.d.ts +189 -38
  23. package/dist/sdk.js +22 -16
  24. package/dist/sdk.test.js +101 -0
  25. package/dist/status/hyperliquid/status.js +18 -5
  26. package/dist/status/hyperliquid/status.test.js +81 -0
  27. package/dist/status/lighter/status.js +18 -11
  28. package/dist/status/lighter/status.test.js +75 -0
  29. package/dist/syncer/base.js +0 -2
  30. package/dist/syncer/base.test.js +0 -1
  31. package/dist/syncer/hyperliquid/syncer.js +96 -43
  32. package/dist/syncer/hyperliquid/syncer.test.js +274 -0
  33. package/dist/syncer/lighter/funding.js +25 -0
  34. package/dist/syncer/lighter/funding.test.js +48 -0
  35. package/dist/syncer/lighter/syncer.js +30 -6
  36. package/dist/syncer/lighter/syncer.test.js +132 -6
  37. package/dist/tracker/base.js +23 -6
  38. package/dist/tracker/base.test.js +132 -0
  39. package/dist/tracker/binance/tracker.js +48 -18
  40. package/dist/tracker/binance/tracker.test.js +138 -0
  41. package/dist/tracker/bybit/tracker.js +78 -23
  42. package/dist/tracker/bybit/tracker.test.js +145 -0
  43. package/dist/tracker/hyperliquid/mapper.js +244 -44
  44. package/dist/tracker/hyperliquid/mapper.test.js +255 -0
  45. package/dist/tracker/hyperliquid/tracker.js +241 -74
  46. package/dist/tracker/hyperliquid/tracker.test.js +587 -0
  47. package/dist/tracker/index.js +15 -37
  48. package/dist/tracker/lighter/mapper.js +163 -33
  49. package/dist/tracker/lighter/mapper.test.js +199 -0
  50. package/dist/tracker/lighter/tracker.js +219 -45
  51. package/dist/tracker/lighter/tracker.test.js +515 -0
  52. package/dist/tracker/lighter/types.js +10 -2
  53. package/dist/tracker/okx/tracker.js +50 -16
  54. package/dist/tracker/okx/tracker.test.js +126 -0
  55. package/dist/tracker/types.js +2 -1
  56. package/dist/tracker/utils.test.js +8 -0
  57. package/package.json +6 -5
@@ -0,0 +1,274 @@
1
+ import { vi, describe, beforeEach, it, expect } from 'vitest';
2
+ import { HyperliquidAccountSyncer } from './syncer';
3
+
4
+ const { fetchInfoMock } = vi.hoisted(() => ({ fetchInfoMock: vi.fn() }));
5
+ vi.mock("../../client/hyperliquid/rest", () => ({ fetchInfo: fetchInfoMock }));
6
+ const account = {
7
+ exchangeIdentifier: "0x1111111111111111111111111111111111111111"
8
+ };
9
+ const instrument = {
10
+ id: "HL-BTC",
11
+ base: "BTC"
12
+ };
13
+ function rawFill(time, tid = time) {
14
+ return {
15
+ coin: "BTC",
16
+ px: "100000",
17
+ sz: "0.1",
18
+ side: "B",
19
+ time,
20
+ startPosition: "0",
21
+ dir: "Open Long",
22
+ closedPnl: "0",
23
+ hash: "0xabc",
24
+ oid: 123,
25
+ crossed: true,
26
+ fee: "1",
27
+ builderFee: "0.2",
28
+ tid,
29
+ feeToken: "USDC",
30
+ twapId: null
31
+ };
32
+ }
33
+ function createSyncer() {
34
+ return new HyperliquidAccountSyncer({
35
+ sdk: {},
36
+ connector: "hyperliquid",
37
+ account,
38
+ credentials: { signer_key: "sk" },
39
+ demo: true
40
+ });
41
+ }
42
+ describe("HyperliquidAccountSyncer fill mapping", () => {
43
+ beforeEach(() => {
44
+ fetchInfoMock.mockReset().mockImplementation(async (body) => {
45
+ if (body.type === "userTwapSliceFillsByTime") {
46
+ return [];
47
+ }
48
+ return [
49
+ {
50
+ coin: "BTC",
51
+ px: "100000",
52
+ sz: "0.1",
53
+ side: "B",
54
+ time: 17e11,
55
+ startPosition: "0",
56
+ dir: "Open Long",
57
+ closedPnl: "0",
58
+ hash: "0xabc",
59
+ oid: 123,
60
+ crossed: true,
61
+ fee: "1",
62
+ builderFee: "0.2",
63
+ tid: 456,
64
+ feeToken: "USDC",
65
+ twapId: null
66
+ }
67
+ ];
68
+ });
69
+ });
70
+ it("uses a globally unique identity, excludes builder fee from trade fee, and routes to testnet", async () => {
71
+ const syncer = createSyncer();
72
+ const fills = await syncer.listExternalFills({
73
+ since: /* @__PURE__ */ new Date(1699999e6),
74
+ instruments: [instrument],
75
+ openPositions: [],
76
+ provisionalFills: []
77
+ });
78
+ expect(fills).toEqual([
79
+ expect.objectContaining({
80
+ exchangeFillId: "1700000000000:BTC:456",
81
+ tradeFee: "0.8",
82
+ builderFee: "0.2"
83
+ })
84
+ ]);
85
+ expect(fetchInfoMock).toHaveBeenCalledWith(expect.objectContaining({ type: "userFillsByTime" }), true);
86
+ expect(fetchInfoMock).toHaveBeenCalledWith(
87
+ expect.objectContaining({ type: "userFillsByTime", aggregateByTime: false }),
88
+ true
89
+ );
90
+ expect(fetchInfoMock).toHaveBeenCalledWith(
91
+ expect.objectContaining({ type: "userTwapSliceFillsByTime", aggregateByTime: false }),
92
+ true
93
+ );
94
+ });
95
+ it("fails closed when a maker rebate cannot satisfy the backend external-fill fee contract", async () => {
96
+ fetchInfoMock.mockImplementation(async (body) => {
97
+ if (body.type === "userTwapSliceFillsByTime") return [];
98
+ return [
99
+ {
100
+ coin: "BTC",
101
+ px: "100000",
102
+ sz: "0.1",
103
+ side: "B",
104
+ time: 17e11,
105
+ startPosition: "0",
106
+ dir: "Open Long",
107
+ closedPnl: "0",
108
+ hash: "0xabc",
109
+ oid: 123,
110
+ crossed: false,
111
+ fee: "-0.08",
112
+ builderFee: "0.02",
113
+ tid: 456,
114
+ feeToken: "USDC",
115
+ twapId: null
116
+ }
117
+ ];
118
+ });
119
+ const syncer = createSyncer();
120
+ await expect(
121
+ syncer.listExternalFills({
122
+ since: /* @__PURE__ */ new Date(1699999e6),
123
+ instruments: [instrument],
124
+ openPositions: [],
125
+ provisionalFills: []
126
+ })
127
+ ).rejects.toThrow(
128
+ "Hyperliquid fill 1700000000000:BTC:456 has a negative net trade fee (-0.1); the backend external-fill contract does not accept Hyperliquid rebates."
129
+ );
130
+ });
131
+ it("warns when inclusive fill pagination cannot move past the current timestamp", async () => {
132
+ const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => void 0);
133
+ const event = {
134
+ coin: "BTC",
135
+ px: "100000",
136
+ sz: "0.1",
137
+ side: "B",
138
+ time: 1e3,
139
+ startPosition: "0",
140
+ dir: "Open Long",
141
+ closedPnl: "0",
142
+ hash: "0xabc",
143
+ oid: 123,
144
+ crossed: true,
145
+ fee: "1",
146
+ builderFee: "0.2",
147
+ tid: 456,
148
+ feeToken: "USDC",
149
+ twapId: null
150
+ };
151
+ fetchInfoMock.mockImplementation(
152
+ async (body) => body.type === "userFillsByTime" ? Array.from({ length: 2e3 }, () => event) : []
153
+ );
154
+ const syncer = createSyncer();
155
+ await syncer.listExternalFills({
156
+ since: /* @__PURE__ */ new Date(1e3),
157
+ instruments: [instrument],
158
+ openPositions: [],
159
+ provisionalFills: []
160
+ });
161
+ expect(consoleWarn).toHaveBeenCalledWith(
162
+ "Hyperliquid fills pagination stalled at timestamp 1000; returned history may be incomplete."
163
+ );
164
+ expect(fetchInfoMock).toHaveBeenCalledTimes(2);
165
+ consoleWarn.mockRestore();
166
+ });
167
+ it("counts raw fills before rejecting history older than the 10,000-fill retention boundary", async () => {
168
+ let page = 0;
169
+ fetchInfoMock.mockImplementation(async (body) => {
170
+ if (body.type !== "userFillsByTime") return [];
171
+ const firstTime = 1e3 + page * 2e3;
172
+ page += 1;
173
+ return Array.from({ length: 2e3 }, (_, index) => rawFill(firstTime + index));
174
+ });
175
+ const syncer = createSyncer();
176
+ await expect(
177
+ syncer.listExternalFills({
178
+ since: /* @__PURE__ */ new Date(0),
179
+ instruments: [instrument],
180
+ openPositions: [],
181
+ provisionalFills: []
182
+ })
183
+ ).rejects.toThrow(
184
+ "Hyperliquid fill history is incomplete: requested since 0 predates oldest reachable fill 1000 within the 10000-fill retention limit."
185
+ );
186
+ expect(fetchInfoMock).toHaveBeenCalledTimes(5);
187
+ expect(fetchInfoMock).toHaveBeenCalledWith(expect.objectContaining({ aggregateByTime: false }), true);
188
+ });
189
+ it("accepts a request exactly at the oldest reachable fill at the retention boundary", async () => {
190
+ let page = 0;
191
+ fetchInfoMock.mockImplementation(async (body) => {
192
+ if (body.type === "userTwapSliceFillsByTime") return [];
193
+ if (page >= 5) return [];
194
+ const firstTime = 1e3 + page * 2e3;
195
+ page += 1;
196
+ return Array.from({ length: 2e3 }, (_, index) => rawFill(firstTime + index));
197
+ });
198
+ const syncer = createSyncer();
199
+ const fills = await syncer.listExternalFills({
200
+ since: /* @__PURE__ */ new Date(1e3),
201
+ instruments: [instrument],
202
+ openPositions: [],
203
+ provisionalFills: []
204
+ });
205
+ expect(fills).toHaveLength(1e4);
206
+ expect(fetchInfoMock).toHaveBeenCalledTimes(7);
207
+ });
208
+ });
209
+ describe("HyperliquidAccountSyncer position and funding snapshots", () => {
210
+ beforeEach(() => {
211
+ fetchInfoMock.mockReset();
212
+ });
213
+ it("de-duplicates DEX descriptors and derives mark price from current position value", async () => {
214
+ fetchInfoMock.mockImplementation(async (body) => {
215
+ if (body.type === "perpDexs") return [null, null, { name: "xyz" }];
216
+ if (body.type === "clearinghouseState" && body.dex === "") {
217
+ return {
218
+ assetPositions: [
219
+ {
220
+ position: {
221
+ coin: "BTC",
222
+ szi: "2",
223
+ entryPx: "150",
224
+ positionValue: "400"
225
+ }
226
+ }
227
+ ]
228
+ };
229
+ }
230
+ if (body.type === "clearinghouseState") return { assetPositions: [] };
231
+ throw new Error(`Unexpected request: ${body.type}`);
232
+ });
233
+ const syncer = createSyncer();
234
+ const positions = await syncer.listExternalPositions({
235
+ instruments: [
236
+ {
237
+ ...instrument,
238
+ precision: { quantity: { contractSize: 1 } }
239
+ }
240
+ ]
241
+ });
242
+ expect(positions).toEqual([
243
+ expect.objectContaining({ symbol: "HL-BTC", size: "2", entryPrice: "150", markPrice: "200" })
244
+ ]);
245
+ expect(fetchInfoMock).toHaveBeenCalledTimes(3);
246
+ });
247
+ it("de-duplicates an inclusive funding boundary and stops when its cursor cannot advance", async () => {
248
+ const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => void 0);
249
+ const event = {
250
+ time: 1e3,
251
+ hash: "0xabc",
252
+ delta: {
253
+ type: "funding",
254
+ coin: "BTC",
255
+ usdc: "-2",
256
+ szi: "4",
257
+ fundingRate: "0.0001",
258
+ nSamples: null
259
+ }
260
+ };
261
+ fetchInfoMock.mockResolvedValue(Array.from({ length: 500 }, () => event));
262
+ const syncer = createSyncer();
263
+ const payments = await syncer.listExternalFunding({
264
+ since: /* @__PURE__ */ new Date(1e3),
265
+ instruments: [instrument]
266
+ });
267
+ expect(payments).toHaveLength(1);
268
+ expect(fetchInfoMock).toHaveBeenCalledTimes(1);
269
+ expect(consoleWarn).toHaveBeenCalledWith(
270
+ "Hyperliquid funding pagination stalled at timestamp 1000; returned history may be incomplete."
271
+ );
272
+ consoleWarn.mockRestore();
273
+ });
274
+ });
@@ -0,0 +1,25 @@
1
+ import BigNumber from 'bignumber.js';
2
+
3
+ function toFundingPaymentEntries(events, instrumentIds) {
4
+ return events.reduce((entries, event) => {
5
+ const instrumentId = event.market_id.toString();
6
+ if (!instrumentIds.has(instrumentId)) {
7
+ console.warn(`Lighter market id ${event.market_id} not found in instrument id map.`);
8
+ return entries;
9
+ }
10
+ const unsignedSize = new BigNumber(event.position_size).abs();
11
+ const signedSize = event.position_side === "short" ? unsignedSize.negated() : unsignedSize;
12
+ const fundingPerUnit = signedSize.isZero() ? "0" : new BigNumber(event.change).div(signedSize).toFixed();
13
+ entries.push({
14
+ connector: "lighter",
15
+ instrumentId,
16
+ fundingRate: event.rate,
17
+ notional: event.change,
18
+ fundingPerUnit,
19
+ timestamp: event.timestamp
20
+ });
21
+ return entries;
22
+ }, []);
23
+ }
24
+
25
+ export { toFundingPaymentEntries };
@@ -0,0 +1,48 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { toFundingPaymentEntries } from './funding';
3
+
4
+ describe("Lighter funding mapping", () => {
5
+ it("derives funding per unit against signed long and short exposure", () => {
6
+ const result = toFundingPaymentEntries(
7
+ [
8
+ fundingEvent({ funding_id: 1, position_side: "long", change: "-2" }),
9
+ fundingEvent({ funding_id: 2, position_side: "short", change: "2" })
10
+ ],
11
+ /* @__PURE__ */ new Set(["1"])
12
+ );
13
+ expect(result).toEqual([
14
+ {
15
+ connector: "lighter",
16
+ instrumentId: "1",
17
+ fundingRate: "0.0001",
18
+ notional: "-2",
19
+ fundingPerUnit: "-0.5",
20
+ timestamp: 1e3
21
+ },
22
+ {
23
+ connector: "lighter",
24
+ instrumentId: "1",
25
+ fundingRate: "0.0001",
26
+ notional: "2",
27
+ fundingPerUnit: "-0.5",
28
+ timestamp: 1e3
29
+ }
30
+ ]);
31
+ });
32
+ it("skips funding for unknown instruments", () => {
33
+ expect(toFundingPaymentEntries([fundingEvent({})], /* @__PURE__ */ new Set())).toEqual([]);
34
+ });
35
+ });
36
+ function fundingEvent(overrides) {
37
+ return {
38
+ timestamp: 1e3,
39
+ market_id: 1,
40
+ funding_id: 1,
41
+ change: "-2",
42
+ discount: "0",
43
+ rate: "0.0001",
44
+ position_size: "4",
45
+ position_side: "long",
46
+ ...overrides
47
+ };
48
+ }
@@ -1,6 +1,7 @@
1
1
  import BigNumber from 'bignumber.js';
2
- import { fetchAccount, fetchTradesSince } from '../../client/lighter/rest';
2
+ import { fetchAccount, fetchTradesSince, fetchPositionFundingSince } from '../../client/lighter/rest';
3
3
  import { BaseAccountSyncer } from '../base';
4
+ import { toFundingPaymentEntries } from './funding';
4
5
 
5
6
  const LIGHTER_FEE_TICK_DENOMINATOR = new BigNumber(1e6);
6
7
  class LighterAccountSyncer extends BaseAccountSyncer {
@@ -20,7 +21,7 @@ class LighterAccountSyncer extends BaseAccountSyncer {
20
21
  const contractSizeByInstrumentId = new Map(
21
22
  instruments.map((instrument) => [instrument.id, instrument.precision.quantity.contractSize])
22
23
  );
23
- const response = await fetchAccount(this.accountIndex);
24
+ const response = await fetchAccount(this.accountIndex, this.demo);
24
25
  const rawPositions = response.accounts?.[0]?.positions ?? [];
25
26
  return rawPositions.reduce((positions, rawPosition) => {
26
27
  const instrumentId = rawPosition.market_id.toString();
@@ -29,12 +30,13 @@ class LighterAccountSyncer extends BaseAccountSyncer {
29
30
  const contractSize = contractSizeByInstrumentId.get(instrumentId) ?? 1;
30
31
  const size = signedSize.abs().times(contractSize).toFixed();
31
32
  const side = rawPosition.sign === 1 ? "BUY" : "SELL";
33
+ const markPrice = new BigNumber(rawPosition.position_value).abs().div(size).toFixed();
32
34
  positions.push({
33
35
  symbol: instrumentId,
34
36
  size,
35
37
  side,
36
38
  entryPrice: rawPosition.avg_entry_price,
37
- markPrice: rawPosition.avg_entry_price
39
+ markPrice
38
40
  });
39
41
  return positions;
40
42
  }, []);
@@ -43,12 +45,13 @@ class LighterAccountSyncer extends BaseAccountSyncer {
43
45
  since,
44
46
  instruments
45
47
  }) {
48
+ this.assertAuthenticatedSyncAvailable();
46
49
  const { token } = await this.sdk.core.misc.lighter.createAuthToken({
47
50
  accountIndex: this.accountIndex,
48
51
  apiKeyIndex: this.apiKeyIndex,
49
52
  apiPrivateKey: this.apiPrivateKey
50
53
  });
51
- const trades = await fetchTradesSince(this.accountIndex, token, since);
54
+ const trades = await fetchTradesSince(this.accountIndex, token, since, this.demo);
52
55
  const instrumentIds = new Set(instruments.map((instrument) => instrument.id));
53
56
  return trades.toSorted((a, b) => a.timestamp - b.timestamp).reduce((fills, rawFill) => {
54
57
  const instrumentId = rawFill.market_id.toString();
@@ -76,8 +79,19 @@ class LighterAccountSyncer extends BaseAccountSyncer {
76
79
  return fills;
77
80
  }, []);
78
81
  }
79
- async listExternalFunding(_params) {
80
- throw new Error("funding sync is not implemented for lighter");
82
+ async listExternalFunding({
83
+ since,
84
+ instruments
85
+ }) {
86
+ this.assertAuthenticatedSyncAvailable();
87
+ const { token } = await this.sdk.core.misc.lighter.createAuthToken({
88
+ accountIndex: this.accountIndex,
89
+ apiKeyIndex: this.apiKeyIndex,
90
+ apiPrivateKey: this.apiPrivateKey
91
+ });
92
+ const fundingEvents = await fetchPositionFundingSince(this.accountIndex, token, since, this.demo);
93
+ const instrumentIds = new Set(instruments.map((instrument) => instrument.id));
94
+ return toFundingPaymentEntries(fundingEvents, instrumentIds);
81
95
  }
82
96
  isReduceOnly(rawFill) {
83
97
  if (rawFill.type === "liquidation" || rawFill.type === "deleverage" || rawFill.type === "market-settlement") {
@@ -87,6 +101,10 @@ class LighterAccountSyncer extends BaseAccountSyncer {
87
101
  if (!accountSide) {
88
102
  return false;
89
103
  }
104
+ const positionSignChanged = this.isMaker(rawFill, accountSide) ? rawFill.maker_position_sign_changed : rawFill.taker_position_sign_changed;
105
+ if (positionSignChanged) {
106
+ return false;
107
+ }
90
108
  const positionSizeBefore = this.getPositionSizeBefore(rawFill, accountSide);
91
109
  if (positionSizeBefore.isZero()) {
92
110
  return false;
@@ -116,6 +134,12 @@ class LighterAccountSyncer extends BaseAccountSyncer {
116
134
  const rateTick = this.isMaker(rawFill, side) ? rawFill.integrator_maker_fee : rawFill.integrator_taker_fee;
117
135
  return this.feeTickToAmount(rateTick, rawFill.usd_amount);
118
136
  }
137
+ /** Prevents testnet reads from silently minting a mainnet auth token. */
138
+ assertAuthenticatedSyncAvailable() {
139
+ if (this.demo) {
140
+ throw new Error("Lighter demo authenticated sync is unavailable because backend signing is mainnet-only");
141
+ }
142
+ }
119
143
  // Lighter fees come as ppm rates (÷1e6), not amounts — the charged fee is that rate times the trade's USD notional.
120
144
  feeTickToAmount(rateTick, usdAmount) {
121
145
  const rate = new BigNumber(rateTick ?? 0).abs().div(LIGHTER_FEE_TICK_DENOMINATOR);
@@ -1,8 +1,10 @@
1
1
  import { vi, describe, beforeEach, it, expect } from 'vitest';
2
- import { fetchTradesSince } from '../../client/lighter/rest';
2
+ import { fetchTradesSince, fetchPositionFundingSince, fetchAccount } from '../../client/lighter/rest';
3
3
  import { LighterAccountSyncer } from './syncer';
4
4
 
5
5
  vi.mock("../../client/lighter/rest", () => ({
6
+ fetchAccount: vi.fn(),
7
+ fetchPositionFundingSince: vi.fn(),
6
8
  fetchTradesSince: vi.fn()
7
9
  }));
8
10
  const ACCOUNT_INDEX = 87440;
@@ -78,11 +80,29 @@ const TRADES = [
78
80
  taker_position_size_before: "0.00000",
79
81
  maker_position_size_before: "0.00000",
80
82
  integrator_maker_fee: 50
83
+ },
84
+ {
85
+ // A flip is not a reduce-only fill: Lighter explicitly reports that the
86
+ // account-side position sign changed after crossing through zero.
87
+ trade_id: 90000000002,
88
+ trade_id_str: "90000000002",
89
+ type: "trade",
90
+ market_id: 1,
91
+ size: "0.00200",
92
+ price: "65000.0",
93
+ usd_amount: "130.000000",
94
+ ask_account_id: ACCOUNT_INDEX,
95
+ bid_account_id: 726722,
96
+ is_maker_ask: false,
97
+ timestamp: 1782050001e3,
98
+ taker_position_size_before: "0.00100",
99
+ taker_position_sign_changed: true,
100
+ maker_position_size_before: "0.00000"
81
101
  }
82
102
  ];
83
- function createSyncer() {
103
+ function createSyncer(demo = false, createAuthToken = vi.fn().mockResolvedValue({ token: "test-token" })) {
84
104
  const sdk = {
85
- core: { misc: { lighter: { createAuthToken: vi.fn().mockResolvedValue({ token: "test-token" }) } } }
105
+ core: { misc: { lighter: { createAuthToken } } }
86
106
  };
87
107
  const config = {
88
108
  sdk,
@@ -90,12 +110,12 @@ function createSyncer() {
90
110
  account: { exchangeIdentifier: String(ACCOUNT_INDEX) },
91
111
  credentials: { api_key_index: 0, api_private_key: "test-key" },
92
112
  ws: {},
93
- demo: false
113
+ demo
94
114
  };
95
115
  return new LighterAccountSyncer(config);
96
116
  }
97
- async function listFills() {
98
- const syncer = createSyncer();
117
+ async function listFills(demo = false) {
118
+ const syncer = createSyncer(demo);
99
119
  const params = {
100
120
  since: /* @__PURE__ */ new Date(0),
101
121
  instruments: [{ id: "1" }],
@@ -104,6 +124,26 @@ async function listFills() {
104
124
  };
105
125
  return syncer.listExternalFills(params);
106
126
  }
127
+ async function listFunding(demo = false) {
128
+ const syncer = createSyncer(demo);
129
+ const params = {
130
+ since: /* @__PURE__ */ new Date(1e3),
131
+ instruments: [{ id: "1" }]
132
+ };
133
+ return syncer.listExternalFunding(params);
134
+ }
135
+ async function listPositions() {
136
+ const syncer = createSyncer();
137
+ const params = {
138
+ instruments: [
139
+ {
140
+ id: "1",
141
+ precision: { quantity: { contractSize: 0.01 } }
142
+ }
143
+ ]
144
+ };
145
+ return syncer.listExternalPositions(params);
146
+ }
107
147
  const byId = (fills, id) => fills.find((fill) => fill.exchangeFillId === id);
108
148
  describe("LighterAccountSyncer fee mapping", () => {
109
149
  beforeEach(() => {
@@ -145,4 +185,90 @@ describe("LighterAccountSyncer fee mapping", () => {
145
185
  builderFee: "0.005"
146
186
  });
147
187
  });
188
+ it("does not classify a position flip as reduce-only", async () => {
189
+ const fills = await listFills();
190
+ expect(byId(fills, "90000000002")).toMatchObject({
191
+ side: "SELL",
192
+ reduceOnly: false
193
+ });
194
+ });
195
+ });
196
+ describe("LighterAccountSyncer funding mapping", () => {
197
+ it("fetches paginated position funding with the generated auth token", async () => {
198
+ vi.mocked(fetchPositionFundingSince).mockResolvedValue([
199
+ {
200
+ timestamp: 1e3,
201
+ market_id: 1,
202
+ funding_id: 7,
203
+ change: "-2",
204
+ discount: "0",
205
+ rate: "0.0001",
206
+ position_size: "4",
207
+ position_side: "long"
208
+ }
209
+ ]);
210
+ await expect(listFunding()).resolves.toEqual([
211
+ {
212
+ connector: "lighter",
213
+ instrumentId: "1",
214
+ fundingRate: "0.0001",
215
+ notional: "-2",
216
+ fundingPerUnit: "-0.5",
217
+ timestamp: 1e3
218
+ }
219
+ ]);
220
+ expect(fetchPositionFundingSince).toHaveBeenCalledWith(ACCOUNT_INDEX, "test-token", /* @__PURE__ */ new Date(1e3), false);
221
+ });
222
+ });
223
+ describe("LighterAccountSyncer demo authentication boundary", () => {
224
+ it("rejects authenticated demo history reads before minting a mainnet token", async () => {
225
+ vi.clearAllMocks();
226
+ await expect(listFills(true)).rejects.toThrow("backend signing is mainnet-only");
227
+ await expect(listFunding(true)).rejects.toThrow("backend signing is mainnet-only");
228
+ expect(fetchTradesSince).not.toHaveBeenCalled();
229
+ expect(fetchPositionFundingSince).not.toHaveBeenCalled();
230
+ });
231
+ });
232
+ describe("LighterAccountSyncer position mapping", () => {
233
+ it("derives mark price from position value in normalized base units", async () => {
234
+ vi.mocked(fetchAccount).mockResolvedValue({
235
+ code: 200,
236
+ accounts: [
237
+ {
238
+ account_type: 0,
239
+ collateral: "1000",
240
+ total_asset_value: "1000",
241
+ available_balance: "900",
242
+ cross_asset_value: "1000",
243
+ cross_initial_margin_requirement: "100",
244
+ cross_maintenance_margin_requirement: "50",
245
+ positions: [
246
+ {
247
+ market_id: 1,
248
+ symbol: "BTC",
249
+ initial_margin_fraction: "10",
250
+ sign: -1,
251
+ position: "100",
252
+ avg_entry_price: "50000",
253
+ position_value: "60000",
254
+ unrealized_pnl: "-10000",
255
+ liquidation_price: "70000",
256
+ margin_mode: 0,
257
+ allocated_margin: "0"
258
+ }
259
+ ]
260
+ }
261
+ ]
262
+ });
263
+ await expect(listPositions()).resolves.toEqual([
264
+ {
265
+ symbol: "1",
266
+ size: "1",
267
+ side: "SELL",
268
+ entryPrice: "50000",
269
+ markPrice: "60000"
270
+ }
271
+ ]);
272
+ expect(fetchAccount).toHaveBeenCalledWith(ACCOUNT_INDEX, false);
273
+ });
148
274
  });
@@ -1,8 +1,9 @@
1
1
  class BaseAccountTracker {
2
2
  onError;
3
3
  demo;
4
- credentials = null;
4
+ credentials;
5
5
  balance = null;
6
+ balanceInitialized = false;
6
7
  positions = /* @__PURE__ */ new Map();
7
8
  initialized = false;
8
9
  balanceListeners = /* @__PURE__ */ new Set();
@@ -10,11 +11,17 @@ class BaseAccountTracker {
10
11
  assetEntries = /* @__PURE__ */ new Map();
11
12
  constructor(config) {
12
13
  this.demo = config.demo;
14
+ this.credentials = config.credentials;
13
15
  this.onError = (error) => {
14
16
  console.warn("[AccountTracker] error:", error.message);
15
17
  config.onError?.(error);
16
18
  };
17
19
  }
20
+ async snapshotAssets(coins) {
21
+ const unique = [...new Set(coins)];
22
+ if (unique.length === 0) return {};
23
+ return this.fetchAssetSnapshots(unique);
24
+ }
18
25
  async disconnect() {
19
26
  this.balanceListeners.clear();
20
27
  this.positionListeners.clear();
@@ -33,7 +40,7 @@ class BaseAccountTracker {
33
40
  }
34
41
  trackBalance(cb) {
35
42
  this.balanceListeners.add(cb);
36
- if (this.balance) this.notify(cb, this.balance);
43
+ if (this.balanceInitialized) this.notify(cb, this.balance);
37
44
  return () => this.balanceListeners.delete(cb);
38
45
  }
39
46
  trackPosition(cb) {
@@ -70,17 +77,27 @@ class BaseAccountTracker {
70
77
  getTrackedAsset(coin) {
71
78
  return this.assetEntries.get(coin)?.info ?? null;
72
79
  }
80
+ /** Drops cached account snapshots without broadcasting a fabricated explicit-null update. */
81
+ resetAccountState() {
82
+ this.balance = null;
83
+ this.balanceInitialized = false;
84
+ this.positions.clear();
85
+ this.initialized = false;
86
+ }
73
87
  onStateUpdate(update) {
74
- if (update.balance) this.applyBalanceUpdate(update.balance);
75
- if (update.positions) this.applyPositionsUpdate(update.positions);
88
+ const hasAccountState = update.balance !== void 0 || update.positions !== void 0;
89
+ if (update.balance !== void 0) this.applyBalanceUpdate(update.balance);
90
+ if (update.positions) this.applyPositionsUpdate(update.positions, update.positionsMode ?? "merge");
76
91
  if (update.trackedAssets) this.applyTrackedAssetsUpdate(update.trackedAssets);
77
- if (!this.initialized) this.initialized = true;
92
+ if (hasAccountState && !this.initialized) this.initialized = true;
78
93
  }
79
94
  applyBalanceUpdate(balance) {
80
95
  this.balance = balance;
96
+ this.balanceInitialized = true;
81
97
  this.broadcast(this.balanceListeners, balance);
82
98
  }
83
- applyPositionsUpdate(positions) {
99
+ applyPositionsUpdate(positions, mode) {
100
+ if (mode === "replace") this.positions.clear();
84
101
  for (const [key, pos] of positions) {
85
102
  if (pos.size === "0") this.positions.delete(key);
86
103
  else this.positions.set(key, pos);