@lifi/perps-sdk-provider-lighter 2.0.0 → 2.2.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 (45) hide show
  1. package/dist/cjs/LighterProvider.d.ts +1 -0
  2. package/dist/cjs/LighterProvider.d.ts.map +1 -1
  3. package/dist/cjs/LighterProvider.js +25 -8
  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 +5 -0
  7. package/dist/cjs/accountConfig.js.map +1 -1
  8. package/dist/cjs/accountSummary.js +1 -1
  9. package/dist/cjs/accountSummary.js.map +1 -1
  10. package/dist/cjs/types/ws.d.ts +7 -0
  11. package/dist/cjs/types/ws.d.ts.map +1 -1
  12. package/dist/cjs/websocket/LighterWsProvider.d.ts +1 -0
  13. package/dist/cjs/websocket/LighterWsProvider.d.ts.map +1 -1
  14. package/dist/cjs/websocket/LighterWsProvider.js +41 -0
  15. package/dist/cjs/websocket/LighterWsProvider.js.map +1 -1
  16. package/dist/esm/LighterProvider.d.ts +7 -0
  17. package/dist/esm/LighterProvider.d.ts.map +1 -1
  18. package/dist/esm/LighterProvider.js +30 -8
  19. package/dist/esm/LighterProvider.js.map +1 -1
  20. package/dist/esm/accountConfig.d.ts.map +1 -1
  21. package/dist/esm/accountConfig.js +11 -2
  22. package/dist/esm/accountConfig.js.map +1 -1
  23. package/dist/esm/accountSummary.d.ts +3 -3
  24. package/dist/esm/accountSummary.js +4 -4
  25. package/dist/esm/accountSummary.js.map +1 -1
  26. package/dist/esm/types/ws.d.ts +11 -1
  27. package/dist/esm/types/ws.d.ts.map +1 -1
  28. package/dist/esm/websocket/LighterWsProvider.d.ts +1 -0
  29. package/dist/esm/websocket/LighterWsProvider.d.ts.map +1 -1
  30. package/dist/esm/websocket/LighterWsProvider.js +44 -0
  31. package/dist/esm/websocket/LighterWsProvider.js.map +1 -1
  32. package/dist/types/LighterProvider.d.ts +7 -0
  33. package/dist/types/LighterProvider.d.ts.map +1 -1
  34. package/dist/types/accountConfig.d.ts.map +1 -1
  35. package/dist/types/accountSummary.d.ts +3 -3
  36. package/dist/types/types/ws.d.ts +11 -1
  37. package/dist/types/types/ws.d.ts.map +1 -1
  38. package/dist/types/websocket/LighterWsProvider.d.ts +1 -0
  39. package/dist/types/websocket/LighterWsProvider.d.ts.map +1 -1
  40. package/package.json +3 -3
  41. package/src/LighterProvider.ts +77 -22
  42. package/src/accountConfig.ts +12 -2
  43. package/src/accountSummary.ts +4 -4
  44. package/src/types/ws.ts +12 -1
  45. package/src/websocket/LighterWsProvider.ts +49 -0
@@ -202,6 +202,13 @@ export interface LighterProviderOptions {
202
202
  tokenLifetimeSeconds?: number
203
203
  /** Re-create when the cached standard token's remaining life is below this. Default 60s. */
204
204
  tokenRenewBufferSeconds?: number
205
+ /**
206
+ * LI.FI's Lighter referral code — the code `getAccount` compares the account's
207
+ * applied referral against to populate `LighterAccountConfig.referralPresent`.
208
+ * Must match the backend's configured `LIGHTER_REFERRAL_CODE`; when omitted,
209
+ * `referralPresent` is always `false` (the SDK has nothing to compare against).
210
+ */
211
+ referralCode?: string
205
212
  }
206
213
 
207
214
  interface CachedStandardToken {
@@ -283,6 +290,7 @@ export const lighterProvider = (
283
290
  })
284
291
  const tokenLifetimeSeconds = options.tokenLifetimeSeconds ?? 60 * 60
285
292
  const tokenRenewBufferSeconds = options.tokenRenewBufferSeconds ?? 60
293
+ const referralCode = options.referralCode
286
294
  const standardTokenByAddress: Map<string, CachedStandardToken> = new Map()
287
295
  // Single-flight + backoff for lazy read-only token creation. Without these,
288
296
  // concurrent reads on first load all race `tokens/create`, and any sustained
@@ -504,6 +512,22 @@ export const lighterProvider = (
504
512
  account_index: accountIndex,
505
513
  })
506
514
 
515
+ // `used_code` is the referral currently applied to the account (empty string
516
+ // when none). Keyed by L1 address, mirroring Lighter's `/referral/use` write
517
+ // contract.
518
+ const fetchAppliedReferralCode = async (
519
+ client: LighterApiClient,
520
+ l1Address: Address,
521
+ authToken: string
522
+ ): Promise<string> => {
523
+ const { used_code } = await client.getAuthed<{ used_code: string }>(
524
+ '/api/v1/referral/userReferrals',
525
+ authToken,
526
+ { l1_address: l1Address.toLowerCase() }
527
+ )
528
+ return used_code
529
+ }
530
+
507
531
  const fetchActiveOrdersForMarket = (
508
532
  client: LighterApiClient,
509
533
  authToken: string,
@@ -639,21 +663,40 @@ export const lighterProvider = (
639
663
  ])
640
664
 
641
665
  const registry = getMarketRegistry(requireClient(), LIGHTER_PROVIDER_KEY)
642
- const [, registeredKey, limitsResult, localKey, storedReadOnlyToken] =
643
- await Promise.all([
644
- registry.sync(),
645
- fetchRegisteredApiKey(client, account.index, DEFAULT_API_KEY_INDEX),
646
- // No token is a legitimate unauthenticated read → undefined → zero fee
647
- // tier. A fetch error is NOT: it must propagate, never be coerced to a
648
- // fabricated 0%/0% fee tier.
649
- token === undefined
650
- ? Promise.resolve(undefined)
651
- : retryOnRevoked(opts, params.address, token, (t) =>
652
- fetchAccountLimits(client, account.index, t)
653
- ),
654
- keyStore ? keyStore.get(params.address) : Promise.resolve(null),
655
- readOnlyTokenManager.get(params.address, account.index),
656
- ])
666
+ const assetRegistry = getAssetRegistry(
667
+ requireClient(),
668
+ LIGHTER_PROVIDER_KEY
669
+ )
670
+ const [
671
+ markets,
672
+ ,
673
+ registeredKey,
674
+ limitsResult,
675
+ localKey,
676
+ storedReadOnlyToken,
677
+ appliedReferralCode,
678
+ ] = await Promise.all([
679
+ registry.sync(),
680
+ assetRegistry.sync(),
681
+ fetchRegisteredApiKey(client, account.index, DEFAULT_API_KEY_INDEX),
682
+ // No token is a legitimate unauthenticated read → undefined → zero fee
683
+ // tier. A fetch error is NOT: it must propagate, never be coerced to a
684
+ // fabricated 0%/0% fee tier.
685
+ token === undefined
686
+ ? Promise.resolve(undefined)
687
+ : retryOnRevoked(opts, params.address, token, (t) =>
688
+ fetchAccountLimits(client, account.index, t)
689
+ ),
690
+ keyStore ? keyStore.get(params.address) : Promise.resolve(null),
691
+ readOnlyTokenManager.get(params.address, account.index),
692
+ // No referral code to compare against, or no token to authenticate the
693
+ // read → undefined → `referralPresent: false`.
694
+ !referralCode || token === undefined
695
+ ? Promise.resolve(undefined)
696
+ : retryOnRevoked(opts, params.address, token, (t) =>
697
+ fetchAppliedReferralCode(client, params.address, t)
698
+ ),
699
+ ])
657
700
 
658
701
  // REGISTER_API_KEY is satisfied only when the locally-held keypair
659
702
  // matches the key registered on-chain at this slot — existence alone is
@@ -677,6 +720,10 @@ export const lighterProvider = (
677
720
  0
678
721
  )
679
722
 
723
+ const quoteAssetByCategory = new Map(
724
+ markets.map((m) => [m.categoryId, m.quoteAsset])
725
+ )
726
+
680
727
  // USDC collateral is the category quote asset → collateralBalances.
681
728
  // `available_balance` is the free collateral (Lighter's `collateral` is
682
729
  // gross, i.e. includes margin locked in positions); the locked portion is
@@ -684,19 +731,24 @@ export const lighterProvider = (
684
731
  const collateralBalances: Balance[] = [
685
732
  {
686
733
  categoryId: LIGHTER_PROVIDER_KEY,
687
- asset: lighterAsset('USDC', 'USDC'),
734
+ asset:
735
+ quoteAssetByCategory.get(LIGHTER_PROVIDER_KEY) ??
736
+ lighterAsset('USDC', 'USDC'),
688
737
  units: account.available_balance,
689
738
  valueUsd: account.available_balance,
690
739
  },
691
740
  ]
692
741
  // Spot token holdings — non-collateral. USDC value is 1:1; other tokens
693
742
  // have no price source at this boundary, so their USD value is unknown.
694
- const balances: Balance[] = account.assets.map((a) => ({
695
- categoryId: LIGHTER_PROVIDER_KEY,
696
- asset: lighterAsset(String(a.asset_id), a.symbol),
697
- units: a.balance,
698
- valueUsd: a.symbol === 'USDC' ? a.balance : '0',
699
- }))
743
+ const balances: Balance[] = account.assets.map((a) => {
744
+ const assetId = String(a.asset_id)
745
+ return {
746
+ categoryId: LIGHTER_PROVIDER_KEY,
747
+ asset: assetRegistry.get(assetId) ?? lighterAsset(assetId, a.symbol),
748
+ units: a.balance,
749
+ valueUsd: a.symbol === 'USDC' ? a.balance : '0',
750
+ }
751
+ })
700
752
 
701
753
  const assetCollateral = account.assets.flatMap((a) =>
702
754
  a.margin_mode === undefined
@@ -722,6 +774,9 @@ export const lighterProvider = (
722
774
  readOnlyTokenApproved: storedReadOnlyToken !== undefined,
723
775
  readOnlyTokenExpiry: storedReadOnlyToken?.expiry,
724
776
  readOnlyTokenScope: storedReadOnlyToken?.scope,
777
+ referralPresent:
778
+ appliedReferralCode !== undefined &&
779
+ appliedReferralCode === referralCode,
725
780
  }
726
781
 
727
782
  return {
@@ -82,10 +82,20 @@ function projectLighterDescriptor(
82
82
  satisfied: config.readOnlyTokenApproved,
83
83
  }
84
84
 
85
- // Backend-gated setup steps: `checkSetup` → `createAction` decides
85
+ // `SET_REFERRAL` is satisfied when LI.FI's referral code is the one applied
86
+ // to the account — an SDK-direct read (`getAccount` → `referralPresent`),
87
+ // since the applied referral is a per-user, auth-gated read. Lighter referral
88
+ // is mutable, so an account on another integrator's code stays gateable.
89
+ case ActionType.SET_REFERRAL:
90
+ return {
91
+ type: descriptor.type,
92
+ values: [],
93
+ satisfied: config.referralPresent,
94
+ }
95
+
96
+ // Backend-gated setup step: `checkSetup` → `createAction` decides
86
97
  // satisfaction (the backend emits no actions once satisfied), so the
87
98
  // projection carries no local state.
88
- case ActionType.SET_REFERRAL:
89
99
  case ActionType.APPROVE_INTEGRATOR:
90
100
  return { type: descriptor.type, values: [] }
91
101
 
@@ -8,9 +8,9 @@ import type {
8
8
  /**
9
9
  * Roll a Lighter {@link AccountResponse} up into an {@link AccountSummary}.
10
10
  * Lighter has a single flat collateral model with no abstraction modes: the
11
- * collateral rows hold `available_balance` (free margin), so available margin
12
- * is the collateral as-is and the locked margin (carried by the positions'
13
- * `marginUsed`) is added back for the gross portfolio value.
11
+ * collateral rows hold `available_balance`, which nets locked margin out but
12
+ * marks unrealized PnL in — so only the positions' `marginUsed` is added
13
+ * back for the portfolio value, never their PnL.
14
14
  *
15
15
  * @public
16
16
  */
@@ -18,5 +18,5 @@ export function getAccountSummary(
18
18
  account: AccountResponse,
19
19
  positions: Position[]
20
20
  ): AccountSummary {
21
- return summarizeAccount(account, positions, 'free')
21
+ return summarizeAccount(account, positions, 'net')
22
22
  }
package/src/types/ws.ts CHANGED
@@ -149,7 +149,18 @@ export type LtWsAccountAllTradesMessage = LtWsMessage & {
149
149
  data?: { trades?: Record<string, unknown[]> | unknown[] }
150
150
  }
151
151
 
152
- /** @public */
152
+ /**
153
+ * `user_stats/{account_index}` frame; decimal strings in USD.
154
+ * @public
155
+ */
156
+ export type LtWsUserStatsMessage = LtWsMessage & {
157
+ stats?: {
158
+ collateral?: string
159
+ portfolio_value?: string
160
+ available_balance?: string
161
+ }
162
+ }
163
+
153
164
  export type LtWsAccountAllPositionsMessage = LtWsMessage & {
154
165
  type: 'subscribed/account_all_positions' | 'update/account_all_positions'
155
166
  positions?: Record<string, unknown>
@@ -40,6 +40,7 @@ import type {
40
40
  LtWsSpotMarketStats,
41
41
  LtWsSpotMarketStatsAllMessage,
42
42
  LtWsTradeMessage,
43
+ LtWsUserStatsMessage,
43
44
  } from '../types/index.js'
44
45
  import { LIGHTER_RETRY_DEFAULTS, LighterApiClient } from '../utils/apiClient.js'
45
46
  import {
@@ -310,6 +311,8 @@ export class LighterWsProvider extends WsProviderBase<SubState> {
310
311
  return `fills:${sub.address.toLowerCase()}`
311
312
  case 'positions':
312
313
  return `positions:${sub.address.toLowerCase()}`
314
+ case 'accountSummary':
315
+ return `accountSummary:${sub.address.toLowerCase()}`
313
316
  // No live wire sub (openChannel returns a no-op), but a stable key is
314
317
  // still needed so the base's fan-out registry can track/release it.
315
318
  case 'candle':
@@ -369,6 +372,17 @@ export class LighterWsProvider extends WsProviderBase<SubState> {
369
372
  }
370
373
  return [{ channel: `trade/${id}`, needsAuth: false }]
371
374
  }
375
+ if (sub.channel === 'accountSummary') {
376
+ const accountIndex = await this.resolveAccountIndex(sub.address)
377
+ // `user_stats` is publicly readable; no token needed.
378
+ return [
379
+ {
380
+ channel: `user_stats/${accountIndex}`,
381
+ needsAuth: false,
382
+ address: sub.address,
383
+ },
384
+ ]
385
+ }
372
386
  if (
373
387
  sub.channel === 'orderUpdates' ||
374
388
  sub.channel === 'fills' ||
@@ -503,6 +517,14 @@ export class LighterWsProvider extends WsProviderBase<SubState> {
503
517
  return
504
518
  }
505
519
 
520
+ if (
521
+ msg.type === 'subscribed/user_stats' ||
522
+ msg.type === 'update/user_stats'
523
+ ) {
524
+ this.handleUserStats(msg as LtWsUserStatsMessage)
525
+ return
526
+ }
527
+
506
528
  if (
507
529
  msg.type === 'subscribed/account_all_positions' ||
508
530
  msg.type === 'update/account_all_positions'
@@ -531,6 +553,33 @@ export class LighterWsProvider extends WsProviderBase<SubState> {
531
553
  })
532
554
  }
533
555
 
556
+ private handleUserStats(msg: LtWsUserStatsMessage): void {
557
+ const address = this.addressFromChannel(msg.channel, 'user_stats')
558
+ if (!address) {
559
+ return
560
+ }
561
+ const stats = msg.stats
562
+ const portfolio = Number.parseFloat(stats?.portfolio_value ?? '')
563
+ const available = Number.parseFloat(stats?.available_balance ?? '')
564
+ if (!Number.isFinite(portfolio) || !Number.isFinite(available)) {
565
+ return
566
+ }
567
+ // Identities of the REST 'net' semantics: `available_balance` nets locked
568
+ // margin out with PnL marked in, and `collateral` is gross, PnL-exclusive.
569
+ const collateral = Number.parseFloat(stats?.collateral ?? '')
570
+ this.emit(`accountSummary:${address}`, {
571
+ channel: 'accountSummary',
572
+ data: {
573
+ portfolioValue: portfolio.toString(),
574
+ availableMargin: available.toString(),
575
+ marginUsed: Math.max(portfolio - available, 0).toString(),
576
+ unrealizedPnl: Number.isFinite(collateral)
577
+ ? (portfolio - collateral).toString()
578
+ : '0',
579
+ },
580
+ })
581
+ }
582
+
534
583
  private handleAccountTrades(msg: LtWsAccountAllTradesMessage): void {
535
584
  const address = this.addressFromChannel(msg.channel, 'account_all_trades')
536
585
  if (!address) {