@lifi/perps-sdk-provider-lighter 1.6.0 → 1.8.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 (87) hide show
  1. package/dist/cjs/LighterProvider.d.ts.map +1 -1
  2. package/dist/cjs/LighterProvider.js +12 -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 +16 -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 +12 -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 +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/types/account.d.ts +1 -0
  23. package/dist/cjs/types/account.d.ts.map +1 -1
  24. package/dist/cjs/utils/apiClient.d.ts +4 -0
  25. package/dist/cjs/utils/apiClient.d.ts.map +1 -1
  26. package/dist/cjs/utils/apiClient.js +15 -0
  27. package/dist/cjs/utils/apiClient.js.map +1 -1
  28. package/dist/cjs/utils/assetCollateral.d.ts +3 -0
  29. package/dist/cjs/utils/assetCollateral.d.ts.map +1 -0
  30. package/dist/cjs/utils/assetCollateral.js +13 -0
  31. package/dist/cjs/utils/assetCollateral.js.map +1 -0
  32. package/dist/esm/LighterProvider.d.ts.map +1 -1
  33. package/dist/esm/LighterProvider.js +12 -0
  34. package/dist/esm/LighterProvider.js.map +1 -1
  35. package/dist/esm/accountConfig.d.ts.map +1 -1
  36. package/dist/esm/accountConfig.js +28 -8
  37. package/dist/esm/accountConfig.js.map +1 -1
  38. package/dist/esm/constants.d.ts +9 -1
  39. package/dist/esm/constants.d.ts.map +1 -1
  40. package/dist/esm/constants.js +9 -1
  41. package/dist/esm/constants.js.map +1 -1
  42. package/dist/esm/signers/LighterSigner.d.ts.map +1 -1
  43. package/dist/esm/signers/LighterSigner.js +12 -0
  44. package/dist/esm/signers/LighterSigner.js.map +1 -1
  45. package/dist/esm/signers/signActions.d.ts +13 -5
  46. package/dist/esm/signers/signActions.d.ts.map +1 -1
  47. package/dist/esm/signers/signActions.js +68 -38
  48. package/dist/esm/signers/signActions.js.map +1 -1
  49. package/dist/esm/signers/wasmLoader.d.ts +12 -0
  50. package/dist/esm/signers/wasmLoader.d.ts.map +1 -1
  51. package/dist/esm/signers/wasmLoader.js +2 -0
  52. package/dist/esm/signers/wasmLoader.js.map +1 -1
  53. package/dist/esm/types/account.d.ts +6 -0
  54. package/dist/esm/types/account.d.ts.map +1 -1
  55. package/dist/esm/utils/apiClient.d.ts +10 -0
  56. package/dist/esm/utils/apiClient.d.ts.map +1 -1
  57. package/dist/esm/utils/apiClient.js +21 -0
  58. package/dist/esm/utils/apiClient.js.map +1 -1
  59. package/dist/esm/utils/assetCollateral.d.ts +5 -0
  60. package/dist/esm/utils/assetCollateral.d.ts.map +1 -0
  61. package/dist/esm/utils/assetCollateral.js +15 -0
  62. package/dist/esm/utils/assetCollateral.js.map +1 -0
  63. package/dist/types/LighterProvider.d.ts.map +1 -1
  64. package/dist/types/accountConfig.d.ts.map +1 -1
  65. package/dist/types/constants.d.ts +9 -1
  66. package/dist/types/constants.d.ts.map +1 -1
  67. package/dist/types/signers/LighterSigner.d.ts.map +1 -1
  68. package/dist/types/signers/signActions.d.ts +13 -5
  69. package/dist/types/signers/signActions.d.ts.map +1 -1
  70. package/dist/types/signers/wasmLoader.d.ts +12 -0
  71. package/dist/types/signers/wasmLoader.d.ts.map +1 -1
  72. package/dist/types/types/account.d.ts +6 -0
  73. package/dist/types/types/account.d.ts.map +1 -1
  74. package/dist/types/utils/apiClient.d.ts +10 -0
  75. package/dist/types/utils/apiClient.d.ts.map +1 -1
  76. package/dist/types/utils/assetCollateral.d.ts +5 -0
  77. package/dist/types/utils/assetCollateral.d.ts.map +1 -0
  78. package/package.json +3 -3
  79. package/src/LighterProvider.ts +15 -0
  80. package/src/accountConfig.ts +31 -8
  81. package/src/constants.ts +10 -0
  82. package/src/signers/LighterSigner.ts +28 -0
  83. package/src/signers/signActions.ts +112 -44
  84. package/src/signers/wasmLoader.ts +14 -0
  85. package/src/types/account.ts +6 -0
  86. package/src/utils/apiClient.ts +25 -0
  87. package/src/utils/assetCollateral.ts +16 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifi/perps-sdk-provider-lighter",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "Lighter provider plugin for @lifi/perps-sdk",
5
5
  "homepage": "https://github.com/lifinance/perps-sdk",
6
6
  "bugs": {
@@ -46,10 +46,10 @@
46
46
  "dependencies": {
47
47
  "big.js": "^7.0.1",
48
48
  "viem": "^2.48.8",
49
- "@lifi/perps-types": "^1.10.0"
49
+ "@lifi/perps-types": "^1.12.0"
50
50
  },
51
51
  "peerDependencies": {
52
- "@lifi/perps-sdk": "^1.5.6"
52
+ "@lifi/perps-sdk": "^1.6.0"
53
53
  },
54
54
  "publishConfig": {
55
55
  "access": "public"
@@ -81,6 +81,7 @@ import {
81
81
  LighterApiClient,
82
82
  LighterAuthRejectedError,
83
83
  } from './utils/apiClient.js'
84
+ import { isAssetMarginEnabled } from './utils/assetCollateral.js'
84
85
  import {
85
86
  classifyAndMapOrders,
86
87
  estimateLiquidationPrice,
@@ -697,12 +698,25 @@ export const lighterProvider = (
697
698
  valueUsd: a.symbol === 'USDC' ? a.balance : '0',
698
699
  }))
699
700
 
701
+ const assetCollateral = account.assets.flatMap((a) =>
702
+ a.margin_mode === undefined
703
+ ? []
704
+ : [
705
+ {
706
+ assetId: String(a.asset_id),
707
+ enabled: isAssetMarginEnabled(a.margin_mode),
708
+ },
709
+ ]
710
+ )
711
+
700
712
  const config: LighterAccountConfig = {
701
713
  provider: LIGHTER_PROVIDER_KEY,
702
714
  accountIndex: account.index,
703
715
  apiKeyIndex: DEFAULT_API_KEY_INDEX,
704
716
  apiKeyRegistered,
705
717
  accountType: account.account_type,
718
+ accountTradingMode: account.account_trading_mode,
719
+ assetCollateral,
706
720
  // Satisfied when a non-expired read-only token is stored locally
707
721
  // (`readOnlyTokenManager.get` filters out expired ones).
708
722
  readOnlyTokenApproved: storedReadOnlyToken !== undefined,
@@ -1202,6 +1216,7 @@ export const lighterProvider = (
1202
1216
  {
1203
1217
  signer: signerRef,
1204
1218
  keyStore: keyStoreRef,
1219
+ apiClient: apiClient(),
1205
1220
  resolveAccountIndex: async (addr) => {
1206
1221
  const apiKey = await keyStoreRef.get(addr)
1207
1222
  if (apiKey !== null) {
@@ -27,6 +27,17 @@ const ACCOUNT_TYPE_INT_TO_WIRE: Readonly<Record<number, string>> = {
27
27
  [LIGHTER_ACCOUNT_TYPE_PREMIUM]: 'premium',
28
28
  }
29
29
 
30
+ // `account_trading_mode` on `DetailedAccount`: 0 = Classic/Simple (segregated
31
+ // margin), 1 = Unified Trading Account (cross-asset margin). Wire strings match
32
+ // the backend descriptor's ParamOption values. An unmapped int projects to null.
33
+ const LIGHTER_ACCOUNT_MODE_SIMPLE = 0
34
+ const LIGHTER_ACCOUNT_MODE_UNIFIED = 1
35
+
36
+ const ACCOUNT_MODE_INT_TO_WIRE: Readonly<Record<number, string>> = {
37
+ [LIGHTER_ACCOUNT_MODE_SIMPLE]: 'simpleTradingAccount',
38
+ [LIGHTER_ACCOUNT_MODE_UNIFIED]: 'unifiedTradingAccount',
39
+ }
40
+
30
41
  /**
31
42
  * Project a single Lighter descriptor against the typed
32
43
  * `LighterAccountConfig` into an `AccountConfigSetting`.
@@ -37,8 +48,9 @@ const ACCOUNT_TYPE_INT_TO_WIRE: Readonly<Record<number, string>> = {
37
48
  * |---------------------------|--------------------------------------------------
38
49
  * | REGISTER_API_KEY | [] (no parameters)
39
50
  * | APPROVE_READ_ONLY_TOKEN | [] (no parameters)
40
- * | ACCOUNT_MODE | [{ name: 'mode', value: null }] (Lighter has no
41
- * | | abstraction-mode equivalent; read-only here)
51
+ * | SET_REFERRAL | [] (no parameters)
52
+ * | APPROVE_INTEGRATOR | [] (no parameters)
53
+ * | ACCOUNT_MODE | [{ name: 'mode', value: config.accountTradingMode }]
42
54
  * | ACCOUNT_TYPE | [{ name: 'tier', value: config.accountType }]
43
55
  *
44
56
  * The switch is exhaustive over `ActionType` so enum additions force a
@@ -70,13 +82,25 @@ function projectLighterDescriptor(
70
82
  satisfied: config.readOnlyTokenApproved,
71
83
  }
72
84
 
73
- // Lighter exposes no abstraction-mode equivalent; if a backend descriptor
74
- // surfaces ACCOUNT_MODE on Lighter it always projects `null` and callers
75
- // fall back to the descriptor's `default` ParamOption.
85
+ // Backend-gated setup steps: `checkSetup` → `createAction` decides
86
+ // satisfaction (the backend emits no actions once satisfied), so the
87
+ // projection carries no local state.
88
+ case ActionType.SET_REFERRAL:
89
+ case ActionType.APPROVE_INTEGRATOR:
90
+ return { type: descriptor.type, values: [] }
91
+
92
+ // `mode` decodes the raw `account_trading_mode` integer to the descriptor's
93
+ // wire strings (`unifiedTradingAccount` / `simpleTradingAccount`).
94
+ // Unrecognised integers project to `null`.
76
95
  case ActionType.ACCOUNT_MODE:
77
96
  return {
78
97
  type: descriptor.type,
79
- values: [{ name: 'mode', value: null }],
98
+ values: [
99
+ {
100
+ name: 'mode',
101
+ value: ACCOUNT_MODE_INT_TO_WIRE[config.accountTradingMode] ?? null,
102
+ },
103
+ ],
80
104
  }
81
105
 
82
106
  // `tier` is the wire string `/changeAccountTier` accepts; we decode the
@@ -97,8 +121,6 @@ function projectLighterDescriptor(
97
121
 
98
122
  case ActionType.APPROVE_AGENT:
99
123
  case ActionType.APPROVE_BUILDER_FEE:
100
- case ActionType.APPROVE_INTEGRATOR:
101
- case ActionType.SET_REFERRAL:
102
124
  case ActionType.SEND_ASSET:
103
125
  case ActionType.WITHDRAWAL:
104
126
  case ActionType.TRANSFER:
@@ -109,6 +131,7 @@ function projectLighterDescriptor(
109
131
  case ActionType.MODIFY_ORDER:
110
132
  case ActionType.UPDATE_LEVERAGE:
111
133
  case ActionType.UPDATE_POSITION_MARGIN:
134
+ case ActionType.UPDATE_ASSET_COLLATERAL:
112
135
  case ActionType.DEPOSIT:
113
136
  case ActionType.META_VOTE:
114
137
  case ActionType.META_ACCEPT_TERMS:
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
  }
@@ -1,5 +1,6 @@
1
1
  import { ActionType } from '@lifi/perps-types'
2
2
  import { DEFAULT_LIGHTER_REST_URL } from '../constants.js'
3
+ import { assetMarginModeInt } from '../utils/assetCollateral.js'
3
4
  import {
4
5
  type LighterWasmExports,
5
6
  type LoadLighterWasmOptions,
@@ -425,6 +426,23 @@ export class LighterSigner {
425
426
  ctx.apiKeyIndex,
426
427
  ctx.accountIndex
427
428
  )
429
+ case ActionType.ACCOUNT_MODE:
430
+ return wasm.SignUpdateAccountConfig(
431
+ numberField(p, 'account_trading_mode'),
432
+ SKIP_NONCE_DISABLED,
433
+ nonce,
434
+ ctx.apiKeyIndex,
435
+ ctx.accountIndex
436
+ )
437
+ case ActionType.UPDATE_ASSET_COLLATERAL:
438
+ return wasm.SignUpdateAccountAssetConfig(
439
+ numberField(p, 'asset_index'),
440
+ assetMarginModeInt(booleanField(p, 'enabled')),
441
+ SKIP_NONCE_DISABLED,
442
+ nonce,
443
+ ctx.apiKeyIndex,
444
+ ctx.accountIndex
445
+ )
428
446
  default:
429
447
  throw new Error(
430
448
  `Lighter WASM signer does not support action: ${action}`
@@ -481,6 +499,16 @@ function optionalNumberField(
481
499
  return numberField(p, key)
482
500
  }
483
501
 
502
+ function booleanField(p: Record<string, unknown>, key: string): boolean {
503
+ const v = p[key]
504
+ if (typeof v === 'boolean') {
505
+ return v
506
+ }
507
+ throw new Error(
508
+ `Lighter sign params missing boolean field '${key}' (got ${typeof v})`
509
+ )
510
+ }
511
+
484
512
  function stringField(p: Record<string, unknown>, key: string): string {
485
513
  const v = p[key]
486
514
  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),
@@ -166,6 +166,18 @@ 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
175
+ /**
176
+ * 6 args: assetIndex, assetMarginMode (0 = MarginDisabled, 1 = MarginEnabled),
177
+ * skipNonce, then the trailing three. Keyed per spot asset, not per market —
178
+ * signs `L2UpdateAccountAssetConfigTx` (tx type 42).
179
+ */
180
+ SignUpdateAccountAssetConfig: (...args: unknown[]) => SignResult
169
181
  }
170
182
 
171
183
  /** @internal */
@@ -191,6 +203,8 @@ const WASM_FUNCTION_NAMES = [
191
203
  'SignModifyOrder',
192
204
  'SignUpdateMargin',
193
205
  'SignApproveIntegrator',
206
+ 'SignUpdateAccountConfig',
207
+ 'SignUpdateAccountAssetConfig',
194
208
  ] as const
195
209
 
196
210
  type GoClass = new () => {
@@ -29,6 +29,12 @@ export type LtAccountAsset = {
29
29
  asset_id: number
30
30
  balance: string
31
31
  locked_balance: string
32
+ /**
33
+ * Cross-margin collateral flag: 0 = MarginDisabled, 1 = MarginEnabled.
34
+ * Lighter surfaces it via `additional_properties`, so it may be absent —
35
+ * chiefly outside Unified Trading Account mode.
36
+ */
37
+ margin_mode?: number
32
38
  }
33
39
 
34
40
  /**
@@ -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)
@@ -0,0 +1,16 @@
1
+ // Lighter's `AssetMarginMode` wire ints (lighter-go
2
+ // `types/txtypes/update_account_asset_config.go`), used for both the write
3
+ // direction (signing `L2UpdateAccountAssetConfigTx`, tx type 42) and decoding
4
+ // an account asset's `margin_mode` on the read side.
5
+ const ASSET_MARGIN_DISABLED = 0
6
+ const ASSET_MARGIN_ENABLED = 1
7
+
8
+ /** Map the user-facing `enabled` flag to Lighter's `AssetMarginMode` wire int. */
9
+ export function assetMarginModeInt(enabled: boolean): number {
10
+ return enabled ? ASSET_MARGIN_ENABLED : ASSET_MARGIN_DISABLED
11
+ }
12
+
13
+ /** Decode Lighter's `margin_mode` int on an account asset back to `enabled`. */
14
+ export function isAssetMarginEnabled(marginMode: number): boolean {
15
+ return marginMode === ASSET_MARGIN_ENABLED
16
+ }