perp-cli 0.5.0 → 0.5.3

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.
@@ -26,6 +26,7 @@ export class HyperliquidSpotAdapter {
26
26
  _spotAssetMap = new Map(); // "ETH" → spotIndex (not +10000)
27
27
  _spotAssetReverse = new Map(); // spotIndex → "ETH"
28
28
  _spotDecimals = new Map();
29
+ _spotCanonical = new Map(); // "PURR" → true, "UBTC" → false
29
30
  _initialized = false;
30
31
  _cachedMetaCtx = null;
31
32
  constructor(hlAdapter) {
@@ -46,27 +47,36 @@ export class HyperliquidSpotAdapter {
46
47
  return;
47
48
  this._cachedMetaCtx = metaCtx;
48
49
  const meta = metaCtx[0];
49
- // Token index → name map
50
+ // Token index → name + szDecimals map (szDecimals lives on tokens, not universe)
50
51
  const tokenNames = new Map();
52
+ const tokenSzDecimals = new Map();
51
53
  for (const t of meta.tokens ?? []) {
52
54
  tokenNames.set(t.index, t.name);
55
+ if (t.szDecimals !== undefined)
56
+ tokenSzDecimals.set(t.index, t.szDecimals);
53
57
  }
54
58
  // Find USDC token index for filtering
55
59
  const usdcTokenIndex = meta.tokens?.find(t => t.name === "USDC")?.index ?? 0;
60
+ // HIP-1 meme tokens with low liquidity / unreliable orderbooks — skip for spot trading
61
+ const SPOT_EXCLUDE = new Set(["TRUMP", "PEPE", "HFUN"]);
56
62
  for (const u of meta.universe) {
57
- const baseToken = tokenNames.get(u.tokens[0]) ?? "";
63
+ const baseTokenIndex = u.tokens[0];
64
+ const baseToken = tokenNames.get(baseTokenIndex) ?? "";
58
65
  if (!baseToken)
59
66
  continue;
60
67
  const key = baseToken.toUpperCase();
68
+ if (SPOT_EXCLUDE.has(key))
69
+ continue;
61
70
  const isUsdcPair = u.tokens[1] === usdcTokenIndex;
62
71
  if (this._spotAssetMap.has(key) && !isUsdcPair)
63
72
  continue;
64
73
  this._spotAssetMap.set(key, u.index);
65
74
  this._spotAssetReverse.set(u.index, key);
66
75
  this._spotDecimals.set(key, {
67
- size: u.szDecimals ?? 2,
76
+ size: tokenSzDecimals.get(baseTokenIndex) ?? 2,
68
77
  price: 6,
69
78
  });
79
+ this._spotCanonical.set(key, u.isCanonical === true);
70
80
  }
71
81
  }
72
82
  catch (e) {
@@ -82,8 +92,11 @@ export class HyperliquidSpotAdapter {
82
92
  if (!metaCtx?.[0]?.universe)
83
93
  return [];
84
94
  const tokenNames = new Map();
95
+ const tokenSzDec = new Map();
85
96
  for (const t of metaCtx[0].tokens ?? []) {
86
97
  tokenNames.set(t.index, t.name);
98
+ if (t.szDecimals !== undefined)
99
+ tokenSzDec.set(t.index, t.szDecimals);
87
100
  }
88
101
  const ctxs = metaCtx[1] ?? [];
89
102
  const markets = [];
@@ -100,7 +113,7 @@ export class HyperliquidSpotAdapter {
100
113
  quoteToken,
101
114
  markPrice: String(ctx.markPx ?? ctx.midPx ?? "0"),
102
115
  volume24h: String(ctx.dayNtlVlm ?? "0"),
103
- sizeDecimals: u.szDecimals ?? 2,
116
+ sizeDecimals: tokenSzDec.get(u.tokens[0]) ?? 2,
104
117
  priceDecimals: 6,
105
118
  });
106
119
  }
@@ -112,12 +125,13 @@ export class HyperliquidSpotAdapter {
112
125
  }
113
126
  async getSpotOrderbook(symbol) {
114
127
  await this.init();
115
- const { spotIndex } = this._resolveBase(symbol);
116
- // HL spot L2 book uses the universe name, which is like "ETH/USDC" internally
117
- // But the API takes coin=<name>, where name is from spotMeta universe
128
+ const { base, spotIndex } = this._resolveBase(symbol);
129
+ // Canonical tokens (e.g. PURR) use "BASE/USDC", non-canonical (e.g. UBTC) use "@index"
130
+ const isCanonical = this._spotCanonical.get(base) ?? false;
131
+ const coin = isCanonical ? `${base}/USDC` : `@${spotIndex}`;
118
132
  const book = await this._infoPost({
119
133
  type: "l2Book",
120
- coin: `@${spotIndex}`,
134
+ coin,
121
135
  });
122
136
  const levels = book?.levels ?? [[], []];
123
137
  return {
@@ -133,8 +147,9 @@ export class HyperliquidSpotAdapter {
133
147
  }
134
148
  async getSpotBalances() {
135
149
  try {
136
- const state = await this._hl.client.info.spot.getSpotClearinghouseState(this._hl.address);
137
- const balances = state?.balances ?? [];
150
+ // Reuse cached spot clearinghouse state (shared with getBalance() — saves 1 API call)
151
+ const state = await this._hl._getSpotClearinghouseState();
152
+ const balances = (state?.balances ?? []);
138
153
  return balances.map((b) => ({
139
154
  token: String(b.coin ?? ""),
140
155
  total: String(b.total ?? "0"),
@@ -43,6 +43,8 @@ export declare class HyperliquidAdapter implements ExchangeAdapter {
43
43
  private ensureAddress;
44
44
  /** Always fetches live clearinghouseState, writes result to shared cache for dashboard */
45
45
  private _getClearinghouseState;
46
+ /** Cached spot clearinghouse state — shared between getBalance() and HyperliquidSpotAdapter */
47
+ _getSpotClearinghouseState(): Promise<Record<string, unknown>>;
46
48
  getBalance(): Promise<ExchangeBalance>;
47
49
  getPositions(): Promise<ExchangePosition[]>;
48
50
  getOpenOrders(): Promise<ExchangeOrder[]>;
@@ -59,6 +61,15 @@ export declare class HyperliquidAdapter implements ExchangeAdapter {
59
61
  * Bypasses the SDK entirely — works for order, batchModify, twapOrder, etc.
60
62
  */
61
63
  private ensureSigner;
64
+ /**
65
+ * Normalize trailing zeros from price ('p') and size ('s') string fields
66
+ * in an action object, recursively. Matches the SDK's normalizeTrailingZeros.
67
+ *
68
+ * The HL validator normalizes these fields before hashing; if we hash without
69
+ * normalizing, the recovered signer address won't match → "User or API Wallet
70
+ * does not exist" error.
71
+ */
72
+ private static _normalizeAction;
62
73
  private _signAndSendAction;
63
74
  /**
64
75
  * Place an order via raw exchange API with EIP-712 signing.
@@ -258,6 +258,14 @@ export class HyperliquidAdapter {
258
258
  return state;
259
259
  });
260
260
  }
261
+ /** Cached spot clearinghouse state — shared between getBalance() and HyperliquidSpotAdapter */
262
+ async _getSpotClearinghouseState() {
263
+ this.ensureAddress();
264
+ const { withCache, TTL_ACCOUNT } = await import("../cache.js");
265
+ return withCache(`acct:hl:spot_chs:${this._address}`, TTL_ACCOUNT, async () => {
266
+ return await this.sdk.info.spot.getSpotClearinghouseState(this._address);
267
+ });
268
+ }
261
269
  async getBalance() {
262
270
  const state = await this._getClearinghouseState();
263
271
  const s = state;
@@ -277,8 +285,8 @@ export class HyperliquidAdapter {
277
285
  // Unified account: spot USDC total IS the true equity (includes perp margin as "hold").
278
286
  // perp accountValue is a subset — adding both double-counts.
279
287
  try {
280
- const spotState = await this.sdk.info.spot.getSpotClearinghouseState(this._address);
281
- const balances = spotState?.balances ?? [];
288
+ const spotState = await this._getSpotClearinghouseState();
289
+ const balances = (spotState?.balances ?? []);
282
290
  const usdc = balances.find((b) => String(b.coin).startsWith("USDC"));
283
291
  const spotTotal = usdc?.total !== undefined ? Number(usdc.total) : NaN;
284
292
  const spotHold = Number(usdc?.hold ?? 0);
@@ -430,6 +438,40 @@ export class HyperliquidAdapter {
430
438
  throw new Error("No private key configured. Run: perp init");
431
439
  }
432
440
  }
441
+ /**
442
+ * Normalize trailing zeros from price ('p') and size ('s') string fields
443
+ * in an action object, recursively. Matches the SDK's normalizeTrailingZeros.
444
+ *
445
+ * The HL validator normalizes these fields before hashing; if we hash without
446
+ * normalizing, the recovered signer address won't match → "User or API Wallet
447
+ * does not exist" error.
448
+ */
449
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
450
+ static _normalizeAction(obj) {
451
+ if (!obj || typeof obj !== "object")
452
+ return obj;
453
+ if (Array.isArray(obj)) {
454
+ return obj.map(item => HyperliquidAdapter._normalizeAction(item));
455
+ }
456
+ const result = { ...obj };
457
+ for (const key in result) {
458
+ if (!Object.prototype.hasOwnProperty.call(result, key))
459
+ continue;
460
+ const value = result[key];
461
+ if (value && typeof value === "object") {
462
+ result[key] = HyperliquidAdapter._normalizeAction(value);
463
+ }
464
+ else if ((key === "p" || key === "s") && typeof value === "string") {
465
+ let v = value;
466
+ if (v.includes("."))
467
+ v = v.replace(/\.?0+$/, "");
468
+ if (v === "-0")
469
+ v = "0";
470
+ result[key] = v;
471
+ }
472
+ }
473
+ return result;
474
+ }
433
475
  async _signAndSendAction(action) {
434
476
  this.ensureSigner();
435
477
  const { encode } = await import("@msgpack/msgpack");
@@ -438,9 +480,13 @@ export class HyperliquidAdapter {
438
480
  const baseUrl = isMainnet
439
481
  ? "https://api.hyperliquid.xyz"
440
482
  : "https://api.hyperliquid-testnet.xyz";
483
+ // Normalize trailing zeros from 'p' and 's' fields before hashing.
484
+ // The HL validator does the same; without this, the hash differs and the
485
+ // recovered signer address is wrong → "User or API Wallet does not exist".
486
+ const normalizedAction = HyperliquidAdapter._normalizeAction(action);
441
487
  // Sign L1 action (replicates SDK's signL1Action)
442
488
  const nonce = Date.now();
443
- const msgPackBytes = encode(action);
489
+ const msgPackBytes = encode(normalizedAction);
444
490
  const data = new Uint8Array(msgPackBytes.length + 9); // +8 nonce +1 vault flag
445
491
  data.set(msgPackBytes);
446
492
  const view = new DataView(data.buffer);
@@ -707,8 +753,8 @@ export class HyperliquidAdapter {
707
753
  r: opts?.reduceOnly ?? true,
708
754
  t: {
709
755
  trigger: {
710
- triggerPx: trimZeros(roundedTrigger),
711
756
  isMarket: opts?.isMarket ?? true,
757
+ triggerPx: trimZeros(roundedTrigger),
712
758
  tpsl,
713
759
  },
714
760
  },
@@ -15,7 +15,9 @@ export declare class LighterSpotAdapter implements SpotAdapter {
15
15
  private _spotBaseMap;
16
16
  private _spotDecimals;
17
17
  private _spotMinSize;
18
+ private _spotPrices;
18
19
  private _initialized;
20
+ private _clientOrderCounter;
19
21
  constructor(ltAdapter: LighterAdapter);
20
22
  init(): Promise<void>;
21
23
  private _loadSpotMarkets;
@@ -32,6 +34,8 @@ export declare class LighterSpotAdapter implements SpotAdapter {
32
34
  tif?: string;
33
35
  }): Promise<unknown>;
34
36
  spotCancelOrder(symbol: string, orderId: string): Promise<unknown>;
37
+ /** Cancel all orders across all markets (spot + perp) */
38
+ spotCancelAllOrders(_symbol?: string): Promise<unknown>;
35
39
  /** Transfer USDC from perp account to spot account (required before spot buys) */
36
40
  transferUsdcToSpot(amount: number): Promise<unknown>;
37
41
  /** Transfer USDC from spot account back to perp account */
@@ -48,6 +52,8 @@ export declare class LighterSpotAdapter implements SpotAdapter {
48
52
  /** Check if a base token has a spot market */
49
53
  hasSpotMarket(base: string): boolean;
50
54
  private _placeOrder;
55
+ /** Generate a unique client order index (uint48). Uses timestamp + counter. */
56
+ private _nextClientOrderIndex;
51
57
  private _getNextNonce;
52
58
  private _sendTx;
53
59
  private _restGet;
@@ -13,7 +13,9 @@ export class LighterSpotAdapter {
13
13
  _spotBaseMap = new Map(); // "ETH" → "ETH/USDC"
14
14
  _spotDecimals = new Map();
15
15
  _spotMinSize = new Map(); // min order sizes
16
+ _spotPrices = new Map(); // "ETH/USDC" → markPrice (from init)
16
17
  _initialized = false;
18
+ _clientOrderCounter = 0;
17
19
  constructor(ltAdapter) {
18
20
  this._lt = ltAdapter;
19
21
  }
@@ -40,6 +42,11 @@ export class LighterSpotAdapter {
40
42
  base: parseFloat(ob.min_base_amount || "0"),
41
43
  quote: parseFloat(ob.min_quote_amount || "0"),
42
44
  });
45
+ // Cache price from orderBooks response (avoids N separate orderbook calls in getSpotMarkets)
46
+ const price = ob.mark_price ?? (ob.best_ask_price && ob.best_bid_price
47
+ ? String((parseFloat(ob.best_ask_price) + parseFloat(ob.best_bid_price)) / 2) : "0");
48
+ if (price !== "0")
49
+ this._spotPrices.set(symbol, price);
43
50
  const base = symbol.split("/")[0];
44
51
  if (base)
45
52
  this._spotBaseMap.set(base, symbol);
@@ -84,22 +91,25 @@ export class LighterSpotAdapter {
84
91
  async getSpotMarkets() {
85
92
  await this.init();
86
93
  const entries = Array.from(this._spotMarketMap.entries());
87
- // Fetch all orderbooks in parallel (instead of sequential per-market)
88
- const obResults = await Promise.allSettled(entries.map(([symbol]) => this.getSpotOrderbook(symbol).then(ob => {
89
- if (ob.bids.length > 0 && ob.asks.length > 0) {
90
- return String((Number(ob.bids[0][0]) + Number(ob.asks[0][0])) / 2);
91
- }
92
- return "0";
93
- })));
94
- return entries.map(([symbol], i) => {
94
+ // Use prices cached from init's /orderBooks response (0 extra API calls).
95
+ // Only fetch individual orderbooks if no cached price available.
96
+ const missingPrices = entries.filter(([sym]) => !this._spotPrices.has(sym));
97
+ if (missingPrices.length > 0) {
98
+ const results = await Promise.allSettled(missingPrices.map(([symbol]) => this.getSpotOrderbook(symbol).then(ob => {
99
+ if (ob.bids.length > 0 && ob.asks.length > 0) {
100
+ this._spotPrices.set(symbol, String((Number(ob.bids[0][0]) + Number(ob.asks[0][0])) / 2));
101
+ }
102
+ })));
103
+ void results; // consume
104
+ }
105
+ return entries.map(([symbol]) => {
95
106
  const parts = symbol.split("/");
96
107
  const base = parts[0] ?? "";
97
108
  const quote = parts[1] ?? "USDC";
98
109
  const dec = this._spotDecimals.get(symbol) ?? { size: 4, price: 2 };
99
- const markPrice = obResults[i].status === "fulfilled" ? obResults[i].value : "0";
100
110
  return {
101
111
  symbol, baseToken: base, quoteToken: quote,
102
- markPrice, volume24h: "0",
112
+ markPrice: this._spotPrices.get(symbol) ?? "0", volume24h: "0",
103
113
  sizeDecimals: dec.size, priceDecimals: dec.price,
104
114
  };
105
115
  });
@@ -128,32 +138,36 @@ export class LighterSpotAdapter {
128
138
  async getSpotBalances() {
129
139
  try {
130
140
  await this.init();
131
- const balances = [];
132
141
  const address = this._lt.address;
133
142
  if (!address)
134
143
  return [];
135
- const res = await this._restGet("/account", {
136
- by: "l1_address",
137
- value: address,
144
+ // Reuse cached account data (shared with LighterAdapter.fetchAccount() — saves 1 API call)
145
+ const { withCache, TTL_ACCOUNT } = await import("../cache.js");
146
+ const acct = await withCache(`acct:lt:account:${address}`, TTL_ACCOUNT, async () => {
147
+ const res = await this._restGet("/account", {
148
+ by: "l1_address",
149
+ value: address,
150
+ });
151
+ const found = res.accounts?.find(a => a.account_index === this._lt.accountIndex)
152
+ ?? res.accounts?.[0];
153
+ return found ?? null;
138
154
  });
139
- const acct = res.accounts?.find(a => a.account_index === this._lt.accountIndex)
140
- ?? res.accounts?.[0];
141
155
  if (!acct)
142
156
  return [];
157
+ const balances = [];
143
158
  // Spot token balances from the assets array only.
144
159
  // Perp USDC (collateral) is already reported by getBalance() — don't duplicate here.
145
- if (acct.assets && acct.assets.length > 0) {
146
- for (const asset of acct.assets) {
147
- const bal = parseFloat(asset.balance || "0");
148
- const locked = parseFloat(asset.locked_balance || "0");
149
- if (bal > 0) {
150
- balances.push({
151
- token: asset.symbol === "USDC" ? "USDC_SPOT" : asset.symbol,
152
- total: asset.balance,
153
- available: String(bal - locked),
154
- held: asset.locked_balance,
155
- });
156
- }
160
+ const assets = (acct.assets ?? []);
161
+ for (const asset of assets) {
162
+ const bal = parseFloat(asset.balance || "0");
163
+ const locked = parseFloat(asset.locked_balance || "0");
164
+ if (bal > 0) {
165
+ balances.push({
166
+ token: asset.symbol === "USDC" ? "USDC_SPOT" : asset.symbol,
167
+ total: asset.balance,
168
+ available: String(bal - locked),
169
+ held: asset.locked_balance,
170
+ });
157
171
  }
158
172
  }
159
173
  return balances;
@@ -244,18 +258,42 @@ export class LighterSpotAdapter {
244
258
  if (marketId === undefined) {
245
259
  throw new Error(`Unknown Lighter spot market: ${symbol}`);
246
260
  }
247
- // Access signer through the adapter
261
+ // Lighter order IDs can exceed Number.MAX_SAFE_INTEGER (2^53-1).
262
+ // The WASM signer only accepts JS number, so large IDs lose precision
263
+ // and the cancel silently targets the wrong order.
264
+ // Fall back to cancelAll for the market when the ID is unsafe.
265
+ const idNum = Number(orderId);
266
+ if (!Number.isSafeInteger(idNum)) {
267
+ return this.spotCancelAllOrders(symbol);
268
+ }
248
269
  const signer = this._lt.signer;
249
270
  const nonce = await this._getNextNonce();
250
271
  const signed = await signer.signCancelOrder({
251
272
  marketIndex: marketId,
252
- orderIndex: parseInt(orderId),
273
+ orderIndex: idNum,
253
274
  nonce,
254
275
  apiKeyIndex: this._lt._apiKeyIndex,
255
276
  accountIndex: this._lt.accountIndex,
256
277
  });
257
278
  return this._sendTx(signed);
258
279
  }
280
+ /** Cancel all orders across all markets (spot + perp) */
281
+ async spotCancelAllOrders(_symbol) {
282
+ if (this._lt.isReadOnly)
283
+ throw new Error("Cancel requires API key");
284
+ const signer = this._lt.signer;
285
+ const nonce = await this._getNextNonce();
286
+ const apiKeyIndex = this._lt._apiKeyIndex;
287
+ // time must be in milliseconds, in the future
288
+ const signed = await signer.signCancelAllOrders({
289
+ timeInForce: 1,
290
+ time: Date.now() + 3600_000,
291
+ nonce,
292
+ apiKeyIndex,
293
+ accountIndex: this._lt.accountIndex,
294
+ });
295
+ return this._sendTx(signed);
296
+ }
259
297
  /** Transfer USDC from perp account to spot account (required before spot buys) */
260
298
  async transferUsdcToSpot(amount) {
261
299
  return this._selfTransfer(amount, 0, 1); // route: Perp(0) → Spot(1)
@@ -333,9 +371,13 @@ export class LighterSpotAdapter {
333
371
  const nonce = await this._getNextNonce();
334
372
  const signer = this._lt.signer;
335
373
  const apiKeyIndex = this._lt._apiKeyIndex;
374
+ // Use a unique clientOrderIndex (uint48, max 2^48-1 ≈ 281 trillion).
375
+ // This value is used for cancel operations instead of the exchange-assigned
376
+ // order_id which can exceed Number.MAX_SAFE_INTEGER.
377
+ const clientOrderIndex = this._nextClientOrderIndex();
336
378
  const signed = await signer.signCreateOrder({
337
379
  marketIndex: opts.marketIndex,
338
- clientOrderIndex: 0,
380
+ clientOrderIndex,
339
381
  baseAmount: opts.baseAmount,
340
382
  price: opts.price,
341
383
  isAsk: opts.isAsk,
@@ -353,6 +395,13 @@ export class LighterSpotAdapter {
353
395
  }
354
396
  return this._sendTx(signed);
355
397
  }
398
+ /** Generate a unique client order index (uint48). Uses timestamp + counter. */
399
+ _nextClientOrderIndex() {
400
+ // Timestamp in seconds gives ~10 digits. Add counter for uniqueness within same second.
401
+ // uint48 max = 281_474_976_710_655. Date.now()/1000 ≈ 1.7 trillion (fits).
402
+ const base = Math.floor(Date.now() / 1000) % 281_474_976_710_000;
403
+ return base + (this._clientOrderCounter++ % 655);
404
+ }
356
405
  async _getNextNonce() {
357
406
  const apiKeyIndex = this._lt._apiKeyIndex;
358
407
  const res = await this._restGet("/nextNonce", {
@@ -9,6 +9,7 @@ export declare class LighterAdapter implements ExchangeAdapter {
9
9
  private _address;
10
10
  private _marketMap;
11
11
  private _marketDecimals;
12
+ private _clientOrderCounter;
12
13
  private _evmKey;
13
14
  private _apiKey;
14
15
  private _accountIndexInit;
@@ -149,6 +150,8 @@ export declare class LighterAdapter implements ExchangeAdapter {
149
150
  private signOrder;
150
151
  private sendTx;
151
152
  private restGet;
153
+ /** Generate a unique client order index (uint48). */
154
+ private nextClientOrderIndex;
152
155
  private ensureSigner;
153
156
  private static _wasmClient;
154
157
  private static getWasmClient;
@@ -7,6 +7,7 @@ export class LighterAdapter {
7
7
  _address;
8
8
  _marketMap = new Map(); // symbol → marketIndex
9
9
  _marketDecimals = new Map(); // symbol → decimals
10
+ _clientOrderCounter = 0;
10
11
  _evmKey;
11
12
  _apiKey;
12
13
  _accountIndexInit;
@@ -73,26 +74,30 @@ export class LighterAdapter {
73
74
  await this._refreshMarketMap();
74
75
  return;
75
76
  }
76
- // Retry account lookup on 429 (rate limit) — critical for _accountIndex
77
- for (let attempt = 0; attempt < 3; attempt++) {
78
- try {
79
- const res = await fetch(`${this._baseUrl}/api/v1/account?by=l1_address&value=${this._address}`);
80
- if (res.status === 429 && attempt < 2) {
81
- await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
82
- continue;
83
- }
84
- const json = await res.json();
85
- if (json.accounts && json.accounts.length > 0) {
86
- this._accountIndex = this._accountIndexInit >= 0
87
- ? this._accountIndexInit
88
- : json.accounts[0].account_index;
77
+ // If accountIndex is already known (from env/opts), skip API lookup entirely
78
+ if (this._accountIndexInit >= 0) {
79
+ this._accountIndex = this._accountIndexInit;
80
+ }
81
+ else {
82
+ // Retry account lookup on 429 (rate limit) — critical for _accountIndex
83
+ for (let attempt = 0; attempt < 3; attempt++) {
84
+ try {
85
+ const res = await fetch(`${this._baseUrl}/api/v1/account?by=l1_address&value=${this._address}`);
86
+ if (res.status === 429 && attempt < 2) {
87
+ await new Promise(r => setTimeout(r, 2000 * (attempt + 1)));
88
+ continue;
89
+ }
90
+ const json = await res.json();
91
+ if (json.accounts && json.accounts.length > 0) {
92
+ this._accountIndex = json.accounts[0].account_index;
93
+ }
94
+ break;
89
95
  }
90
- break;
91
- }
92
- catch {
93
- if (attempt < 2) {
94
- await new Promise(r => setTimeout(r, 1000));
95
- continue;
96
+ catch {
97
+ if (attempt < 2) {
98
+ await new Promise(r => setTimeout(r, 2000));
99
+ continue;
100
+ }
96
101
  }
97
102
  }
98
103
  }
@@ -434,7 +439,7 @@ export class LighterAdapter {
434
439
  const { priceTicks: slippageTicks } = this.toTicks(symbol, 0, slippagePrice);
435
440
  const signed = await this.signOrder({
436
441
  marketIndex,
437
- clientOrderIndex: 0,
442
+ clientOrderIndex: this.nextClientOrderIndex(),
438
443
  baseAmount,
439
444
  price: Math.max(slippageTicks, 1),
440
445
  isAsk: side === "sell" ? 1 : 0,
@@ -457,7 +462,7 @@ export class LighterAdapter {
457
462
  const isIoc = opts?.tif?.toUpperCase() === "IOC";
458
463
  const signed = await this.signOrder({
459
464
  marketIndex,
460
- clientOrderIndex: 0,
465
+ clientOrderIndex: this.nextClientOrderIndex(),
461
466
  baseAmount,
462
467
  price: priceTicks,
463
468
  isAsk: side === "sell" ? 1 : 0,
@@ -473,10 +478,17 @@ export class LighterAdapter {
473
478
  async cancelOrder(symbol, orderId) {
474
479
  this.ensureSigner();
475
480
  await this.ensureMarketMap();
481
+ // Lighter order IDs can exceed Number.MAX_SAFE_INTEGER (2^53-1).
482
+ // The WASM signer only accepts JS number, so large IDs lose precision.
483
+ // Fall back to cancelAllOrders when the ID is unsafe.
484
+ const idNum = Number(orderId);
485
+ if (!Number.isSafeInteger(idNum)) {
486
+ return this.cancelAllOrders(symbol);
487
+ }
476
488
  const nonce = await this.getNextNonce();
477
489
  const marketIndex = this.getMarketIndex(symbol);
478
490
  const signed = await this._signer.signCancelOrder({
479
- marketIndex, orderIndex: parseInt(orderId), nonce,
491
+ marketIndex, orderIndex: idNum, nonce,
480
492
  apiKeyIndex: this._apiKeyIndex, accountIndex: this._accountIndex,
481
493
  });
482
494
  return this.sendTx(signed);
@@ -495,7 +507,7 @@ export class LighterAdapter {
495
507
  }
496
508
  const nonce = await this.getNextNonce();
497
509
  const signed = await this._signer.signCancelAllOrders({
498
- timeInForce: 0, time: Math.floor(Date.now() / 1000) + 86400, nonce,
510
+ timeInForce: 1, time: Date.now() + 3600_000, nonce,
499
511
  apiKeyIndex: this._apiKeyIndex, accountIndex: this._accountIndex,
500
512
  });
501
513
  return this.sendTx(signed);
@@ -506,9 +518,13 @@ export class LighterAdapter {
506
518
  const nonce = await this.getNextNonce();
507
519
  const marketIndex = this.getMarketIndex(symbol);
508
520
  const { baseAmount, priceTicks } = this.toTicks(symbol, parseFloat(size), parseFloat(price));
521
+ const idNum = Number(orderId);
522
+ if (!Number.isSafeInteger(idNum)) {
523
+ throw new Error(`Order ID ${orderId} exceeds MAX_SAFE_INTEGER — cancel and re-place instead`);
524
+ }
509
525
  const signed = await this._signer.signModifyOrder({
510
526
  marketIndex,
511
- index: parseInt(orderId),
527
+ index: idNum,
512
528
  baseAmount,
513
529
  price: priceTicks,
514
530
  triggerPrice: 0,
@@ -541,7 +557,7 @@ export class LighterAdapter {
541
557
  }
542
558
  const signed = await this.signOrder({
543
559
  marketIndex,
544
- clientOrderIndex: 0,
560
+ clientOrderIndex: this.nextClientOrderIndex(),
545
561
  baseAmount,
546
562
  price: Math.max(priceTicks, 1),
547
563
  isAsk: side === "sell" ? 1 : 0,
@@ -598,7 +614,7 @@ export class LighterAdapter {
598
614
  // /recentTrades returns is_maker_ask: maker was asking → taker bought
599
615
  side: t.is_maker_ask ? "buy" : "sell",
600
616
  price: String(t.price ?? "0"),
601
- size: String(t.base_amount ?? t.amount ?? ""),
617
+ size: String(t.size ?? t.base_amount ?? t.amount ?? ""),
602
618
  fee: String(t.fee ?? "0"),
603
619
  }));
604
620
  }
@@ -946,6 +962,11 @@ export class LighterAdapter {
946
962
  throw new Error(`GET ${path} failed (${res.status}): ${await res.text()}`);
947
963
  return res.json();
948
964
  }
965
+ /** Generate a unique client order index (uint48). */
966
+ nextClientOrderIndex() {
967
+ const base = Math.floor(Date.now() / 1000) % 281_474_976_710_000;
968
+ return base + (this._clientOrderCounter++ % 655);
969
+ }
949
970
  ensureSigner() {
950
971
  if (this._readOnly || this._accountIndex < 0 || !this._signer) {
951
972
  throw new Error("This command requires a Lighter API key. Run `perp -e lighter manage setup-api-key` first, " +
@@ -79,13 +79,26 @@ export class PacificaAdapter {
79
79
  async getBalance() {
80
80
  if (!this._hasRealKey)
81
81
  throw new Error("No private key configured — account data unavailable. Run: perp init");
82
- const [info, positions] = await Promise.all([
82
+ const [info, positions, prices] = await Promise.all([
83
83
  this.client.getAccount(this.account),
84
84
  this._getPositions(),
85
+ this._getPrices(),
85
86
  ]);
86
87
  const raw = info;
87
- // Use API-provided unrealized PnL from positions
88
- const totalPnl = positions.reduce((sum, p) => sum + Number(p.unrealized_pnl ?? 0), 0);
88
+ const priceMap = new Map(prices.map((p) => [p.symbol, p]));
89
+ // API often returns unrealized_pnl=0 — compute from price delta when needed
90
+ let totalPnl = 0;
91
+ for (const p of positions) {
92
+ let upnl = Number(p.unrealized_pnl ?? 0);
93
+ if (upnl === 0 && Number(p.amount) > 0) {
94
+ const mark = Number(priceMap.get(p.symbol)?.mark ?? p.mark_price ?? 0);
95
+ const entry = Number(p.entry_price);
96
+ const dir = p.side === "bid" ? 1 : -1;
97
+ if (mark > 0 && entry > 0)
98
+ upnl = (mark - entry) * Number(p.amount) * dir;
99
+ }
100
+ totalPnl += upnl;
101
+ }
89
102
  return {
90
103
  equity: info.account_equity,
91
104
  available: info.available_to_spend,
@@ -114,6 +127,15 @@ export class PacificaAdapter {
114
127
  return positions.map((p) => {
115
128
  const mark = priceMap.get(p.symbol)?.mark ?? p.mark_price ?? "0";
116
129
  const side = p.side === "bid" ? "long" : "short";
130
+ const size = Number(p.amount);
131
+ const entry = Number(p.entry_price);
132
+ const markNum = Number(mark);
133
+ // API often returns unrealized_pnl=0 — compute from price delta when needed
134
+ let upnl = Number(p.unrealized_pnl ?? 0);
135
+ if (upnl === 0 && size > 0 && entry > 0 && markNum > 0) {
136
+ const dir = side === "long" ? 1 : -1;
137
+ upnl = (markNum - entry) * size * dir;
138
+ }
117
139
  return {
118
140
  symbol: p.symbol,
119
141
  side: side,
@@ -121,7 +143,7 @@ export class PacificaAdapter {
121
143
  entryPrice: String(p.entry_price),
122
144
  markPrice: mark,
123
145
  liquidationPrice: String(p.liquidation_price ?? "N/A"),
124
- unrealizedPnl: String(p.unrealized_pnl ?? "0"),
146
+ unrealizedPnl: upnl.toFixed(4),
125
147
  leverage: p.leverage ?? levMap.get(String(p.symbol)) ?? 1,
126
148
  };
127
149
  });