@lifi/perps-sdk-provider-lighter 21.0.0 → 22.0.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 (68) hide show
  1. package/dist/cjs/LighterProvider.d.ts.map +1 -1
  2. package/dist/cjs/LighterProvider.js +42 -20
  3. package/dist/cjs/LighterProvider.js.map +1 -1
  4. package/dist/cjs/constants.d.ts +1 -0
  5. package/dist/cjs/constants.d.ts.map +1 -1
  6. package/dist/cjs/constants.js +2 -1
  7. package/dist/cjs/constants.js.map +1 -1
  8. package/dist/cjs/index.d.ts +1 -1
  9. package/dist/cjs/index.d.ts.map +1 -1
  10. package/dist/cjs/index.js +2 -1
  11. package/dist/cjs/index.js.map +1 -1
  12. package/dist/cjs/signers/signActions.d.ts.map +1 -1
  13. package/dist/cjs/signers/signActions.js +13 -5
  14. package/dist/cjs/signers/signActions.js.map +1 -1
  15. package/dist/cjs/types/order.d.ts +4 -0
  16. package/dist/cjs/types/order.d.ts.map +1 -1
  17. package/dist/cjs/utils/apiClient.d.ts +1 -1
  18. package/dist/cjs/utils/apiClient.d.ts.map +1 -1
  19. package/dist/cjs/utils/apiClient.js +15 -7
  20. package/dist/cjs/utils/apiClient.js.map +1 -1
  21. package/dist/cjs/utils/lighterErrorCode.d.ts +3 -0
  22. package/dist/cjs/utils/lighterErrorCode.d.ts.map +1 -0
  23. package/dist/cjs/utils/lighterErrorCode.js +17 -0
  24. package/dist/cjs/utils/lighterErrorCode.js.map +1 -0
  25. package/dist/esm/LighterProvider.d.ts.map +1 -1
  26. package/dist/esm/LighterProvider.js +63 -28
  27. package/dist/esm/LighterProvider.js.map +1 -1
  28. package/dist/esm/constants.d.ts +14 -2
  29. package/dist/esm/constants.d.ts.map +1 -1
  30. package/dist/esm/constants.js +14 -2
  31. package/dist/esm/constants.js.map +1 -1
  32. package/dist/esm/index.d.ts +1 -1
  33. package/dist/esm/index.d.ts.map +1 -1
  34. package/dist/esm/index.js +1 -1
  35. package/dist/esm/index.js.map +1 -1
  36. package/dist/esm/signers/signActions.d.ts.map +1 -1
  37. package/dist/esm/signers/signActions.js +22 -8
  38. package/dist/esm/signers/signActions.js.map +1 -1
  39. package/dist/esm/types/order.d.ts +12 -0
  40. package/dist/esm/types/order.d.ts.map +1 -1
  41. package/dist/esm/utils/apiClient.d.ts +15 -7
  42. package/dist/esm/utils/apiClient.d.ts.map +1 -1
  43. package/dist/esm/utils/apiClient.js +32 -14
  44. package/dist/esm/utils/apiClient.js.map +1 -1
  45. package/dist/esm/utils/lighterErrorCode.d.ts +7 -0
  46. package/dist/esm/utils/lighterErrorCode.d.ts.map +1 -0
  47. package/dist/esm/utils/lighterErrorCode.js +26 -0
  48. package/dist/esm/utils/lighterErrorCode.js.map +1 -0
  49. package/dist/types/LighterProvider.d.ts.map +1 -1
  50. package/dist/types/constants.d.ts +14 -2
  51. package/dist/types/constants.d.ts.map +1 -1
  52. package/dist/types/index.d.ts +1 -1
  53. package/dist/types/index.d.ts.map +1 -1
  54. package/dist/types/signers/signActions.d.ts.map +1 -1
  55. package/dist/types/types/order.d.ts +12 -0
  56. package/dist/types/types/order.d.ts.map +1 -1
  57. package/dist/types/utils/apiClient.d.ts +15 -7
  58. package/dist/types/utils/apiClient.d.ts.map +1 -1
  59. package/dist/types/utils/lighterErrorCode.d.ts +7 -0
  60. package/dist/types/utils/lighterErrorCode.d.ts.map +1 -0
  61. package/package.json +2 -2
  62. package/src/LighterProvider.ts +104 -50
  63. package/src/constants.ts +15 -2
  64. package/src/index.ts +1 -0
  65. package/src/signers/signActions.ts +28 -11
  66. package/src/types/order.ts +13 -0
  67. package/src/utils/apiClient.ts +36 -13
  68. package/src/utils/lighterErrorCode.ts +30 -0
@@ -4,6 +4,7 @@ import {
4
4
  getAssetRegistry,
5
5
  getMarketRegistry,
6
6
  getProviders,
7
+ isActiveOrderStatus,
7
8
  localStorageAdapter,
8
9
  PerpsError,
9
10
  type PerpsProviderPlugin,
@@ -66,6 +67,7 @@ import {
66
67
  DEFAULT_TRADES_LIMIT,
67
68
  LIGHTER_ALL_MARKETS_WILDCARD,
68
69
  LIGHTER_BASE_FEE_TIER,
70
+ LIGHTER_CLIENT_ORDER_INDEX_ID_PREFIX,
69
71
  LIGHTER_FEE_TICK_SCALE,
70
72
  LIGHTER_HISTORY_PAGE_SIZE,
71
73
  LIGHTER_MAINNET_DEPLOYMENT,
@@ -88,6 +90,7 @@ import {
88
90
  import type {
89
91
  LtAccount,
90
92
  LtAccountLimits,
93
+ LtAccountOrdersResponse,
91
94
  LtDepositHistoryItem,
92
95
  LtDepositHistoryResponse,
93
96
  LtDetailedAccountPosition,
@@ -165,6 +168,13 @@ const orderCountFor = (p: LtDetailedAccountPosition): number =>
165
168
  */
166
169
  const TX_HASH_PATTERN = /^[0-9a-f]{80}$/
167
170
 
171
+ /**
172
+ * Lighter's `client_order_index` is an int64 and `accountOrders` takes it as a
173
+ * decimal string, so a prefixed id's suffix goes to the venue verbatim rather
174
+ * than through a lossy JS number.
175
+ */
176
+ const CLIENT_ORDER_INDEX_PATTERN = /^\d+$/
177
+
168
178
  const INACTIVE_ORDERS_LOOKUP_LIMIT = 100
169
179
 
170
180
  /** Activity surfaces whose rows name a market, so they need the market registry. */
@@ -567,7 +577,7 @@ export const createLighterProvider = (
567
577
  account_index: accountIndex,
568
578
  })
569
579
 
570
- const fetchActiveOrdersForMarket = async (
580
+ const fetchActiveOrders = async (
571
581
  client: LighterApiClient,
572
582
  authToken: string,
573
583
  accountIndex: number,
@@ -581,6 +591,20 @@ export const createLighterProvider = (
581
591
  return { ...response, orders: wireList(response.orders) }
582
592
  }
583
593
 
594
+ const fetchOrdersByClientOrderIndex = async (
595
+ client: LighterApiClient,
596
+ authToken: string,
597
+ accountIndex: number,
598
+ clientOrderIndex: string
599
+ ): Promise<LtOrder[]> => {
600
+ const response = await client.getAuthed<LtAccountOrdersResponse>(
601
+ '/api/v1/accountOrders',
602
+ authToken,
603
+ { account_index: accountIndex, client_order_indexes: clientOrderIndex }
604
+ )
605
+ return wireList(response.orders)
606
+ }
607
+
584
608
  const deriveOrderBearingMarketIds = (account: LtAccount): number[] =>
585
609
  account.positions
586
610
  .filter((p) => orderCountFor(p) > 0)
@@ -1017,21 +1041,17 @@ export const createLighterProvider = (
1017
1041
  registry.sync(),
1018
1042
  ])
1019
1043
 
1020
- const marketIds =
1044
+ const marketId =
1021
1045
  params.marketId === undefined
1022
- ? deriveOrderBearingMarketIds(account)
1023
- : [Number(registry.require(params.marketId).id)]
1046
+ ? LIGHTER_ALL_MARKETS_WILDCARD
1047
+ : Number(registry.require(params.marketId).id)
1024
1048
 
1025
- const responses = await retryOnRevoked(opts, params.address, token, (t) =>
1026
- Promise.all(
1027
- marketIds.map((id) =>
1028
- fetchActiveOrdersForMarket(client, t, account.index, id)
1029
- )
1030
- )
1049
+ const response = await retryOnRevoked(opts, params.address, token, (t) =>
1050
+ fetchActiveOrders(client, t, account.index, marketId)
1031
1051
  )
1032
1052
 
1033
1053
  const { openOrders, triggerOrders } = classifyAndMapOrders(
1034
- responses.flatMap((r) => r.orders),
1054
+ response.orders,
1035
1055
  (marketIndex) => registry.require(String(marketIndex))
1036
1056
  )
1037
1057
 
@@ -1065,9 +1085,7 @@ export const createLighterProvider = (
1065
1085
  : [Number(registry.require(params.marketId).id)]
1066
1086
  const responses = await retryOnRevoked(opts, params.address, token, (t) =>
1067
1087
  Promise.all(
1068
- marketIds.map((id) =>
1069
- fetchActiveOrdersForMarket(client, t, account.index, id)
1070
- )
1088
+ marketIds.map((id) => fetchActiveOrders(client, t, account.index, id))
1071
1089
  )
1072
1090
  )
1073
1091
 
@@ -1087,6 +1105,14 @@ export const createLighterProvider = (
1087
1105
  return twaps
1088
1106
  },
1089
1107
 
1108
+ /**
1109
+ * Resolves either reference Lighter hands out for an order: a bare
1110
+ * `order_index` — the value `Order.orderId` carries, as streamed by
1111
+ * `orderUpdates` / `fills` — or a `client_order_index` prefixed with
1112
+ * {@link LIGHTER_CLIENT_ORDER_INDEX_ID_PREFIX}, which a signed placement
1113
+ * step returns and which resolves before the venue has assigned an
1114
+ * `order_index`.
1115
+ */
1090
1116
  async getOrder(
1091
1117
  params: ProviderGetOrderParams,
1092
1118
  opts?: SDKRequestOptions
@@ -1108,41 +1134,75 @@ export const createLighterProvider = (
1108
1134
  registry.sync(),
1109
1135
  ])
1110
1136
 
1111
- // Native `order_index` route only. The cross-provider `Order.orderId`
1112
- // for Lighter is `String(order_index)` — see `mapOrder`. A tx-hash route
1113
- // would require mapping the caller's executeAction tx hash → wasm nonce
1114
- // → matching order, which the LI.FI backend did via its `UserAction`
1115
- // table; the SDK has no equivalent persistence, so we refuse rather
1116
- // than mis-resolve.
1137
+ const notFound = (): PerpsError =>
1138
+ new PerpsError(
1139
+ PerpsErrorCode.OrderNotFound,
1140
+ `Lighter order ${params.id} not found for ${params.address}`
1141
+ )
1142
+ const detail = (order: LtOrder): Order =>
1143
+ mapOrderDetail(order, registry.require(String(order.market_index)))
1144
+
1145
+ // A tx-hash route would require mapping the caller's executeAction tx
1146
+ // hash → wasm nonce → matching order, which the LI.FI backend did via
1147
+ // its `UserAction` table; the SDK has no equivalent persistence, so we
1148
+ // refuse rather than mis-resolve.
1117
1149
  if (TX_HASH_PATTERN.test(params.id)) {
1118
1150
  throw new PerpsError(
1119
1151
  PerpsErrorCode.OrderNotFound,
1120
1152
  `Lighter order id "${params.id}" looks like a tx hash. The SDK ` +
1121
- `resolves orders by Lighter \`order_index\` only — surface the orderId ` +
1122
- `from the orderUpdates / fills WS stream and pass it here.`
1153
+ `resolves orders by Lighter \`order_index\` or by a ` +
1154
+ `\`${LIGHTER_CLIENT_ORDER_INDEX_ID_PREFIX}<index>\` id — surface the ` +
1155
+ `orderId from the orderUpdates / fills WS stream, or the ` +
1156
+ `clientOrderIndex from the signed placement step, and pass it here.`
1123
1157
  )
1124
1158
  }
1125
- const predicate: (o: { order_index: number }) => boolean = (o) =>
1126
- String(o.order_index) === params.id
1127
1159
 
1128
- const marketIds = deriveOrderBearingMarketIds(account)
1129
- const activeResponses = await retryOnRevoked(
1130
- opts,
1131
- params.address,
1132
- token,
1133
- (t) =>
1134
- Promise.all(
1135
- marketIds.map((id) =>
1136
- fetchActiveOrdersForMarket(client, t, account.index, id)
1137
- )
1160
+ if (params.id.startsWith(LIGHTER_CLIENT_ORDER_INDEX_ID_PREFIX)) {
1161
+ const clientOrderIndex = params.id.slice(
1162
+ LIGHTER_CLIENT_ORDER_INDEX_ID_PREFIX.length
1163
+ )
1164
+ if (!CLIENT_ORDER_INDEX_PATTERN.test(clientOrderIndex)) {
1165
+ throw new PerpsError(
1166
+ PerpsErrorCode.SDKError,
1167
+ `Lighter order id "${params.id}" names a client order index, but ` +
1168
+ `"${clientOrderIndex}" is not a decimal integer.`
1138
1169
  )
1139
- )
1140
-
1141
- for (const response of activeResponses) {
1142
- const hit = response.orders.find(predicate as (o: unknown) => boolean)
1143
- if (hit !== undefined) {
1144
- return mapOrderDetail(hit, registry.require(String(hit.market_index)))
1145
1170
  }
1171
+ const orders = await retryOnRevoked(opts, params.address, token, (t) =>
1172
+ fetchOrdersByClientOrderIndex(
1173
+ client,
1174
+ t,
1175
+ account.index,
1176
+ clientOrderIndex
1177
+ )
1178
+ )
1179
+ // `accountOrders` reaches active and inactive rows alike, so a reused
1180
+ // client order index can match more than one row. Prefer the live one.
1181
+ const matches = orders
1182
+ .filter((o) => String(o.client_order_index) === clientOrderIndex)
1183
+ .map(detail)
1184
+ const hit =
1185
+ matches.find((o) => isActiveOrderStatus(o.status)) ?? matches[0]
1186
+ if (hit === undefined) {
1187
+ throw notFound()
1188
+ }
1189
+ return hit
1190
+ }
1191
+
1192
+ const byOrderIndex = (o: LtOrder): boolean =>
1193
+ String(o.order_index) === params.id
1194
+
1195
+ const active = await retryOnRevoked(opts, params.address, token, (t) =>
1196
+ fetchActiveOrders(
1197
+ client,
1198
+ t,
1199
+ account.index,
1200
+ LIGHTER_ALL_MARKETS_WILDCARD
1201
+ )
1202
+ )
1203
+ const activeHit = active.orders.find(byOrderIndex)
1204
+ if (activeHit !== undefined) {
1205
+ return detail(activeHit)
1146
1206
  }
1147
1207
 
1148
1208
  const inactive = await retryOnRevoked(opts, params.address, token, (t) =>
@@ -1152,17 +1212,11 @@ export const createLighterProvider = (
1152
1212
  limit: INACTIVE_ORDERS_LOOKUP_LIMIT,
1153
1213
  })
1154
1214
  )
1155
- const hit = wireList(inactive.orders).find(
1156
- predicate as (o: unknown) => boolean
1157
- )
1158
- if (hit !== undefined) {
1159
- return mapOrderDetail(hit, registry.require(String(hit.market_index)))
1215
+ const inactiveHit = wireList(inactive.orders).find(byOrderIndex)
1216
+ if (inactiveHit === undefined) {
1217
+ throw notFound()
1160
1218
  }
1161
-
1162
- throw new PerpsError(
1163
- PerpsErrorCode.OrderNotFound,
1164
- `Lighter order ${params.id} not found for ${params.address}`
1165
- )
1219
+ return detail(inactiveHit)
1166
1220
  },
1167
1221
 
1168
1222
  async getFills(
package/src/constants.ts CHANGED
@@ -204,13 +204,26 @@ export const LIGHTER_BASE_FEE_TIER: FeeTier = { maker: '0', taker: '0' }
204
204
 
205
205
  /**
206
206
  * Wildcard `market_id` accepted by Lighter's per-market account endpoints to
207
- * indicate "every market". Used on `accountInactiveOrders` and
208
- * `liquidations`.
207
+ * indicate "every market". Used on `accountActiveOrders`,
208
+ * `accountInactiveOrders` and `liquidations`. It is also the venue's own
209
+ * documented default for `accountActiveOrders.market_id`.
209
210
  *
210
211
  * @internal
211
212
  */
212
213
  export const LIGHTER_ALL_MARKETS_WILDCARD = 255
213
214
 
215
+ /**
216
+ * Prefix that marks a `getOrder` id as a Lighter `client_order_index` rather
217
+ * than an `order_index`. Compose the id as
218
+ * `` `${LIGHTER_CLIENT_ORDER_INDEX_ID_PREFIX}${clientOrderIndex}` `` from the
219
+ * `clientOrderIndex` a signed placement step returns. Both references are
220
+ * decimal integers, so without the prefix `getOrder` could resolve one as the
221
+ * other and return an unrelated order.
222
+ *
223
+ * @public
224
+ */
225
+ export const LIGHTER_CLIENT_ORDER_INDEX_ID_PREFIX = 'client_order_index:'
226
+
214
227
  /**
215
228
  * Lighter caps `limit` at 100 on positionFunding/liquidations endpoints.
216
229
  *
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ export {
23
23
  DEFAULT_LIGHTER_EXPLORER_TX_BASE_URL,
24
24
  DEFAULT_LIGHTER_REST_URL,
25
25
  DEFAULT_LIGHTER_WS_URL,
26
+ LIGHTER_CLIENT_ORDER_INDEX_ID_PREFIX,
26
27
  LIGHTER_COLLATERAL_ASSETS,
27
28
  LIGHTER_MAINNET_DEPLOYMENT,
28
29
  LIGHTER_MAINNET_SIGNER_CHAIN_ID,
@@ -230,13 +230,30 @@ async function signStandardWasmAction(
230
230
  apiKeyIndex: apiKey.apiKeyIndex,
231
231
  accountIndex: apiKey.accountIndex,
232
232
  })
233
+ const clientOrderIndex = clientOrderIndexOf(step.wasmSignParams)
233
234
  return {
234
235
  action: step.action,
235
236
  wasmSignParams: step.wasmSignParams,
236
237
  signedTx,
238
+ ...(clientOrderIndex === undefined ? {} : { clientOrderIndex }),
237
239
  }
238
240
  }
239
241
 
242
+ /**
243
+ * Project the signed order's `client_order_index` so the caller can resolve it
244
+ * through `getOrder` before the venue assigns an `order_index`. Only the
245
+ * order-placing actions carry one, and the value is read back off the params
246
+ * that were signed rather than generated a second time.
247
+ */
248
+ function clientOrderIndexOf(
249
+ wasmSignParams: Record<string, unknown>
250
+ ): string | undefined {
251
+ const value = wasmSignParams.client_order_index
252
+ return typeof value === 'number' && Number.isInteger(value)
253
+ ? String(value)
254
+ : undefined
255
+ }
256
+
240
257
  /**
241
258
  * REGISTER_API_KEY flow:
242
259
  * 1. Look up the user's Lighter accountIndex (Lighter REST).
@@ -484,11 +501,11 @@ async function executeTokenAuthMutation(
484
501
  }
485
502
  }
486
503
 
487
- const { path, params } = buildTokenAuthMutationRequest(step, authToken)
504
+ const { path, params } = buildTokenAuthMutationRequest(step)
488
505
  const { status, data } = await deps.apiClient.postForm<{
489
506
  code?: number
490
507
  message?: string
491
- }>(path, params)
508
+ }>(path, authToken, params)
492
509
 
493
510
  const code = data?.code
494
511
  const referralAlreadyApplied =
@@ -533,14 +550,14 @@ async function readAppliedReferralCode(
533
550
  }
534
551
 
535
552
  /**
536
- * Map a token-authenticated mutation step to its Lighter endpoint and
537
- * form body, mirroring the backend's request contract. The auth token is the
538
- * `auth` field; the remaining fields come from `wasmSignParams`.
553
+ * Map a token-authenticated mutation step to its Lighter endpoint and form
554
+ * body. The fields come from `wasmSignParams`; the auth token travels in the
555
+ * `Authorization` header, not the body.
539
556
  */
540
- function buildTokenAuthMutationRequest(
541
- step: WasmBlobActionStep,
542
- authToken: string
543
- ): { path: string; params: ApiParams } {
557
+ function buildTokenAuthMutationRequest(step: WasmBlobActionStep): {
558
+ path: string
559
+ params: ApiParams
560
+ } {
544
561
  const kind = step.wasmSignParams.kind
545
562
  if (kind === 'changeAccountTier') {
546
563
  const { account_index, new_tier } = step.wasmSignParams as {
@@ -555,7 +572,7 @@ function buildTokenAuthMutationRequest(
555
572
  }
556
573
  return {
557
574
  path: '/api/v1/changeAccountTier',
558
- params: { auth: authToken, account_index, new_tier },
575
+ params: { account_index, new_tier },
559
576
  }
560
577
  }
561
578
  if (kind === 'referralUse') {
@@ -572,7 +589,7 @@ function buildTokenAuthMutationRequest(
572
589
  }
573
590
  return {
574
591
  path: '/api/v1/referral/use',
575
- params: { auth: authToken, l1_address, referral_code, x },
592
+ params: { l1_address, referral_code, x },
576
593
  }
577
594
  }
578
595
  throw new PerpsError(
@@ -69,6 +69,19 @@ export interface LtOrdersResponse {
69
69
  orders: LtOrder[] | null
70
70
  }
71
71
 
72
+ /**
73
+ * Order-lookup response from Lighter's `accountOrders` path. The lookup takes
74
+ * up to 20 `client_order_indexes` rather than a page, so it carries no cursor.
75
+ * It reaches the last 10K active orders with no time bound, and the last 1K
76
+ * inactive orders from the past 24 hours.
77
+ *
78
+ * @public
79
+ */
80
+ export interface LtAccountOrdersResponse {
81
+ code: number
82
+ orders: LtOrder[] | null
83
+ }
84
+
72
85
  /**
73
86
  * Single order-book level returned by Lighter. Amounts and prices are decimal
74
87
  * strings in the market's native precision; `order_expiry` is an absolute
@@ -9,6 +9,7 @@ import {
9
9
  LIGHTER_SUCCESS_CODES,
10
10
  LIGHTER_TOKEN_REVOKED_CODE,
11
11
  } from '../constants.js'
12
+ import { lighterErrorCodeFromBody } from './lighterErrorCode.js'
12
13
 
13
14
  /** @internal */
14
15
  export type ApiParams = Record<string, string | number | boolean>
@@ -50,9 +51,17 @@ const isLighterTokenRevoked = (data: unknown): boolean =>
50
51
 
51
52
  /**
52
53
  * A CR or LF in a token would split the request when it reaches a
53
- * `fetchImpl` that does not validate header values itself.
54
+ * `fetchImpl` that does not validate header values itself. A blank token
55
+ * would dispatch an `Authorization` header that carries no credential at
56
+ * all, which Lighter answers as a generic rejection.
54
57
  */
55
58
  const assertHeaderSafe = (authToken: string): string => {
59
+ if (authToken.trim().length === 0) {
60
+ throw new PerpsError(
61
+ PerpsErrorCode.ValidationError,
62
+ 'Lighter auth token is blank and cannot go in a header'
63
+ )
64
+ }
56
65
  if (/[\r\n]/.test(authToken)) {
57
66
  throw new PerpsError(
58
67
  PerpsErrorCode.ValidationError,
@@ -136,6 +145,11 @@ export interface LighterApiClientOptions {
136
145
  * `Access-Control-Max-Age: 86400`, so a browser sends one preflight per day
137
146
  * per origin.
138
147
  *
148
+ * The `/api/v1/changeAccountTier` and `/api/v1/referral/use` mutations send
149
+ * the same header with a fresh per-call token. Their `OPTIONS` response is
150
+ * not yet probed against a live host, so a browser may reject the preflight
151
+ * even though the GET paths pass.
152
+ *
139
153
  * Lighter signals rate limits through HTTP 429 or HTTP 405. This client never
140
154
  * retries such a response. It throws `RateLimitExceeded` and holds all network
141
155
  * dispatch until `Retry-After` expires (capped at five minutes), or for 60
@@ -226,13 +240,13 @@ export class LighterApiClient {
226
240
  })
227
241
  if (isLighterTokenRevoked(data)) {
228
242
  throw new LighterTokenRevokedError(
229
- PerpsErrorCode.ThirdPartyError,
243
+ PerpsErrorCode.Unauthorized,
230
244
  `Lighter reports a revoked auth token for ${path}`
231
245
  )
232
246
  }
233
247
  if (isLighterAuthRejection(status, data)) {
234
248
  throw new LighterAuthRejectedError(
235
- PerpsErrorCode.ThirdPartyError,
249
+ PerpsErrorCode.Unauthorized,
236
250
  `Lighter rejected the auth token for ${path}`
237
251
  )
238
252
  }
@@ -274,13 +288,15 @@ export class LighterApiClient {
274
288
  }
275
289
 
276
290
  /**
277
- * Form-encoded POST to a Lighter mutation endpoint. Single-shot — never
278
- * retried, since these are money/state writes whose outcome is unknown on a
279
- * transport failure. Surfaces the raw `{status, body}` pair so the caller can
280
- * map Lighter's per-endpoint business-rule `code` to a domain error verbatim.
291
+ * Form-encoded POST to a Lighter mutation endpoint, carrying the Lighter
292
+ * token in the `Authorization` header. Single-shot — never retried, since
293
+ * these are money/state writes whose outcome is unknown on a transport
294
+ * failure. Surfaces the raw `{status, body}` pair so the caller can map
295
+ * Lighter's per-endpoint business-rule `code` to a domain error verbatim.
281
296
  */
282
297
  async postForm<T>(
283
298
  path: string,
299
+ authToken: string,
284
300
  params: ApiParams
285
301
  ): Promise<{ status: number; data: T }> {
286
302
  const body = new URLSearchParams()
@@ -289,7 +305,10 @@ export class LighterApiClient {
289
305
  }
290
306
  const response = await this.fetchWithHold(`${this.baseUrl}${path}`, {
291
307
  method: 'POST',
292
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
308
+ headers: {
309
+ 'content-type': 'application/x-www-form-urlencoded',
310
+ Authorization: assertHeaderSafe(authToken),
311
+ },
293
312
  body: body.toString(),
294
313
  signal: this.signal,
295
314
  })
@@ -306,20 +325,24 @@ export class LighterApiClient {
306
325
 
307
326
  /**
308
327
  * Post-parse validation shared by every checked read: rejects a non-2xx HTTP
309
- * status and a 200 body carrying a non-success `code`. Callers that surface a
310
- * distinct auth-rejection error must run that check before this one.
328
+ * status and a 200 body carrying a non-success `code`. A body `code` that
329
+ * names a nonce, margin, or balance rejection sets the matching
330
+ * `PerpsErrorCode`; every other rejection is `ThirdPartyError`. Callers that
331
+ * surface a distinct auth-rejection error must run that check before this one.
311
332
  */
312
333
  private assertOk(path: string, status: number, data: unknown): void {
334
+ const errorCode = lighterBodyErrorCode(data)
335
+ const code =
336
+ lighterErrorCodeFromBody(errorCode) ?? PerpsErrorCode.ThirdPartyError
313
337
  if (status < 200 || status >= 300) {
314
338
  throw new PerpsError(
315
- PerpsErrorCode.ThirdPartyError,
339
+ code,
316
340
  `Lighter API request failed: ${status} — ${JSON.stringify(data).slice(0, 200)}`
317
341
  )
318
342
  }
319
- const errorCode = lighterBodyErrorCode(data)
320
343
  if (errorCode !== undefined) {
321
344
  throw new PerpsError(
322
- PerpsErrorCode.ThirdPartyError,
345
+ code,
323
346
  `Lighter API error for ${path}: code ${errorCode} — ${JSON.stringify(data).slice(0, 200)}`
324
347
  )
325
348
  }
@@ -0,0 +1,30 @@
1
+ import { PerpsErrorCode } from '@lifi/perps-types'
2
+
3
+ /** Lighter body `code` values that name a rejection a `PerpsErrorCode` describes. */
4
+ const LIGHTER_BODY_ERROR_CODES: ReadonlyMap<number, PerpsErrorCode> = new Map([
5
+ // invalid nonce
6
+ [21104, PerpsErrorCode.InvalidNonce],
7
+ // batch transaction nonce is not increasing
8
+ [21105, PerpsErrorCode.InvalidNonce],
9
+ // not enough collateral
10
+ [21301, PerpsErrorCode.InsufficientBalance],
11
+ // not enough asset balance
12
+ [21304, PerpsErrorCode.InsufficientBalance],
13
+ // not enough asset balance for fee
14
+ [21305, PerpsErrorCode.InsufficientBalance],
15
+ // account is below maintenance margin
16
+ [21507, PerpsErrorCode.InsufficientMargin],
17
+ // account is below initial margin
18
+ [21508, PerpsErrorCode.InsufficientMargin],
19
+ // not enough margin to create the order
20
+ [21739, PerpsErrorCode.InsufficientMargin],
21
+ ])
22
+
23
+ /**
24
+ * Maps a Lighter body error `code` to the `PerpsErrorCode` it names. Returns
25
+ * `undefined` for every other code so the status-driven layer decides.
26
+ */
27
+ export const lighterErrorCodeFromBody = (
28
+ code: number | undefined
29
+ ): PerpsErrorCode | undefined =>
30
+ code === undefined ? undefined : LIGHTER_BODY_ERROR_CODES.get(code)