@lifi/perps-sdk-provider-lighter 1.6.0 → 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 (69) 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 -3
  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 +2 -0
  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 +1 -0
  19. package/dist/cjs/signers/wasmLoader.d.ts.map +1 -1
  20. package/dist/cjs/signers/wasmLoader.js +1 -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 -8
  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.map +1 -1
  37. package/dist/esm/signers/LighterSigner.js +2 -0
  38. package/dist/esm/signers/LighterSigner.js.map +1 -1
  39. package/dist/esm/signers/signActions.d.ts +13 -5
  40. package/dist/esm/signers/signActions.d.ts.map +1 -1
  41. package/dist/esm/signers/signActions.js +68 -38
  42. package/dist/esm/signers/signActions.js.map +1 -1
  43. package/dist/esm/signers/wasmLoader.d.ts +6 -0
  44. package/dist/esm/signers/wasmLoader.d.ts.map +1 -1
  45. package/dist/esm/signers/wasmLoader.js +1 -0
  46. package/dist/esm/signers/wasmLoader.js.map +1 -1
  47. package/dist/esm/utils/apiClient.d.ts +10 -0
  48. package/dist/esm/utils/apiClient.d.ts.map +1 -1
  49. package/dist/esm/utils/apiClient.js +21 -0
  50. package/dist/esm/utils/apiClient.js.map +1 -1
  51. package/dist/types/LighterProvider.d.ts.map +1 -1
  52. package/dist/types/accountConfig.d.ts.map +1 -1
  53. package/dist/types/constants.d.ts +9 -1
  54. package/dist/types/constants.d.ts.map +1 -1
  55. package/dist/types/signers/LighterSigner.d.ts.map +1 -1
  56. package/dist/types/signers/signActions.d.ts +13 -5
  57. package/dist/types/signers/signActions.d.ts.map +1 -1
  58. package/dist/types/signers/wasmLoader.d.ts +6 -0
  59. package/dist/types/signers/wasmLoader.d.ts.map +1 -1
  60. package/dist/types/utils/apiClient.d.ts +10 -0
  61. package/dist/types/utils/apiClient.d.ts.map +1 -1
  62. package/package.json +3 -3
  63. package/src/LighterProvider.ts +2 -0
  64. package/src/accountConfig.ts +30 -8
  65. package/src/constants.ts +10 -0
  66. package/src/signers/LighterSigner.ts +8 -0
  67. package/src/signers/signActions.ts +112 -44
  68. package/src/signers/wasmLoader.ts +7 -0
  69. package/src/utils/apiClient.ts +25 -0
@@ -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),
@@ -166,6 +166,12 @@ export interface LighterWasmExports {
166
166
  * fee requires a non-nil integrator index.
167
167
  */
168
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
169
175
  }
170
176
 
171
177
  /** @internal */
@@ -191,6 +197,7 @@ const WASM_FUNCTION_NAMES = [
191
197
  'SignModifyOrder',
192
198
  'SignUpdateMargin',
193
199
  'SignApproveIntegrator',
200
+ 'SignUpdateAccountConfig',
194
201
  ] as const
195
202
 
196
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)