@lifi/perps-sdk-provider-lighter 1.5.6 → 1.7.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 (71) hide show
  1. package/dist/cjs/LighterProvider.d.ts.map +1 -1
  2. package/dist/cjs/LighterProvider.js +2 -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 +15 -2
  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/LighterSigner.d.ts.map +1 -1
  12. package/dist/cjs/signers/LighterSigner.js +13 -2
  13. package/dist/cjs/signers/LighterSigner.js.map +1 -1
  14. package/dist/cjs/signers/signActions.d.ts +2 -0
  15. package/dist/cjs/signers/signActions.d.ts.map +1 -1
  16. package/dist/cjs/signers/signActions.js +37 -19
  17. package/dist/cjs/signers/signActions.js.map +1 -1
  18. package/dist/cjs/signers/wasmLoader.d.ts +2 -0
  19. package/dist/cjs/signers/wasmLoader.d.ts.map +1 -1
  20. package/dist/cjs/signers/wasmLoader.js +2 -0
  21. package/dist/cjs/signers/wasmLoader.js.map +1 -1
  22. package/dist/cjs/utils/apiClient.d.ts +4 -0
  23. package/dist/cjs/utils/apiClient.d.ts.map +1 -1
  24. package/dist/cjs/utils/apiClient.js +15 -0
  25. package/dist/cjs/utils/apiClient.js.map +1 -1
  26. package/dist/esm/LighterProvider.d.ts.map +1 -1
  27. package/dist/esm/LighterProvider.js +2 -0
  28. package/dist/esm/LighterProvider.js.map +1 -1
  29. package/dist/esm/accountConfig.d.ts.map +1 -1
  30. package/dist/esm/accountConfig.js +27 -7
  31. package/dist/esm/accountConfig.js.map +1 -1
  32. package/dist/esm/constants.d.ts +9 -1
  33. package/dist/esm/constants.d.ts.map +1 -1
  34. package/dist/esm/constants.js +9 -1
  35. package/dist/esm/constants.js.map +1 -1
  36. package/dist/esm/signers/LighterSigner.d.ts +3 -2
  37. package/dist/esm/signers/LighterSigner.d.ts.map +1 -1
  38. package/dist/esm/signers/LighterSigner.js +16 -4
  39. package/dist/esm/signers/LighterSigner.js.map +1 -1
  40. package/dist/esm/signers/signActions.d.ts +13 -5
  41. package/dist/esm/signers/signActions.d.ts.map +1 -1
  42. package/dist/esm/signers/signActions.js +68 -38
  43. package/dist/esm/signers/signActions.js.map +1 -1
  44. package/dist/esm/signers/wasmLoader.d.ts +13 -0
  45. package/dist/esm/signers/wasmLoader.d.ts.map +1 -1
  46. package/dist/esm/signers/wasmLoader.js +2 -0
  47. package/dist/esm/signers/wasmLoader.js.map +1 -1
  48. package/dist/esm/utils/apiClient.d.ts +10 -0
  49. package/dist/esm/utils/apiClient.d.ts.map +1 -1
  50. package/dist/esm/utils/apiClient.js +21 -0
  51. package/dist/esm/utils/apiClient.js.map +1 -1
  52. package/dist/types/LighterProvider.d.ts.map +1 -1
  53. package/dist/types/accountConfig.d.ts.map +1 -1
  54. package/dist/types/constants.d.ts +9 -1
  55. package/dist/types/constants.d.ts.map +1 -1
  56. package/dist/types/signers/LighterSigner.d.ts +3 -2
  57. package/dist/types/signers/LighterSigner.d.ts.map +1 -1
  58. package/dist/types/signers/signActions.d.ts +13 -5
  59. package/dist/types/signers/signActions.d.ts.map +1 -1
  60. package/dist/types/signers/wasmLoader.d.ts +13 -0
  61. package/dist/types/signers/wasmLoader.d.ts.map +1 -1
  62. package/dist/types/utils/apiClient.d.ts +10 -0
  63. package/dist/types/utils/apiClient.d.ts.map +1 -1
  64. package/package.json +3 -3
  65. package/src/LighterProvider.ts +2 -0
  66. package/src/accountConfig.ts +30 -7
  67. package/src/constants.ts +10 -0
  68. package/src/signers/LighterSigner.ts +66 -8
  69. package/src/signers/signActions.ts +112 -44
  70. package/src/signers/wasmLoader.ts +15 -0
  71. package/src/utils/apiClient.ts +25 -0
package/src/constants.ts CHANGED
@@ -83,8 +83,18 @@ const LIGHTER_INVALID_AUTH_CODE = 20013
83
83
  */
84
84
  const LIGHTER_SUCCESS_CODES = new Set([0, 200])
85
85
 
86
+ /**
87
+ * Lighter body `code` meaning "operation succeeded" on the non-`/sendTx`
88
+ * mutation endpoints (`/changeAccountTier`, `/referral/use`). Any other code is
89
+ * a business-rule rejection surfaced verbatim to the caller.
90
+ *
91
+ * @internal
92
+ */
93
+ const LIGHTER_MUTATION_SUCCESS_CODE = 200
94
+
86
95
  export {
87
96
  LIGHTER_CODE_ACCOUNT_NOT_FOUND,
88
97
  LIGHTER_INVALID_AUTH_CODE,
98
+ LIGHTER_MUTATION_SUCCESS_CODE,
89
99
  LIGHTER_SUCCESS_CODES,
90
100
  }
@@ -263,8 +263,9 @@ export class LighterSigner {
263
263
  /**
264
264
  * Map an ActionType + backend-provided params object to the positional-arg
265
265
  * WASM call. The Go signer exports take primitives in order, not an object
266
- * — so we pick fields in the exact order the Go side expects. Unknown
267
- * fields (e.g. backend's integrator_* hints) are ignored.
266
+ * — so we pick fields in the exact order the Go side expects. Integrator
267
+ * fee fields pass through when present, falling back to the nil sentinels
268
+ * when absent; unrecognised fields are ignored.
268
269
  */
269
270
  private dispatch(
270
271
  wasm: LighterWasmExports,
@@ -290,9 +291,21 @@ export class LighterSigner {
290
291
  numberField(p, 'reduce_only'),
291
292
  numberField(p, 'trigger_price'),
292
293
  numberField(p, 'order_expiry'),
293
- NIL_INTEGRATOR_INDEX,
294
- NIL_INTEGRATOR_TAKER_FEE,
295
- NIL_INTEGRATOR_MAKER_FEE,
294
+ optionalNumberField(
295
+ p,
296
+ 'integrator_account_index',
297
+ NIL_INTEGRATOR_INDEX
298
+ ),
299
+ optionalNumberField(
300
+ p,
301
+ 'integrator_taker_fee',
302
+ NIL_INTEGRATOR_TAKER_FEE
303
+ ),
304
+ optionalNumberField(
305
+ p,
306
+ 'integrator_maker_fee',
307
+ NIL_INTEGRATOR_MAKER_FEE
308
+ ),
296
309
  SELF_TRADE_BEHAVIOR_EXPIRE_MAKER,
297
310
  SELF_TRADE_EQUALITY_ACCOUNT_INDEX,
298
311
  SKIP_NONCE_DISABLED,
@@ -326,9 +339,21 @@ export class LighterSigner {
326
339
  numberField(p, 'base_amount'),
327
340
  numberField(p, 'price'),
328
341
  numberField(p, 'trigger_price'),
329
- NIL_INTEGRATOR_INDEX,
330
- NIL_INTEGRATOR_TAKER_FEE,
331
- NIL_INTEGRATOR_MAKER_FEE,
342
+ optionalNumberField(
343
+ p,
344
+ 'integrator_account_index',
345
+ NIL_INTEGRATOR_INDEX
346
+ ),
347
+ optionalNumberField(
348
+ p,
349
+ 'integrator_taker_fee',
350
+ NIL_INTEGRATOR_TAKER_FEE
351
+ ),
352
+ optionalNumberField(
353
+ p,
354
+ 'integrator_maker_fee',
355
+ NIL_INTEGRATOR_MAKER_FEE
356
+ ),
332
357
  SELF_TRADE_BEHAVIOR_EXPIRE_MAKER,
333
358
  SELF_TRADE_EQUALITY_ACCOUNT_INDEX,
334
359
  SKIP_NONCE_DISABLED,
@@ -387,6 +412,27 @@ export class LighterSigner {
387
412
  ctx.apiKeyIndex,
388
413
  ctx.accountIndex
389
414
  )
415
+ case ActionType.APPROVE_INTEGRATOR:
416
+ return wasm.SignApproveIntegrator(
417
+ numberField(p, 'integrator_account_index'),
418
+ numberField(p, 'max_perps_taker_fee'),
419
+ numberField(p, 'max_perps_maker_fee'),
420
+ numberField(p, 'max_spot_taker_fee'),
421
+ numberField(p, 'max_spot_maker_fee'),
422
+ numberField(p, 'approval_expiry'),
423
+ SKIP_NONCE_DISABLED,
424
+ nonce,
425
+ ctx.apiKeyIndex,
426
+ ctx.accountIndex
427
+ )
428
+ case ActionType.ACCOUNT_MODE:
429
+ return wasm.SignUpdateAccountConfig(
430
+ numberField(p, 'account_trading_mode'),
431
+ SKIP_NONCE_DISABLED,
432
+ nonce,
433
+ ctx.apiKeyIndex,
434
+ ctx.accountIndex
435
+ )
390
436
  default:
391
437
  throw new Error(
392
438
  `Lighter WASM signer does not support action: ${action}`
@@ -431,6 +477,18 @@ function numberField(p: Record<string, unknown>, key: string): number {
431
477
  )
432
478
  }
433
479
 
480
+ function optionalNumberField(
481
+ p: Record<string, unknown>,
482
+ key: string,
483
+ fallback: number
484
+ ): number {
485
+ const v = p[key]
486
+ if (v === undefined || v === null) {
487
+ return fallback
488
+ }
489
+ return numberField(p, key)
490
+ }
491
+
434
492
  function stringField(p: Record<string, unknown>, key: string): string {
435
493
  const v = p[key]
436
494
  if (typeof v === 'string' && v !== '') {
@@ -11,20 +11,29 @@ import { ActionType, PerpsErrorCode, SigningMethod } from '@lifi/perps-types'
11
11
  import type { Address } from 'viem'
12
12
  import { parseAbi } from 'viem'
13
13
  import { waitForTransactionReceipt } from 'viem/actions'
14
- import { DEFAULT_API_KEY_INDEX } from '../constants.js'
14
+ import {
15
+ DEFAULT_API_KEY_INDEX,
16
+ LIGHTER_MUTATION_SUCCESS_CODE,
17
+ } from '../constants.js'
18
+ import type { ApiParams, LighterApiClient } from '../utils/apiClient.js'
15
19
  import type { LighterApiKey, LighterKeyStore } from './LighterKeyStore.js'
16
20
  import type { LighterSigner } from './LighterSigner.js'
17
21
 
18
22
  /**
19
23
  * Per-batch dependencies the Lighter `signActions` implementation needs:
20
24
  * a WASM signer (`LighterSigner`), the API-key store keyed on L1 address,
21
- * and a reference to the host SDK client for descriptor/account fetches
22
- * within the REGISTER_API_KEY hybrid flow.
25
+ * a REST client for the token-authenticated venue mutations that execute
26
+ * client-side, and a reference to the host SDK client for descriptor/account
27
+ * fetches within the REGISTER_API_KEY hybrid flow.
23
28
  * @internal
24
29
  */
25
30
  export interface LighterSignActionsDeps {
26
31
  signer: LighterSigner
27
32
  keyStore: LighterKeyStore
33
+ /** REST client bound to the user's Lighter base URL, used for the direct
34
+ * `changeAccountTier` / `referral/use` POSTs so their auth token never
35
+ * transits the LI.FI backend. */
36
+ apiClient: LighterApiClient
28
37
  /**
29
38
  * Resolve the user's Lighter `accountIndex` for an L1 address. Required
30
39
  * by REGISTER_API_KEY so the ChangePubKey blob carries the right
@@ -34,15 +43,32 @@ export interface LighterSignActionsDeps {
34
43
  resolveAccountIndex(address: Address): Promise<number>
35
44
  }
36
45
 
46
+ /**
47
+ * `wasmSignParams.kind` values for the Lighter mutations that authenticate with
48
+ * a short-lived auth token instead of a wasm-signed transaction. Both execute
49
+ * client-side (SDK → Lighter REST) so the token never reaches the backend.
50
+ * @internal
51
+ */
52
+ const TOKEN_AUTH_MUTATION_KINDS = new Set(['changeAccountTier', 'referralUse'])
53
+
54
+ /**
55
+ * Lifetime of the per-call auth token minted for a token-authenticated venue
56
+ * mutation. Minutes, not the read endpoints' hours: it authenticates one POST
57
+ * that completes immediately, so it never lingers usable.
58
+ */
59
+ const TOKEN_AUTH_MUTATION_DEADLINE_SECONDS = 5 * 60
60
+
37
61
  /**
38
62
  * Sign a `WASM_BLOB` batch (Lighter). Ensures the user's API keypair is
39
63
  * registered first — generating one and running the REGISTER_API_KEY
40
64
  * hybrid flow via the L1 signer if not — then feeds each subsequent step
41
65
  * through the WASM signer.
42
66
  *
43
- * `ACCOUNT_TYPE` (Lighter `/changeAccountTier`) is a WASM_BLOB envelope
44
- * authenticated with an auth token rather than a wasm tx signature —
45
- * created via `LighterSigner.createAuthToken` and parked in `signedTx.txInfo`.
67
+ * The token-authenticated mutations (`ACCOUNT_TYPE` → `/changeAccountTier`,
68
+ * `SET_REFERRAL` → `/referral/use`) are NOT wasm-signed: they execute
69
+ * client-side here via a direct Lighter POST authenticated with a short-lived
70
+ * auth token, so the token never transits the LI.FI backend. They produce no
71
+ * backend-bound step and are omitted from the returned array.
46
72
  * @internal
47
73
  */
48
74
  export async function signWasmBlobActions(
@@ -55,8 +81,10 @@ export async function signWasmBlobActions(
55
81
  for (const step of steps) {
56
82
  if (step.action === ActionType.REGISTER_API_KEY) {
57
83
  signed.push(await signRegisterApiKey(deps, address, step, ctx))
58
- } else if (step.action === ActionType.ACCOUNT_TYPE) {
59
- signed.push(await signAccountTierChange(deps, address, step))
84
+ } else if (
85
+ TOKEN_AUTH_MUTATION_KINDS.has(step.wasmSignParams.kind as string)
86
+ ) {
87
+ await executeTokenAuthMutation(deps, address, step)
60
88
  } else {
61
89
  signed.push(await signStandardWasmAction(deps, address, step))
62
90
  }
@@ -169,44 +197,94 @@ async function signRegisterApiKey(
169
197
  }
170
198
 
171
199
  /**
172
- * Sign an `ACCOUNT_TYPE` step (Lighter `changeAccountTier`).
173
- *
174
- * Lighter's `/api/v1/changeAccountTier` is an HTTP-only mutation —
175
- * Lighter does NOT consume a wasm-signed transaction here; it
176
- * authenticates the request with the same auth token its read endpoints
177
- * use, and enforces anti-replay business rules server-side. The backend
178
- * declares the step as a `WasmBlobActionStep` with
179
- * `wasmSignParams.kind = 'changeAccountTier'` and expects the SDK to create
180
- * an auth token in lieu of a transaction signature; the executor reads
181
- * `signedTx.txInfo` as the `auth` form parameter.
200
+ * Execute a token-authenticated Lighter mutation (`ACCOUNT_TYPE` →
201
+ * `/changeAccountTier`, `SET_REFERRAL` → `/referral/use`) client-side.
182
202
  *
183
- * The 1h deadline mirrors the previous behaviour — Lighter caps tokens at
184
- * 8h hard, and the backend's executor runs `verifyPendingAction` then a
185
- * single POST, which completes well inside an hour.
203
+ * Neither endpoint consumes a wasm-signed transaction — both authenticate with
204
+ * a Lighter auth token and enforce their business rules (open positions,
205
+ * pending orders, 24h tier cooldown) server-side. We mint a fresh short-lived
206
+ * token per call (never the stored read-only token, never persisted) and POST
207
+ * the mirror of the backend's form body directly to Lighter, so no auth token
208
+ * ever transits the LI.FI backend. A non-success verdict surfaces Lighter's
209
+ * `code`/`message` verbatim as an {@link PerpsErrorCode.ExchangeRejected}.
186
210
  */
187
- async function signAccountTierChange(
211
+ async function executeTokenAuthMutation(
188
212
  deps: LighterSignActionsDeps,
189
213
  address: Address,
190
214
  step: WasmBlobActionStep
191
- ): Promise<WasmBlobSignedActionStep> {
215
+ ): Promise<void> {
192
216
  const apiKey = await requireApiKey(deps, address)
193
- const deadline = Math.floor(Date.now() / 1000) + 60 * 60
217
+ const deadline =
218
+ Math.floor(Date.now() / 1000) + TOKEN_AUTH_MUTATION_DEADLINE_SECONDS
194
219
  const authToken = await deps.signer.createAuthToken(deadline, {
195
220
  apiKeyPrivateKey: apiKey.apiKeyPrivateKey,
196
221
  apiKeyIndex: apiKey.apiKeyIndex,
197
222
  accountIndex: apiKey.accountIndex,
198
223
  })
199
- return {
200
- action: step.action,
201
- wasmSignParams: step.wasmSignParams,
202
- signedTx: {
203
- // `/changeAccountTier` reads only `txInfo` (the auth token); `txType`
204
- // and `txHash` are placeholders to satisfy the envelope shape.
205
- txType: 0,
206
- txInfo: authToken,
207
- txHash: '',
208
- },
224
+
225
+ const { path, params } = buildTokenAuthMutationRequest(step, authToken)
226
+ const { status, data } = await deps.apiClient.postForm<{
227
+ code?: number
228
+ message?: string
229
+ }>(path, params)
230
+
231
+ const code = data?.code
232
+ if (status < 200 || status >= 300 || code !== LIGHTER_MUTATION_SUCCESS_CODE) {
233
+ const suffix = data?.message ? `: ${data.message}` : ''
234
+ throw new PerpsError(
235
+ PerpsErrorCode.ExchangeRejected,
236
+ `Lighter ${step.action} rejected (code ${code ?? status})${suffix}`
237
+ )
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Map a token-authenticated mutation step to its Lighter endpoint and
243
+ * form body, mirroring the backend's request contract. The auth token is the
244
+ * `auth` field; the remaining fields come from `wasmSignParams`.
245
+ */
246
+ function buildTokenAuthMutationRequest(
247
+ step: WasmBlobActionStep,
248
+ authToken: string
249
+ ): { path: string; params: ApiParams } {
250
+ const kind = step.wasmSignParams.kind
251
+ if (kind === 'changeAccountTier') {
252
+ const { account_index, new_tier } = step.wasmSignParams as {
253
+ account_index?: number
254
+ new_tier?: string
255
+ }
256
+ if (account_index == null || new_tier == null) {
257
+ throw new PerpsError(
258
+ PerpsErrorCode.SDKError,
259
+ 'Lighter ACCOUNT_TYPE wasmSignParams missing account_index or new_tier'
260
+ )
261
+ }
262
+ return {
263
+ path: '/api/v1/changeAccountTier',
264
+ params: { auth: authToken, account_index, new_tier },
265
+ }
266
+ }
267
+ if (kind === 'referralUse') {
268
+ const { l1_address, referral_code, x } = step.wasmSignParams as {
269
+ l1_address?: string
270
+ referral_code?: string
271
+ x?: string
272
+ }
273
+ if (l1_address == null || referral_code == null || x == null) {
274
+ throw new PerpsError(
275
+ PerpsErrorCode.SDKError,
276
+ 'Lighter SET_REFERRAL wasmSignParams missing l1_address, referral_code, or x'
277
+ )
278
+ }
279
+ return {
280
+ path: '/api/v1/referral/use',
281
+ params: { auth: authToken, l1_address, referral_code, x },
282
+ }
209
283
  }
284
+ throw new PerpsError(
285
+ PerpsErrorCode.SDKError,
286
+ `Lighter token-auth mutation: unknown wasmSignParams.kind '${String(kind)}'`
287
+ )
210
288
  }
211
289
 
212
290
  async function requireApiKey(
@@ -266,16 +344,6 @@ export async function signEvmTxActions(
266
344
  )
267
345
  }
268
346
 
269
- if (walletSigner.chain?.id !== params.chainId) {
270
- throw new PerpsError(
271
- PerpsErrorCode.SDKError,
272
- `EVM_TX leg '${step.action}' targets chain ${params.chainId}, but the ` +
273
- `connected wallet is on chain ${walletSigner.chain?.id ?? 'unknown'}. ` +
274
- `Switch the wallet to chain ${params.chainId} before signing — the ` +
275
- "SDK broadcasts on the wallet's active chain and does not switch it."
276
- )
277
- }
278
-
279
347
  const txHash = await walletSigner.writeContract({
280
348
  address: params.to,
281
349
  abi: parseAbi(params.abi),
@@ -159,6 +159,19 @@ export interface LighterWasmExports {
159
159
  * three.
160
160
  */
161
161
  SignUpdateMargin: (...args: unknown[]) => SignResult
162
+ /**
163
+ * 10 args: integratorAccountIndex, maxPerpsTakerFee, maxPerpsMakerFee,
164
+ * maxSpotTakerFee, maxSpotMakerFee, approvalExpiry, skipNonce, then the
165
+ * trailing three. Fees are uint32 ppm of `FeeTick` (1_000_000); a non-nil
166
+ * fee requires a non-nil integrator index.
167
+ */
168
+ SignApproveIntegrator: (...args: unknown[]) => SignResult
169
+ /**
170
+ * 5 args: accountTradingMode (0 = Classic/Simple, 1 = Unified), skipNonce,
171
+ * then the trailing three. `accountTradingMode` is validated to {0, 1} by
172
+ * lighter-go before signing.
173
+ */
174
+ SignUpdateAccountConfig: (...args: unknown[]) => SignResult
162
175
  }
163
176
 
164
177
  /** @internal */
@@ -183,6 +196,8 @@ const WASM_FUNCTION_NAMES = [
183
196
  'SignUpdateLeverage',
184
197
  'SignModifyOrder',
185
198
  'SignUpdateMargin',
199
+ 'SignApproveIntegrator',
200
+ 'SignUpdateAccountConfig',
186
201
  ] as const
187
202
 
188
203
  type GoClass = new () => {
@@ -160,6 +160,31 @@ export class LighterApiClient {
160
160
  return { status: response.status, data }
161
161
  }
162
162
 
163
+ /**
164
+ * Form-encoded POST to a Lighter mutation endpoint. Single-shot — never
165
+ * retried, since these are money/state writes whose outcome is unknown on a
166
+ * transport failure. Surfaces the raw `{status, body}` pair so the caller can
167
+ * map Lighter's per-endpoint business-rule `code` to a domain error verbatim.
168
+ */
169
+ async postForm<T>(
170
+ path: string,
171
+ params: ApiParams
172
+ ): Promise<{ status: number; data: T }> {
173
+ const body = new URLSearchParams()
174
+ for (const [k, v] of Object.entries(params)) {
175
+ body.set(k, String(v))
176
+ }
177
+ const fetchImpl = this.fetchImpl ?? fetch
178
+ const response = await fetchImpl(`${this.baseUrl}${path}`, {
179
+ method: 'POST',
180
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
181
+ body: body.toString(),
182
+ signal: this.signal,
183
+ })
184
+ const data = (await response.json().catch(() => undefined)) as T
185
+ return { status: response.status, data }
186
+ }
187
+
163
188
  private async getChecked<T>(path: string, params?: ApiParams): Promise<T> {
164
189
  const { status, data } = await this.getWithStatus<unknown>(path, params)
165
190
  this.assertOk(path, status, data)