@lifi/perps-sdk-provider-lighter 17.2.2 → 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.
Files changed (91) 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/utils/apiClient.d.ts +12 -0
  23. package/dist/cjs/utils/apiClient.d.ts.map +1 -1
  24. package/dist/cjs/utils/apiClient.js +62 -7
  25. package/dist/cjs/utils/apiClient.js.map +1 -1
  26. package/dist/cjs/utils/index.d.ts +1 -0
  27. package/dist/cjs/utils/index.d.ts.map +1 -1
  28. package/dist/cjs/utils/index.js +3 -1
  29. package/dist/cjs/utils/index.js.map +1 -1
  30. package/dist/cjs/utils/marketLimits.d.ts +7 -0
  31. package/dist/cjs/utils/marketLimits.d.ts.map +1 -0
  32. package/dist/cjs/utils/marketLimits.js +15 -0
  33. package/dist/cjs/utils/marketLimits.js.map +1 -0
  34. package/dist/esm/LighterProvider.d.ts.map +1 -1
  35. package/dist/esm/LighterProvider.js +78 -102
  36. package/dist/esm/LighterProvider.js.map +1 -1
  37. package/dist/esm/accountConfig.d.ts.map +1 -1
  38. package/dist/esm/accountConfig.js +2 -4
  39. package/dist/esm/accountConfig.js.map +1 -1
  40. package/dist/esm/constants.d.ts +7 -1
  41. package/dist/esm/constants.d.ts.map +1 -1
  42. package/dist/esm/constants.js +7 -1
  43. package/dist/esm/constants.js.map +1 -1
  44. package/dist/esm/signers/LighterKeyStore.d.ts +4 -1
  45. package/dist/esm/signers/LighterKeyStore.d.ts.map +1 -1
  46. package/dist/esm/signers/LighterKeyStore.js +15 -3
  47. package/dist/esm/signers/LighterKeyStore.js.map +1 -1
  48. package/dist/esm/signers/LighterReadOnlyTokenManager.d.ts +12 -0
  49. package/dist/esm/signers/LighterReadOnlyTokenManager.d.ts.map +1 -1
  50. package/dist/esm/signers/LighterReadOnlyTokenManager.js +23 -7
  51. package/dist/esm/signers/LighterReadOnlyTokenManager.js.map +1 -1
  52. package/dist/esm/signers/signActions.d.ts.map +1 -1
  53. package/dist/esm/signers/signActions.js +9 -0
  54. package/dist/esm/signers/signActions.js.map +1 -1
  55. package/dist/esm/utils/apiClient.d.ts +34 -7
  56. package/dist/esm/utils/apiClient.d.ts.map +1 -1
  57. package/dist/esm/utils/apiClient.js +77 -14
  58. package/dist/esm/utils/apiClient.js.map +1 -1
  59. package/dist/esm/utils/index.d.ts +1 -0
  60. package/dist/esm/utils/index.d.ts.map +1 -1
  61. package/dist/esm/utils/index.js +1 -0
  62. package/dist/esm/utils/index.js.map +1 -1
  63. package/dist/esm/utils/marketLimits.d.ts +8 -0
  64. package/dist/esm/utils/marketLimits.d.ts.map +1 -0
  65. package/dist/esm/utils/marketLimits.js +12 -0
  66. package/dist/esm/utils/marketLimits.js.map +1 -0
  67. package/dist/types/LighterProvider.d.ts.map +1 -1
  68. package/dist/types/accountConfig.d.ts.map +1 -1
  69. package/dist/types/constants.d.ts +7 -1
  70. package/dist/types/constants.d.ts.map +1 -1
  71. package/dist/types/signers/LighterKeyStore.d.ts +4 -1
  72. package/dist/types/signers/LighterKeyStore.d.ts.map +1 -1
  73. package/dist/types/signers/LighterReadOnlyTokenManager.d.ts +12 -0
  74. package/dist/types/signers/LighterReadOnlyTokenManager.d.ts.map +1 -1
  75. package/dist/types/signers/signActions.d.ts.map +1 -1
  76. package/dist/types/utils/apiClient.d.ts +34 -7
  77. package/dist/types/utils/apiClient.d.ts.map +1 -1
  78. package/dist/types/utils/index.d.ts +1 -0
  79. package/dist/types/utils/index.d.ts.map +1 -1
  80. package/dist/types/utils/marketLimits.d.ts +8 -0
  81. package/dist/types/utils/marketLimits.d.ts.map +1 -0
  82. package/package.json +2 -2
  83. package/src/LighterProvider.ts +103 -138
  84. package/src/accountConfig.ts +2 -4
  85. package/src/constants.ts +8 -0
  86. package/src/signers/LighterKeyStore.ts +28 -3
  87. package/src/signers/LighterReadOnlyTokenManager.ts +39 -6
  88. package/src/signers/signActions.ts +10 -0
  89. package/src/utils/apiClient.ts +106 -13
  90. package/src/utils/index.ts +1 -0
  91. package/src/utils/marketLimits.ts +28 -0
@@ -75,7 +75,10 @@ import {
75
75
  } from './constants.js'
76
76
  import { lighterDepositFlow } from './depositFlow.js'
77
77
  import { createAuthToken } from './signers/createAuthToken.js'
78
- import { LighterKeyStore } from './signers/LighterKeyStore.js'
78
+ import {
79
+ type LighterApiKey,
80
+ LighterKeyStore,
81
+ } from './signers/LighterKeyStore.js'
79
82
  import { LighterReadOnlyTokenManager } from './signers/LighterReadOnlyTokenManager.js'
80
83
  import { LighterSigner } from './signers/LighterSigner.js'
81
84
  import {
@@ -103,9 +106,8 @@ import {
103
106
  import {
104
107
  LIGHTER_RETRY_DEFAULTS,
105
108
  LighterApiClient,
106
- LighterAuthRejectedError,
109
+ LighterTokenRevokedError,
107
110
  } from './utils/apiClient.js'
108
- import { fetchAppliedReferralCode } from './utils/appliedReferralCode.js'
109
111
  import { isAssetMarginEnabled } from './utils/assetCollateral.js'
110
112
  import {
111
113
  classifyAndMapOrders,
@@ -311,11 +313,6 @@ export const createLighterProvider = (
311
313
  })
312
314
  const keyStore = new LighterKeyStore(storage, providerKey)
313
315
  const apiKeyFreshness = createLighterApiKeyFreshness()
314
- const readOnlyTokenManager = new LighterReadOnlyTokenManager({
315
- storage,
316
- providerKey,
317
- lighterApiUrl: restUrl,
318
- })
319
316
  const tokenLifetimeSeconds = options.tokenLifetimeSeconds ?? 60 * 60
320
317
  const tokenRenewBufferSeconds = options.tokenRenewBufferSeconds ?? 60
321
318
  const standardTokenByAddress: Map<string, CachedStandardToken> = new Map()
@@ -326,6 +323,7 @@ export const createLighterProvider = (
326
323
  const readOnlyCreationInFlight: Map<string, Promise<string>> = new Map()
327
324
  const readOnlyCreationBackoff: Map<string, ReadOnlyCreationBackoff> =
328
325
  new Map()
326
+ const rateLimitHold = { untilMs: 0 }
329
327
 
330
328
  const apiClient = (opts?: SDKRequestOptions): LighterApiClient => {
331
329
  const client = requireClient()
@@ -337,9 +335,17 @@ export const createLighterProvider = (
337
335
  providerKey
338
336
  ),
339
337
  fetchImpl: client.config.fetch,
338
+ rateLimitHold,
340
339
  })
341
340
  }
342
341
 
342
+ const readOnlyTokenManager = new LighterReadOnlyTokenManager({
343
+ storage,
344
+ providerKey,
345
+ lighterApiUrl: restUrl,
346
+ fetchImpl: (input, init) => apiClient().request(input, init),
347
+ })
348
+
343
349
  const getStandardAuthToken = async (
344
350
  address: Address,
345
351
  apiKeyPrivateKey: string,
@@ -385,7 +391,8 @@ export const createLighterProvider = (
385
391
  */
386
392
  const resolveAuthToken = async (
387
393
  opts: SDKRequestOptions | undefined,
388
- address?: Address
394
+ address?: Address,
395
+ knownApiKey?: LighterApiKey
389
396
  ): Promise<string | undefined> => {
390
397
  if (opts?.lighterAuthToken !== undefined) {
391
398
  return opts.lighterAuthToken
@@ -396,7 +403,7 @@ export const createLighterProvider = (
396
403
  if (address === undefined) {
397
404
  return undefined
398
405
  }
399
- const apiKey = await keyStore.get(address)
406
+ const apiKey = knownApiKey ?? (await keyStore.get(address))
400
407
  if (apiKey === null) {
401
408
  return undefined
402
409
  }
@@ -468,27 +475,60 @@ export const createLighterProvider = (
468
475
  return attempt
469
476
  }
470
477
 
471
- const evictResolvedTokens = async (address: Address): Promise<void> => {
472
- const cacheKey = address.toLowerCase()
473
- readOnlyCreationBackoff.delete(cacheKey)
474
- // The cached standard token may itself be the revoked credential — it
475
- // rides reads during the creation-failure fallback and authorises
476
- // read-only token creation — so a revocation must re-sign it too.
477
- standardTokenByAddress.delete(cacheKey)
478
+ const replaceRevokedReadOnlyToken = async (
479
+ address: Address,
480
+ revokedToken: string
481
+ ): Promise<string | undefined> => {
478
482
  const apiKey = await keyStore.get(address)
479
- if (apiKey !== null) {
480
- await readOnlyTokenManager.remove(address, apiKey.accountIndex)
483
+ if (apiKey === null) {
484
+ return undefined
485
+ }
486
+ const flightKey = address.toLowerCase()
487
+ const activeFlight = readOnlyCreationInFlight.get(flightKey)
488
+ if (activeFlight !== undefined) {
489
+ return activeFlight
490
+ }
491
+ const current = await readOnlyTokenManager.get(address, apiKey.accountIndex)
492
+ if (current !== undefined && current.token !== revokedToken) {
493
+ return current.token
494
+ }
495
+ const inFlightAfterRead = readOnlyCreationInFlight.get(flightKey)
496
+ if (inFlightAfterRead !== undefined) {
497
+ return inFlightAfterRead
498
+ }
499
+ const attempt = (async (): Promise<string> => {
500
+ const standardToken = await getStandardAuthToken(
501
+ address,
502
+ apiKey.apiKeyPrivateKey,
503
+ {
504
+ apiKeyIndex: apiKey.apiKeyIndex,
505
+ accountIndex: apiKey.accountIndex,
506
+ }
507
+ )
508
+ const { token } = await readOnlyTokenManager.replaceRevoked(
509
+ standardToken,
510
+ {
511
+ address,
512
+ accountIndex: apiKey.accountIndex,
513
+ expirySeconds:
514
+ Math.floor(Date.now() / 1000) +
515
+ DEFAULT_READ_ONLY_TOKEN_LIFETIME_SECONDS,
516
+ scope: 'all',
517
+ }
518
+ )
519
+ readOnlyCreationBackoff.delete(flightKey)
520
+ return token.token
521
+ })()
522
+ readOnlyCreationInFlight.set(flightKey, attempt)
523
+ try {
524
+ return await attempt
525
+ } finally {
526
+ if (readOnlyCreationInFlight.get(flightKey) === attempt) {
527
+ readOnlyCreationInFlight.delete(flightKey)
528
+ }
481
529
  }
482
530
  }
483
531
 
484
- /**
485
- * Run an auth-gated read; if Lighter rejects the token (revoked server-side —
486
- * invisible to `checkSetup`, since the read-only token is a client-only
487
- * concern), evict the SDK-resolved credentials (stored read-only token and
488
- * cached standard token) and retry once with freshly resolved ones. Only
489
- * self-heals tokens the SDK itself resolved — a caller-supplied
490
- * `lighterAuthToken`/`authToken` source is the caller's to fix.
491
- */
492
532
  const retryOnRevoked = async <T>(
493
533
  opts: SDKRequestOptions | undefined,
494
534
  address: Address,
@@ -500,11 +540,10 @@ export const createLighterProvider = (
500
540
  } catch (err) {
501
541
  const sdkOwnsToken =
502
542
  opts?.lighterAuthToken === undefined && authTokenSource === undefined
503
- if (!(err instanceof LighterAuthRejectedError) || !sdkOwnsToken) {
543
+ if (!(err instanceof LighterTokenRevokedError) || !sdkOwnsToken) {
504
544
  throw err
505
545
  }
506
- await evictResolvedTokens(address)
507
- const fresh = await resolveAuthToken(opts, address)
546
+ const fresh = await replaceRevokedReadOnlyToken(address, token)
508
547
  if (fresh === undefined || fresh === token) {
509
548
  throw err
510
549
  }
@@ -512,38 +551,6 @@ export const createLighterProvider = (
512
551
  }
513
552
  }
514
553
 
515
- /**
516
- * Degrade an auth-gated `getAccount` read to `undefined` when Lighter
517
- * rejects an SDK-owned token even after `retryOnRevoked` re-resolved it —
518
- * the signature of a stale local API key (the venue's slot was
519
- * re-registered elsewhere). `getAccount` must resolve in that state so
520
- * `apiKeyRegistered: false` can render the REGISTER_API_KEY gate, which is
521
- * the only recovery. A caller-supplied token stays the caller's to fix, so
522
- * its rejection propagates, as does every other error class.
523
- */
524
- const degradeOnAuthRejection = async <T>(
525
- opts: SDKRequestOptions | undefined,
526
- endpoint: string,
527
- read: Promise<T>
528
- ): Promise<T | undefined> => {
529
- try {
530
- return await read
531
- } catch (err) {
532
- const sdkOwnsToken =
533
- opts?.lighterAuthToken === undefined && authTokenSource === undefined
534
- if (err instanceof LighterAuthRejectedError && sdkOwnsToken) {
535
- console.warn(
536
- `[lighter] ${endpoint} rejected the auth token after re-resolution; ` +
537
- 'degrading the read — the stored API key no longer matches the ' +
538
- 'registered key, and the REGISTER_API_KEY gate will surface it.',
539
- err
540
- )
541
- return undefined
542
- }
543
- throw err
544
- }
545
- }
546
-
547
554
  const fetchAccountLimits = (
548
555
  client: LighterApiClient,
549
556
  accountIndex: number,
@@ -702,86 +709,46 @@ export const createLighterProvider = (
702
709
  opts?: SDKRequestOptions
703
710
  ): Promise<AccountResponse> {
704
711
  const client = apiClient(opts)
705
- const [account, token] = await Promise.all([
706
- fetchDetailedAccount(client, params.address),
707
- resolveAuthToken(opts, params.address),
708
- ])
709
-
710
- const registry = getMarketRegistry(requireClient(), providerKey)
711
- const assetRegistry = getAssetRegistry(requireClient(), providerKey)
712
- // Shared promise: the referral read below awaits this instance's runtime
713
- // metadata off the same `/providers` fetch — no second request.
714
- const providersPromise = getProviders(requireClient())
715
- // Shared promise: the registered-key read names the slot off the local
716
- // record, and the config readout reports the record — one storage read.
717
- const localKeyPromise = keyStore.get(params.address)
718
- const [
719
- { providers },
720
- ,
721
- ,
722
- registeredKey,
723
- limitsResult,
724
- localKey,
725
- storedReadOnlyToken,
726
- appliedReferralCode,
727
- ] = await Promise.all([
728
- providersPromise,
729
- registry.sync(),
730
- assetRegistry.sync(),
731
- // The local record names the only slot worth reading: no record means
732
- // nothing to compare the registered key against, so the read is skipped.
733
- localKeyPromise.then((key) =>
734
- key === null
735
- ? undefined
736
- : fetchRegisteredApiKey(client, account.index, key.apiKeyIndex)
737
- ),
738
- // No token is a legitimate unauthenticated read → undefined → zero fee
739
- // tier. A generic fetch error is NOT: it must propagate, never be
740
- // coerced to a fabricated 0%/0% fee tier. One carve-out: a token the
741
- // venue rejects even after re-resolution (stale local API key) degrades
742
- // to undefined, so the REGISTER_API_KEY gate can render the recovery.
743
- token === undefined
712
+ const accountPromise = fetchDetailedAccount(client, params.address)
713
+ const localKey = await keyStore.get(params.address)
714
+ const registeredKeyPromise =
715
+ localKey === null
744
716
  ? Promise.resolve(undefined)
745
- : degradeOnAuthRejection(
746
- opts,
747
- '/api/v1/accountLimits',
748
- retryOnRevoked(opts, params.address, token, (t) =>
749
- fetchAccountLimits(client, account.index, t)
750
- )
751
- ),
752
- localKeyPromise,
753
- readOnlyTokenManager.get(params.address, account.index),
754
- // The expected code is backend-owned runtime metadata on this
755
- // instance's own provider descriptor (keyed by `providerKey`, so the
756
- // RH instance never compares against mainnet attribution). Metadata
757
- // without a code, or no token to authenticate the read → undefined →
758
- // `referralPresent: false`. The read stays SDK-direct: the token only
759
- // ever goes to Lighter, never to the LI.FI backend.
760
- token === undefined
761
- ? Promise.resolve(undefined)
762
- : providersPromise.then((response) =>
763
- response.providers.find((p) => p.key === providerKey)
764
- ?.referralCode
765
- ? degradeOnAuthRejection(
766
- opts,
767
- '/api/v1/referral/userReferrals',
768
- retryOnRevoked(opts, params.address, token, (t) =>
769
- fetchAppliedReferralCode(client, params.address, t)
770
- )
771
- )
772
- : undefined
773
- ),
717
+ : fetchRegisteredApiKey(
718
+ client,
719
+ localKey.accountIndex,
720
+ localKey.apiKeyIndex
721
+ )
722
+ const [account, registeredKey] = await Promise.all([
723
+ accountPromise,
724
+ registeredKeyPromise,
774
725
  ])
775
726
 
776
- // REGISTER_API_KEY is satisfied only when the locally-held keypair
777
- // matches the key registered on-chain at this slot — existence alone is
778
- // insufficient (a stale/rotated local key can't sign valid auth tokens).
779
727
  const apiKeyRegistered =
780
728
  registeredKey !== undefined &&
781
729
  localKey !== null &&
782
730
  normalizeLighterPublicKey(localKey.apiKeyPublicKey) ===
783
731
  normalizeLighterPublicKey(registeredKey.public_key)
732
+ const token = apiKeyRegistered
733
+ ? await resolveAuthToken(opts, params.address, localKey)
734
+ : undefined
784
735
 
736
+ const registry = getMarketRegistry(requireClient(), providerKey)
737
+ const assetRegistry = getAssetRegistry(requireClient(), providerKey)
738
+ const [{ providers }, , , limitsResult, storedReadOnlyToken] =
739
+ await Promise.all([
740
+ getProviders(requireClient()),
741
+ registry.sync(),
742
+ assetRegistry.sync(),
743
+ token === undefined
744
+ ? Promise.resolve(undefined)
745
+ : retryOnRevoked(opts, params.address, token, (resolvedToken) =>
746
+ fetchAccountLimits(client, account.index, resolvedToken)
747
+ ),
748
+ apiKeyRegistered
749
+ ? readOnlyTokenManager.get(params.address, localKey.accountIndex)
750
+ : Promise.resolve(undefined),
751
+ ])
785
752
  const positions: Position[] = mapOpenPositions(account.positions, (id) =>
786
753
  toPerpsMarketDisplay(registry.require(String(id)))
787
754
  )
@@ -868,12 +835,10 @@ export const createLighterProvider = (
868
835
  readOnlyTokenApproved: storedReadOnlyToken !== undefined,
869
836
  readOnlyTokenExpiry: storedReadOnlyToken?.expiry,
870
837
  readOnlyTokenScope: storedReadOnlyToken?.scope,
871
- // True only when the authenticated `used_code` equals the backend-owned
872
- // code for this instance; `appliedReferralCode` is only ever fetched
873
- // when that code exists.
874
838
  referralPresent:
875
- appliedReferralCode !== undefined &&
876
- appliedReferralCode === instanceMeta?.referralCode,
839
+ apiKeyRegistered &&
840
+ instanceMeta?.referralCode !== undefined &&
841
+ localKey?.appliedReferralCode === instanceMeta.referralCode,
877
842
  }
878
843
 
879
844
  return {
@@ -104,10 +104,8 @@ function projectLighterDescriptor(
104
104
  satisfied: config.readOnlyTokenApproved,
105
105
  }
106
106
 
107
- // `SET_REFERRAL` is satisfied when LI.FI's referral code is the one applied
108
- // to the account — an SDK-direct read (`getAccount` → `referralPresent`),
109
- // since the applied referral is a per-user, auth-gated read. Lighter referral
110
- // is mutable, so an account on another integrator's code stays gateable.
107
+ // `SET_REFERRAL` persists its confirmed code beside the local API key.
108
+ // The account read compares that marker with this instance's runtime code.
111
109
  case ActionType.SET_REFERRAL:
112
110
  return {
113
111
  type: descriptor.type,
package/src/constants.ts CHANGED
@@ -228,6 +228,13 @@ const LIGHTER_CODE_ACCOUNT_NOT_FOUND = 21100
228
228
  */
229
229
  const LIGHTER_INVALID_AUTH_CODE = 20013
230
230
 
231
+ /**
232
+ * Lighter body `code` for a read-only auth token revoked at the venue.
233
+ *
234
+ * @internal
235
+ */
236
+ const LIGHTER_TOKEN_REVOKED_CODE = 61006
237
+
231
238
  /**
232
239
  * Lighter body `code` values that mean success. Lighter is inconsistent per
233
240
  * endpoint: `/api/v1/account` returns `code: 200`, most others return `code: 0`.
@@ -251,4 +258,5 @@ export {
251
258
  LIGHTER_INVALID_AUTH_CODE,
252
259
  LIGHTER_MUTATION_SUCCESS_CODE,
253
260
  LIGHTER_SUCCESS_CODES,
261
+ LIGHTER_TOKEN_REVOKED_CODE,
254
262
  }
@@ -1,5 +1,9 @@
1
- import { readValidatedRecord, type StorageAdapter } from '@lifi/perps-sdk'
2
- import type { LighterProviderKey } from '@lifi/perps-types'
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
- const { address, accountIndex, expirySeconds, scope } = inputs
408
+ await this.revokeStaleOwnTokens(
409
+ authorization,
410
+ inputs.address,
411
+ inputs.accountIndex
412
+ )
413
+ return this.createAndPersist(authorization, inputs)
414
+ }
402
415
 
403
- await this.revokeStaleOwnTokens(authorization, address, accountIndex)
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
  /**