@lifi/perps-sdk-provider-lighter 17.3.0 → 18.0.1

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 (81) hide show
  1. package/README.md +8 -0
  2. package/dist/cjs/LighterProvider.d.ts.map +1 -1
  3. package/dist/cjs/LighterProvider.js +76 -57
  4. package/dist/cjs/LighterProvider.js.map +1 -1
  5. package/dist/cjs/accountConfig.d.ts.map +1 -1
  6. package/dist/cjs/accountConfig.js.map +1 -1
  7. package/dist/cjs/constants.d.ts +2 -1
  8. package/dist/cjs/constants.d.ts.map +1 -1
  9. package/dist/cjs/constants.js +3 -1
  10. package/dist/cjs/constants.js.map +1 -1
  11. package/dist/cjs/signers/LighterKeyStore.d.ts +3 -1
  12. package/dist/cjs/signers/LighterKeyStore.d.ts.map +1 -1
  13. package/dist/cjs/signers/LighterKeyStore.js +14 -2
  14. package/dist/cjs/signers/LighterKeyStore.js.map +1 -1
  15. package/dist/cjs/signers/LighterReadOnlyTokenManager.d.ts +7 -0
  16. package/dist/cjs/signers/LighterReadOnlyTokenManager.d.ts.map +1 -1
  17. package/dist/cjs/signers/LighterReadOnlyTokenManager.js +19 -7
  18. package/dist/cjs/signers/LighterReadOnlyTokenManager.js.map +1 -1
  19. package/dist/cjs/signers/signActions.d.ts.map +1 -1
  20. package/dist/cjs/signers/signActions.js +8 -0
  21. package/dist/cjs/signers/signActions.js.map +1 -1
  22. package/dist/cjs/types/ws.d.ts +3 -3
  23. package/dist/cjs/types/ws.d.ts.map +1 -1
  24. package/dist/cjs/utils/apiClient.d.ts +12 -0
  25. package/dist/cjs/utils/apiClient.d.ts.map +1 -1
  26. package/dist/cjs/utils/apiClient.js +62 -7
  27. package/dist/cjs/utils/apiClient.js.map +1 -1
  28. package/dist/cjs/utils/mapMarketContext.js +2 -2
  29. package/dist/cjs/utils/mapMarketContext.js.map +1 -1
  30. package/dist/esm/LighterProvider.d.ts.map +1 -1
  31. package/dist/esm/LighterProvider.js +78 -102
  32. package/dist/esm/LighterProvider.js.map +1 -1
  33. package/dist/esm/accountConfig.d.ts.map +1 -1
  34. package/dist/esm/accountConfig.js +2 -4
  35. package/dist/esm/accountConfig.js.map +1 -1
  36. package/dist/esm/constants.d.ts +7 -1
  37. package/dist/esm/constants.d.ts.map +1 -1
  38. package/dist/esm/constants.js +7 -1
  39. package/dist/esm/constants.js.map +1 -1
  40. package/dist/esm/signers/LighterKeyStore.d.ts +4 -1
  41. package/dist/esm/signers/LighterKeyStore.d.ts.map +1 -1
  42. package/dist/esm/signers/LighterKeyStore.js +15 -3
  43. package/dist/esm/signers/LighterKeyStore.js.map +1 -1
  44. package/dist/esm/signers/LighterReadOnlyTokenManager.d.ts +12 -0
  45. package/dist/esm/signers/LighterReadOnlyTokenManager.d.ts.map +1 -1
  46. package/dist/esm/signers/LighterReadOnlyTokenManager.js +23 -7
  47. package/dist/esm/signers/LighterReadOnlyTokenManager.js.map +1 -1
  48. package/dist/esm/signers/signActions.d.ts.map +1 -1
  49. package/dist/esm/signers/signActions.js +9 -0
  50. package/dist/esm/signers/signActions.js.map +1 -1
  51. package/dist/esm/types/ws.d.ts +6 -5
  52. package/dist/esm/types/ws.d.ts.map +1 -1
  53. package/dist/esm/utils/apiClient.d.ts +34 -7
  54. package/dist/esm/utils/apiClient.d.ts.map +1 -1
  55. package/dist/esm/utils/apiClient.js +77 -14
  56. package/dist/esm/utils/apiClient.js.map +1 -1
  57. package/dist/esm/utils/mapMarketContext.js +2 -2
  58. package/dist/esm/utils/mapMarketContext.js.map +1 -1
  59. package/dist/types/LighterProvider.d.ts.map +1 -1
  60. package/dist/types/accountConfig.d.ts.map +1 -1
  61. package/dist/types/constants.d.ts +7 -1
  62. package/dist/types/constants.d.ts.map +1 -1
  63. package/dist/types/signers/LighterKeyStore.d.ts +4 -1
  64. package/dist/types/signers/LighterKeyStore.d.ts.map +1 -1
  65. package/dist/types/signers/LighterReadOnlyTokenManager.d.ts +12 -0
  66. package/dist/types/signers/LighterReadOnlyTokenManager.d.ts.map +1 -1
  67. package/dist/types/signers/signActions.d.ts.map +1 -1
  68. package/dist/types/types/ws.d.ts +6 -5
  69. package/dist/types/types/ws.d.ts.map +1 -1
  70. package/dist/types/utils/apiClient.d.ts +34 -7
  71. package/dist/types/utils/apiClient.d.ts.map +1 -1
  72. package/package.json +1 -1
  73. package/src/LighterProvider.ts +103 -138
  74. package/src/accountConfig.ts +2 -4
  75. package/src/constants.ts +8 -0
  76. package/src/signers/LighterKeyStore.ts +28 -3
  77. package/src/signers/LighterReadOnlyTokenManager.ts +39 -6
  78. package/src/signers/signActions.ts +10 -0
  79. package/src/types/ws.ts +6 -5
  80. package/src/utils/apiClient.ts +106 -13
  81. package/src/utils/mapMarketContext.ts +2 -2
@@ -7,6 +7,7 @@ import { PerpsErrorCode } from '@lifi/perps-types'
7
7
  import {
8
8
  LIGHTER_INVALID_AUTH_CODE,
9
9
  LIGHTER_SUCCESS_CODES,
10
+ LIGHTER_TOKEN_REVOKED_CODE,
10
11
  } from '../constants.js'
11
12
 
12
13
  /** @internal */
@@ -26,19 +27,27 @@ const lighterBodyErrorCode = (data: unknown): number | undefined => {
26
27
  }
27
28
 
28
29
  /**
29
- * Auth-gated read whose token Lighter rejected. Distinct from a generic
30
- * {@link PerpsError} so callers can evict the stored read-only token and retry
31
- * with a freshly-created one. Lighter signals this on either channel: an HTTP
32
- * 401/403, or an HTTP 200 carrying an error `code` in the body.
30
+ * Auth-gated read whose token format Lighter rejected.
31
+ *
33
32
  * @internal
34
33
  */
35
34
  export class LighterAuthRejectedError extends PerpsError {}
36
35
 
36
+ /**
37
+ * Auth-gated read whose read-only token Lighter reports as revoked.
38
+ *
39
+ * @internal
40
+ */
41
+ export class LighterTokenRevokedError extends PerpsError {}
42
+
37
43
  const isLighterAuthRejection = (status: number, data: unknown): boolean =>
38
44
  status === 401 ||
39
45
  status === 403 ||
40
46
  lighterBodyErrorCode(data) === LIGHTER_INVALID_AUTH_CODE
41
47
 
48
+ const isLighterTokenRevoked = (data: unknown): boolean =>
49
+ lighterBodyErrorCode(data) === LIGHTER_TOKEN_REVOKED_CODE
50
+
42
51
  /** @internal */
43
52
  export const LIGHTER_RETRY_DEFAULTS: ResolvedRetryPolicy = {
44
53
  enabled: true,
@@ -47,9 +56,6 @@ export const LIGHTER_RETRY_DEFAULTS: ResolvedRetryPolicy = {
47
56
  maxDelayMs: 60_000,
48
57
  respectRetryAfter: true,
49
58
  classify: ({ response }) => {
50
- if (response.status === 429 || response.status === 405) {
51
- return 'retry-rate-limit'
52
- }
53
59
  if (
54
60
  response.status === 502 ||
55
61
  response.status === 503 ||
@@ -61,11 +67,42 @@ export const LIGHTER_RETRY_DEFAULTS: ResolvedRetryPolicy = {
61
67
  },
62
68
  }
63
69
 
70
+ const LIGHTER_RATE_LIMIT_HOLD_MS = 60_000
71
+ const LIGHTER_RATE_LIMIT_HOLD_MAX_MS = 300_000
72
+
73
+ const isLighterRateLimit = (status: number): boolean =>
74
+ status === 429 || status === 405
75
+
76
+ const retryAfterMs = (header: string | null, nowMs: number): number => {
77
+ if (header === null) {
78
+ return LIGHTER_RATE_LIMIT_HOLD_MS
79
+ }
80
+ const seconds = Number(header)
81
+ if (Number.isFinite(seconds) && seconds >= 0) {
82
+ return Math.min(seconds * 1000, LIGHTER_RATE_LIMIT_HOLD_MAX_MS)
83
+ }
84
+ const dateMs = Date.parse(header)
85
+ return Number.isFinite(dateMs)
86
+ ? Math.min(Math.max(0, dateMs - nowMs), LIGHTER_RATE_LIMIT_HOLD_MAX_MS)
87
+ : LIGHTER_RATE_LIMIT_HOLD_MS
88
+ }
89
+
90
+ /**
91
+ * Mutable rate-limit deadline shared by the request clients for one provider
92
+ * instance. A client creates an isolated hold when the caller omits this.
93
+ *
94
+ * @internal
95
+ */
96
+ export interface LighterRateLimitHold {
97
+ untilMs: number
98
+ }
99
+
64
100
  /** @internal */
65
101
  export interface LighterApiClientOptions {
66
102
  signal?: AbortSignal
67
103
  policy?: ResolvedRetryPolicy
68
104
  fetchImpl?: typeof fetch
105
+ rateLimitHold?: LighterRateLimitHold
69
106
  }
70
107
 
71
108
  /**
@@ -82,9 +119,10 @@ export interface LighterApiClientOptions {
82
119
  * `Authorization` header. This matches Lighter's OpenAPI spec and lets the
83
120
  * same call work browser-direct and from server-side proxies.
84
121
  *
85
- * Lighter signals rate limiting via 429 OR 405 (documented behaviour) with a
86
- * documented 60s firewall cooldown. The default {@link ResolvedRetryPolicy}
87
- * waits long enough to avoid hammering through the cooldown.
122
+ * Lighter signals rate limits through HTTP 429 or HTTP 405. This client never
123
+ * retries such a response. It throws `RateLimitExceeded` and holds all network
124
+ * dispatch until `Retry-After` expires (capped at five minutes), or for 60
125
+ * seconds when the response has no valid header.
88
126
  * @public
89
127
  */
90
128
  export class LighterApiClient {
@@ -92,14 +130,57 @@ export class LighterApiClient {
92
130
  private readonly signal: AbortSignal | undefined
93
131
  private readonly policy: ResolvedRetryPolicy
94
132
  private readonly fetchImpl: typeof fetch | undefined
133
+ private readonly rateLimitHold: LighterRateLimitHold
134
+ private readonly fetchWithHold: typeof fetch
95
135
 
96
136
  constructor(baseUrl: string, options?: LighterApiClientOptions) {
97
137
  this.baseUrl = baseUrl.replace(/\/$/, '')
98
138
  this.signal = options?.signal
99
139
  this.policy = options?.policy ?? LIGHTER_RETRY_DEFAULTS
100
140
  this.fetchImpl = options?.fetchImpl
141
+ this.rateLimitHold = options?.rateLimitHold ?? { untilMs: 0 }
142
+ this.fetchWithHold = async (input, init) => {
143
+ this.assertRequestAllowed()
144
+ const response = await (this.fetchImpl ?? fetch)(input, init)
145
+ if (isLighterRateLimit(response.status)) {
146
+ const responseNowMs = Date.now()
147
+ this.rateLimitHold.untilMs = Math.max(
148
+ this.rateLimitHold.untilMs,
149
+ responseNowMs +
150
+ retryAfterMs(response.headers.get('Retry-After'), responseNowMs)
151
+ )
152
+ }
153
+ return response
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Send a single request through this client's rate-limit hold.
159
+ *
160
+ * @internal
161
+ */
162
+ request(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
163
+ return this.fetchWithHold(input, init)
164
+ }
165
+
166
+ private rateLimitError(): PerpsError {
167
+ return new PerpsError(
168
+ PerpsErrorCode.RateLimitExceeded,
169
+ `Lighter API requests are held until ${new Date(this.rateLimitHold.untilMs).toISOString()}`
170
+ )
101
171
  }
102
172
 
173
+ private assertRequestAllowed(): void {
174
+ if (Date.now() < this.rateLimitHold.untilMs) {
175
+ throw this.rateLimitError()
176
+ }
177
+ }
178
+
179
+ private assertNotRateLimited(response: Response): void {
180
+ if (isLighterRateLimit(response.status)) {
181
+ throw this.rateLimitError()
182
+ }
183
+ }
103
184
  private buildUrl(path: string, params?: ApiParams): string {
104
185
  const url = `${this.baseUrl}${path}`
105
186
  if (!params || Object.keys(params).length === 0) {
@@ -131,6 +212,12 @@ export class LighterApiClient {
131
212
  ...params,
132
213
  auth: authToken,
133
214
  })
215
+ if (isLighterTokenRevoked(data)) {
216
+ throw new LighterTokenRevokedError(
217
+ PerpsErrorCode.ThirdPartyError,
218
+ `Lighter reports a revoked auth token for ${path}`
219
+ )
220
+ }
134
221
  if (isLighterAuthRejection(status, data)) {
135
222
  throw new LighterAuthRejectedError(
136
223
  PerpsErrorCode.ThirdPartyError,
@@ -150,12 +237,18 @@ export class LighterApiClient {
150
237
  path: string,
151
238
  params?: ApiParams
152
239
  ): Promise<{ status: number; data: T }> {
240
+ this.assertRequestAllowed()
153
241
  const url = this.buildUrl(path, params)
154
242
  const response = await fetchWithRetry(
155
243
  url,
156
244
  {},
157
- { policy: this.policy, fetchImpl: this.fetchImpl, signal: this.signal }
245
+ {
246
+ policy: this.policy,
247
+ fetchImpl: this.fetchWithHold,
248
+ signal: this.signal,
249
+ }
158
250
  )
251
+ this.assertNotRateLimited(response)
159
252
  const data = (await response.json().catch(() => undefined)) as T
160
253
  return { status: response.status, data }
161
254
  }
@@ -174,13 +267,13 @@ export class LighterApiClient {
174
267
  for (const [k, v] of Object.entries(params)) {
175
268
  body.set(k, String(v))
176
269
  }
177
- const fetchImpl = this.fetchImpl ?? fetch
178
- const response = await fetchImpl(`${this.baseUrl}${path}`, {
270
+ const response = await this.fetchWithHold(`${this.baseUrl}${path}`, {
179
271
  method: 'POST',
180
272
  headers: { 'content-type': 'application/x-www-form-urlencoded' },
181
273
  body: body.toString(),
182
274
  signal: this.signal,
183
275
  })
276
+ this.assertNotRateLimited(response)
184
277
  const data = (await response.json().catch(() => undefined)) as T
185
278
  return { status: response.status, data }
186
279
  }
@@ -22,8 +22,8 @@ export const mapMarketContext = (
22
22
  midPrice: stats.mid_price,
23
23
  markPrice: stats.mark_price,
24
24
  oraclePrice: stats.index_price,
25
- priceChange24h: stats.daily_price_change,
26
- volume24h: stats.daily_quote_token_volume,
25
+ priceChange24h: String(stats.daily_price_change),
26
+ volume24h: String(stats.daily_quote_token_volume),
27
27
  openInterest: stats.open_interest,
28
28
  funding: {
29
29
  rate: stats.current_funding_rate,