@lifi/perps-sdk-provider-lighter 17.0.1 → 17.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 (73) hide show
  1. package/dist/cjs/LighterProvider.d.ts.map +1 -1
  2. package/dist/cjs/LighterProvider.js +1 -0
  3. package/dist/cjs/LighterProvider.js.map +1 -1
  4. package/dist/cjs/accountConfig.d.ts.map +1 -1
  5. package/dist/cjs/accountConfig.js +12 -4
  6. package/dist/cjs/accountConfig.js.map +1 -1
  7. package/dist/cjs/constants.d.ts +1 -0
  8. package/dist/cjs/constants.d.ts.map +1 -1
  9. package/dist/cjs/constants.js +2 -1
  10. package/dist/cjs/constants.js.map +1 -1
  11. package/dist/cjs/signers/LighterKeyStore.d.ts +2 -1
  12. package/dist/cjs/signers/LighterKeyStore.d.ts.map +1 -1
  13. package/dist/cjs/signers/LighterKeyStore.js +8 -6
  14. package/dist/cjs/signers/LighterKeyStore.js.map +1 -1
  15. package/dist/cjs/signers/LighterReadOnlyTokenManager.d.ts +42 -0
  16. package/dist/cjs/signers/LighterReadOnlyTokenManager.d.ts.map +1 -1
  17. package/dist/cjs/signers/LighterReadOnlyTokenManager.js +87 -6
  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 +9 -5
  21. package/dist/cjs/signers/signActions.js.map +1 -1
  22. package/dist/cjs/types/fill.d.ts +2 -0
  23. package/dist/cjs/types/fill.d.ts.map +1 -1
  24. package/dist/cjs/utils/mapFill.d.ts.map +1 -1
  25. package/dist/cjs/utils/mapFill.js +8 -0
  26. package/dist/cjs/utils/mapFill.js.map +1 -1
  27. package/dist/esm/LighterProvider.d.ts.map +1 -1
  28. package/dist/esm/LighterProvider.js +1 -0
  29. package/dist/esm/LighterProvider.js.map +1 -1
  30. package/dist/esm/accountConfig.d.ts.map +1 -1
  31. package/dist/esm/accountConfig.js +29 -16
  32. package/dist/esm/accountConfig.js.map +1 -1
  33. package/dist/esm/constants.d.ts +9 -0
  34. package/dist/esm/constants.d.ts.map +1 -1
  35. package/dist/esm/constants.js +9 -0
  36. package/dist/esm/constants.js.map +1 -1
  37. package/dist/esm/signers/LighterKeyStore.d.ts +12 -4
  38. package/dist/esm/signers/LighterKeyStore.d.ts.map +1 -1
  39. package/dist/esm/signers/LighterKeyStore.js +16 -15
  40. package/dist/esm/signers/LighterKeyStore.js.map +1 -1
  41. package/dist/esm/signers/LighterReadOnlyTokenManager.d.ts +112 -12
  42. package/dist/esm/signers/LighterReadOnlyTokenManager.d.ts.map +1 -1
  43. package/dist/esm/signers/LighterReadOnlyTokenManager.js +119 -8
  44. package/dist/esm/signers/LighterReadOnlyTokenManager.js.map +1 -1
  45. package/dist/esm/signers/signActions.d.ts.map +1 -1
  46. package/dist/esm/signers/signActions.js +15 -9
  47. package/dist/esm/signers/signActions.js.map +1 -1
  48. package/dist/esm/types/fill.d.ts +2 -0
  49. package/dist/esm/types/fill.d.ts.map +1 -1
  50. package/dist/esm/utils/mapFill.d.ts.map +1 -1
  51. package/dist/esm/utils/mapFill.js +16 -1
  52. package/dist/esm/utils/mapFill.js.map +1 -1
  53. package/dist/types/LighterProvider.d.ts.map +1 -1
  54. package/dist/types/accountConfig.d.ts.map +1 -1
  55. package/dist/types/constants.d.ts +9 -0
  56. package/dist/types/constants.d.ts.map +1 -1
  57. package/dist/types/signers/LighterKeyStore.d.ts +12 -4
  58. package/dist/types/signers/LighterKeyStore.d.ts.map +1 -1
  59. package/dist/types/signers/LighterReadOnlyTokenManager.d.ts +112 -12
  60. package/dist/types/signers/LighterReadOnlyTokenManager.d.ts.map +1 -1
  61. package/dist/types/signers/signActions.d.ts.map +1 -1
  62. package/dist/types/types/fill.d.ts +2 -0
  63. package/dist/types/types/fill.d.ts.map +1 -1
  64. package/dist/types/utils/mapFill.d.ts.map +1 -1
  65. package/package.json +3 -3
  66. package/src/LighterProvider.ts +1 -0
  67. package/src/accountConfig.ts +33 -16
  68. package/src/constants.ts +10 -0
  69. package/src/signers/LighterKeyStore.ts +50 -29
  70. package/src/signers/LighterReadOnlyTokenManager.ts +245 -18
  71. package/src/signers/signActions.ts +18 -11
  72. package/src/types/fill.ts +6 -0
  73. package/src/utils/mapFill.ts +21 -1
package/src/constants.ts CHANGED
@@ -183,6 +183,16 @@ export const DEFAULT_TRADES_LIMIT = 50
183
183
  */
184
184
  export const LIGHTER_FEE_TICK_SCALE = 1_000_000
185
185
 
186
+ /**
187
+ * Scale of the integer `initial_margin_fraction` values Lighter publishes on
188
+ * trade rows and order-book details: the integer is a percent times 100, so
189
+ * `500` is 5.00% and `200` is 2.00%. Account positions publish the same
190
+ * fraction as a plain percent string ("5.00") instead.
191
+ *
192
+ * @internal
193
+ */
194
+ export const LIGHTER_IMF_PERCENT_SCALE = 100
195
+
186
196
  /**
187
197
  * Lighter's public base fee tier (Standard Account): 0% maker / 0% taker on all
188
198
  * perps markets. Per-account Premium tiers exist but require auth to resolve;
@@ -4,10 +4,7 @@ import type { Address } from 'viem'
4
4
  import { LIGHTER_PROVIDER_KEY } from '../constants.js'
5
5
 
6
6
  // The private key here is a Lighter custom keypair — generated via WASM
7
- // GenerateAPIKey — not an Ethereum private key. The storage key is namespaced
8
- // by the resolved provider instance key so two Lighter instances sharing a
9
- // storage adapter (e.g. `lighter` and `lighter-rh`) never clobber each other's
10
- // API key.
7
+ // GenerateAPIKey — not an Ethereum private key.
11
8
 
12
9
  const STORAGE_PREFIX = 'lifi-perps-lighter-key'
13
10
 
@@ -19,6 +16,12 @@ const STORAGE_PREFIX = 'lifi-perps-lighter-key'
19
16
  * @public
20
17
  */
21
18
  export interface LighterApiKey {
19
+ /**
20
+ * Lighter instance the record belongs to. A record read by any other
21
+ * instance is discarded, so its key material never signs on a chain that
22
+ * does not hold it.
23
+ */
24
+ providerKey: LighterProviderKey
22
25
  /** Lighter account index, looked up once via accountsByL1Address. */
23
26
  accountIndex: number
24
27
  /** API key slot (0-255), as named by the backend registration payload. */
@@ -29,28 +32,37 @@ export interface LighterApiKey {
29
32
  apiKeyPublicKey: string
30
33
  }
31
34
 
32
- const isLighterApiKey = (value: unknown): value is LighterApiKey => {
33
- if (typeof value !== 'object' || value === null) {
34
- return false
35
+ const isLighterApiKeyOf =
36
+ (owner: LighterProviderKey) =>
37
+ (value: unknown): value is LighterApiKey => {
38
+ if (typeof value !== 'object' || value === null) {
39
+ return false
40
+ }
41
+ const {
42
+ providerKey,
43
+ accountIndex,
44
+ apiKeyIndex,
45
+ apiKeyPrivateKey,
46
+ apiKeyPublicKey,
47
+ } = value as Record<string, unknown>
48
+ return (
49
+ providerKey === owner &&
50
+ typeof accountIndex === 'number' &&
51
+ Number.isFinite(accountIndex) &&
52
+ typeof apiKeyIndex === 'number' &&
53
+ Number.isFinite(apiKeyIndex) &&
54
+ typeof apiKeyPrivateKey === 'string' &&
55
+ apiKeyPrivateKey.length > 0 &&
56
+ typeof apiKeyPublicKey === 'string' &&
57
+ apiKeyPublicKey.length > 0
58
+ )
35
59
  }
36
- const { accountIndex, apiKeyIndex, apiKeyPrivateKey, apiKeyPublicKey } =
37
- value as Record<string, unknown>
38
- return (
39
- typeof accountIndex === 'number' &&
40
- Number.isFinite(accountIndex) &&
41
- typeof apiKeyIndex === 'number' &&
42
- Number.isFinite(apiKeyIndex) &&
43
- typeof apiKeyPrivateKey === 'string' &&
44
- apiKeyPrivateKey.length > 0 &&
45
- typeof apiKeyPublicKey === 'string' &&
46
- apiKeyPublicKey.length > 0
47
- )
48
- }
49
60
 
50
61
  /**
51
62
  * Storage-backed cache for Lighter API keys. Records are namespaced by L1
52
- * address and provider instance so multiple Lighter deployments can share one
53
- * {@link StorageAdapter} without collisions.
63
+ * address and provider instance, and each record names its own instance, so
64
+ * multiple Lighter deployments can share one {@link StorageAdapter} and no
65
+ * instance ever reads another instance's key material.
54
66
  *
55
67
  * @public
56
68
  */
@@ -82,8 +94,8 @@ export class LighterKeyStore {
82
94
  this.providerKey = providerKey
83
95
  }
84
96
 
85
- // The default instance keeps the legacy, un-namespaced key so existing
86
- // `lighter` users are not orphaned; only additional instances get a segment.
97
+ // The default instance keeps the legacy, un-namespaced slot name; only
98
+ // additional instances get a segment.
87
99
  private storageKey(address: Address): string {
88
100
  const lower = address.toLowerCase()
89
101
  return this.providerKey === LIGHTER_PROVIDER_KEY
@@ -101,7 +113,11 @@ export class LighterKeyStore {
101
113
  if (cached) {
102
114
  return cached
103
115
  }
104
- const parsed = await readValidatedRecord(this.storage, key, isLighterApiKey)
116
+ const parsed = await readValidatedRecord(
117
+ this.storage,
118
+ key,
119
+ isLighterApiKeyOf(this.providerKey)
120
+ )
105
121
  if (!parsed) {
106
122
  return null
107
123
  }
@@ -111,12 +127,17 @@ export class LighterKeyStore {
111
127
 
112
128
  /**
113
129
  * Persist an API key for an L1 address, replacing any existing record under
114
- * that provider namespace.
130
+ * that provider namespace. The store stamps the record with its own instance
131
+ * key, so callers never name the instance themselves.
115
132
  */
116
- async set(address: Address, value: LighterApiKey): Promise<void> {
133
+ async set(
134
+ address: Address,
135
+ value: Omit<LighterApiKey, 'providerKey'>
136
+ ): Promise<void> {
117
137
  const key = this.storageKey(address)
118
- this.cache.set(key, value)
119
- await this.storage.set(key, JSON.stringify(value))
138
+ const record: LighterApiKey = { ...value, providerKey: this.providerKey }
139
+ this.cache.set(key, record)
140
+ await this.storage.set(key, JSON.stringify(record))
120
141
  }
121
142
 
122
143
  /**
@@ -17,7 +17,7 @@ import { DEFAULT_LIGHTER_REST_URL, LIGHTER_PROVIDER_KEY } from '../constants.js'
17
17
  * Default token name persisted alongside Lighter's `tokens/create` row.
18
18
  * Lighter requires a non-empty `name` form field; the literal here is what
19
19
  * surfaces in Lighter's UI listing under `app.lighter.xyz/read-only-tokens`.
20
- * @public
20
+ * @internal
21
21
  */
22
22
  export const DEFAULT_READ_ONLY_TOKEN_NAME = 'LI.FI Perps'
23
23
 
@@ -26,7 +26,7 @@ export const DEFAULT_READ_ONLY_TOKEN_NAME = 'LI.FI Perps'
26
26
  * Lighter's opaque `ro:{accountIndex}:{scope}:{expiry}:{rand}` bearer; the
27
27
  * SDK never parses it — `expiry`/`scope`/`accountIndex` are create-time inputs
28
28
  * we keep alongside so consumers can render expiry UX without re-fetching.
29
- * @public
29
+ * @internal
30
30
  */
31
31
  export interface LighterReadOnlyToken {
32
32
  /** Opaque bearer string — never parse client-side. */
@@ -37,19 +37,27 @@ export interface LighterReadOnlyToken {
37
37
  scope: 'single' | 'all'
38
38
  /** Lighter L2 account index the token authorises. */
39
39
  accountIndex: number
40
+ /**
41
+ * Lighter's monotonic registry row id. Optional: a record persisted without
42
+ * it stays valid, and the cleanup pass then identifies its row by the
43
+ * bearer string instead.
44
+ */
45
+ tokenId?: number
40
46
  }
41
47
 
42
48
  /**
43
- * Lighter `POST /api/v1/tokens/create` response shape (subset). Lighter's
44
- * OpenAPI documents additional fields (`token_id`, `name`, `revoked`, etc.)
45
- * we don't need client-side.
46
- * @public
49
+ * Lighter `POST /api/v1/tokens/create` response shape — the subset of
50
+ * Lighter's `RespPostApiToken` the SDK consumes. Lighter documents further
51
+ * fields (`name`, `revoked`, `sub_account_access`) the SDK does not need.
52
+ * @internal
47
53
  */
48
54
  export interface LighterCreateTokenResponse {
49
55
  api_token: string
50
56
  account_index: number
51
57
  expiry: number
52
58
  scopes: string
59
+ /** Monotonic registry row id Lighter assigns to the new row. */
60
+ token_id: number
53
61
  }
54
62
 
55
63
  /**
@@ -57,7 +65,7 @@ export interface LighterCreateTokenResponse {
57
65
  * endpoint. Returning a parsed {@link LighterCreateTokenResponse} keeps the
58
66
  * create code free of fetch/multipart plumbing and lets tests drop a fixture
59
67
  * in without spinning a mock server.
60
- * @public
68
+ * @internal
61
69
  */
62
70
  export type LighterTokenFetcher = (params: {
63
71
  url: string
@@ -69,12 +77,76 @@ export type LighterTokenFetcher = (params: {
69
77
  scopes: string
70
78
  }) => Promise<LighterCreateTokenResponse>
71
79
 
80
+ /**
81
+ * One row of Lighter's read-only token registry, matching Lighter's
82
+ * `ApiToken`. `name` carries the label supplied at create time, which is how
83
+ * the SDK recognises a row it owns.
84
+ * @internal
85
+ */
86
+ export interface LighterApiToken {
87
+ token_id: number
88
+ api_token: string
89
+ name: string
90
+ account_index: number
91
+ expiry: number
92
+ sub_account_access: boolean
93
+ revoked: boolean
94
+ scopes: string
95
+ }
96
+
97
+ /**
98
+ * Lighter `GET /api/v1/tokens` response shape, matching `RespGetApiTokens`.
99
+ * @internal
100
+ */
101
+ export interface LighterListTokensResponse {
102
+ code: number
103
+ message?: string
104
+ api_tokens: LighterApiToken[]
105
+ }
106
+
107
+ /**
108
+ * Lighter `POST /api/v1/tokens/revoke` response shape, matching Lighter's
109
+ * `RespRevokeApiToken`.
110
+ * @internal
111
+ */
112
+ export interface LighterRevokeTokenResponse {
113
+ code: number
114
+ message?: string
115
+ token_id: number
116
+ revoked: boolean
117
+ }
118
+
119
+ /**
120
+ * Function injected for the HTTP boundary against Lighter's
121
+ * `GET /api/v1/tokens` endpoint. Injected for the same reason as
122
+ * {@link LighterTokenFetcher}: a unit spec supplies a registry fixture
123
+ * without a mock server.
124
+ * @internal
125
+ */
126
+ export type LighterTokenListFetcher = (params: {
127
+ url: string
128
+ authorization: string
129
+ accountIndex: number
130
+ }) => Promise<LighterListTokensResponse>
131
+
132
+ /**
133
+ * Function injected for the HTTP boundary against Lighter's
134
+ * `POST /api/v1/tokens/revoke` endpoint.
135
+ * @internal
136
+ */
137
+ export type LighterTokenRevokeFetcher = (params: {
138
+ url: string
139
+ authorization: string
140
+ tokenId: number
141
+ accountIndex: number
142
+ }) => Promise<LighterRevokeTokenResponse>
143
+
72
144
  /**
73
145
  * Dependencies and overrides for {@link LighterReadOnlyTokenManager}.
74
146
  * Storage and token-fetching defaults target the browser's local storage and
75
147
  * Lighter's mainnet REST API.
76
148
  *
77
- * @public
149
+ * @internal
78
150
  */
79
151
  export interface LighterReadOnlyTokenManagerOptions {
80
152
  storage?: StorageAdapter
@@ -89,6 +161,10 @@ export interface LighterReadOnlyTokenManagerOptions {
89
161
  lighterApiUrl?: string
90
162
  /** Override the multipart POST. Defaults to a `fetch`-based implementation. */
91
163
  fetcher?: LighterTokenFetcher
164
+ /** Override the registry list GET. Defaults to a `fetch`-based implementation. */
165
+ listFetcher?: LighterTokenListFetcher
166
+ /** Override the revoke POST. Defaults to a `fetch`-based implementation. */
167
+ revokeFetcher?: LighterTokenRevokeFetcher
92
168
  /** Clock injection for testing expiry logic. Defaults to `Date.now`. */
93
169
  now?: () => number
94
170
  }
@@ -97,7 +173,7 @@ export interface LighterReadOnlyTokenManagerOptions {
97
173
  * Input to the read-only-token approval flow. Extends the shared approval
98
174
  * parameters with the L1 address whose wallet authorizes token creation.
99
175
  *
100
- * @public
176
+ * @internal
101
177
  */
102
178
  export interface ApproveReadOnlyTokenInputs extends ApproveReadOnlyTokenParams {
103
179
  /** L1 wallet address that signs the create message. */
@@ -108,7 +184,7 @@ export interface ApproveReadOnlyTokenInputs extends ApproveReadOnlyTokenParams {
108
184
  * Result of approving or creating a Lighter read-only token. `config` is the
109
185
  * account-state projection callers can persist alongside the token.
110
186
  *
111
- * @public
187
+ * @internal
112
188
  */
113
189
  export interface ApproveReadOnlyTokenResult {
114
190
  token: LighterReadOnlyToken
@@ -131,10 +207,12 @@ const isLighterReadOnlyToken = (
131
207
  if (typeof value !== 'object' || value === null) {
132
208
  return false
133
209
  }
134
- const { token, expiry, scope, accountIndex } = value as Record<
210
+ const { token, expiry, scope, accountIndex, tokenId } = value as Record<
135
211
  string,
136
212
  unknown
137
213
  >
214
+ // Accept a record that carries no `tokenId`: rejecting one would discard a
215
+ // usable token and force an unnecessary replacement.
138
216
  return (
139
217
  typeof token === 'string' &&
140
218
  token.length > 0 &&
@@ -142,10 +220,19 @@ const isLighterReadOnlyToken = (
142
220
  Number.isFinite(expiry) &&
143
221
  (scope === 'single' || scope === 'all') &&
144
222
  typeof accountIndex === 'number' &&
145
- Number.isFinite(accountIndex)
223
+ Number.isFinite(accountIndex) &&
224
+ (tokenId === undefined ||
225
+ (typeof tokenId === 'number' && Number.isFinite(tokenId)))
146
226
  )
147
227
  }
148
228
 
229
+ const isLiveRow = (
230
+ row: LighterApiToken,
231
+ live: LighterReadOnlyToken | undefined
232
+ ): boolean =>
233
+ live !== undefined &&
234
+ (row.token_id === live.tokenId || row.api_token === live.token)
235
+
149
236
  /**
150
237
  * Manage the per-account Lighter read-only token alongside the existing
151
238
  * `(L1 address, account index)`-scoped storage adapter pattern. Takes an
@@ -163,13 +250,15 @@ const isLighterReadOnlyToken = (
163
250
  * a fully compromised page — a same-origin script can still drive this manager
164
251
  * to decrypt. Blast radius is limited to reads: the token cannot sign orders
165
252
  * or move funds.
166
- * @public
253
+ * @internal
167
254
  */
168
255
  export class LighterReadOnlyTokenManager {
169
256
  private readonly storage: StorageAdapter
170
257
  private readonly providerKey: LighterProviderKey
171
258
  private readonly lighterApiUrl: string
172
259
  private readonly fetcher: LighterTokenFetcher
260
+ private readonly listFetcher: LighterTokenListFetcher
261
+ private readonly revokeFetcher: LighterTokenRevokeFetcher
173
262
  private readonly now: () => number
174
263
  private readonly cache = new Map<string, LighterReadOnlyToken>()
175
264
 
@@ -178,6 +267,9 @@ export class LighterReadOnlyTokenManager {
178
267
  this.providerKey = options.providerKey ?? LIGHTER_PROVIDER_KEY
179
268
  this.lighterApiUrl = options.lighterApiUrl ?? DEFAULT_LIGHTER_REST_URL
180
269
  this.fetcher = options.fetcher ?? defaultLighterTokenFetcher
270
+ this.listFetcher = options.listFetcher ?? defaultLighterTokenListFetcher
271
+ this.revokeFetcher =
272
+ options.revokeFetcher ?? defaultLighterTokenRevokeFetcher
181
273
  this.now = options.now ?? (() => Date.now())
182
274
  }
183
275
 
@@ -189,6 +281,19 @@ export class LighterReadOnlyTokenManager {
189
281
  return token.expiry * 1000 <= this.now()
190
282
  }
191
283
 
284
+ /** Stored record for the pair, whether or not it is past its `expiry`. */
285
+ private async readStoredToken(
286
+ address: Address,
287
+ accountIndex: number
288
+ ): Promise<LighterReadOnlyToken | undefined> {
289
+ const key = this.storageKey(address, accountIndex)
290
+ return (
291
+ this.cache.get(key) ??
292
+ (await readValidatedRecord(this.storage, key, isLighterReadOnlyToken)) ??
293
+ undefined
294
+ )
295
+ }
296
+
192
297
  /**
193
298
  * Return the stored token for the `(address, accountIndex)` pair, or
194
299
  * `undefined` if absent or past its `expiry`. Never surfaces an expired
@@ -226,10 +331,7 @@ export class LighterReadOnlyTokenManager {
226
331
  accountIndex: number,
227
332
  thresholdDays = 30
228
333
  ): Promise<boolean> {
229
- const key = this.storageKey(address, accountIndex)
230
- const token =
231
- this.cache.get(key) ??
232
- (await readValidatedRecord(this.storage, key, isLighterReadOnlyToken))
334
+ const token = await this.readStoredToken(address, accountIndex)
233
335
  if (!token) {
234
336
  return false
235
337
  }
@@ -272,6 +374,9 @@ export class LighterReadOnlyTokenManager {
272
374
  * and persists the returned `ro:` bearer alongside its
273
375
  * `expiry`/`scope`/`accountIndex`.
274
376
  *
377
+ * Before the create call, the flow revokes this SDK's own stale registry
378
+ * rows. That pass is best-effort: a failure is logged and the create runs.
379
+ *
275
380
  * `authorization` MUST be a **standard** Lighter auth token — one created by
276
381
  * the account's API key (`createAuthToken` / the WASM signer), NOT an L1
277
382
  * wallet signature. Lighter authenticates the create request with that token
@@ -295,6 +400,8 @@ export class LighterReadOnlyTokenManager {
295
400
  ): Promise<ApproveReadOnlyTokenResult> {
296
401
  const { address, accountIndex, expirySeconds, scope } = inputs
297
402
 
403
+ await this.revokeStaleOwnTokens(authorization, address, accountIndex)
404
+
298
405
  const response = await this.fetcher({
299
406
  url: `${this.lighterApiUrl}/api/v1/tokens/create`,
300
407
  authorization,
@@ -313,6 +420,12 @@ export class LighterReadOnlyTokenManager {
313
420
  // `get()` looks up by, else a divergent echo orphans the token and re-creates every read.
314
421
  scope,
315
422
  accountIndex,
423
+ // Lighter declares `token_id` required but the response is unvalidated.
424
+ // A non-numeric id must degrade to the bearer-string match: persisting it
425
+ // would fail record validation and re-create the token on every read.
426
+ tokenId: Number.isFinite(response.token_id)
427
+ ? response.token_id
428
+ : undefined,
316
429
  }
317
430
  await this.set(address, token.accountIndex, token)
318
431
 
@@ -327,6 +440,57 @@ export class LighterReadOnlyTokenManager {
327
440
  },
328
441
  }
329
442
  }
443
+
444
+ /**
445
+ * Revoke the registry rows this SDK owns and no longer needs. Lighter's
446
+ * `tokens/create` appends a row on every call, so without this pass the
447
+ * user's registry grows on every rotation.
448
+ */
449
+ private async revokeStaleOwnTokens(
450
+ authorization: string,
451
+ address: Address,
452
+ accountIndex: number
453
+ ): Promise<void> {
454
+ try {
455
+ const live = await this.readStoredToken(address, accountIndex)
456
+ const { api_tokens: rows } = await this.listFetcher({
457
+ url: `${this.lighterApiUrl}/api/v1/tokens`,
458
+ authorization,
459
+ accountIndex,
460
+ })
461
+ const nowSeconds = Math.floor(this.now() / 1000)
462
+ const stale = rows
463
+ // A row is ours only when its name matches the name we create rows
464
+ // under. Any other name belongs to the user and must survive.
465
+ .filter((row) => row.name === DEFAULT_READ_ONLY_TOKEN_NAME)
466
+ .filter((row) => !row.revoked)
467
+ // Keep the row the local store holds; with no stored record every owned
468
+ // row is stale, and a peer client's row self-heals via `retryOnRevoked`.
469
+ .filter((row) => row.expiry <= nowSeconds || !isLiveRow(row, live))
470
+ .sort((a, b) => a.token_id - b.token_id)
471
+
472
+ for (const row of stale) {
473
+ try {
474
+ await this.revokeFetcher({
475
+ url: `${this.lighterApiUrl}/api/v1/tokens/revoke`,
476
+ authorization,
477
+ tokenId: row.token_id,
478
+ accountIndex,
479
+ })
480
+ } catch (err) {
481
+ console.warn(
482
+ `[lighter] could not revoke stale read-only token ${row.token_id}; continuing.`,
483
+ err
484
+ )
485
+ }
486
+ }
487
+ } catch (err) {
488
+ console.warn(
489
+ '[lighter] could not clean up stale read-only tokens; creating the new one anyway.',
490
+ err
491
+ )
492
+ }
493
+ }
330
494
  }
331
495
 
332
496
  /**
@@ -334,7 +498,7 @@ export class LighterReadOnlyTokenManager {
334
498
  * `tokens/create` endpoint and returns the parsed response. Throws a
335
499
  * {@link PerpsError} with the Lighter-side body when the response is
336
500
  * non-2xx.
337
- * @public
501
+ * @internal
338
502
  */
339
503
  export const defaultLighterTokenFetcher: LighterTokenFetcher = async ({
340
504
  url,
@@ -368,3 +532,66 @@ export const defaultLighterTokenFetcher: LighterTokenFetcher = async ({
368
532
 
369
533
  return (await response.json()) as LighterCreateTokenResponse
370
534
  }
535
+
536
+ /**
537
+ * Default list fetcher: `GET /api/v1/tokens` with `account_index` as a query
538
+ * parameter. Lighter's OpenAPI defines no request body for this route. Throws
539
+ * a {@link PerpsError} with the Lighter-side body when the response is
540
+ * non-2xx.
541
+ * @internal
542
+ */
543
+ export const defaultLighterTokenListFetcher: LighterTokenListFetcher = async ({
544
+ url,
545
+ authorization,
546
+ accountIndex,
547
+ }) => {
548
+ const query = new URLSearchParams({ account_index: String(accountIndex) })
549
+
550
+ const response = await fetch(`${url}?${query.toString()}`, {
551
+ headers: { authorization },
552
+ })
553
+
554
+ if (!response.ok) {
555
+ const body = await response.text()
556
+ throw new PerpsError(
557
+ PerpsErrorCode.ServerError,
558
+ `Lighter tokens returned ${response.status}: ${body || '<empty>'}`
559
+ )
560
+ }
561
+
562
+ return (await response.json()) as LighterListTokensResponse
563
+ }
564
+
565
+ /**
566
+ * Default revoke fetcher: posts `application/x-www-form-urlencoded` to
567
+ * Lighter's `tokens/revoke` endpoint, which is the content type Lighter's
568
+ * OpenAPI declares for that route. Throws a {@link PerpsError} with the
569
+ * Lighter-side body when the response is non-2xx.
570
+ * @internal
571
+ */
572
+ export const defaultLighterTokenRevokeFetcher: LighterTokenRevokeFetcher =
573
+ async ({ url, authorization, tokenId, accountIndex }) => {
574
+ const form = new URLSearchParams({
575
+ token_id: String(tokenId),
576
+ account_index: String(accountIndex),
577
+ })
578
+
579
+ const response = await fetch(url, {
580
+ method: 'POST',
581
+ headers: {
582
+ authorization,
583
+ 'content-type': 'application/x-www-form-urlencoded',
584
+ },
585
+ body: form,
586
+ })
587
+
588
+ if (!response.ok) {
589
+ const body = await response.text()
590
+ throw new PerpsError(
591
+ PerpsErrorCode.ServerError,
592
+ `Lighter tokens/revoke returned ${response.status}: ${body || '<empty>'}`
593
+ )
594
+ }
595
+
596
+ return (await response.json()) as LighterRevokeTokenResponse
597
+ }
@@ -64,7 +64,7 @@ export interface LighterSignActionsDeps {
64
64
  const TOKEN_AUTH_MUTATION_KINDS = new Set(['changeAccountTier', 'referralUse'])
65
65
 
66
66
  /**
67
- * Lifetime of the per-call auth token minted for a token-authenticated venue
67
+ * Lifetime of the per-call auth token issued for a token-authenticated venue
68
68
  * mutation. Minutes, not the read endpoints' hours: it authenticates one POST
69
69
  * that completes immediately, so it never lingers usable.
70
70
  */
@@ -133,11 +133,19 @@ export const createLighterApiKeyFreshness = (): LighterApiKeyFreshness => {
133
133
  checkedAtMs.set(windowKey, Date.now())
134
134
  return
135
135
  }
136
- // A slot carrying no entry leaves nothing to compare against.
137
- if (
138
- registered !== undefined &&
139
- normalizeLighterPublicKey(registered.public_key) !== storedKey
140
- ) {
136
+ // An answered read that names no entry is a verdict, not a gap: the slot
137
+ // holds no key the stored one can match, so a signature it produces
138
+ // cannot verify.
139
+ if (registered === undefined) {
140
+ throw new PerpsError(
141
+ PerpsErrorCode.SDKError,
142
+ `The Lighter API key stored for ${address} is not registered: ` +
143
+ `account ${apiKey.accountIndex} holds no key in API-key slot ` +
144
+ `${apiKey.apiKeyIndex}. Run prepareAccount / REGISTER_API_KEY to ` +
145
+ 'register a fresh key.'
146
+ )
147
+ }
148
+ if (normalizeLighterPublicKey(registered.public_key) !== storedKey) {
141
149
  throw new PerpsError(
142
150
  PerpsErrorCode.SDKError,
143
151
  `The Lighter API key stored for ${address} is no longer registered: ` +
@@ -146,7 +154,7 @@ export const createLighterApiKeyFreshness = (): LighterApiKeyFreshness => {
146
154
  )
147
155
  }
148
156
  // Every read the venue answered counts against the window, so a burst
149
- // costs one read whatever the verdict. A mismatch throws above, so it
157
+ // costs one read whatever the verdict. A rejection throws above, so it
150
158
  // never records and the next batch reads again.
151
159
  checkedAtMs.set(windowKey, Date.now())
152
160
  },
@@ -295,13 +303,12 @@ async function signRegisterApiKey(
295
303
  l1Signature
296
304
  )
297
305
 
298
- const apiKey: LighterApiKey = {
306
+ await deps.keyStore.set(address, {
299
307
  accountIndex,
300
308
  apiKeyIndex,
301
309
  apiKeyPrivateKey: keypair.privateKey,
302
310
  apiKeyPublicKey: keypair.publicKey,
303
- }
304
- await deps.keyStore.set(address, apiKey)
311
+ })
305
312
 
306
313
  return {
307
314
  action: step.action,
@@ -431,7 +438,7 @@ async function signTransfer(
431
438
  *
432
439
  * Neither endpoint consumes a wasm-signed transaction — both authenticate with
433
440
  * a Lighter auth token and enforce their business rules (open positions,
434
- * pending orders, 24h tier cooldown) server-side. We mint a fresh short-lived
441
+ * pending orders, 24h tier cooldown) server-side. We create a fresh short-lived
435
442
  * token per call (never the stored read-only token, never persisted) and POST
436
443
  * the mirror of the backend's form body directly to Lighter, so no auth token
437
444
  * ever transits the LI.FI backend. A non-success verdict surfaces Lighter's
package/src/types/fill.ts CHANGED
@@ -48,6 +48,12 @@ export type LtTrade = {
48
48
  // trade rows that predate the field.
49
49
  taker_entry_quote_before?: string
50
50
  maker_entry_quote_before?: string
51
+ // Per-counterparty initial-margin-fraction snapshot BEFORE the trade: the
52
+ // leverage that counterparty had set on the market, as an integer percent on
53
+ // `LIGHTER_IMF_PERCENT_SCALE` (`500` = 5.00% = 20x). Optional: absent on
54
+ // older trade rows that predate the field.
55
+ taker_initial_margin_fraction_before?: number
56
+ maker_initial_margin_fraction_before?: number
51
57
  }
52
58
 
53
59
  /**
@@ -11,8 +11,12 @@ import {
11
11
  OrderType,
12
12
  } from '@lifi/perps-types'
13
13
  import Big from 'big.js'
14
- import { LIGHTER_FEE_TICK_SCALE } from '../constants.js'
14
+ import {
15
+ LIGHTER_FEE_TICK_SCALE,
16
+ LIGHTER_IMF_PERCENT_SCALE,
17
+ } from '../constants.js'
15
18
  import type { LtTrade } from '../types/index.js'
19
+ import { leverageFromImf } from './mapPosition.js'
16
20
 
17
21
  /**
18
22
  * Fee charged on a fill, in the market's quote asset. Lighter publishes the
@@ -35,6 +39,18 @@ const tickToFeeAmount = (
35
39
  .div(LIGHTER_FEE_TICK_SCALE)
36
40
  .toFixed()
37
41
 
42
+ /**
43
+ * Display leverage the viewer had set on the market when the trade executed.
44
+ * Lighter reports it as the pre-trade initial margin fraction, an integer
45
+ * percent on `LIGHTER_IMF_PERCENT_SCALE`: `500` is 5.00%, so 20x. Returns
46
+ * `undefined` when the row omits the fraction. Lighter declares the fraction a
47
+ * `StrictInt`, so the scale division is exact and `toFixed()` loses nothing.
48
+ */
49
+ const leverageFromTradeImf = (imf: number | undefined): number | undefined =>
50
+ imf === undefined
51
+ ? undefined
52
+ : leverageFromImf(new Big(imf).div(LIGHTER_IMF_PERCENT_SCALE).toFixed())
53
+
38
54
  /**
39
55
  * Realized PnL on a position-reducing fill, derived from the pre-trade entry
40
56
  * basis. Returns `undefined` for opens/increases (nothing closes) or when the
@@ -101,6 +117,9 @@ export const mapFill = (
101
117
  const integratorFeeTick = isMaker
102
118
  ? trade.integrator_maker_fee
103
119
  : trade.integrator_taker_fee
120
+ const imfBefore = isMaker
121
+ ? trade.maker_initial_margin_fraction_before
122
+ : trade.taker_initial_margin_fraction_before
104
123
 
105
124
  return {
106
125
  id: trade.trade_id.toString(),
@@ -124,6 +143,7 @@ export const mapFill = (
124
143
  ),
125
144
  asset: market.quoteAsset.displaySymbol,
126
145
  },
146
+ leverage: leverageFromTradeImf(imfBefore),
127
147
  realizedPnl: deriveRealizedPnl(
128
148
  startPosition,
129
149
  entryQuoteBefore,