@lifi/perps-sdk-provider-lighter 17.3.0 → 18.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.
- package/README.md +8 -0
- package/dist/cjs/LighterProvider.d.ts.map +1 -1
- package/dist/cjs/LighterProvider.js +76 -57
- package/dist/cjs/LighterProvider.js.map +1 -1
- package/dist/cjs/accountConfig.d.ts.map +1 -1
- package/dist/cjs/accountConfig.js.map +1 -1
- package/dist/cjs/constants.d.ts +2 -1
- package/dist/cjs/constants.d.ts.map +1 -1
- package/dist/cjs/constants.js +3 -1
- package/dist/cjs/constants.js.map +1 -1
- package/dist/cjs/signers/LighterKeyStore.d.ts +3 -1
- package/dist/cjs/signers/LighterKeyStore.d.ts.map +1 -1
- package/dist/cjs/signers/LighterKeyStore.js +14 -2
- package/dist/cjs/signers/LighterKeyStore.js.map +1 -1
- package/dist/cjs/signers/LighterReadOnlyTokenManager.d.ts +7 -0
- package/dist/cjs/signers/LighterReadOnlyTokenManager.d.ts.map +1 -1
- package/dist/cjs/signers/LighterReadOnlyTokenManager.js +19 -7
- package/dist/cjs/signers/LighterReadOnlyTokenManager.js.map +1 -1
- package/dist/cjs/signers/signActions.d.ts.map +1 -1
- package/dist/cjs/signers/signActions.js +8 -0
- package/dist/cjs/signers/signActions.js.map +1 -1
- package/dist/cjs/utils/apiClient.d.ts +12 -0
- package/dist/cjs/utils/apiClient.d.ts.map +1 -1
- package/dist/cjs/utils/apiClient.js +62 -7
- package/dist/cjs/utils/apiClient.js.map +1 -1
- package/dist/esm/LighterProvider.d.ts.map +1 -1
- package/dist/esm/LighterProvider.js +78 -102
- package/dist/esm/LighterProvider.js.map +1 -1
- package/dist/esm/accountConfig.d.ts.map +1 -1
- package/dist/esm/accountConfig.js +2 -4
- package/dist/esm/accountConfig.js.map +1 -1
- package/dist/esm/constants.d.ts +7 -1
- package/dist/esm/constants.d.ts.map +1 -1
- package/dist/esm/constants.js +7 -1
- package/dist/esm/constants.js.map +1 -1
- package/dist/esm/signers/LighterKeyStore.d.ts +4 -1
- package/dist/esm/signers/LighterKeyStore.d.ts.map +1 -1
- package/dist/esm/signers/LighterKeyStore.js +15 -3
- package/dist/esm/signers/LighterKeyStore.js.map +1 -1
- package/dist/esm/signers/LighterReadOnlyTokenManager.d.ts +12 -0
- package/dist/esm/signers/LighterReadOnlyTokenManager.d.ts.map +1 -1
- package/dist/esm/signers/LighterReadOnlyTokenManager.js +23 -7
- package/dist/esm/signers/LighterReadOnlyTokenManager.js.map +1 -1
- package/dist/esm/signers/signActions.d.ts.map +1 -1
- package/dist/esm/signers/signActions.js +9 -0
- package/dist/esm/signers/signActions.js.map +1 -1
- package/dist/esm/utils/apiClient.d.ts +34 -7
- package/dist/esm/utils/apiClient.d.ts.map +1 -1
- package/dist/esm/utils/apiClient.js +77 -14
- package/dist/esm/utils/apiClient.js.map +1 -1
- package/dist/types/LighterProvider.d.ts.map +1 -1
- package/dist/types/accountConfig.d.ts.map +1 -1
- package/dist/types/constants.d.ts +7 -1
- package/dist/types/constants.d.ts.map +1 -1
- package/dist/types/signers/LighterKeyStore.d.ts +4 -1
- package/dist/types/signers/LighterKeyStore.d.ts.map +1 -1
- package/dist/types/signers/LighterReadOnlyTokenManager.d.ts +12 -0
- package/dist/types/signers/LighterReadOnlyTokenManager.d.ts.map +1 -1
- package/dist/types/signers/signActions.d.ts.map +1 -1
- package/dist/types/utils/apiClient.d.ts +34 -7
- package/dist/types/utils/apiClient.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/LighterProvider.ts +103 -138
- package/src/accountConfig.ts +2 -4
- package/src/constants.ts +8 -0
- package/src/signers/LighterKeyStore.ts +28 -3
- package/src/signers/LighterReadOnlyTokenManager.ts +39 -6
- package/src/signers/signActions.ts +10 -0
- package/src/utils/apiClient.ts +106 -13
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
PerpsError,
|
|
3
|
+
readValidatedRecord,
|
|
4
|
+
type StorageAdapter,
|
|
5
|
+
} from '@lifi/perps-sdk'
|
|
6
|
+
import { type LighterProviderKey, PerpsErrorCode } from '@lifi/perps-types'
|
|
3
7
|
import type { Address } from 'viem'
|
|
4
8
|
import { LIGHTER_PROVIDER_KEY } from '../constants.js'
|
|
5
9
|
|
|
@@ -30,6 +34,8 @@ export interface LighterApiKey {
|
|
|
30
34
|
apiKeyPrivateKey: string
|
|
31
35
|
/** Corresponding public key, registered via ChangePubKey. */
|
|
32
36
|
apiKeyPublicKey: string
|
|
37
|
+
/** Referral code confirmed by the standard-token SET_REFERRAL action. */
|
|
38
|
+
appliedReferralCode?: string
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
const isLighterApiKeyOf =
|
|
@@ -44,6 +50,7 @@ const isLighterApiKeyOf =
|
|
|
44
50
|
apiKeyIndex,
|
|
45
51
|
apiKeyPrivateKey,
|
|
46
52
|
apiKeyPublicKey,
|
|
53
|
+
appliedReferralCode,
|
|
47
54
|
} = value as Record<string, unknown>
|
|
48
55
|
return (
|
|
49
56
|
providerKey === owner &&
|
|
@@ -54,7 +61,10 @@ const isLighterApiKeyOf =
|
|
|
54
61
|
typeof apiKeyPrivateKey === 'string' &&
|
|
55
62
|
apiKeyPrivateKey.length > 0 &&
|
|
56
63
|
typeof apiKeyPublicKey === 'string' &&
|
|
57
|
-
apiKeyPublicKey.length > 0
|
|
64
|
+
apiKeyPublicKey.length > 0 &&
|
|
65
|
+
(appliedReferralCode === undefined ||
|
|
66
|
+
(typeof appliedReferralCode === 'string' &&
|
|
67
|
+
appliedReferralCode.length > 0))
|
|
58
68
|
)
|
|
59
69
|
}
|
|
60
70
|
|
|
@@ -140,6 +150,21 @@ export class LighterKeyStore {
|
|
|
140
150
|
await this.storage.set(key, JSON.stringify(record))
|
|
141
151
|
}
|
|
142
152
|
|
|
153
|
+
async markReferralApplied(
|
|
154
|
+
address: Address,
|
|
155
|
+
appliedReferralCode: string
|
|
156
|
+
): Promise<void> {
|
|
157
|
+
const current = await this.get(address)
|
|
158
|
+
if (current === null) {
|
|
159
|
+
throw new PerpsError(
|
|
160
|
+
PerpsErrorCode.SDKError,
|
|
161
|
+
`Cannot persist the Lighter referral marker without an API key for ${address}`
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
const { providerKey: _providerKey, ...value } = current
|
|
165
|
+
await this.set(address, { ...value, appliedReferralCode })
|
|
166
|
+
}
|
|
167
|
+
|
|
143
168
|
/**
|
|
144
169
|
* Remove the persisted API key for an L1 address and clear its in-memory
|
|
145
170
|
* cache entry.
|
|
@@ -75,6 +75,7 @@ export type LighterTokenFetcher = (params: {
|
|
|
75
75
|
expiry: number
|
|
76
76
|
subAccountAccess: boolean
|
|
77
77
|
scopes: string
|
|
78
|
+
fetchImpl?: typeof fetch
|
|
78
79
|
}) => Promise<LighterCreateTokenResponse>
|
|
79
80
|
|
|
80
81
|
/**
|
|
@@ -127,6 +128,7 @@ export type LighterTokenListFetcher = (params: {
|
|
|
127
128
|
url: string
|
|
128
129
|
authorization: string
|
|
129
130
|
accountIndex: number
|
|
131
|
+
fetchImpl?: typeof fetch
|
|
130
132
|
}) => Promise<LighterListTokensResponse>
|
|
131
133
|
|
|
132
134
|
/**
|
|
@@ -139,6 +141,7 @@ export type LighterTokenRevokeFetcher = (params: {
|
|
|
139
141
|
authorization: string
|
|
140
142
|
tokenId: number
|
|
141
143
|
accountIndex: number
|
|
144
|
+
fetchImpl?: typeof fetch
|
|
142
145
|
}) => Promise<LighterRevokeTokenResponse>
|
|
143
146
|
|
|
144
147
|
/**
|
|
@@ -165,6 +168,8 @@ export interface LighterReadOnlyTokenManagerOptions {
|
|
|
165
168
|
listFetcher?: LighterTokenListFetcher
|
|
166
169
|
/** Override the revoke POST. Defaults to a `fetch`-based implementation. */
|
|
167
170
|
revokeFetcher?: LighterTokenRevokeFetcher
|
|
171
|
+
/** Transport used by the default endpoint fetchers. Defaults to `fetch`. */
|
|
172
|
+
fetchImpl?: typeof fetch
|
|
168
173
|
/** Clock injection for testing expiry logic. Defaults to `Date.now`. */
|
|
169
174
|
now?: () => number
|
|
170
175
|
}
|
|
@@ -259,6 +264,7 @@ export class LighterReadOnlyTokenManager {
|
|
|
259
264
|
private readonly fetcher: LighterTokenFetcher
|
|
260
265
|
private readonly listFetcher: LighterTokenListFetcher
|
|
261
266
|
private readonly revokeFetcher: LighterTokenRevokeFetcher
|
|
267
|
+
private readonly fetchImpl: typeof fetch | undefined
|
|
262
268
|
private readonly now: () => number
|
|
263
269
|
private readonly cache = new Map<string, LighterReadOnlyToken>()
|
|
264
270
|
|
|
@@ -270,6 +276,7 @@ export class LighterReadOnlyTokenManager {
|
|
|
270
276
|
this.listFetcher = options.listFetcher ?? defaultLighterTokenListFetcher
|
|
271
277
|
this.revokeFetcher =
|
|
272
278
|
options.revokeFetcher ?? defaultLighterTokenRevokeFetcher
|
|
279
|
+
this.fetchImpl = options.fetchImpl
|
|
273
280
|
this.now = options.now ?? (() => Date.now())
|
|
274
281
|
}
|
|
275
282
|
|
|
@@ -398,10 +405,31 @@ export class LighterReadOnlyTokenManager {
|
|
|
398
405
|
authorization: string,
|
|
399
406
|
inputs: ApproveReadOnlyTokenInputs
|
|
400
407
|
): Promise<ApproveReadOnlyTokenResult> {
|
|
401
|
-
|
|
408
|
+
await this.revokeStaleOwnTokens(
|
|
409
|
+
authorization,
|
|
410
|
+
inputs.address,
|
|
411
|
+
inputs.accountIndex
|
|
412
|
+
)
|
|
413
|
+
return this.createAndPersist(authorization, inputs)
|
|
414
|
+
}
|
|
402
415
|
|
|
403
|
-
|
|
416
|
+
/**
|
|
417
|
+
* Replace a token that Lighter explicitly reports as revoked.
|
|
418
|
+
* The venue already revoked the row, so this path skips registry cleanup.
|
|
419
|
+
*/
|
|
420
|
+
async replaceRevoked(
|
|
421
|
+
authorization: string,
|
|
422
|
+
inputs: ApproveReadOnlyTokenInputs
|
|
423
|
+
): Promise<ApproveReadOnlyTokenResult> {
|
|
424
|
+
await this.remove(inputs.address, inputs.accountIndex)
|
|
425
|
+
return this.createAndPersist(authorization, inputs)
|
|
426
|
+
}
|
|
404
427
|
|
|
428
|
+
private async createAndPersist(
|
|
429
|
+
authorization: string,
|
|
430
|
+
inputs: ApproveReadOnlyTokenInputs
|
|
431
|
+
): Promise<ApproveReadOnlyTokenResult> {
|
|
432
|
+
const { address, accountIndex, expirySeconds, scope } = inputs
|
|
405
433
|
const response = await this.fetcher({
|
|
406
434
|
url: `${this.lighterApiUrl}/api/v1/tokens/create`,
|
|
407
435
|
authorization,
|
|
@@ -410,6 +438,7 @@ export class LighterReadOnlyTokenManager {
|
|
|
410
438
|
expiry: expirySeconds,
|
|
411
439
|
subAccountAccess: scope === 'all',
|
|
412
440
|
scopes: 'read.*',
|
|
441
|
+
fetchImpl: this.fetchImpl,
|
|
413
442
|
})
|
|
414
443
|
|
|
415
444
|
const token: LighterReadOnlyToken = {
|
|
@@ -457,6 +486,7 @@ export class LighterReadOnlyTokenManager {
|
|
|
457
486
|
url: `${this.lighterApiUrl}/api/v1/tokens`,
|
|
458
487
|
authorization,
|
|
459
488
|
accountIndex,
|
|
489
|
+
fetchImpl: this.fetchImpl,
|
|
460
490
|
})
|
|
461
491
|
const nowSeconds = Math.floor(this.now() / 1000)
|
|
462
492
|
const stale = rows
|
|
@@ -476,6 +506,7 @@ export class LighterReadOnlyTokenManager {
|
|
|
476
506
|
authorization,
|
|
477
507
|
tokenId: row.token_id,
|
|
478
508
|
accountIndex,
|
|
509
|
+
fetchImpl: this.fetchImpl,
|
|
479
510
|
})
|
|
480
511
|
} catch (err) {
|
|
481
512
|
console.warn(
|
|
@@ -508,6 +539,7 @@ export const defaultLighterTokenFetcher: LighterTokenFetcher = async ({
|
|
|
508
539
|
expiry,
|
|
509
540
|
subAccountAccess,
|
|
510
541
|
scopes,
|
|
542
|
+
fetchImpl,
|
|
511
543
|
}) => {
|
|
512
544
|
const form = new FormData()
|
|
513
545
|
form.set('name', name)
|
|
@@ -516,7 +548,7 @@ export const defaultLighterTokenFetcher: LighterTokenFetcher = async ({
|
|
|
516
548
|
form.set('sub_account_access', String(subAccountAccess))
|
|
517
549
|
form.set('scopes', scopes)
|
|
518
550
|
|
|
519
|
-
const response = await fetch(url, {
|
|
551
|
+
const response = await (fetchImpl ?? fetch)(url, {
|
|
520
552
|
method: 'POST',
|
|
521
553
|
headers: { authorization },
|
|
522
554
|
body: form,
|
|
@@ -544,10 +576,11 @@ export const defaultLighterTokenListFetcher: LighterTokenListFetcher = async ({
|
|
|
544
576
|
url,
|
|
545
577
|
authorization,
|
|
546
578
|
accountIndex,
|
|
579
|
+
fetchImpl,
|
|
547
580
|
}) => {
|
|
548
581
|
const query = new URLSearchParams({ account_index: String(accountIndex) })
|
|
549
582
|
|
|
550
|
-
const response = await fetch(`${url}?${query.toString()}`, {
|
|
583
|
+
const response = await (fetchImpl ?? fetch)(`${url}?${query.toString()}`, {
|
|
551
584
|
headers: { authorization },
|
|
552
585
|
})
|
|
553
586
|
|
|
@@ -570,13 +603,13 @@ export const defaultLighterTokenListFetcher: LighterTokenListFetcher = async ({
|
|
|
570
603
|
* @internal
|
|
571
604
|
*/
|
|
572
605
|
export const defaultLighterTokenRevokeFetcher: LighterTokenRevokeFetcher =
|
|
573
|
-
async ({ url, authorization, tokenId, accountIndex }) => {
|
|
606
|
+
async ({ url, authorization, tokenId, accountIndex, fetchImpl }) => {
|
|
574
607
|
const form = new URLSearchParams({
|
|
575
608
|
token_id: String(tokenId),
|
|
576
609
|
account_index: String(accountIndex),
|
|
577
610
|
})
|
|
578
611
|
|
|
579
|
-
const response = await fetch(url, {
|
|
612
|
+
const response = await (fetchImpl ?? fetch)(url, {
|
|
580
613
|
method: 'POST',
|
|
581
614
|
headers: {
|
|
582
615
|
authorization,
|
|
@@ -304,11 +304,14 @@ async function signRegisterApiKey(
|
|
|
304
304
|
l1Signature
|
|
305
305
|
)
|
|
306
306
|
|
|
307
|
+
// The referral marker belongs to the L1 address, not to the key slot.
|
|
308
|
+
const previousKey = await deps.keyStore.get(address)
|
|
307
309
|
await deps.keyStore.set(address, {
|
|
308
310
|
accountIndex,
|
|
309
311
|
apiKeyIndex,
|
|
310
312
|
apiKeyPrivateKey: keypair.privateKey,
|
|
311
313
|
apiKeyPublicKey: keypair.publicKey,
|
|
314
|
+
appliedReferralCode: previousKey?.appliedReferralCode,
|
|
312
315
|
})
|
|
313
316
|
|
|
314
317
|
return {
|
|
@@ -461,12 +464,15 @@ async function executeTokenAuthMutation(
|
|
|
461
464
|
accountIndex: apiKey.accountIndex,
|
|
462
465
|
})
|
|
463
466
|
|
|
467
|
+
let appliedReferralCode: string | undefined
|
|
464
468
|
if (step.wasmSignParams.kind === 'referralUse') {
|
|
465
469
|
const { referral_code } = step.wasmSignParams as { referral_code?: string }
|
|
470
|
+
appliedReferralCode = referral_code
|
|
466
471
|
if (
|
|
467
472
|
typeof referral_code === 'string' &&
|
|
468
473
|
(await isReferralAlreadyApplied(deps, address, authToken, referral_code))
|
|
469
474
|
) {
|
|
475
|
+
await deps.keyStore.markReferralApplied(address, referral_code)
|
|
470
476
|
return
|
|
471
477
|
}
|
|
472
478
|
}
|
|
@@ -485,6 +491,10 @@ async function executeTokenAuthMutation(
|
|
|
485
491
|
`Lighter ${step.action} rejected (code ${code ?? status})${suffix}`
|
|
486
492
|
)
|
|
487
493
|
}
|
|
494
|
+
|
|
495
|
+
if (appliedReferralCode !== undefined) {
|
|
496
|
+
await deps.keyStore.markReferralApplied(address, appliedReferralCode)
|
|
497
|
+
}
|
|
488
498
|
}
|
|
489
499
|
|
|
490
500
|
/**
|
package/src/utils/apiClient.ts
CHANGED
|
@@ -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.
|
|
30
|
-
*
|
|
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
|
|
86
|
-
*
|
|
87
|
-
*
|
|
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
|
-
{
|
|
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
|
|
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
|
}
|