@lifi/perps-sdk 18.0.0 → 19.0.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 (67) hide show
  1. package/README.md +12 -0
  2. package/dist/cjs/client/PerpsClient.d.ts +15 -8
  3. package/dist/cjs/client/PerpsClient.d.ts.map +1 -1
  4. package/dist/cjs/client/PerpsClient.js +145 -52
  5. package/dist/cjs/client/PerpsClient.js.map +1 -1
  6. package/dist/cjs/index.d.ts +4 -4
  7. package/dist/cjs/index.d.ts.map +1 -1
  8. package/dist/cjs/index.js +2 -1
  9. package/dist/cjs/index.js.map +1 -1
  10. package/dist/cjs/types/api.d.ts +8 -2
  11. package/dist/cjs/types/api.d.ts.map +1 -1
  12. package/dist/cjs/types/provider.d.ts +9 -6
  13. package/dist/cjs/types/provider.d.ts.map +1 -1
  14. package/dist/cjs/types/withdrawal.d.ts +11 -1
  15. package/dist/cjs/types/withdrawal.d.ts.map +1 -1
  16. package/dist/cjs/utils/setupActions.d.ts +4 -2
  17. package/dist/cjs/utils/setupActions.d.ts.map +1 -1
  18. package/dist/cjs/utils/setupActions.js +9 -1
  19. package/dist/cjs/utils/setupActions.js.map +1 -1
  20. package/dist/cjs/utils/switchChain.js +1 -1
  21. package/dist/cjs/utils/switchChain.js.map +1 -1
  22. package/dist/cjs/version.d.ts +1 -1
  23. package/dist/cjs/version.js +1 -1
  24. package/dist/esm/client/PerpsClient.d.ts +74 -55
  25. package/dist/esm/client/PerpsClient.d.ts.map +1 -1
  26. package/dist/esm/client/PerpsClient.js +218 -100
  27. package/dist/esm/client/PerpsClient.js.map +1 -1
  28. package/dist/esm/index.d.ts +4 -4
  29. package/dist/esm/index.d.ts.map +1 -1
  30. package/dist/esm/index.js +1 -1
  31. package/dist/esm/index.js.map +1 -1
  32. package/dist/esm/types/api.d.ts +40 -14
  33. package/dist/esm/types/api.d.ts.map +1 -1
  34. package/dist/esm/types/provider.d.ts +39 -28
  35. package/dist/esm/types/provider.d.ts.map +1 -1
  36. package/dist/esm/types/withdrawal.d.ts +30 -1
  37. package/dist/esm/types/withdrawal.d.ts.map +1 -1
  38. package/dist/esm/utils/setupActions.d.ts +17 -8
  39. package/dist/esm/utils/setupActions.d.ts.map +1 -1
  40. package/dist/esm/utils/setupActions.js +20 -7
  41. package/dist/esm/utils/setupActions.js.map +1 -1
  42. package/dist/esm/utils/switchChain.js +1 -1
  43. package/dist/esm/utils/switchChain.js.map +1 -1
  44. package/dist/esm/version.d.ts +1 -1
  45. package/dist/esm/version.js +1 -1
  46. package/dist/types/client/PerpsClient.d.ts +74 -55
  47. package/dist/types/client/PerpsClient.d.ts.map +1 -1
  48. package/dist/types/index.d.ts +4 -4
  49. package/dist/types/index.d.ts.map +1 -1
  50. package/dist/types/types/api.d.ts +40 -14
  51. package/dist/types/types/api.d.ts.map +1 -1
  52. package/dist/types/types/provider.d.ts +39 -28
  53. package/dist/types/types/provider.d.ts.map +1 -1
  54. package/dist/types/types/withdrawal.d.ts +30 -1
  55. package/dist/types/types/withdrawal.d.ts.map +1 -1
  56. package/dist/types/utils/setupActions.d.ts +17 -8
  57. package/dist/types/utils/setupActions.d.ts.map +1 -1
  58. package/dist/types/version.d.ts +1 -1
  59. package/package.json +2 -2
  60. package/src/client/PerpsClient.ts +303 -130
  61. package/src/index.ts +9 -1
  62. package/src/types/api.ts +42 -14
  63. package/src/types/provider.ts +44 -30
  64. package/src/types/withdrawal.ts +33 -1
  65. package/src/utils/setupActions.ts +27 -13
  66. package/src/utils/switchChain.ts +1 -1
  67. package/src/version.ts +1 -1
@@ -1,4 +1,4 @@
1
- import { ActionType, META_PROVIDER, PerpsErrorCode, PerpsSigner, SigningMethod, } from '@lifi/perps-types';
1
+ import { ActionRelay, ActionType, META_PROVIDER, PerpsErrorCode, PerpsSigner, SigningMethod, } from '@lifi/perps-types';
2
2
  import Big from 'big.js';
3
3
  import { PerpsError } from '../errors/PerpsError.js';
4
4
  import { getAssetRegistry, toAssetDisplay } from '../registry/assetRegistry.js';
@@ -9,10 +9,95 @@ import { getAccount as fetchAccount } from '../services/getAccount.js';
9
9
  import { getOrder as fetchOrder, } from '../services/getOrder.js';
10
10
  import { getOrders as fetchOrders, } from '../services/getOrders.js';
11
11
  import { getProviders } from '../services/getProviders.js';
12
+ import { isUserFacingSetupStep } from '../utils/setupActions.js';
12
13
  import { signTypedDataWithSigner } from '../utils/signTypedData.js';
13
14
  import { eip712DomainChainId, switchSigningChain, userEip712TargetChainId, } from '../utils/switchChain.js';
14
15
  import { createPerpsClient } from './createPerpsClient.js';
15
16
  import { requireProvider as resolveProvider } from './requireProvider.js';
17
+ /** Absent `sequence` sorts last, so an unordered step gates nothing. */
18
+ function sequenceOf(descriptor) {
19
+ return descriptor.sequence ?? Number.MAX_SAFE_INTEGER;
20
+ }
21
+ const SIGNERS = new Set(Object.values(PerpsSigner));
22
+ const RELAYS = new Set(Object.values(ActionRelay));
23
+ /**
24
+ * Reject provider metadata whose steps the SDK cannot classify. A step with no
25
+ * known `signer`, `relay`, `options` or `revoke` would otherwise read as
26
+ * satisfied and report a not-onboarded account as ready.
27
+ */
28
+ function assertSetupContract(metadata) {
29
+ const reject = (step, problem) => {
30
+ throw new PerpsError(PerpsErrorCode.SDKError, `Provider '${metadata.key}' step '${step}' ${problem}; this SDK needs a ` +
31
+ `backend that emits signer, relay, options and revoke.`);
32
+ };
33
+ for (const descriptor of [...metadata.setup, ...metadata.actions]) {
34
+ if (!SIGNERS.has(descriptor.signer)) {
35
+ reject(descriptor.type, 'declares no known signer');
36
+ }
37
+ if (!RELAYS.has(descriptor.relay)) {
38
+ reject(descriptor.type, 'declares no known relay');
39
+ }
40
+ }
41
+ const actionTypes = new Set(metadata.actions.map((d) => d.type));
42
+ for (const descriptor of metadata.setup) {
43
+ if (descriptor.options === undefined) {
44
+ reject(descriptor.type, 'declares no options');
45
+ }
46
+ if (descriptor.options !== null && descriptor.options.length === 0) {
47
+ reject(descriptor.type, 'declares an empty options list');
48
+ }
49
+ if (descriptor.revoke === undefined) {
50
+ reject(descriptor.type, 'declares no revoke');
51
+ }
52
+ if (descriptor.revoke !== null && !actionTypes.has(descriptor.revoke)) {
53
+ reject(descriptor.type, `revokes with '${descriptor.revoke}', which Provider.actions does not declare`);
54
+ }
55
+ const defaults = (descriptor.options ?? []).filter((option) => option.default === true);
56
+ if (defaults.length > 1) {
57
+ reject(descriptor.type, 'marks more than one option default');
58
+ }
59
+ }
60
+ }
61
+ /** Only a USER-signed non-choice step is staged for the user to sign. */
62
+ function isStageable(descriptor) {
63
+ return descriptor.options === null && descriptor.signer === PerpsSigner.USER;
64
+ }
65
+ function satisfiedTypes(settings) {
66
+ return new Set(settings.filter((setting) => setting.satisfied).map((s) => s.type));
67
+ }
68
+ /** The choice option whose every bound param equals the projected value. */
69
+ function selectedOption(descriptor, settings) {
70
+ if (descriptor.options === null) {
71
+ return null;
72
+ }
73
+ const values = settings.find((setting) => setting.type === descriptor.type)?.values ?? [];
74
+ return (descriptor.options.find((option) => {
75
+ const bound = Object.entries(option.params);
76
+ return (bound.length > 0 &&
77
+ bound.every(([name, value]) => values.some((v) => v.name === name && v.value === value)));
78
+ }) ?? null);
79
+ }
80
+ /**
81
+ * The projected value of the param a choice's options bind, as the venue
82
+ * names it, whether or not an option binds that value.
83
+ */
84
+ function currentChoiceValue(descriptor, settings) {
85
+ if (descriptor.options === null) {
86
+ return null;
87
+ }
88
+ const names = new Set(descriptor.options.flatMap((option) => Object.keys(option.params)));
89
+ const value = settings
90
+ .find((setting) => setting.type === descriptor.type)
91
+ ?.values.find((v) => names.has(v.name))?.value ?? null;
92
+ return value === null ? null : String(value);
93
+ }
94
+ /** Throw the first per-action failure a 200 OK `/executeAction` carried. */
95
+ function assertAllSucceeded(results) {
96
+ const failure = results.find((r) => !r.success);
97
+ if (failure) {
98
+ throw new PerpsError(failure.errorCode ?? PerpsErrorCode.ExchangeRejected, failure.error);
99
+ }
100
+ }
16
101
  function isNonNegativeDecimal(value) {
17
102
  try {
18
103
  return new Big(value).gte(0);
@@ -26,11 +111,7 @@ function isNonNegativeDecimal(value) {
26
111
  * action isn't declared — defensive: better to fail loudly than to mis-sign.
27
112
  */
28
113
  function findActionDescriptor(metadata, action) {
29
- const descriptor = [
30
- ...metadata.setup,
31
- ...metadata.options,
32
- ...metadata.actions,
33
- ].find((d) => d.type === action);
114
+ const descriptor = [...metadata.setup, ...metadata.actions].find((d) => d.type === action);
34
115
  if (!descriptor) {
35
116
  throw new PerpsError(PerpsErrorCode.SDKError, `Provider '${metadata.key}' does not declare action '${action}'.`);
36
117
  }
@@ -82,7 +163,7 @@ export class PerpsClient {
82
163
  }
83
164
  /**
84
165
  * Set or update the end-user's wallet. Used whenever an action's descriptor
85
- * names the user wallet in its `signers` list. Pass undefined to clear.
166
+ * names `PerpsSigner.USER` as its `signer`. Pass undefined to clear.
86
167
  *
87
168
  * @public
88
169
  */
@@ -113,20 +194,20 @@ export class PerpsClient {
113
194
  return this.sdkClient;
114
195
  }
115
196
  async getProviderMetadata(provider) {
116
- const cached = this.providerMetadataCache.get(provider);
117
- if (cached) {
118
- return cached;
119
- }
120
- const { providers } = await getProviders(this.sdkClient);
121
- for (const d of providers) {
122
- this.providerMetadataCache.set(d.key, d);
197
+ let metadata = this.providerMetadataCache.get(provider);
198
+ if (!metadata) {
199
+ const { providers } = await getProviders(this.sdkClient);
200
+ for (const d of providers) {
201
+ this.providerMetadataCache.set(d.key, d);
202
+ }
203
+ metadata = this.providerMetadataCache.get(provider);
123
204
  }
124
- const metadata = this.providerMetadataCache.get(provider);
125
205
  if (!metadata) {
126
206
  const error = new PerpsError(PerpsErrorCode.SDKError, `Unsupported provider: ${provider}`);
127
207
  error.tool = '@lifi/perps-sdk';
128
208
  throw error;
129
209
  }
210
+ assertSetupContract(metadata);
130
211
  return metadata;
131
212
  }
132
213
  /**
@@ -149,7 +230,7 @@ export class PerpsClient {
149
230
  * Ask the provider plugin for the signer-bearing wire fields of `action` —
150
231
  * the on-wire `signerAddress` and any signer-derived params (e.g.
151
232
  * Hyperliquid's `agentAddress` for `APPROVE_AGENT`). Forwards the descriptor's
152
- * `signers` so the plugin can branch on signer role. Returns empty when the
233
+ * `signer` so the plugin can branch on signer role. Returns empty when the
153
234
  * plugin signs as the user or with a non-EVM credential. Core constructs no
154
235
  * `signerAddress` itself; signer identity is plugin-owned.
155
236
  */
@@ -158,15 +239,15 @@ export class PerpsClient {
158
239
  if (typeof plugin.resolveActionRequest !== 'function') {
159
240
  return {};
160
241
  }
161
- return plugin.resolveActionRequest(descriptor.type, address, descriptor.signers);
242
+ return plugin.resolveActionRequest(descriptor.type, address, descriptor.signer);
162
243
  }
163
244
  /**
164
245
  * Delegate signing of `actions` to the provider plugin, one call per
165
246
  * consecutive run of steps sharing a descriptor. Each call carries that
166
- * descriptor's `signingMethod` and `signers`, so a mixed batch signs every
247
+ * descriptor's `signingMethod` and `signer`, so a mixed batch signs every
167
248
  * step with the signer its own descriptor declares; the signed steps come
168
249
  * back in the batch's original order. The plugin owns every signing arm and
169
- * branches on `signers` internally, reading the end-user's wallet from the
250
+ * branches on `signer` internally, reading the end-user's wallet from the
170
251
  * {@link SignActionsContext} when an arm signs as the user.
171
252
  *
172
253
  * @throws {PerpsError} When a step's action is not declared by the provider,
@@ -250,9 +331,9 @@ export class PerpsClient {
250
331
  }
251
332
  /**
252
333
  * Assemble the per-call context the provider plugin needs in order to sign:
253
- * the end-user's wallet and the descriptor's declared `signers`. Core
254
- * forwards `signers` as data so the plugin can pick WHO signs; it does not
255
- * branch on them. Provider-owned session credentials (the Hyperliquid agent
334
+ * the end-user's wallet and the descriptor's declared `signer`. Core
335
+ * forwards `signer` as data so the plugin can pick WHO signs; it does not
336
+ * branch on it. Provider-owned session credentials (the Hyperliquid agent
256
337
  * keypair, Lighter's API key) are resolved inside the provider's
257
338
  * `signActions`, not threaded through here.
258
339
  *
@@ -266,7 +347,7 @@ export class PerpsClient {
266
347
  * `sdkClient.userWallet`.
267
348
  */
268
349
  buildSignActionsContext(descriptor, userWallet, onProgress) {
269
- const ctx = { signers: descriptor.signers };
350
+ const ctx = { signer: descriptor.signer };
270
351
  if (onProgress !== undefined) {
271
352
  ctx.onProgress = onProgress;
272
353
  }
@@ -321,7 +402,7 @@ export class PerpsClient {
321
402
  /**
322
403
  * Fetch the user's account state from the backend and attach the
323
404
  * SDK-projected `settings` array — one `AccountConfigSetting` per
324
- * descriptor on `Provider.setup` + `Provider.options`. Callers read
405
+ * descriptor on `Provider.setup`. Callers read
325
406
  * `result.settings` directly without re-deriving values from the typed
326
407
  * `AccountConfig`.
327
408
  *
@@ -335,7 +416,7 @@ export class PerpsClient {
335
416
  fetchAccount(this.sdkClient, params),
336
417
  this.getProviderMetadata(params.provider),
337
418
  ]);
338
- const settings = plugin.projectConfig(response.config, metadata.setup, metadata.options);
419
+ const settings = plugin.projectConfig(response.config, metadata.setup);
339
420
  return { ...response, settings };
340
421
  }
341
422
  /**
@@ -436,6 +517,20 @@ export class PerpsClient {
436
517
  const plugin = this.requireProvider(params.provider);
437
518
  return plugin.getDepositFlow?.({ address: params.address });
438
519
  }
520
+ /**
521
+ * The withdrawal flow for `params.address` at `params.provider`, delegated to
522
+ * the provider plugin — the withdrawal destination and any setup gate before
523
+ * it are venue-owned.
524
+ *
525
+ * @returns `undefined` when the registered plugin declares no withdrawal flow.
526
+ * @throws {PerpsError} When the provider plugin is not registered, or the
527
+ * plugin's flow resolution fails.
528
+ * @public
529
+ */
530
+ async getWithdrawFlow(params) {
531
+ const plugin = this.requireProvider(params.provider);
532
+ return plugin.getWithdrawFlow?.({ address: params.address });
533
+ }
439
534
  /**
440
535
  * The `(asset, route)` selections `params.address` can actually withdraw at
441
536
  * `params.provider`. The venue owns how its balances split across routes;
@@ -511,15 +606,17 @@ export class PerpsClient {
511
606
  return plugin.getPortfolioHistory({ address: params.address, range: params.range }, options);
512
607
  }
513
608
  /**
514
- * Return the unsatisfied entries on `Provider.setup` for this account as a
515
- * flat, self-describing list. Trading is gated on `isReady === true`.
516
- * `checklist` carries the renderable onboarding list: every USER-signed
517
- * descriptor with its satisfied state, with not-required conditional steps
518
- * omitted.
609
+ * Return the setup steps this account must still sign as a flat,
610
+ * self-describing list. Trading is gated on `isReady === true`.
611
+ * `checklist` carries the renderable onboarding list: every choice step and
612
+ * every `USER`-signed step with its satisfied state, with not-required
613
+ * conditional steps omitted.
519
614
  *
520
- * `Provider.options` descriptors are NEVER returned here — options are
521
- * post-setup tunables and never gate trading. Option state is surfaced
522
- * separately via `getAccount().settings`.
615
+ * A non-choice `SDK`-signed step is NEVER returned here — the SDK drains it
616
+ * with the provider's own credentials. A choice is listed but never staged
617
+ * or executed, whatever its `signer`: the user picks an option through
618
+ * {@link executeProviderOption}, and `isReady` stays `false` until every
619
+ * choice on `checklist` is satisfied.
523
620
  *
524
621
  * @public
525
622
  */
@@ -540,61 +637,58 @@ export class PerpsClient {
540
637
  checklist: [],
541
638
  };
542
639
  }
543
- const satisfiedSetup = await this.resolveSatisfiedSetup(provider, address);
640
+ const settings = await this.resolveSetupSettings(provider, address);
641
+ const satisfiedSetup = satisfiedTypes(settings);
544
642
  const pendingSetup = metadata.setup.filter((descriptor) => !satisfiedSetup.has(descriptor.type));
545
643
  const plugin = this.sdkClient.getProvider(provider);
546
- const internalTypes = new Set(plugin?.internalSetupActions ?? []);
547
- const isInternal = (descriptor) => internalTypes.has(descriptor.type) &&
548
- !descriptor.signers.includes(PerpsSigner.USER);
549
- const visibleSetup = pendingSetup.filter((descriptor) => !isInternal(descriptor));
644
+ const stageable = pendingSetup.filter(isStageable);
550
645
  // The backend filters already-satisfied setup actions and returns typed
551
646
  // data for those still outstanding; each plugin contributes its own
552
647
  // signer-bearing request fields.
553
- const actions = await this.buildProviderSetupActions(provider, address, visibleSetup);
648
+ const actions = await this.buildProviderSetupActions(provider, address, stageable);
554
649
  // A staged step is one the build produced actions for — a pending
555
650
  // descriptor the backend staged nothing for is either satisfied
556
651
  // (backend-gated) or not applicable to this account.
557
652
  const stagedTypes = new Set(actions.map((step) => step.action));
558
653
  const conditionalTypes = new Set(plugin?.conditionalSetupActions ?? []);
559
- const sequenceOf = (descriptor) => descriptor.sequence ?? Number.MAX_SAFE_INTEGER;
560
654
  const checklist = metadata.setup
561
- .filter((descriptor) => descriptor.signers.includes(PerpsSigner.USER))
562
- .filter((descriptor) => !conditionalTypes.has(descriptor.type) ||
655
+ .filter(isUserFacingSetupStep)
656
+ .filter((descriptor) => descriptor.options !== null ||
657
+ !conditionalTypes.has(descriptor.type) ||
563
658
  stagedTypes.has(descriptor.type))
564
659
  .sort((a, b) => sequenceOf(a) - sequenceOf(b))
565
660
  .map((descriptor) => ({
566
661
  descriptor,
567
- satisfied: !stagedTypes.has(descriptor.type),
662
+ satisfied: descriptor.options !== null
663
+ ? satisfiedSetup.has(descriptor.type)
664
+ : !stagedTypes.has(descriptor.type),
665
+ selected: selectedOption(descriptor, settings),
666
+ currentValue: currentChoiceValue(descriptor, settings),
568
667
  }));
569
- await this.drainInternalSetup(provider, address, pendingSetup.filter(isInternal), visibleSetup.filter((descriptor) => stagedTypes.has(descriptor.type)));
668
+ await this.drainSetup(provider, address, pendingSetup.filter((descriptor) => descriptor.options === null && descriptor.signer === PerpsSigner.SDK), stageable.filter((descriptor) => stagedTypes.has(descriptor.type)));
570
669
  return {
571
670
  accountExists: true,
572
671
  setup: actions,
573
- isReady: actions.length === 0,
672
+ isReady: actions.length === 0 && checklist.every((item) => item.satisfied),
574
673
  checklist,
575
674
  };
576
675
  }
577
676
  /**
578
- * Resolve setup descriptors already satisfied from the provider's own typed
579
- * account config projection. This catches client-held auth state (e.g. Ondo
580
- * SIWE/JWT) that the backend cannot observe.
677
+ * Project the provider's own typed account config onto its setup steps. This
678
+ * catches client-held auth state (e.g. Ondo SIWE/JWT) that the backend
679
+ * cannot observe, and carries the current value of each choice.
581
680
  */
582
- async resolveSatisfiedSetup(provider, address) {
681
+ async resolveSetupSettings(provider, address) {
583
682
  const metadata = await this.getProviderMetadata(provider);
584
683
  const plugin = this.sdkClient.getProvider(provider);
585
684
  if (!plugin || typeof plugin.getAccount !== 'function') {
586
- return new Set();
685
+ return [];
587
686
  }
588
687
  const account = await plugin.getAccount({ address });
589
- const settings = plugin.projectConfig(account.config, metadata.setup, metadata.options);
590
- const setupTypes = new Set(metadata.setup.map((descriptor) => descriptor.type));
591
- return new Set(settings
592
- .filter((setting) => setting.satisfied && setupTypes.has(setting.type))
593
- .map((setting) => setting.type));
688
+ return plugin.projectConfig(account.config, metadata.setup);
594
689
  }
595
690
  /**
596
- * Drain the pending setup steps the provider declares as internal via
597
- * `PerpsProviderPlugin.internalSetupActions`. Each step is built, signed,
691
+ * Drain the pending `SDK`-signed non-choice steps: each is built, signed,
598
692
  * and executed in place with the provider's own credentials. A step is
599
693
  * deferred while any staged user-facing step with a lower `sequence` is
600
694
  * outstanding — it cannot succeed before its prerequisite (e.g. SET_REFERRAL
@@ -603,11 +697,10 @@ export class PerpsClient {
603
697
  * blocks setup — the step stays unsatisfied and is retried on a later
604
698
  * `checkSetup`.
605
699
  */
606
- async drainInternalSetup(provider, address, internalPending, stagedVisible) {
607
- const sequenceOf = (descriptor) => descriptor.sequence ?? Number.MAX_SAFE_INTEGER;
608
- for (const descriptor of internalPending) {
700
+ async drainSetup(provider, address, drainable, stagedVisible) {
701
+ for (const descriptor of drainable) {
609
702
  // A tie (equal sequences, or both absent) declares no order, so the
610
- // internal step defers to the next checkSetup rather than racing.
703
+ // drained step defers to the next checkSetup rather than racing.
611
704
  const blocked = stagedVisible.some((staged) => sequenceOf(staged) <= sequenceOf(descriptor));
612
705
  if (blocked) {
613
706
  continue;
@@ -629,7 +722,7 @@ export class PerpsClient {
629
722
  }
630
723
  }
631
724
  catch (error) {
632
- console.debug(`[perps-sdk] internal setup step '${descriptor.type}' for '${provider}' did not complete; will retry on the next checkSetup.`, error);
725
+ console.debug(`[perps-sdk] setup step '${descriptor.type}' for '${provider}' did not drain; will retry on the next checkSetup.`, error);
633
726
  }
634
727
  }
635
728
  }
@@ -666,16 +759,18 @@ export class PerpsClient {
666
759
  }
667
760
  /**
668
761
  * Build the unsigned setup `ActionStep`s still outstanding for an account,
669
- * ordered by descriptor `sequence`. The backend filters already-satisfied
670
- * setup; each plugin contributes its own signer-bearing request fields and
671
- * any local-state params (e.g. Lighter's known pubkey).
762
+ * ordered by descriptor `sequence`. Only a `USER`-signed non-choice step is
763
+ * staged: an `SDK`-signed non-choice step drains inside {@link checkSetup}
764
+ * and a choice executes the option the user picks. The backend filters
765
+ * already-satisfied setup; each plugin contributes its own signer-bearing
766
+ * request fields and any local-state params (e.g. Lighter's known pubkey).
672
767
  *
673
768
  * @public
674
769
  */
675
770
  async buildProviderSetup(params) {
676
771
  const { provider, address } = params;
677
772
  const metadata = await this.getProviderMetadata(provider);
678
- const actions = await this.buildProviderSetupActions(provider, address, metadata.setup);
773
+ const actions = await this.buildProviderSetupActions(provider, address, metadata.setup.filter(isStageable));
679
774
  return { actions };
680
775
  }
681
776
  /**
@@ -704,35 +799,33 @@ export class PerpsClient {
704
799
  });
705
800
  const results = this.resolveExplorerLinks(provider, response.results);
706
801
  await this.notifyExecuteResults(provider, address, results);
707
- const failure = results.find((r) => !r.success);
708
- if (failure) {
709
- throw new PerpsError(failure.errorCode ?? PerpsErrorCode.ExchangeRejected, failure.error);
710
- }
802
+ assertAllSucceeded(results);
711
803
  return { results: { results } };
712
804
  }
713
805
  /**
714
806
  * Sign and submit one pre-staged setup `ActionStep` end-to-end.
715
807
  *
716
- * The caller is expected to have already obtained the step from a prior
717
- * {@link checkSetup} call (typically cached by the widget's react-query) —
718
- * we do NOT refetch. This avoids the double `createAction` round-trip and
719
- * keeps the nonce that was allocated at staging time committed all the way
720
- * through submit. If the cached step has gone stale (Lighter's `/nextNonce`
721
- * advanced underneath us), `executeProviderSetup` will surface a nonce
722
- * conflict that the caller invalidates on, refetches `checkSetup`, and
723
- * retries with a fresh step.
808
+ * `step` and `checklist` must come from the same {@link checkSetup} result.
809
+ * This preserves the nonce allocated while staging and validates sequence
810
+ * prerequisites against that same snapshot, without another `createAction`
811
+ * round-trip.
724
812
  *
725
813
  * @throws {PerpsError} When the step's action is not in the provider's
726
- * `setup` descriptors.
814
+ * `setup` descriptors, or when a lower-`sequence` step on the same
815
+ * checklist, a choice included, is still unsatisfied.
727
816
  * @public
728
817
  */
729
818
  async executeProviderSetupAction(params) {
730
- const { provider, address, step } = params;
819
+ const { provider, address, step, checklist } = params;
731
820
  const metadata = await this.getProviderMetadata(provider);
732
821
  const descriptor = metadata.setup.find((d) => d.type === step.action);
733
822
  if (!descriptor) {
734
823
  throw new PerpsError(PerpsErrorCode.SDKError, `Action '${step.action}' is not in '${provider}'.setup`);
735
824
  }
825
+ const blockingStep = checklist.find((item) => !item.satisfied && sequenceOf(item.descriptor) < sequenceOf(descriptor));
826
+ if (blockingStep) {
827
+ throw new PerpsError(PerpsErrorCode.SDKError, `Setup step '${step.action}' for '${provider}' is blocked: '${blockingStep.descriptor.type}' runs first and is not satisfied.`);
828
+ }
736
829
  const signed = await this.signProviderSetupAction(provider, address, step);
737
830
  // A client-executed setup action (Lighter SET_REFERRAL) produces no
738
831
  // backend-bound step — it already ran during signing, so there is nothing
@@ -748,22 +841,13 @@ export class PerpsClient {
748
841
  });
749
842
  }
750
843
  /**
751
- * Sign and submit a single provider-option change (a `Provider.options`
752
- * tunable such as Lighter `ACCOUNT_TYPE` or Hyperliquid `ACCOUNT_MODE`)
753
- * end-to-end, throwing on a venue rejection.
844
+ * Execute one option of a choice setup step (Lighter `ACCOUNT_TYPE`,
845
+ * Hyperliquid `ACCOUNT_MODE`) end-to-end with its bound `{ type, params }`,
846
+ * throwing on a venue rejection.
754
847
  *
755
- * Options are dispatched through the same {@link execute} pipeline as
756
- * trades, but unlike a trade an option change is a single mandatory action:
848
+ * The option runs through the same {@link execute} pipeline as a trade, but
757
849
  * a per-action `success: false` (returned as a 200 OK) means the user's
758
- * selection was rejected and must surface, not be silently dropped. This
759
- * wrapper inspects the result and throws a {@link PerpsError} carrying the
760
- * venue `error`, giving options the same throw contract that setup has via
761
- * {@link executeProviderSetupAction}. The only structural difference is that
762
- * an option carries `params` (the selected value) rather than a pre-staged
763
- * step.
764
- *
765
- * `execute` itself is unchanged — it still returns results without throwing,
766
- * which the trade hooks rely on for partial-fill handling.
850
+ * selection was rejected and must surface, not be silently dropped.
767
851
  *
768
852
  * @throws {PerpsError} Carrying the venue error when any returned result has
769
853
  * `success: false`, under that result's `errorCode` when the backend
@@ -773,11 +857,40 @@ export class PerpsClient {
773
857
  * @public
774
858
  */
775
859
  async executeProviderOption(params) {
776
- const { results } = await this.execute(params);
777
- const failure = results.find((r) => !r.success);
778
- if (failure) {
779
- throw new PerpsError(failure.errorCode ?? PerpsErrorCode.ExchangeRejected, failure.error);
860
+ const { provider, address, option } = params;
861
+ const { results } = await this.execute({
862
+ provider,
863
+ address,
864
+ action: option.type,
865
+ params: option.params,
866
+ });
867
+ assertAllSucceeded(results);
868
+ }
869
+ /**
870
+ * Undo a satisfied setup step by executing its `revoke` action through
871
+ * create, sign and execute, signed with that action's `Provider.actions`
872
+ * descriptor. The step stages again on the next {@link checkSetup}.
873
+ *
874
+ * @throws {PerpsError} `SDKError` when `step.revoke` is `null` or when
875
+ * `createAction` stages nothing to revoke; otherwise the errors
876
+ * {@link executeProviderOption} throws.
877
+ * @public
878
+ */
879
+ async executeProviderRevoke(params) {
880
+ const { provider, address, step } = params;
881
+ if (step.revoke === null) {
882
+ throw new PerpsError(PerpsErrorCode.SDKError, `Setup step '${step.type}' for '${provider}' declares no revoke.`);
883
+ }
884
+ const { staged, results } = await this.runAction({
885
+ provider,
886
+ address,
887
+ action: step.revoke,
888
+ params: {},
889
+ });
890
+ if (staged.length === 0) {
891
+ throw new PerpsError(PerpsErrorCode.SDKError, `Nothing to revoke for setup step '${step.type}' on '${provider}'.`);
780
892
  }
893
+ assertAllSucceeded(results);
781
894
  }
782
895
  /**
783
896
  * Place a market or limit order. Convenience wrapper over {@link execute}
@@ -922,6 +1035,11 @@ export class PerpsClient {
922
1035
  * @public
923
1036
  */
924
1037
  async execute(params) {
1038
+ const { results } = await this.runAction(params);
1039
+ return { results };
1040
+ }
1041
+ /** {@link execute}, also returning the steps `createAction` staged. */
1042
+ async runAction(params) {
925
1043
  const { provider, address, action, onProgress } = params;
926
1044
  const metadata = await this.getProviderMetadata(provider);
927
1045
  const descriptor = findActionDescriptor(metadata, action);
@@ -941,7 +1059,7 @@ export class PerpsClient {
941
1059
  // token-authenticated venue mutations), leaving no backend-bound step. With
942
1060
  // nothing to submit, skip the `/executeAction` hop.
943
1061
  if (signedActions.length === 0) {
944
- return { results: [] };
1062
+ return { staged: actions, results: [] };
945
1063
  }
946
1064
  const response = await executeAction(this.sdkClient, {
947
1065
  provider,
@@ -954,7 +1072,7 @@ export class PerpsClient {
954
1072
  });
955
1073
  const results = this.resolveExplorerLinks(provider, response.results);
956
1074
  await this.notifyExecuteResults(provider, address, results);
957
- return { results };
1075
+ return { staged: actions, results };
958
1076
  }
959
1077
  /**
960
1078
  * Accept the current terms and, when a code is supplied and the backend