@metamask-previews/perps-controller 22.0.0-preview-d0145b59e → 22.0.0-preview-896e0b6

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.
@@ -791,6 +791,9 @@ export class HyperLiquidProvider {
791
791
  // Session cache for referral state (cleared on disconnect/reconnect)
792
792
  // Key: `network:userAddress`, Value: true if referral is set
793
793
  #referralCheckCache = new Map();
794
+ // Session-scoped, account/network-keyed support probes. Provider instances
795
+ // are discarded on reconnect; rejected probes are not retained.
796
+ #accountSupportByContext = new Map();
794
797
  // Session cache for builder fee approval state (cleared on disconnect/reconnect)
795
798
  // Key: `network:userAddress`, Value: true if builder fee is approved
796
799
  #builderFeeCheckCache = new Map();
@@ -1736,19 +1739,21 @@ export class HyperLiquidProvider {
1736
1739
  * multi-sig account with `ApiRequestError: Multi-sig required`, so the
1737
1740
  * unified-account migration must not be attempted for those accounts.
1738
1741
  *
1739
- * If the probe throws (transient network), returns `false` — fail open so
1740
- * one bad probe never blocks migration for a normal single-signer account.
1742
+ * If the probe throws (transient network), returns `undefined` so callers
1743
+ * can fail open without caching the transient result.
1741
1744
  * The `isHyperLiquidMultiSigRequiredError` fallback in the write's catch
1742
1745
  * block remains the safety net.
1743
1746
  *
1744
1747
  * @param userAddress - The wallet address to check.
1745
1748
  * @param infoClient - Optional captured reader for an operation-scoped probe.
1746
- * @returns True only when Hyperliquid reports a multi-sig signer set.
1749
+ * @returns True for multi-sig, false for single-signer, or undefined when
1750
+ * the probe could not complete.
1747
1751
  * @private
1748
1752
  */
1749
- async #isHyperliquidMultiSigAccount(userAddress, infoClient = this.#clientService.getInfoClient()) {
1753
+ async #isHyperliquidMultiSigAccount(userAddress, infoClient) {
1750
1754
  try {
1751
- const signers = await infoClient.userToMultiSigSigners({
1755
+ const reader = infoClient ?? this.#clientService.getInfoClient();
1756
+ const signers = await reader.userToMultiSigSigners({
1752
1757
  user: userAddress,
1753
1758
  });
1754
1759
  return signers !== null && signers !== undefined;
@@ -1758,8 +1763,165 @@ export class HyperLiquidProvider {
1758
1763
  user: userAddress,
1759
1764
  error: ensureError(error, 'HyperLiquidProvider.isHyperliquidMultiSigAccount').message,
1760
1765
  });
1766
+ return undefined;
1767
+ }
1768
+ }
1769
+ #getAccountSupportCacheKey(network, userAddress) {
1770
+ return `${network}:${userAddress.toLowerCase()}`;
1771
+ }
1772
+ #recordAccountSupport(network, userAddress, support) {
1773
+ this.#accountSupportByContext.set(this.#getAccountSupportCacheKey(network, userAddress), Promise.resolve(support));
1774
+ }
1775
+ /**
1776
+ * Replace an optimistic support result after the venue authoritatively
1777
+ * rejects a write from the account that supplied its signature.
1778
+ *
1779
+ * The captured context, rather than the currently selected account, owns the
1780
+ * rejection because selection may change while the request is in flight.
1781
+ *
1782
+ * @param error - The failed write or mapped account-mode error.
1783
+ * @param accountContext - Account and network that authorized the write.
1784
+ * @returns Whether the rejection requires Hyperliquid multi-signature.
1785
+ */
1786
+ #recordUnsupportedAccountFromError(error, accountContext) {
1787
+ const isMultiSigRequired = isHyperLiquidMultiSigRequiredError(error) ||
1788
+ ensureError(error, 'HyperLiquidProvider.recordUnsupportedAccountFromError').message === PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED;
1789
+ if (!isMultiSigRequired) {
1761
1790
  return false;
1762
1791
  }
1792
+ this.#recordAccountSupport(accountContext.network, accountContext.userAddress, {
1793
+ isSupported: false,
1794
+ reason: 'multi_sig_account',
1795
+ });
1796
+ return true;
1797
+ }
1798
+ /**
1799
+ * Correct support from errors returned as resolved per-entry statuses.
1800
+ *
1801
+ * @param statuses - Venue status entries.
1802
+ * @param answer - Full venue answer that owns the statuses.
1803
+ * @param accountContext - Account and network that authorized the write.
1804
+ * @returns Whether any status requires Hyperliquid multi-signature.
1805
+ */
1806
+ #recordUnsupportedAccountFromStatuses(statuses, answer, accountContext) {
1807
+ return statuses.some((status) => isStatusObject(status) &&
1808
+ typeof status.error === 'string' &&
1809
+ this.#recordUnsupportedAccountFromError(createStatusError(status.error, answer), accountContext));
1810
+ }
1811
+ /**
1812
+ * Read support for an account context without initializing the provider.
1813
+ * Successful results are stable for the provider session and coalesced by
1814
+ * account and network. Failed probes fail open and remain retryable.
1815
+ *
1816
+ * @param context - An operation-scoped account context. Defaults to the
1817
+ * currently active account and network.
1818
+ * @returns Whether the account can submit Hyperliquid actions.
1819
+ */
1820
+ async #getAccountSupportForContext(context) {
1821
+ const userAddress = context?.userAddress ??
1822
+ (await this.#walletService.getUserAddressWithDefault());
1823
+ const network = context?.network ??
1824
+ (this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet');
1825
+ const cacheKey = this.#getAccountSupportCacheKey(network, userAddress);
1826
+ const cachedSupport = this.#accountSupportByContext.get(cacheKey);
1827
+ if (cachedSupport) {
1828
+ return cachedSupport;
1829
+ }
1830
+ const probe = this.#isHyperliquidMultiSigAccount(userAddress, context?.infoClient);
1831
+ const support = probe.then((isMultiSig) => isMultiSig
1832
+ ? { isSupported: false, reason: 'multi_sig_account' }
1833
+ : { isSupported: true }, () => ({ isSupported: true }));
1834
+ this.#accountSupportByContext.set(cacheKey, support);
1835
+ probe
1836
+ .then((isMultiSig) => {
1837
+ if (isMultiSig === undefined &&
1838
+ this.#accountSupportByContext.get(cacheKey) === support) {
1839
+ this.#accountSupportByContext.delete(cacheKey);
1840
+ }
1841
+ }, () => {
1842
+ if (this.#accountSupportByContext.get(cacheKey) === support) {
1843
+ this.#accountSupportByContext.delete(cacheKey);
1844
+ }
1845
+ })
1846
+ .catch(() => undefined);
1847
+ return support;
1848
+ }
1849
+ async #assertAccountContextCurrent(context, operation) {
1850
+ this.#assertProviderLifecycleCurrent(context.lifecycleGeneration, operation);
1851
+ await this.#assertAccountIdentityCurrent(context);
1852
+ this.#assertProviderLifecycleCurrent(context.lifecycleGeneration, operation);
1853
+ }
1854
+ /**
1855
+ * Confirm that the selected address and network still match an action,
1856
+ * without requiring the provider lifecycle that created it to remain active.
1857
+ *
1858
+ * @param context - The account and network captured by the action.
1859
+ */
1860
+ async #assertAccountIdentityCurrent(context) {
1861
+ const currentAddress = await this.#walletService
1862
+ .getUserAddressWithDefault()
1863
+ .catch(() => undefined);
1864
+ const currentNetwork = this.#clientService.isTestnetMode()
1865
+ ? 'testnet'
1866
+ : 'mainnet';
1867
+ if (currentAddress?.toLowerCase() !== context.userAddress.toLowerCase() ||
1868
+ currentNetwork !== context.network) {
1869
+ throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE);
1870
+ }
1871
+ }
1872
+ /**
1873
+ * Permit best-effort rollback after teardown only while it still belongs to
1874
+ * the same address and network, and the account is not known unsupported.
1875
+ *
1876
+ * @param context - The account and network captured by the parent action.
1877
+ */
1878
+ async #assertRollbackAccountWritable(context) {
1879
+ await this.#assertAccountIdentityCurrent(context);
1880
+ const cachedSupport = this.#accountSupportByContext.get(this.#getAccountSupportCacheKey(context.network, context.userAddress));
1881
+ const support = cachedSupport ? await cachedSupport : undefined;
1882
+ await this.#assertAccountIdentityCurrent(context);
1883
+ if (support && !support.isSupported) {
1884
+ throw new Error(PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED);
1885
+ }
1886
+ }
1887
+ /**
1888
+ * Block an action when another step has authoritatively corrected support
1889
+ * for its captured account. A missing cache entry remains fail-open: this
1890
+ * fence must not turn a transient probe failure into another network probe.
1891
+ *
1892
+ * @param context - The account, network, and lifecycle captured by the action.
1893
+ * @param operation - Operation name included in lifecycle diagnostics.
1894
+ */
1895
+ async #assertNoKnownUnsupportedAccount(context, operation) {
1896
+ await this.#assertAccountContextCurrent(context, operation);
1897
+ const cachedSupport = this.#accountSupportByContext.get(this.#getAccountSupportCacheKey(context.network, context.userAddress));
1898
+ const support = cachedSupport ? await cachedSupport : undefined;
1899
+ await this.#assertAccountContextCurrent(context, operation);
1900
+ if (support && !support.isSupported) {
1901
+ throw new Error(PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED);
1902
+ }
1903
+ }
1904
+ /**
1905
+ * Stop an action before signing when Hyperliquid reports a native multi-sig
1906
+ * signer set for the active account.
1907
+ *
1908
+ * @param expectedContext - Existing operation context to validate, or omit
1909
+ * to capture the currently selected account and network.
1910
+ * @returns The account context whose support was checked.
1911
+ */
1912
+ async #assertAccountSupported(expectedContext) {
1913
+ const context = expectedContext ??
1914
+ {
1915
+ lifecycleGeneration: this.#lifecycleGeneration,
1916
+ network: this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet',
1917
+ userAddress: await this.#walletService.getUserAddressWithDefault(),
1918
+ };
1919
+ const support = await this.#getAccountSupportForContext(context);
1920
+ await this.#assertAccountContextCurrent(context, 'Account support check');
1921
+ if (!support.isSupported) {
1922
+ throw new Error(PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED);
1923
+ }
1924
+ return context;
1763
1925
  }
1764
1926
  /**
1765
1927
  * Attempt to enable HyperLiquid Unified Account mode for HIP-3 orders
@@ -1772,6 +1934,8 @@ export class HyperLiquidProvider {
1772
1934
  *
1773
1935
  * @param options - Optional configuration.
1774
1936
  * @param options.allowUserSigning - When true, runs the migration for `default` / `disabled` accounts. Defaults to false so init does not surface a signing prompt; action-time entry points (trading, withdraw) pass true.
1937
+ * @param options.accountContext - Account/network/lifecycle captured by the
1938
+ * action that is allowed to authorize the migration.
1775
1939
  * @private
1776
1940
  */
1777
1941
  async #ensureUnifiedAccountEnabled(options) {
@@ -1790,8 +1954,11 @@ export class HyperLiquidProvider {
1790
1954
  if (!this.#useUnifiedAccount) {
1791
1955
  return; // Feature disabled
1792
1956
  }
1793
- const userAddress = await this.#walletService.getUserAddressWithDefault();
1794
- const network = this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet';
1957
+ const { accountContext } = options ?? {};
1958
+ const userAddress = accountContext?.userAddress ??
1959
+ (await this.#walletService.getUserAddressWithDefault());
1960
+ const network = accountContext?.network ??
1961
+ (this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet');
1795
1962
  // Check global cache first to avoid repeated signing requests
1796
1963
  // This is CRITICAL for hardware wallets to prevent repeated signing prompts
1797
1964
  // while browsing.
@@ -1859,6 +2026,9 @@ export class HyperLiquidProvider {
1859
2026
  currentMode = await infoClient.userAbstraction({
1860
2027
  user: userAddress,
1861
2028
  });
2029
+ if (accountContext) {
2030
+ await this.#assertNoKnownUnsupportedAccount(accountContext, 'Unified account mode lookup');
2031
+ }
1862
2032
  if (currentMode === 'unifiedAccount' ||
1863
2033
  currentMode === 'portfolioMargin') {
1864
2034
  // portfolioMargin is a superset of unifiedAccount — it already supports
@@ -1902,17 +2072,21 @@ export class HyperLiquidProvider {
1902
2072
  // surfaced on the Perps tab on every entry (TAT-3214). Probe right
1903
2073
  // before the write so accounts that never reach one (already compatible,
1904
2074
  // deferred, unknown mode) do not pay the extra round trip.
1905
- const isMultiSig = await this.#isHyperliquidMultiSigAccount(userAddress);
1906
- if (isMultiSig) {
2075
+ const accountSupport = await this.#getAccountSupportForContext({
2076
+ userAddress,
2077
+ network,
2078
+ infoClient,
2079
+ });
2080
+ if (!accountSupport.isSupported) {
1907
2081
  this.#deps.debugLogger.log('[ensureUnifiedAccountEnabled] Multi-sig account, skipping unified account migration', { user: userAddress, network, mode: currentMode });
1908
2082
  this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.AccountSetup, {
1909
2083
  [PERPS_EVENT_PROPERTY.PREVIOUS_ABSTRACTION_MODE]: currentMode,
1910
2084
  [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.NOT_APPLICABLE,
1911
2085
  [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: 'multi_sig_account',
1912
2086
  });
1913
- // Final state: the write can never succeed for this account, so cache
1914
- // it as attempted with unified mode off. Perps keeps working through
1915
- // the programmatic collateral-transfer fallback.
2087
+ // Final state: the migration can never succeed for this account, so
2088
+ // cache it as attempted with unified mode off. Action readiness blocks
2089
+ // this account before any later exchange write.
1916
2090
  TradingReadinessCache.set(network, userAddress, {
1917
2091
  attempted: true,
1918
2092
  enabled: false,
@@ -1933,7 +2107,13 @@ export class HyperLiquidProvider {
1933
2107
  previousMode: currentMode,
1934
2108
  note: 'HyperLiquid will auto-manage collateral for HIP-3 orders',
1935
2109
  });
1936
- await this.#clientService.getExchangeClient().agentSetAbstraction({
2110
+ if (accountContext) {
2111
+ await this.#assertNoKnownUnsupportedAccount(accountContext, 'Unified account migration');
2112
+ }
2113
+ const exchangeClient = this.#clientService.getExchangeClient(accountContext
2114
+ ? async () => await this.#assertNoKnownUnsupportedAccount(accountContext, 'Unified account migration dispatch')
2115
+ : undefined);
2116
+ await exchangeClient.agentSetAbstraction({
1937
2117
  abstraction: HL_ABSTRACTION_WIRE.unifiedAccount,
1938
2118
  });
1939
2119
  this.#deps.debugLogger.log('✅ HyperLiquidProvider: Unified Account enabled successfully');
@@ -1988,6 +2168,10 @@ export class HyperLiquidProvider {
1988
2168
  // account-shape condition, not a failure worth reporting or retrying.
1989
2169
  if (isHyperLiquidMultiSigRequiredError(error)) {
1990
2170
  this.#deps.debugLogger.log('[ensureUnifiedAccountEnabled] Multi-sig account (race/probe fallback), skipping unified account migration', { user: userAddress, network, mode: currentMode });
2171
+ this.#recordAccountSupport(network, userAddress, {
2172
+ isSupported: false,
2173
+ reason: 'multi_sig_account',
2174
+ });
1991
2175
  this.#deps.metrics.trackPerpsEvent(PerpsAnalyticsEvent.AccountSetup, {
1992
2176
  ...(currentMode && {
1993
2177
  [PERPS_EVENT_PROPERTY.PREVIOUS_ABSTRACTION_MODE]: currentMode,
@@ -2020,6 +2204,12 @@ export class HyperLiquidProvider {
2020
2204
  completeInFlight();
2021
2205
  return;
2022
2206
  }
2207
+ if (accountContext &&
2208
+ ensureError(error, 'HyperLiquidProvider.ensureUnifiedAccountEnabled')
2209
+ .message === PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE) {
2210
+ completeInFlight();
2211
+ throw error;
2212
+ }
2023
2213
  // Agent-path failures and read-only userAbstraction lookup failures
2024
2214
  // are not final: signal #ensureReady to drop its memoized promise and
2025
2215
  // retry on the next entry instead of pinning the user in the
@@ -2088,10 +2278,26 @@ export class HyperLiquidProvider {
2088
2278
  // over. The memoized promise is dropped while the cooldown is active
2089
2279
  // (see below), so a later entry retries. Action-time callers are not
2090
2280
  // gated.
2091
- if (Date.now() >= this.#unifiedAccountTransportRetryAt) {
2092
- await this.#ensureUnifiedAccountEnabled({
2093
- allowUserSigning: !this.#walletService.requiresSignatureConfirmation(),
2094
- });
2281
+ if (!this.#isDisconnected &&
2282
+ Date.now() >= this.#unifiedAccountTransportRetryAt) {
2283
+ if (this.#walletService.requiresSignatureConfirmation()) {
2284
+ await this.#ensureUnifiedAccountEnabled({
2285
+ allowUserSigning: false,
2286
+ });
2287
+ }
2288
+ else {
2289
+ const accountContext = {
2290
+ lifecycleGeneration: this.#lifecycleGeneration,
2291
+ network: this.#clientService.isTestnetMode()
2292
+ ? 'testnet'
2293
+ : 'mainnet',
2294
+ userAddress: await this.#walletService.getUserAddressWithDefault(),
2295
+ };
2296
+ await this.#ensureUnifiedAccountEnabled({
2297
+ allowUserSigning: true,
2298
+ accountContext,
2299
+ });
2300
+ }
2095
2301
  }
2096
2302
  })();
2097
2303
  // Await initialization - keep the promise so subsequent calls resolve immediately
@@ -2169,13 +2375,17 @@ export class HyperLiquidProvider {
2169
2375
  * code (the approval is otherwise non-blocking).
2170
2376
  * @param options.reportRefusal - Throw the venue's refusal, even without an
2171
2377
  * approval failure code.
2378
+ * @param accountContext - Account/network/lifecycle captured by the action
2379
+ * that is allowed to authorize this approval.
2172
2380
  * @returns The account, network, and configured builder for the action.
2173
2381
  */
2174
- async #ensureBuilderFeeSetup(approvalFailureCode, options = {}) {
2175
- const lifecycleGeneration = this.#lifecycleGeneration;
2176
- const isTestnet = this.#clientService.isTestnetMode();
2177
- const network = isTestnet ? 'testnet' : 'mainnet';
2178
- const userAddress = await this.#walletService.getUserAddressWithDefault();
2382
+ async #ensureBuilderFeeSetup(approvalFailureCode, options = {}, accountContext) {
2383
+ const lifecycleGeneration = accountContext?.lifecycleGeneration ?? this.#lifecycleGeneration;
2384
+ const network = accountContext?.network ??
2385
+ (this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet');
2386
+ const isTestnet = network === 'testnet';
2387
+ const userAddress = accountContext?.userAddress ??
2388
+ (await this.#walletService.getUserAddressWithDefault());
2179
2389
  const cacheKey = this.#getCacheKey(network, userAddress);
2180
2390
  const builderAddress = this.#getBuilderAddress(isTestnet);
2181
2391
  const context = {
@@ -2203,7 +2413,7 @@ export class HyperLiquidProvider {
2203
2413
  }
2204
2414
  let pendingApproval = this.#builderFeeSetupPromises.get(setupKey);
2205
2415
  if (!pendingApproval) {
2206
- pendingApproval = this.#ensureBuilderFeeApproval(context);
2416
+ pendingApproval = this.#ensureBuilderFeeApproval(context, accountContext);
2207
2417
  this.#builderFeeSetupPromises.set(setupKey, pendingApproval);
2208
2418
  }
2209
2419
  try {
@@ -2211,6 +2421,10 @@ export class HyperLiquidProvider {
2211
2421
  }
2212
2422
  catch (error) {
2213
2423
  this.#deps.debugLogger.log('[ensureBuilderFeeSetup] Builder fee approval failed', error);
2424
+ if (accountContext &&
2425
+ this.#recordUnsupportedAccountFromError(error, accountContext)) {
2426
+ throw new Error(PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED);
2427
+ }
2214
2428
  // A signer that could not sign is retryable, not an approval failure.
2215
2429
  if (approvalFailureCode || options.reportSignerFailure) {
2216
2430
  const signerFailure = await this.#classifySignerFailure(error);
@@ -2243,10 +2457,15 @@ export class HyperLiquidProvider {
2243
2457
  async #ensureReadyForTrading(options) {
2244
2458
  // First ensure basic initialization is complete
2245
2459
  await this.#ensureReady();
2460
+ const accountContext = await this.#assertAccountSupported(options.accountContext);
2246
2461
  // The migration was deferred during init to avoid a signing prompt on
2247
2462
  // Perps section open. Drive it here, gated by its own cache so
2248
2463
  // already-migrated users are not re-prompted.
2249
- await this.#ensureUnifiedAccountEnabled({ allowUserSigning: true });
2464
+ await this.#ensureUnifiedAccountEnabled({
2465
+ allowUserSigning: true,
2466
+ accountContext,
2467
+ });
2468
+ await this.#assertNoKnownUnsupportedAccount(accountContext, 'Trading setup');
2250
2469
  // Reset right before the check, with no await in between, so a failure
2251
2470
  // above does not leave the setup for an order to run.
2252
2471
  if (options.recheckPendingReferral && this.#referralAwaitsBuilderCode) {
@@ -2268,7 +2487,7 @@ export class HyperLiquidProvider {
2268
2487
  }
2269
2488
  }
2270
2489
  // Set up referral code independently from builder-fee applicability.
2271
- await this.#ensureReferralSet();
2490
+ await this.#ensureReferralSet(accountContext);
2272
2491
  this.#assertProviderLifecycleCurrent(lifecycleGeneration, 'Trading setup completion');
2273
2492
  // Only mark complete if the main-account signer is ready (signing
2274
2493
  // could actually happen) and the referral does not need another
@@ -2291,10 +2510,14 @@ export class HyperLiquidProvider {
2291
2510
  }
2292
2511
  }
2293
2512
  const builderFeeSetupContext = options.requiresBuilderFee
2294
- ? await this.#ensureBuilderFeeSetup(options.builderFeeApprovalFailureCode)
2513
+ ? await this.#ensureBuilderFeeSetup(options.builderFeeApprovalFailureCode, {}, accountContext)
2295
2514
  : undefined;
2515
+ await this.#assertNoKnownUnsupportedAccount(accountContext, 'Trading setup');
2296
2516
  this.#deps.debugLogger.log('[ensureReadyForTrading] Trading setup complete');
2297
- return builderFeeSetupContext;
2517
+ return {
2518
+ accountContext,
2519
+ ...(builderFeeSetupContext && { builderFeeSetupContext }),
2520
+ };
2298
2521
  }
2299
2522
  /**
2300
2523
  * Get current price for a symbol using WebSocket cache first, REST API fallback
@@ -3228,10 +3451,14 @@ export class HyperLiquidProvider {
3228
3451
  *
3229
3452
  * @param message - The status entry's error.
3230
3453
  * @param answer - The venue answer with the entry.
3454
+ * @param accountContext - Account and network that authorized the write.
3231
3455
  * @returns The mapped error.
3232
3456
  */
3233
- async #mapStatusError(message, answer) {
3457
+ async #mapStatusError(message, answer, accountContext) {
3234
3458
  const error = createStatusError(message, answer);
3459
+ if (accountContext) {
3460
+ this.#recordUnsupportedAccountFromError(error, accountContext);
3461
+ }
3235
3462
  return (await this.#classifySignerFailure(error)) ?? this.#mapError(error);
3236
3463
  }
3237
3464
  /**
@@ -3360,10 +3587,17 @@ export class HyperLiquidProvider {
3360
3587
  * @param params.network - HyperLiquid network for the approval.
3361
3588
  * @param params.userAddress - Account that owns the approval.
3362
3589
  * @param params.builderAddress - Builder address being approved.
3590
+ * @param accountContext - Account/network/lifecycle captured by the action
3591
+ * that is allowed to authorize this approval.
3363
3592
  */
3364
- async #ensureBuilderFeeApproval(params) {
3365
- const lifecycleGeneration = this.#lifecycleGeneration;
3593
+ async #ensureBuilderFeeApproval(params, accountContext) {
3594
+ const lifecycleGeneration = accountContext?.lifecycleGeneration ?? this.#lifecycleGeneration;
3366
3595
  const { network, userAddress, builderAddress } = params;
3596
+ const supportContext = accountContext ?? {
3597
+ lifecycleGeneration,
3598
+ network,
3599
+ userAddress,
3600
+ };
3367
3601
  this.#assertProviderLifecycleCurrent(lifecycleGeneration, 'Builder fee approval');
3368
3602
  const cacheKey = this.#getCacheKey(network, userAddress);
3369
3603
  // Check GLOBAL cache first to avoid repeated signing requests across reconnections
@@ -3401,7 +3635,12 @@ export class HyperLiquidProvider {
3401
3635
  return;
3402
3636
  }
3403
3637
  const { isApproved, requiredDecimal } = await this.#checkBuilderFeeStatus(builderAddress, userAddress);
3404
- this.#assertProviderLifecycleCurrent(lifecycleGeneration, 'Builder fee approval');
3638
+ if (accountContext) {
3639
+ await this.#assertNoKnownUnsupportedAccount(accountContext, 'Builder fee approval lookup');
3640
+ }
3641
+ else {
3642
+ this.#assertProviderLifecycleCurrent(lifecycleGeneration, 'Builder fee approval');
3643
+ }
3405
3644
  if (isApproved) {
3406
3645
  // User already has approval on-chain
3407
3646
  PerpsSigningCache.setBuilderFee(network, userAddress, {
@@ -3414,12 +3653,20 @@ export class HyperLiquidProvider {
3414
3653
  }
3415
3654
  else {
3416
3655
  this.#deps.debugLogger.log('[ensureBuilderFeeApproval] Approval required (will show signing request)', { builder: builderAddress, requiredDecimal });
3656
+ if (accountContext) {
3657
+ await this.#assertNoKnownUnsupportedAccount(accountContext, 'Builder fee approval');
3658
+ }
3417
3659
  const exchangeClient = this.#clientService.getExchangeClient();
3418
3660
  const maxFeeRate = BUILDER_FEE_CONFIG.MaxFeeRate;
3419
- await exchangeClient.approveBuilderFee({
3661
+ const approvalResult = await exchangeClient.approveBuilderFee({
3420
3662
  builder: builderAddress,
3421
3663
  maxFeeRate,
3422
3664
  });
3665
+ if (approvalResult.status !== 'ok') {
3666
+ const approvalError = new Error(`Builder fee approval failed: ${JSON.stringify(approvalResult)}`);
3667
+ this.#recordUnsupportedAccountFromError(approvalError, supportContext);
3668
+ throw approvalError;
3669
+ }
3423
3670
  // Verify approval was successful before caching
3424
3671
  const afterApprovalDecimal = await this.#checkBuilderFeeApproval(builderAddress, userAddress);
3425
3672
  if (afterApprovalDecimal === null ||
@@ -3445,6 +3692,7 @@ export class HyperLiquidProvider {
3445
3692
  completeInFlight();
3446
3693
  }
3447
3694
  catch (error) {
3695
+ this.#recordUnsupportedAccountFromError(error, supportContext);
3448
3696
  if (ensureError(error, 'HyperLiquidProvider.ensureBuilderFeeApproval')
3449
3697
  .message === PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE) {
3450
3698
  completeInFlight();
@@ -3641,11 +3889,13 @@ export class HyperLiquidProvider {
3641
3889
  * @param params - Transfer parameters
3642
3890
  * @param params.targetDex - HIP-3 DEX name (e.g., 'xyz')
3643
3891
  * @param params.requiredMargin - Required margin with buffer
3892
+ * @param params.accountContext - Account/network/lifecycle captured by the
3893
+ * order that may authorize a transfer.
3644
3894
  * @returns Transfer info for rollback, or null if no transfer needed
3645
3895
  * @private
3646
3896
  */
3647
3897
  async #autoTransferForHip3Order(params) {
3648
- const { targetDex, requiredMargin } = params;
3898
+ const { targetDex, requiredMargin, accountContext } = params;
3649
3899
  // Check target DEX balance
3650
3900
  const targetBalance = await this.#getBalanceForDex({ dex: targetDex });
3651
3901
  this.#deps.debugLogger.log('HyperLiquidProvider: HIP-3 balance check', {
@@ -3674,15 +3924,19 @@ export class HyperLiquidProvider {
3674
3924
  to: targetDex,
3675
3925
  amount: transferAmount,
3676
3926
  });
3677
- const result = await this.transferBetweenDexs({
3927
+ const transferParams = {
3678
3928
  sourceDex: source.sourceDex,
3679
3929
  destinationDex: targetDex,
3680
3930
  amount: transferAmount,
3681
- });
3931
+ };
3932
+ const result = accountContext
3933
+ ? await this.#transferBetweenDexsForContext(transferParams, accountContext)
3934
+ : await this.transferBetweenDexs(transferParams);
3682
3935
  if (!result.success) {
3683
- // The signer could not sign the transfer: retryable, not a defect.
3684
- if (result.error === PERPS_ERROR_CODES.KEYRING_LOCKED) {
3685
- throw new Error(PERPS_ERROR_CODES.KEYRING_LOCKED);
3936
+ // Preserve stable account/signer outcomes that callers handle directly.
3937
+ if (result.error === PERPS_ERROR_CODES.KEYRING_LOCKED ||
3938
+ result.error === PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED) {
3939
+ throw new Error(result.error);
3686
3940
  }
3687
3941
  throw new Error(`Auto-transfer failed: ${result.error ?? 'Unknown error'}`);
3688
3942
  }
@@ -3710,11 +3964,13 @@ export class HyperLiquidProvider {
3710
3964
  * @param params.freedMargin - Amount of margin released from position close
3711
3965
  * @param params.transferAll - (Future) Transfer all available balance instead
3712
3966
  * @param params.skipTransfer - (Future) Skip auto-transfer if disabled
3967
+ * @param params.accountContext - Original action context to preserve across
3968
+ * the balance read and transfer.
3713
3969
  * @returns Transfer info if successful, null if skipped/failed
3714
3970
  * @private
3715
3971
  */
3716
3972
  async #autoTransferBackAfterClose(params) {
3717
- const { sourceDex, freedMargin, transferAll = false, skipTransfer = false, } = params;
3973
+ const { sourceDex, freedMargin, transferAll = false, skipTransfer = false, accountContext, } = params;
3718
3974
  // Future: Check user preference to skip auto-transfer
3719
3975
  if (skipTransfer) {
3720
3976
  this.#deps.debugLogger.log('Auto-transfer back skipped (disabled by config)');
@@ -3750,11 +4006,14 @@ export class HyperLiquidProvider {
3750
4006
  to: 'main',
3751
4007
  });
3752
4008
  // Execute transfer back to main DEX (empty string '' represents main DEX)
3753
- const result = await this.transferBetweenDexs({
4009
+ const transferParams = {
3754
4010
  sourceDex,
3755
4011
  destinationDex: '',
3756
4012
  amount: transferAmount.toFixed(USDC_DECIMALS),
3757
- });
4013
+ };
4014
+ const result = accountContext
4015
+ ? await this.#transferBetweenDexsForContext(transferParams, accountContext)
4016
+ : await this.transferBetweenDexs(transferParams);
3758
4017
  if (!result.success) {
3759
4018
  this.#deps.debugLogger.log('❌ Auto-transfer back failed', {
3760
4019
  error: result.error,
@@ -3878,11 +4137,12 @@ export class HyperLiquidProvider {
3878
4137
  * @param params.transferInfo - The transfer information.
3879
4138
  * @param params.transferInfo.amount - The amount value.
3880
4139
  * @param params.transferInfo.sourceDex - The source DEX for the transfer.
4140
+ * @param params.accountContext - Original action context for any rebalance.
3881
4141
  * @returns Whether the balance check and any required transfer succeeded.
3882
4142
  * @private
3883
4143
  */
3884
4144
  async #handleHip3PostOrderRebalance(params) {
3885
- const { dexName, transferInfo } = params;
4145
+ const { accountContext, dexName, transferInfo } = params;
3886
4146
  try {
3887
4147
  const postOrderBalance = await this.#getBalanceForDex({ dex: dexName });
3888
4148
  const transferredAmount = transferInfo.amount;
@@ -3907,11 +4167,11 @@ export class HyperLiquidProvider {
3907
4167
  excessAmount: excessAmount.toFixed(2),
3908
4168
  destinationDex: transferInfo.sourceDex,
3909
4169
  });
3910
- const transferResult = await this.transferBetweenDexs({
4170
+ const transferResult = await this.#transferBetweenDexsForContext({
3911
4171
  sourceDex: dexName,
3912
4172
  destinationDex: transferInfo.sourceDex,
3913
4173
  amount: excessAmount.toFixed(USDC_DECIMALS),
3914
- });
4174
+ }, accountContext);
3915
4175
  if (!transferResult.success) {
3916
4176
  // The signer could not sign the transfer: retryable, not a defect.
3917
4177
  if (transferResult.error === PERPS_ERROR_CODES.KEYRING_LOCKED) {
@@ -3971,10 +4231,11 @@ export class HyperLiquidProvider {
3971
4231
  * @param params.transferInfo - The transfer information.
3972
4232
  * @param params.transferInfo.amount - The amount value.
3973
4233
  * @param params.transferInfo.sourceDex - The source DEX for the transfer.
4234
+ * @param params.accountContext - Original action context for the rollback.
3974
4235
  * @private
3975
4236
  */
3976
4237
  async #handleHip3OrderRollback(params) {
3977
- const { dexName, transferInfo } = params;
4238
+ const { accountContext, dexName, transferInfo } = params;
3978
4239
  try {
3979
4240
  this.#deps.debugLogger.log('HyperLiquidProvider: Rolling back failed order transfer', {
3980
4241
  from: dexName,
@@ -3982,11 +4243,11 @@ export class HyperLiquidProvider {
3982
4243
  amount: transferInfo.amount.toFixed(USDC_DECIMALS),
3983
4244
  reason: 'order_failed',
3984
4245
  });
3985
- const rollbackResult = await this.transferBetweenDexs({
4246
+ const rollbackResult = await this.#transferBetweenDexsForContext({
3986
4247
  sourceDex: dexName, // From HIP-3 DEX
3987
4248
  destinationDex: transferInfo.sourceDex, // Back to source
3988
4249
  amount: transferInfo.amount.toFixed(USDC_DECIMALS),
3989
- });
4250
+ }, accountContext);
3990
4251
  if (rollbackResult.success) {
3991
4252
  this.#deps.debugLogger.log('✅ HyperLiquidProvider: Rollback successful', {
3992
4253
  amount: transferInfo.amount.toFixed(USDC_DECIMALS),
@@ -4160,7 +4421,7 @@ export class HyperLiquidProvider {
4160
4421
  * @param params - The operation parameters.
4161
4422
  */
4162
4423
  async #prepareAssetForTrading(params) {
4163
- const { symbol, assetId, leverage } = params;
4424
+ const { symbol, assetId, leverage, accountContext } = params;
4164
4425
  if (!leverage) {
4165
4426
  return;
4166
4427
  }
@@ -4171,14 +4432,21 @@ export class HyperLiquidProvider {
4171
4432
  requestedLeverage: leverage,
4172
4433
  leverageType: marginMode,
4173
4434
  });
4174
- const exchangeClient = this.#clientService.getExchangeClient();
4175
- const leverageResult = await exchangeClient.updateLeverage({
4176
- asset: assetId,
4177
- isCross: marginMode === 'cross',
4178
- leverage,
4179
- });
4180
- if (leverageResult.status !== 'ok') {
4181
- throw new Error(`Failed to update leverage: ${JSON.stringify(leverageResult)}`);
4435
+ await this.#assertNoKnownUnsupportedAccount(accountContext, 'Leverage update');
4436
+ try {
4437
+ const exchangeClient = this.#clientService.getExchangeClient();
4438
+ const leverageResult = await exchangeClient.updateLeverage({
4439
+ asset: assetId,
4440
+ isCross: marginMode === 'cross',
4441
+ leverage,
4442
+ });
4443
+ if (leverageResult.status !== 'ok') {
4444
+ throw new Error(`Failed to update leverage: ${JSON.stringify(leverageResult)}`);
4445
+ }
4446
+ }
4447
+ catch (error) {
4448
+ this.#recordUnsupportedAccountFromError(error, accountContext);
4449
+ throw error;
4182
4450
  }
4183
4451
  this.#deps.debugLogger.log('Leverage updated successfully:', {
4184
4452
  symbol,
@@ -4216,7 +4484,7 @@ export class HyperLiquidProvider {
4216
4484
  * @returns A promise that resolves to the result.
4217
4485
  */
4218
4486
  async #handleHip3PreOrder(params) {
4219
- const { dexName, symbol, orderPrice, positionSize, leverage, isBuy } = params;
4487
+ const { accountContext, dexName, symbol, orderPrice, positionSize, leverage, isBuy, } = params;
4220
4488
  // TAT-3304: Only USDC-collateral HIP-3 DEXs are supported for trading.
4221
4489
  // Following the USDH sunset, reject orders on any non-USDC-collateral
4222
4490
  // DEX here instead of attempting the (now-removed) USDH auto-swap path.
@@ -4254,6 +4522,7 @@ export class HyperLiquidProvider {
4254
4522
  const transferInfo = await this.#autoTransferForHip3Order({
4255
4523
  targetDex: dexName,
4256
4524
  requiredMargin: requiredMarginWithBuffer,
4525
+ accountContext,
4257
4526
  });
4258
4527
  return { transferInfo };
4259
4528
  }
@@ -4278,7 +4547,7 @@ export class HyperLiquidProvider {
4278
4547
  const exchangeClient = this.#clientService.getExchangeClient();
4279
4548
  const builder = params.chargesMetamaskBuilderFee
4280
4549
  ? await this.#getBuilderOrderContext(params.builderFeeSetupContext ??
4281
- (await this.#ensureBuilderFeeSetup()))
4550
+ (await this.#ensureBuilderFeeSetup(undefined, {}, params.accountContext)))
4282
4551
  : undefined;
4283
4552
  this.#deps.debugLogger.log('Submitting order via asset ID routing', {
4284
4553
  symbol,
@@ -4289,6 +4558,7 @@ export class HyperLiquidProvider {
4289
4558
  isHip3: Boolean(dexName),
4290
4559
  });
4291
4560
  try {
4561
+ await this.#assertNoKnownUnsupportedAccount(params.accountContext, 'Order submission');
4292
4562
  const result = await exchangeClient.order({
4293
4563
  orders: this.#applySubscriptionCloid(orders),
4294
4564
  grouping,
@@ -4298,6 +4568,9 @@ export class HyperLiquidProvider {
4298
4568
  throw new Error(`Order failed: ${JSON.stringify(result)}`);
4299
4569
  }
4300
4570
  const status = result.response?.data?.statuses?.[0];
4571
+ if (hasProperty(status, 'error') && typeof status.error === 'string') {
4572
+ throw createStatusError(status.error, result);
4573
+ }
4301
4574
  // Note: `in` narrows the HyperLiquid SDK discriminated union to the
4302
4575
  // branch that has the property; `hasProperty` types the property as
4303
4576
  // `unknown`, losing downstream access to `.oid`, `.totalSz`, `.avgPx`.
@@ -4307,7 +4580,11 @@ export class HyperLiquidProvider {
4307
4580
  /* eslint-enable no-restricted-syntax */
4308
4581
  // Success - auto-rebalance excess funds
4309
4582
  if (isHip3Order && transferInfo && dexName) {
4310
- await this.#handleHip3PostOrderRebalance({ dexName, transferInfo });
4583
+ await this.#handleHip3PostOrderRebalance({
4584
+ accountContext: params.accountContext,
4585
+ dexName,
4586
+ transferInfo,
4587
+ });
4311
4588
  }
4312
4589
  return {
4313
4590
  success: true,
@@ -4323,9 +4600,14 @@ export class HyperLiquidProvider {
4323
4600
  };
4324
4601
  }
4325
4602
  catch (orderError) {
4603
+ this.#recordUnsupportedAccountFromError(orderError, params.accountContext);
4326
4604
  // Failure - rollback transfer
4327
4605
  if (transferInfo && dexName) {
4328
- await this.#handleHip3OrderRollback({ dexName, transferInfo });
4606
+ await this.#handleHip3OrderRollback({
4607
+ accountContext: params.accountContext,
4608
+ dexName,
4609
+ transferInfo,
4610
+ });
4329
4611
  }
4330
4612
  throw orderError;
4331
4613
  }
@@ -4380,6 +4662,20 @@ export class HyperLiquidProvider {
4380
4662
  * @returns A promise that resolves to the result.
4381
4663
  */
4382
4664
  async placeOrder(input, _retryCount = 0, reportFailure = true) {
4665
+ return await this.#placeOrder(input, _retryCount, reportFailure);
4666
+ }
4667
+ /**
4668
+ * Place an order while preserving one account context across parent actions
4669
+ * and minimum-value retries.
4670
+ *
4671
+ * @param input - Order parameters.
4672
+ * @param _retryCount - Unused. Preserves the public call shape internally.
4673
+ * @param reportFailure - Whether this call owns provider-level reporting.
4674
+ * @param expectedAccountContext - Context captured by a parent action or the
4675
+ * first minimum-value attempt.
4676
+ * @returns The order result.
4677
+ */
4678
+ async #placeOrder(input, _retryCount, reportFailure, expectedAccountContext) {
4383
4679
  const deferProviderErrorReport = input.deferProviderErrorReport === true;
4384
4680
  const params = { ...input };
4385
4681
  delete params.deferProviderErrorReport;
@@ -4390,6 +4686,7 @@ export class HyperLiquidProvider {
4390
4686
  error: PERPS_ERROR_CODES.ORDER_SCALE_PREVIEW_STALE,
4391
4687
  };
4392
4688
  }
4689
+ let actionAccountContext = expectedAccountContext;
4393
4690
  try {
4394
4691
  this.#deps.debugLogger.log('Placing order via HyperLiquid SDK:', params);
4395
4692
  // Basic sync validation (backward compatibility)
@@ -4417,7 +4714,7 @@ export class HyperLiquidProvider {
4417
4714
  // array, the HIP-3 transfer, and the atomic single-order submit, none of
4418
4715
  // which describe what a TWAP, a ladder, or a chase does.
4419
4716
  if (isStrategyOrderType(params.orderType)) {
4420
- return await this.#placeStrategyOrder(params, params.orderType);
4717
+ return await this.#placeStrategyOrder(params, params.orderType, expectedAccountContext);
4421
4718
  }
4422
4719
  // Extract DEX name for API calls (main DEX = null)
4423
4720
  const { dex: dexName } = parseAssetName(params.symbol);
@@ -4466,9 +4763,11 @@ export class HyperLiquidProvider {
4466
4763
  // Kept after validation so invalid orders never trigger signature prompts
4467
4764
  // (builder-fee approval, DEX abstraction enablement, etc.).
4468
4765
  const { chargesMetamaskBuilderFee } = this.#resolveOrderFeePolicy(params);
4469
- const builderFeeSetupContext = await this.#ensureReadyForTrading({
4766
+ const { accountContext, builderFeeSetupContext } = await this.#ensureReadyForTrading({
4470
4767
  requiresBuilderFee: chargesMetamaskBuilderFee,
4768
+ accountContext: actionAccountContext,
4471
4769
  });
4770
+ actionAccountContext = accountContext;
4472
4771
  // Debug: Log asset map state before order placement
4473
4772
  const allMapKeys = Array.from(this.#symbolToAssetId.keys());
4474
4773
  const hip3Keys = allMapKeys.filter((key) => key.includes(':'));
@@ -4526,6 +4825,7 @@ export class HyperLiquidProvider {
4526
4825
  assetId,
4527
4826
  leverage: params.leverage,
4528
4827
  marginMode: params.marginMode,
4828
+ accountContext,
4529
4829
  });
4530
4830
  // 6. Handle HIP-3 balance management (if applicable)
4531
4831
  const isHip3Order = dexName !== null;
@@ -4533,6 +4833,7 @@ export class HyperLiquidProvider {
4533
4833
  if (isHip3Order && dexName) {
4534
4834
  const effectiveLeverage = params.leverage ?? assetInfo.maxLeverage ?? 1;
4535
4835
  const hip3Result = await this.#handleHip3PreOrder({
4836
+ accountContext,
4536
4837
  dexName,
4537
4838
  symbol: params.symbol,
4538
4839
  orderPrice,
@@ -4575,6 +4876,7 @@ export class HyperLiquidProvider {
4575
4876
  symbol: params.symbol,
4576
4877
  assetId,
4577
4878
  chargesMetamaskBuilderFee,
4879
+ accountContext,
4578
4880
  builderFeeSetupContext,
4579
4881
  });
4580
4882
  }
@@ -4607,15 +4909,16 @@ export class HyperLiquidProvider {
4607
4909
  *
4608
4910
  * @param params - Order parameters, already validated.
4609
4911
  * @param orderType - The strategy type, narrowed by the caller.
4912
+ * @param expectedAccountContext - Context captured by a parent action.
4610
4913
  * @returns A promise that resolves to the result.
4611
4914
  */
4612
- async #placeStrategyOrder(params, orderType) {
4915
+ async #placeStrategyOrder(params, orderType, expectedAccountContext) {
4613
4916
  // Captured before the shared preamble, not after it: that preamble awaits
4614
4917
  // asset info, validation, trading readiness and a leverage update, and a
4615
4918
  // disconnect during any of them has to be seen before or after submission.
4616
4919
  const generation = this.#strategyGeneration;
4617
4920
  if (orderType !== 'chase') {
4618
- const context = await this.#prepareStrategyPlacement(params);
4921
+ const context = await this.#prepareStrategyPlacement(params, expectedAccountContext);
4619
4922
  return await this.#submitPreparedStrategy(params, context, async () => {
4620
4923
  if (generation !== this.#strategyGeneration) {
4621
4924
  throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE);
@@ -4648,7 +4951,7 @@ export class HyperLiquidProvider {
4648
4951
  this.#chasePlacementWaiters.add(placementWaiter);
4649
4952
  try {
4650
4953
  await this.#chaseTickQueue;
4651
- const context = await this.#prepareStrategyPlacement(params);
4954
+ const context = await this.#prepareStrategyPlacement(params, expectedAccountContext);
4652
4955
  return await this.#submitPreparedStrategy(params, context, async () => await this.#startChaseSession(params, context, generation));
4653
4956
  }
4654
4957
  finally {
@@ -4674,24 +4977,36 @@ export class HyperLiquidProvider {
4674
4977
  * @returns The strategy placement result.
4675
4978
  */
4676
4979
  async #submitPreparedStrategy(params, context, submit) {
4677
- const { dexName, network, transferInfo, userAddress } = context;
4980
+ const { accountContext, dexName, network, transferInfo, userAddress } = context;
4678
4981
  let result;
4679
4982
  try {
4680
4983
  result = await submit();
4681
4984
  }
4682
4985
  catch (error) {
4986
+ this.#recordUnsupportedAccountFromError(error, accountContext);
4683
4987
  if (dexName && transferInfo) {
4684
- await this.#handleHip3OrderRollback({ dexName, transferInfo });
4988
+ await this.#handleHip3OrderRollback({
4989
+ accountContext,
4990
+ dexName,
4991
+ transferInfo,
4992
+ });
4685
4993
  }
4686
4994
  throw error;
4687
4995
  }
4996
+ if (!result.success && result.error) {
4997
+ this.#recordUnsupportedAccountFromError(new Error(result.error), accountContext);
4998
+ }
4688
4999
  const resultFilledSize = new BigNumber(result.filledSize ?? 0);
4689
5000
  const hasVenueExposure = result.success === true ||
4690
5001
  result.orderId !== undefined ||
4691
5002
  (result.childOrderIds?.length ?? 0) > 0 ||
4692
5003
  (resultFilledSize.isFinite() && resultFilledSize.gt(0));
4693
5004
  if (dexName && transferInfo && !hasVenueExposure) {
4694
- await this.#handleHip3OrderRollback({ dexName, transferInfo });
5005
+ await this.#handleHip3OrderRollback({
5006
+ accountContext,
5007
+ dexName,
5008
+ transferInfo,
5009
+ });
4695
5010
  return result;
4696
5011
  }
4697
5012
  if (params.orderType === 'twap' && result.orderId) {
@@ -4702,7 +5017,13 @@ export class HyperLiquidProvider {
4702
5017
  }), {
4703
5018
  symbol: params.symbol,
4704
5019
  ...(dexName && transferInfo
4705
- ? { hip3Transfer: { dexName, transferInfo } }
5020
+ ? {
5021
+ hip3Transfer: {
5022
+ accountContext,
5023
+ dexName,
5024
+ transferInfo,
5025
+ },
5026
+ }
4706
5027
  : {}),
4707
5028
  });
4708
5029
  return result;
@@ -4714,7 +5035,11 @@ export class HyperLiquidProvider {
4714
5035
  transferInfo) {
4715
5036
  const session = this.#chaseSessions.get(result.orderId);
4716
5037
  if (session) {
4717
- session.hip3Transfer = { dexName, transferInfo };
5038
+ session.hip3Transfer = {
5039
+ accountContext,
5040
+ dexName,
5041
+ transferInfo,
5042
+ };
4718
5043
  return result;
4719
5044
  }
4720
5045
  }
@@ -4726,7 +5051,11 @@ export class HyperLiquidProvider {
4726
5051
  return result;
4727
5052
  }
4728
5053
  if (dexName && transferInfo) {
4729
- await this.#handleHip3PostOrderRebalance({ dexName, transferInfo });
5054
+ await this.#handleHip3PostOrderRebalance({
5055
+ accountContext,
5056
+ dexName,
5057
+ transferInfo,
5058
+ });
4730
5059
  }
4731
5060
  return result;
4732
5061
  }
@@ -4738,9 +5067,10 @@ export class HyperLiquidProvider {
4738
5067
  * so a strategy order is held to the same rules as an ordinary one.
4739
5068
  *
4740
5069
  * @param params - Order parameters.
5070
+ * @param expectedAccountContext - Context captured by a parent action.
4741
5071
  * @returns The asset and sizing context the strategy handlers submit with.
4742
5072
  */
4743
- async #prepareStrategyPlacement(params) {
5073
+ async #prepareStrategyPlacement(params, expectedAccountContext) {
4744
5074
  const { dex: dexName } = parseAssetName(params.symbol);
4745
5075
  const { assetInfo, currentPrice, meta } = await this.#getAssetInfo({
4746
5076
  symbol: params.symbol,
@@ -4803,9 +5133,16 @@ export class HyperLiquidProvider {
4803
5133
  // Kept after validation so an invalid strategy order never triggers the
4804
5134
  // signature prompts in trading setup — same ordering as `placeOrder`.
4805
5135
  const { chargesMetamaskBuilderFee } = this.#resolveOrderFeePolicy(params);
4806
- const builderFeeSetupContext = chargesMetamaskBuilderFee
4807
- ? await this.#ensureReadyForTrading({ requiresBuilderFee: true })
4808
- : await this.#ensureReadyForTrading({ requiresBuilderFee: false });
5136
+ const tradingActionContext = chargesMetamaskBuilderFee
5137
+ ? await this.#ensureReadyForTrading({
5138
+ requiresBuilderFee: true,
5139
+ accountContext: expectedAccountContext,
5140
+ })
5141
+ : await this.#ensureReadyForTrading({
5142
+ requiresBuilderFee: false,
5143
+ accountContext: expectedAccountContext,
5144
+ });
5145
+ const { accountContext, builderFeeSetupContext } = tradingActionContext;
4809
5146
  const assetId = await this.#getAssetIdWithRepair({
4810
5147
  symbol: params.symbol,
4811
5148
  dexName,
@@ -4816,19 +5153,19 @@ export class HyperLiquidProvider {
4816
5153
  assetId,
4817
5154
  leverage: params.leverage,
4818
5155
  marginMode: params.marginMode,
5156
+ accountContext,
4819
5157
  });
4820
5158
  const builder = builderFeeSetupContext
4821
5159
  ? await this.#getBuilderOrderContext(builderFeeSetupContext)
4822
5160
  : undefined;
4823
- const network = this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet';
4824
- const userAddress = builderFeeSetupContext?.userAddress ??
4825
- (await this.#walletService.getUserAddressWithDefault());
5161
+ const { network, userAddress } = accountContext;
4826
5162
  let transferInfo = null;
4827
5163
  if (dexName) {
4828
5164
  const orderPrice = ladder === undefined
4829
5165
  ? effectivePrice
4830
5166
  : Math.max(...ladder.prices.map(Number.parseFloat));
4831
5167
  const hip3Result = await this.#handleHip3PreOrder({
5168
+ accountContext,
4832
5169
  dexName,
4833
5170
  symbol: params.symbol,
4834
5171
  orderPrice,
@@ -4840,6 +5177,7 @@ export class HyperLiquidProvider {
4840
5177
  transferInfo = hip3Result.transferInfo;
4841
5178
  }
4842
5179
  return {
5180
+ accountContext,
4843
5181
  assetId,
4844
5182
  szDecimals: assetInfo.szDecimals,
4845
5183
  formattedSize,
@@ -4927,7 +5265,7 @@ export class HyperLiquidProvider {
4927
5265
  * @returns A promise that resolves to the result.
4928
5266
  */
4929
5267
  async #placeTwapOrder(params, context, generation) {
4930
- const { assetId, formattedSize } = context;
5268
+ const { accountContext, assetId, formattedSize } = context;
4931
5269
  const durationMinutes = params.twapDuration;
4932
5270
  if (durationMinutes === undefined) {
4933
5271
  throw new Error(PERPS_ERROR_CODES.ORDER_TWAP_DURATION_REQUIRED);
@@ -4945,6 +5283,7 @@ export class HyperLiquidProvider {
4945
5283
  durationMinutes,
4946
5284
  randomize: params.twapRandomize ?? false,
4947
5285
  });
5286
+ await this.#assertNoKnownUnsupportedAccount(accountContext, 'TWAP order submission');
4948
5287
  const result = await exchangeClient.twapOrder({
4949
5288
  twap: {
4950
5289
  a: assetId,
@@ -4974,6 +5313,7 @@ export class HyperLiquidProvider {
4974
5313
  if (generation !== this.#strategyGeneration) {
4975
5314
  let remainsLive = true;
4976
5315
  try {
5316
+ await this.#assertRollbackAccountWritable(accountContext);
4977
5317
  const cancelResult = await exchangeClient.twapCancel({
4978
5318
  a: assetId,
4979
5319
  t: running.twapId,
@@ -5021,7 +5361,7 @@ export class HyperLiquidProvider {
5021
5361
  * @returns A promise that resolves to the result.
5022
5362
  */
5023
5363
  async #placeScaleOrder(params, context, generation) {
5024
- const { assetId, formattedSize, ladder, builder } = context;
5364
+ const { accountContext, assetId, formattedSize, ladder, builder } = context;
5025
5365
  // Built and validated in `#prepareStrategyPlacement`, before anything was
5026
5366
  // signed, so what is submitted here is exactly what the minimums were
5027
5367
  // applied to.
@@ -5062,6 +5402,7 @@ export class HyperLiquidProvider {
5062
5402
  let result;
5063
5403
  let thrownBulkOrderError;
5064
5404
  try {
5405
+ await this.#assertNoKnownUnsupportedAccount(accountContext, 'Scale order submission');
5065
5406
  result = await exchangeClient.order({
5066
5407
  orders,
5067
5408
  grouping: 'na',
@@ -5078,6 +5419,7 @@ export class HyperLiquidProvider {
5078
5419
  }
5079
5420
  const rawStatuses = result.response?.data?.statuses;
5080
5421
  const statuses = Array.isArray(rawStatuses) ? rawStatuses : [];
5422
+ const isMultiSigRequired = this.#recordUnsupportedAccountFromStatuses(statuses, result, accountContext);
5081
5423
  const outcomes = Array.from({ length: count }, (_unused, index) => parseScaleBulkOrderStatus(statuses[index]));
5082
5424
  const acceptedCount = outcomes.filter((outcome) => outcome?.kind === 'accepted').length;
5083
5425
  const everyStatusClassified = outcomes.every((outcome) => outcome !== undefined);
@@ -5130,6 +5472,19 @@ export class HyperLiquidProvider {
5130
5472
  ...acceptedResult,
5131
5473
  });
5132
5474
  const cancelNonRejectedOrders = async () => {
5475
+ try {
5476
+ await this.#assertRollbackAccountWritable(accountContext);
5477
+ }
5478
+ catch (error) {
5479
+ this.#deps.debugLogger.log('Scale placement rollback not signed after its account context changed', {
5480
+ error: ensureError(error, 'HyperLiquidProvider.placeScaleOrder.rollback').message,
5481
+ groupId,
5482
+ });
5483
+ return {
5484
+ orderIds: restingChildOrderIds,
5485
+ clientOrderIds: cleanupClientOrderIds,
5486
+ };
5487
+ }
5133
5488
  const [orderIds, pendingClientOrderIds] = await Promise.all([
5134
5489
  this.#cancelOrderRequests(exchangeClient, restingChildOrderIds.map((orderId) => ({
5135
5490
  a: assetId,
@@ -5219,7 +5574,9 @@ export class HyperLiquidProvider {
5219
5574
  childOrderIds: [],
5220
5575
  });
5221
5576
  }
5222
- throw new Error(PERPS_ERROR_CODES.ORDER_REJECTED);
5577
+ throw new Error(isMultiSigRequired
5578
+ ? PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED
5579
+ : PERPS_ERROR_CODES.ORDER_REJECTED);
5223
5580
  }
5224
5581
  this.#scaleOrderGroups.set(groupId, {
5225
5582
  symbol: params.symbol,
@@ -5240,7 +5597,7 @@ export class HyperLiquidProvider {
5240
5597
  * @returns A promise that resolves to the result.
5241
5598
  */
5242
5599
  async #startChaseSession(params, context, generation) {
5243
- const { assetId, szDecimals, formattedSize, builder } = context;
5600
+ const { accountContext, assetId, szDecimals, formattedSize, builder } = context;
5244
5601
  // Captured now, alongside the builder fee and for the same reason: the fee
5245
5602
  // resolution behind it is cleared when the caller's `placeOrder` returns,
5246
5603
  // which for a chase is before any replacement runs.
@@ -5288,6 +5645,7 @@ export class HyperLiquidProvider {
5288
5645
  reduceOnly: params.reduceOnly ?? false,
5289
5646
  builder,
5290
5647
  marksSubscriptionCloid,
5648
+ accountContext,
5291
5649
  exchangeClient: placingClient,
5292
5650
  });
5293
5651
  break;
@@ -5321,6 +5679,7 @@ export class HyperLiquidProvider {
5321
5679
  const intervalMs = params.chaseIntervalMs ?? CHASE_ORDER_CONFIG.DefaultIntervalMs;
5322
5680
  const sessionId = generatePerpsId('chase');
5323
5681
  const session = {
5682
+ accountContext,
5324
5683
  symbol: params.symbol,
5325
5684
  assetId,
5326
5685
  isBuy: params.isBuy,
@@ -5455,9 +5814,12 @@ export class HyperLiquidProvider {
5455
5814
  * than looked up here so a first placement can keep the instance it signed
5456
5815
  * with, which is the only one that can take the order back once `disconnect`
5457
5816
  * has dropped the service's reference.
5817
+ * @param params.accountContext - Account/network/lifecycle captured by the
5818
+ * action that owns the chase.
5458
5819
  * @returns The resting order's exchange ID.
5459
5820
  */
5460
5821
  async #restChaseOrder(params) {
5822
+ await this.#assertNoKnownUnsupportedAccount(params.accountContext, 'Chase order submission');
5461
5823
  const result = await params.exchangeClient.order({
5462
5824
  orders: this.#applySubscriptionCloid([
5463
5825
  {
@@ -5498,6 +5860,15 @@ export class HyperLiquidProvider {
5498
5860
  this.#chaseTickQueue = this.#chaseTickQueue
5499
5861
  .then(() => this.#runChaseTick(sessionId))
5500
5862
  .catch(async (error) => {
5863
+ const failedSession = this.#chaseSessions.get(sessionId);
5864
+ const accountUnsupported = failedSession
5865
+ ? this.#recordUnsupportedAccountFromError(error, failedSession.accountContext)
5866
+ : false;
5867
+ if (accountUnsupported) {
5868
+ this.#stopChaseSession(sessionId);
5869
+ this.#deps.debugLogger.log('Chase stopped after account support correction', { sessionId });
5870
+ return;
5871
+ }
5501
5872
  // A rejected agent is dropped so the next tick asks for another.
5502
5873
  await this.#evictRejectedAgent(error);
5503
5874
  // Resolve the shared queue after every failure. Otherwise one
@@ -5812,8 +6183,7 @@ export class HyperLiquidProvider {
5812
6183
  reduceOnly: session.reduceOnly,
5813
6184
  builder: session.builder,
5814
6185
  marksSubscriptionCloid: session.marksSubscriptionCloid,
5815
- // A running session is on a live provider, so the current client is
5816
- // the right one; only the first placement has a teardown to survive.
6186
+ accountContext: session.accountContext,
5817
6187
  exchangeClient: this.#clientService.getExchangeClient(),
5818
6188
  });
5819
6189
  session.size = remaining;
@@ -6045,6 +6415,9 @@ export class HyperLiquidProvider {
6045
6415
  }
6046
6416
  // Looked up only once there is something to cancel, so a session with
6047
6417
  // nothing resting still answers on a provider whose client is already gone.
6418
+ if (!placingClient) {
6419
+ await this.#assertNoKnownUnsupportedAccount(session.accountContext, 'Chase cancellation');
6420
+ }
6048
6421
  const exchangeClient = placingClient ?? this.#clientService.getExchangeClient();
6049
6422
  let result;
6050
6423
  try {
@@ -6053,6 +6426,7 @@ export class HyperLiquidProvider {
6053
6426
  });
6054
6427
  }
6055
6428
  catch (error) {
6429
+ this.#recordUnsupportedAccountFromError(error, session.accountContext);
6056
6430
  const message = ensureError(error, 'HyperLiquidProvider.cancelChaseChild').message.toLowerCase();
6057
6431
  if (ALREADY_GONE_CANCEL_MARKERS.some((marker) => message.includes(marker))) {
6058
6432
  return CancelChildOutcome.Gone;
@@ -6060,6 +6434,9 @@ export class HyperLiquidProvider {
6060
6434
  throw error;
6061
6435
  }
6062
6436
  const status = result.response?.data?.statuses?.[0];
6437
+ if (isStatusObject(status) && typeof status.error === 'string') {
6438
+ this.#recordUnsupportedAccountFromError(createStatusError(status.error, result), session.accountContext);
6439
+ }
6063
6440
  const signerFailure = await this.#classifyStatusSignerFailure([status], result);
6064
6441
  if (signerFailure) {
6065
6442
  throw signerFailure;
@@ -6258,7 +6635,16 @@ export class HyperLiquidProvider {
6258
6635
  }
6259
6636
  let { rebalancePromise } = trackedOrder;
6260
6637
  if (!rebalancePromise) {
6261
- rebalancePromise = this.#handleHip3PostOrderRebalance(trackedOrder.hip3Transfer);
6638
+ const { hip3Transfer } = trackedOrder;
6639
+ rebalancePromise = this.#handleHip3PostOrderRebalance({
6640
+ ...hip3Transfer,
6641
+ accountContext: {
6642
+ ...hip3Transfer.accountContext,
6643
+ // TWAP tracking deliberately survives reconnects. Keep its stable
6644
+ // account/network scope, but bind the cleanup write to this lifecycle.
6645
+ lifecycleGeneration: this.#lifecycleGeneration,
6646
+ },
6647
+ });
6262
6648
  trackedOrder.rebalancePromise = rebalancePromise;
6263
6649
  }
6264
6650
  try {
@@ -6511,9 +6897,10 @@ export class HyperLiquidProvider {
6511
6897
  if (!/^\d+$/u.test(params.orderId) || !Number.isSafeInteger(twapId)) {
6512
6898
  throw new Error(PERPS_ERROR_CODES.ORDER_STRATEGY_HANDLE_UNKNOWN);
6513
6899
  }
6514
- await this.#ensureReadyForTrading({ requiresBuilderFee: false });
6515
- const network = this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet';
6516
- const userAddress = await this.#walletService.getUserAddressWithDefault();
6900
+ const { accountContext } = await this.#ensureReadyForTrading({
6901
+ requiresBuilderFee: false,
6902
+ });
6903
+ const { network, userAddress } = accountContext;
6517
6904
  const trackingKey = getTwapOrderScopeKey({
6518
6905
  network,
6519
6906
  userAddress,
@@ -6549,6 +6936,7 @@ export class HyperLiquidProvider {
6549
6936
  symbol: params.symbol,
6550
6937
  dexName: parseAssetName(params.symbol).dex,
6551
6938
  });
6939
+ await this.#assertNoKnownUnsupportedAccount(accountContext, 'TWAP cancellation');
6552
6940
  const result = await this.#clientService.getExchangeClient().twapCancel({
6553
6941
  a: assetId,
6554
6942
  t: twapId,
@@ -6565,7 +6953,7 @@ export class HyperLiquidProvider {
6565
6953
  const rawError = isStatusObject(status) && typeof status.error === 'string'
6566
6954
  ? status.error
6567
6955
  : 'TWAP cancellation failed';
6568
- return createErrorResult(await this.#mapStatusError(rawError, result), {
6956
+ return createErrorResult(await this.#mapStatusError(rawError, result, accountContext), {
6569
6957
  success: false,
6570
6958
  orderId: params.orderId,
6571
6959
  });
@@ -6581,7 +6969,9 @@ export class HyperLiquidProvider {
6581
6969
  if (!group) {
6582
6970
  throw new Error(PERPS_ERROR_CODES.ORDER_STRATEGY_HANDLE_UNKNOWN);
6583
6971
  }
6584
- await this.#ensureReadyForTrading({ requiresBuilderFee: false });
6972
+ const { accountContext } = await this.#ensureReadyForTrading({
6973
+ requiresBuilderFee: false,
6974
+ });
6585
6975
  const assetId = await this.#getAssetIdWithRepair({
6586
6976
  symbol: group.symbol,
6587
6977
  dexName: parseAssetName(group.symbol).dex,
@@ -6595,9 +6985,14 @@ export class HyperLiquidProvider {
6595
6985
  asset: assetId,
6596
6986
  cloid: clientOrderId,
6597
6987
  }));
6988
+ await this.#assertNoKnownUnsupportedAccount(accountContext, 'Scale cancellation');
6598
6989
  const [orderCancellation, cloidCancellation] = await Promise.all([
6599
- this.#cancelOrderRequestBatch(exchangeClient, cancelRequests),
6600
- this.#cancelOrderCloidRequestBatch(exchangeClient, cancelByCloidRequests),
6990
+ this.#cancelOrderRequestBatch(exchangeClient, cancelRequests, (error) => {
6991
+ this.#recordUnsupportedAccountFromError(error, accountContext);
6992
+ }),
6993
+ this.#cancelOrderCloidRequestBatch(exchangeClient, cancelByCloidRequests, (error) => {
6994
+ this.#recordUnsupportedAccountFromError(error, accountContext);
6995
+ }),
6601
6996
  ]);
6602
6997
  const remaining = orderCancellation.remainingOrderIds.map(String);
6603
6998
  const { remainingClientOrderIds } = cloidCancellation;
@@ -6697,7 +7092,18 @@ export class HyperLiquidProvider {
6697
7092
  }
6698
7093
  return { success: true, orderId: params.orderId };
6699
7094
  }
6700
- await this.#ensureReadyForTrading({ requiresBuilderFee: false });
7095
+ this.#assertProviderLifecycleCurrent(session.accountContext.lifecycleGeneration, 'Chase cancellation setup');
7096
+ const currentAddress = await this.#walletService.getUserAddressWithDefault();
7097
+ if (currentAddress.toLowerCase() !==
7098
+ session.accountContext.userAddress.toLowerCase() ||
7099
+ (this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet') !==
7100
+ session.accountContext.network) {
7101
+ throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE);
7102
+ }
7103
+ await this.#ensureReadyForTrading({
7104
+ requiresBuilderFee: false,
7105
+ accountContext: session.accountContext,
7106
+ });
6701
7107
  // Only a refusal leaves an order behind. A child that had already filled or
6702
7108
  // been cancelled is reported as a rejection too, but nothing of it is
6703
7109
  // resting — treating that as a failure would pin the handle open forever on
@@ -6741,11 +7147,11 @@ export class HyperLiquidProvider {
6741
7147
  *
6742
7148
  * @param exchangeClient - Client that owns the orders.
6743
7149
  * @param requests - Venue cancel-by-CLOID requests.
7150
+ * @param onError - Optional operation-specific failure classifier.
6744
7151
  * @returns Client order IDs that may still be resting.
6745
7152
  */
6746
- async #cancelOrderCloidRequests(exchangeClient, requests) {
6747
- return (await this.#cancelOrderCloidRequestBatch(exchangeClient, requests))
6748
- .remainingClientOrderIds;
7153
+ async #cancelOrderCloidRequests(exchangeClient, requests, onError) {
7154
+ return (await this.#cancelOrderCloidRequestBatch(exchangeClient, requests, onError)).remainingClientOrderIds;
6749
7155
  }
6750
7156
  /**
6751
7157
  * Cancel pending orders by client order ID, reporting a signer that could
@@ -6753,14 +7159,20 @@ export class HyperLiquidProvider {
6753
7159
  *
6754
7160
  * @param exchangeClient - Client that owns the orders.
6755
7161
  * @param requests - Venue cancel-by-CLOID requests.
7162
+ * @param onError - Optional operation-specific failure classifier.
6756
7163
  * @returns Client order IDs that may still be resting, and the signer
6757
7164
  * failure when the signer could not sign the request.
6758
7165
  */
6759
- async #cancelOrderCloidRequestBatch(exchangeClient, requests) {
7166
+ async #cancelOrderCloidRequestBatch(exchangeClient, requests, onError) {
6760
7167
  if (requests.length === 0) {
6761
7168
  return { remainingClientOrderIds: [] };
6762
7169
  }
6763
7170
  const classifyStatuses = async (statuses, answer) => {
7171
+ for (const status of statuses) {
7172
+ if (isStatusObject(status) && typeof status.error === 'string') {
7173
+ await onError?.(createStatusError(status.error, answer));
7174
+ }
7175
+ }
6764
7176
  // A signer failure is classified (and reported) once for the request;
6765
7177
  // entries the venue cancelled are not resting either way.
6766
7178
  const signerFailure = await this.#classifyStatusSignerFailure(statuses, answer);
@@ -6784,6 +7196,7 @@ export class HyperLiquidProvider {
6784
7196
  return await classifyStatuses(statuses, result);
6785
7197
  }
6786
7198
  catch (error) {
7199
+ await onError?.(error);
6787
7200
  // The SDK throws when any entry failed, with every entry's status.
6788
7201
  const statuses = getCancelStatusesFromError(error, requests.length);
6789
7202
  if (statuses) {
@@ -6824,6 +7237,11 @@ export class HyperLiquidProvider {
6824
7237
  };
6825
7238
  }
6826
7239
  const classifyStatuses = async (statuses, answer) => {
7240
+ for (const status of statuses) {
7241
+ if (isStatusObject(status) && typeof status.error === 'string') {
7242
+ await onError?.(createStatusError(status.error, answer));
7243
+ }
7244
+ }
6827
7245
  // A signer failure is classified (and reported) once for the request;
6828
7246
  // entries the venue cancelled still count as cancelled.
6829
7247
  const signerFailure = await this.#classifyStatusSignerFailure(statuses, answer);
@@ -6969,9 +7387,11 @@ export class HyperLiquidProvider {
6969
7387
  * Apply both signer invalidation and account-shape classification to writes.
6970
7388
  *
6971
7389
  * @param error - Failed protection write.
7390
+ * @param accountContext - Account and network that authorized the write.
6972
7391
  * @returns Actionable error retaining the venue message when unmapped.
6973
7392
  */
6974
- async #classifyProtectionWriteError(error) {
7393
+ async #classifyProtectionWriteError(error, accountContext) {
7394
+ this.#recordUnsupportedAccountFromError(error, accountContext);
6975
7395
  return ((await this.#classifySignerFailure(error)) ??
6976
7396
  (isHyperLiquidMultiSigRequiredError(error)
6977
7397
  ? new Error(PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED)
@@ -7378,19 +7798,30 @@ export class HyperLiquidProvider {
7378
7798
  // Every refusal is behind us, so shared trading readiness can run now.
7379
7799
  // HyperLiquid's modify action has no builder field and must not request a
7380
7800
  // builder-fee approval.
7381
- await this.#ensureReadyForTrading({ requiresBuilderFee: false });
7801
+ const { accountContext } = await this.#ensureReadyForTrading({
7802
+ requiresBuilderFee: false,
7803
+ });
7382
7804
  // Submit modification via SDK. The cloid is deliberately left unmarked:
7383
7805
  // `modify` carries no builder field, as the readiness call above records,
7384
7806
  // so no MetaMask fee is charged on this action and marking it would tell
7385
7807
  // the fill fan-out a reduction applied to an order that paid nothing.
7386
7808
  // The replacement inherits the resting order's own attribution.
7809
+ await this.#assertNoKnownUnsupportedAccount(accountContext, 'Order modification');
7387
7810
  const exchangeClient = this.#clientService.getExchangeClient();
7388
- const result = await exchangeClient.modify({
7389
- oid: typeof params.orderId === 'string' ? params.orderId : params.orderId,
7390
- order: newOrder,
7391
- });
7392
- if (result.status !== 'ok') {
7393
- throw new Error(`Order modification failed: ${JSON.stringify(result)}`);
7811
+ try {
7812
+ const result = await exchangeClient.modify({
7813
+ oid: typeof params.orderId === 'string'
7814
+ ? params.orderId
7815
+ : params.orderId,
7816
+ order: newOrder,
7817
+ });
7818
+ if (result.status !== 'ok') {
7819
+ throw new Error(`Order modification failed: ${JSON.stringify(result)}`);
7820
+ }
7821
+ }
7822
+ catch (error) {
7823
+ this.#recordUnsupportedAccountFromError(error, accountContext);
7824
+ throw error;
7394
7825
  }
7395
7826
  // `params.orderId` is the order that was just REPLACED, so returning it
7396
7827
  // as OrderResult.orderId (documented as the exchange order ID) names an
@@ -7450,6 +7881,7 @@ export class HyperLiquidProvider {
7450
7881
  // current child instead of leaving a replacement chasing behind the user.
7451
7882
  const owningChase = [...this.#chaseSessions.entries()].find(([, session]) => session.orderId === params.orderId ||
7452
7883
  session.replacingOrderId === params.orderId);
7884
+ let accountContext;
7453
7885
  try {
7454
7886
  if (owningChase) {
7455
7887
  this.#stopChaseSession(owningChase[0]);
@@ -7472,12 +7904,15 @@ export class HyperLiquidProvider {
7472
7904
  if (!coinValidation.isValid) {
7473
7905
  throw new Error(coinValidation.error);
7474
7906
  }
7475
- await this.#ensureReadyForTrading({ requiresBuilderFee: false });
7476
- const exchangeClient = this.#clientService.getExchangeClient();
7907
+ ({ accountContext } = await this.#ensureReadyForTrading({
7908
+ requiresBuilderFee: false,
7909
+ }));
7477
7910
  const asset = await this.#getAssetIdWithRepair({
7478
7911
  symbol: params.symbol,
7479
7912
  dexName: parseAssetName(params.symbol).dex,
7480
7913
  });
7914
+ await this.#assertNoKnownUnsupportedAccount(accountContext, 'Order cancellation');
7915
+ const exchangeClient = this.#clientService.getExchangeClient();
7481
7916
  const result = await exchangeClient.cancel({
7482
7917
  cancels: [
7483
7918
  {
@@ -7500,12 +7935,15 @@ export class HyperLiquidProvider {
7500
7935
  const rawError = isStatusObject(status) && typeof status.error === 'string'
7501
7936
  ? status.error
7502
7937
  : 'Order cancellation failed';
7503
- return createErrorResult(await this.#mapStatusError(rawError, result), {
7938
+ return createErrorResult(await this.#mapStatusError(rawError, result, accountContext), {
7504
7939
  success: false,
7505
7940
  orderId: params.orderId,
7506
7941
  });
7507
7942
  }
7508
7943
  catch (error) {
7944
+ if (accountContext) {
7945
+ this.#recordUnsupportedAccountFromError(error, accountContext);
7946
+ }
7509
7947
  const mappedError = await this.#reportWriteError(error, 'cancelOrder', {
7510
7948
  orderId: params.orderId,
7511
7949
  coin: params.symbol,
@@ -7577,8 +8015,9 @@ export class HyperLiquidProvider {
7577
8015
  if (ordinaryOrders.length > 0) {
7578
8016
  // Cancellation carries no builder context, so it must not prompt for a
7579
8017
  // fee approval that the action cannot use.
7580
- await this.#ensureReadyForTrading({ requiresBuilderFee: false });
7581
- const exchangeClient = this.#clientService.getExchangeClient();
8018
+ const { accountContext } = await this.#ensureReadyForTrading({
8019
+ requiresBuilderFee: false,
8020
+ });
7582
8021
  const cancelRequests = await Promise.all(ordinaryOrders.map(async ({ order }) => {
7583
8022
  const asset = await this.#getAssetIdWithRepair({
7584
8023
  symbol: order.symbol,
@@ -7589,6 +8028,8 @@ export class HyperLiquidProvider {
7589
8028
  o: parseInt(order.orderId, 10),
7590
8029
  };
7591
8030
  }));
8031
+ await this.#assertNoKnownUnsupportedAccount(accountContext, 'Batch order cancellation');
8032
+ const exchangeClient = this.#clientService.getExchangeClient();
7592
8033
  let statuses;
7593
8034
  let answer;
7594
8035
  try {
@@ -7623,6 +8064,7 @@ export class HyperLiquidProvider {
7623
8064
  : undefined;
7624
8065
  let error = PERPS_ERROR_CODES.BATCH_CANCEL_FAILED;
7625
8066
  if (statusError) {
8067
+ this.#recordUnsupportedAccountFromError(statusError, accountContext);
7626
8068
  error =
7627
8069
  signerFailure && (await this.#isSignerFailure(statusError))
7628
8070
  ? signerFailure.message
@@ -7666,11 +8108,14 @@ export class HyperLiquidProvider {
7666
8108
  async closePositions(params) {
7667
8109
  // Declare outside try block so it's accessible in catch block
7668
8110
  let positionsToClose = [];
8111
+ let accountContext;
7669
8112
  const unavailableResults = [];
7670
8113
  try {
7671
8114
  // Batch preparation needs trading readiness but not builder approval yet.
7672
8115
  // The provider-owned market policy is resolved from the positions below.
7673
- await this.#ensureReadyForTrading({ requiresBuilderFee: false });
8116
+ ({ accountContext } = await this.#ensureReadyForTrading({
8117
+ requiresBuilderFee: false,
8118
+ }));
7674
8119
  // Selected symbols only need the DEXes they belong to. Loading every
7675
8120
  // market group would refuse a BTC close when an unrelated HIP-3 DEX
7676
8121
  // cannot be read. closeAll / empty symbols still need a complete
@@ -7741,8 +8186,6 @@ export class HyperLiquidProvider {
7741
8186
  results: unavailableResults,
7742
8187
  };
7743
8188
  }
7744
- // Get exchange client for order submission
7745
- const exchangeClient = this.#clientService.getExchangeClient();
7746
8189
  // Pre-fetch meta for all unique DEXs to avoid N API calls in loop
7747
8190
  const uniqueDexs = [
7748
8191
  ...new Set(positionsToClose.map((pos) => parseAssetName(pos.symbol).dex ?? 'main')),
@@ -7857,10 +8300,16 @@ export class HyperLiquidProvider {
7857
8300
  // policy charges, preserving deterministic behavior for future policies
7858
8301
  // that vary by market or route.
7859
8302
  const chargesMetamaskBuilderFee = feePolicyContexts.some((context) => this.#resolveOrderFeePolicy(context).chargesMetamaskBuilderFee);
7860
- const builder = chargesMetamaskBuilderFee
7861
- ? await this.#getBuilderOrderContext(await this.#ensureReadyForTrading({ requiresBuilderFee: true }))
7862
- : undefined;
8303
+ let builder;
8304
+ if (chargesMetamaskBuilderFee) {
8305
+ await this.#assertNoKnownUnsupportedAccount(accountContext, 'Batch close setup');
8306
+ const builderFeeSetupContext = await this.#ensureBuilderFeeSetup(undefined, {}, accountContext);
8307
+ await this.#assertNoKnownUnsupportedAccount(accountContext, 'Batch close setup');
8308
+ builder = await this.#getBuilderOrderContext(builderFeeSetupContext);
8309
+ }
7863
8310
  // Single batch API call
8311
+ await this.#assertNoKnownUnsupportedAccount(accountContext, 'Batch close submission');
8312
+ const exchangeClient = this.#clientService.getExchangeClient();
7864
8313
  const result = await exchangeClient.order({
7865
8314
  orders: this.#applySubscriptionCloid(orders),
7866
8315
  grouping: 'na',
@@ -7868,6 +8317,11 @@ export class HyperLiquidProvider {
7868
8317
  });
7869
8318
  // Parse response statuses (one per order)
7870
8319
  const { statuses } = result.response.data;
8320
+ for (const status of statuses) {
8321
+ if (hasProperty(status, 'error') && typeof status.error === 'string') {
8322
+ this.#recordUnsupportedAccountFromError(createStatusError(status.error, result), accountContext);
8323
+ }
8324
+ }
7871
8325
  const successCount = statuses.filter((stat) => isStatusObject(stat) &&
7872
8326
  (hasProperty(stat, 'filled') || hasProperty(stat, 'resting'))).length;
7873
8327
  const failureCount = statuses.length -
@@ -7886,6 +8340,7 @@ export class HyperLiquidProvider {
7886
8340
  this.#deps.debugLogger.log('Position closed successfully, initiating manual auto-transfer back', { symbol: orderedPositions[i].symbol, freedMargin });
7887
8341
  // Non-blocking: Transfer freed margin back to main DEX
7888
8342
  await this.#autoTransferBackAfterClose({
8343
+ accountContext,
7889
8344
  sourceDex,
7890
8345
  freedMargin,
7891
8346
  });
@@ -7921,6 +8376,9 @@ export class HyperLiquidProvider {
7921
8376
  };
7922
8377
  }
7923
8378
  catch (error) {
8379
+ if (accountContext) {
8380
+ this.#recordUnsupportedAccountFromError(error, accountContext);
8381
+ }
7924
8382
  const signerFailure = await this.#handleSignerFailure(error, 'closePositions', {
7925
8383
  positionCount: positionsToClose.length,
7926
8384
  });
@@ -8004,6 +8462,11 @@ export class HyperLiquidProvider {
8004
8462
  const lifecycle = this.#lifecycleGeneration;
8005
8463
  const isTestnet = this.#clientService.isTestnetMode();
8006
8464
  const userAddress = await this.#walletService.getUserAddressWithDefault();
8465
+ const accountContext = {
8466
+ lifecycleGeneration: lifecycle,
8467
+ network: isTestnet ? 'testnet' : 'mainnet',
8468
+ userAddress,
8469
+ };
8007
8470
  const assertScope = async () => {
8008
8471
  const current = await this.#walletService.getUserAddressWithDefault();
8009
8472
  this.#assertProviderLifecycleCurrent(lifecycle, 'updatePositionTPSL');
@@ -8399,12 +8862,18 @@ export class HyperLiquidProvider {
8399
8862
  const requiresBuilderFee = replacementChargesMetamaskBuilderFee || rollbackRequiresBuilderFee;
8400
8863
  // Approval and builder-context resolution both finish before the
8401
8864
  // pre-cancel. A failure here therefore leaves the old protection intact.
8402
- const builderFeeSetupContext = requiresBuilderFee
8865
+ await assertScope();
8866
+ const tradingActionContext = requiresBuilderFee
8403
8867
  ? await this.#ensureReadyForTrading({
8404
8868
  requiresBuilderFee: true,
8405
8869
  builderFeeApprovalFailureCode: PERPS_ERROR_CODES.TPSL_UPDATE_FAILED,
8870
+ accountContext,
8406
8871
  })
8407
- : await this.#ensureReadyForTrading({ requiresBuilderFee: false });
8872
+ : await this.#ensureReadyForTrading({
8873
+ requiresBuilderFee: false,
8874
+ accountContext,
8875
+ });
8876
+ const { builderFeeSetupContext } = tradingActionContext;
8408
8877
  const builderOrderContext = builderFeeSetupContext
8409
8878
  ? await this.#getBuilderOrderContext(builderFeeSetupContext)
8410
8879
  : undefined;
@@ -8449,9 +8918,10 @@ export class HyperLiquidProvider {
8449
8918
  builderOrderContext && { builder: builderOrderContext }),
8450
8919
  });
8451
8920
  statuses = result.response?.data?.statuses ?? [];
8921
+ this.#recordUnsupportedAccountFromStatuses(statuses, result, accountContext);
8452
8922
  }
8453
8923
  catch (error) {
8454
- const failure = await this.#classifyProtectionWriteError(error);
8924
+ const failure = await this.#classifyProtectionWriteError(error, accountContext);
8455
8925
  restorationFailure = failure;
8456
8926
  statuses =
8457
8927
  this.#readProtectionErrorStatuses(error) ??
@@ -8505,10 +8975,10 @@ export class HyperLiquidProvider {
8505
8975
  await assertCurrentPosition();
8506
8976
  const oldCancellation = await this.#cancelOrderRequestBatch(exchangeClient, cancelRequests, async (error) => {
8507
8977
  if (cancelGuardFailure !== undefined) {
8508
- throw await this.#classifyProtectionWriteError(cancelGuardFailure);
8978
+ throw await this.#classifyProtectionWriteError(cancelGuardFailure, accountContext);
8509
8979
  }
8510
8980
  if (isHyperLiquidMultiSigRequiredError(error)) {
8511
- throw await this.#classifyProtectionWriteError(error);
8981
+ throw await this.#classifyProtectionWriteError(error, accountContext);
8512
8982
  }
8513
8983
  });
8514
8984
  // The signer could not sign the cancel. With nothing cancelled the old
@@ -8606,11 +9076,12 @@ export class HyperLiquidProvider {
8606
9076
  builderOrderContext && { builder: builderOrderContext }),
8607
9077
  });
8608
9078
  placementStatuses = result.response?.data?.statuses ?? [];
9079
+ this.#recordUnsupportedAccountFromStatuses(placementStatuses, result, accountContext);
8609
9080
  responseAccepted = result.status === 'ok';
8610
9081
  }
8611
9082
  catch (error) {
8612
9083
  // Drop and report a rejected agent before restoration signs.
8613
- placementFailure = await this.#classifyProtectionWriteError(error);
9084
+ placementFailure = await this.#classifyProtectionWriteError(error, accountContext);
8614
9085
  await assertScope();
8615
9086
  const statuses = this.#readProtectionErrorStatuses(error);
8616
9087
  if (statuses !== undefined) {
@@ -8726,7 +9197,7 @@ export class HyperLiquidProvider {
8726
9197
  error.message === PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED)) {
8727
9198
  // Old protection has already changed. Classification must not
8728
9199
  // throw away this batch's still-live replacement identities.
8729
- cleanupFailure = await this.#classifyProtectionWriteError(error);
9200
+ cleanupFailure = await this.#classifyProtectionWriteError(error, accountContext);
8730
9201
  }
8731
9202
  });
8732
9203
  await assertScope();
@@ -8804,7 +9275,9 @@ export class HyperLiquidProvider {
8804
9275
  this.#deps.debugLogger.log('Closing position:', params);
8805
9276
  // The delegated placeOrder call resolves the builder-fee policy for the
8806
9277
  // concrete close order after validation.
8807
- await this.#ensureReadyForTrading({ requiresBuilderFee: false });
9278
+ const { accountContext } = await this.#ensureReadyForTrading({
9279
+ requiresBuilderFee: false,
9280
+ });
8808
9281
  // A caller snapshot is never authoritative for a reduce-only close. Use
8809
9282
  // the current DEX slice or its HTTP fallback, and fail closed if neither
8810
9283
  // answers. This keeps the size and side on one freshness contract across
@@ -8883,7 +9356,8 @@ export class HyperLiquidProvider {
8883
9356
  // position.
8884
9357
  const isFullClose = closeSizeNum >= absPositionSize;
8885
9358
  // Execute position close with consistent slippage handling
8886
- const result = await this.placeOrder({
9359
+ await this.#assertNoKnownUnsupportedAccount(accountContext, 'Position close');
9360
+ const result = await this.#placeOrder({
8887
9361
  symbol: params.symbol,
8888
9362
  isBuy,
8889
9363
  size: closeSize,
@@ -8903,7 +9377,7 @@ export class HyperLiquidProvider {
8903
9377
  usdAmount: isFullClose ? undefined : params.usdAmount,
8904
9378
  priceAtCalculation: params.priceAtCalculation,
8905
9379
  maxSlippageBps: params.maxSlippageBps,
8906
- }, 0, false);
9380
+ }, 0, false, accountContext);
8907
9381
  // Return freed margin using native abstraction or programmatic transfer
8908
9382
  if (result.success &&
8909
9383
  isHip3Position &&
@@ -8912,6 +9386,7 @@ export class HyperLiquidProvider {
8912
9386
  this.#deps.debugLogger.log('Position closed successfully, initiating manual auto-transfer back');
8913
9387
  // Non-blocking: Transfer freed margin back to main DEX
8914
9388
  await this.#autoTransferBackAfterClose({
9389
+ accountContext,
8915
9390
  sourceDex: hip3Dex,
8916
9391
  freedMargin,
8917
9392
  });
@@ -8954,11 +9429,13 @@ export class HyperLiquidProvider {
8954
9429
  * - ntli: Amount in micro-units (amount * 1e6)
8955
9430
  */
8956
9431
  async updateMargin(params) {
9432
+ let accountContext;
8957
9433
  try {
8958
9434
  this.#deps.debugLogger.log('Updating position margin:', params);
8959
9435
  const { symbol, amount } = params;
8960
9436
  // Ensure provider is ready
8961
9437
  await this.#ensureReady();
9438
+ accountContext = await this.#assertAccountSupported();
8962
9439
  // Use the target DEX's current slice or one targeted HTTP read before
8963
9440
  // deriving the position side.
8964
9441
  const positions = await this.#getPositionsForOperation(parseAssetName(symbol).dex ?? '');
@@ -8994,6 +9471,7 @@ export class HyperLiquidProvider {
8994
9471
  }
8995
9472
  }
8996
9473
  // Call SDK to update isolated margin
9474
+ await this.#assertNoKnownUnsupportedAccount(accountContext, 'Margin update');
8997
9475
  const exchangeClient = this.#clientService.getExchangeClient();
8998
9476
  const result = await exchangeClient.updateIsolatedMargin({
8999
9477
  asset: assetId,
@@ -9009,13 +9487,18 @@ export class HyperLiquidProvider {
9009
9487
  };
9010
9488
  }
9011
9489
  catch (error) {
9490
+ const isMultiSigRequired = accountContext
9491
+ ? this.#recordUnsupportedAccountFromError(error, accountContext)
9492
+ : false;
9012
9493
  const signerFailure = await this.#handleSignerFailure(error, 'updateMargin', {
9013
9494
  symbol: params.symbol,
9014
9495
  });
9015
9496
  if (signerFailure) {
9016
9497
  return { success: false, error: signerFailure.message };
9017
9498
  }
9018
- const safeError = ensureError(error, 'HyperLiquidProvider.updateMargin');
9499
+ const safeError = isMultiSigRequired
9500
+ ? new Error(PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED)
9501
+ : ensureError(error, 'HyperLiquidProvider.updateMargin');
9019
9502
  this.#deps.logger.error(safeError, this.#getErrorContext('updateMargin', {
9020
9503
  symbol: params.symbol,
9021
9504
  amount: params.amount,
@@ -9904,6 +10387,22 @@ export class HyperLiquidProvider {
9904
10387
  return [];
9905
10388
  }
9906
10389
  }
10390
+ /**
10391
+ * Return whether the active wallet can submit Hyperliquid actions.
10392
+ *
10393
+ * @returns The provider-owned account support result.
10394
+ */
10395
+ async getAccountSupport() {
10396
+ await this.#ensureReady();
10397
+ const context = {
10398
+ lifecycleGeneration: this.#lifecycleGeneration,
10399
+ network: this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet',
10400
+ userAddress: await this.#walletService.getUserAddressWithDefault(),
10401
+ };
10402
+ const support = await this.#getAccountSupportForContext(context);
10403
+ await this.#assertAccountContextCurrent(context, 'Account support read');
10404
+ return support;
10405
+ }
9907
10406
  /**
9908
10407
  * Resolve the provider's currently active CAIP account identifier.
9909
10408
  * Used by the MarketDataService REST coalesce layer so cached payloads
@@ -10943,6 +11442,7 @@ export class HyperLiquidProvider {
10943
11442
  * @returns Result with txHash (HyperLiquid internal) and withdrawal ID
10944
11443
  */
10945
11444
  async withdraw(params) {
11445
+ let accountContext;
10946
11446
  try {
10947
11447
  this.#deps.debugLogger.log('HyperLiquidProvider: STARTING WITHDRAWAL', {
10948
11448
  params,
@@ -10995,6 +11495,16 @@ export class HyperLiquidProvider {
10995
11495
  this.#deps.debugLogger.log('HyperLiquidProvider: ASSET SUPPORTED', {
10996
11496
  assetId: params.assetId,
10997
11497
  });
11498
+ const lifecycleGeneration = this.#lifecycleGeneration;
11499
+ const network = this.#clientService.isTestnetMode()
11500
+ ? 'testnet'
11501
+ : 'mainnet';
11502
+ const userAddress = await this.#walletService.getUserAddressWithDefault();
11503
+ accountContext = {
11504
+ lifecycleGeneration,
11505
+ network,
11506
+ userAddress,
11507
+ };
10998
11508
  // Step 3: Determine destination address
10999
11509
  this.#deps.debugLogger.log('HyperLiquidProvider: DETERMINING DESTINATION ADDRESS');
11000
11510
  let destination;
@@ -11005,7 +11515,7 @@ export class HyperLiquidProvider {
11005
11515
  });
11006
11516
  }
11007
11517
  else {
11008
- destination = await this.#walletService.getUserAddressWithDefault();
11518
+ destination = userAddress;
11009
11519
  this.#deps.debugLogger.log('HyperLiquidProvider: USING USER WALLET ADDRESS', {
11010
11520
  destination,
11011
11521
  });
@@ -11013,7 +11523,12 @@ export class HyperLiquidProvider {
11013
11523
  // Step 4: Ensure client is ready
11014
11524
  this.#deps.debugLogger.log('HyperLiquidProvider: ENSURING CLIENT READY');
11015
11525
  await this.#ensureReady();
11016
- await this.#ensureUnifiedAccountEnabled({ allowUserSigning: true });
11526
+ accountContext = await this.#assertAccountSupported(accountContext);
11527
+ await this.#ensureUnifiedAccountEnabled({
11528
+ allowUserSigning: true,
11529
+ accountContext,
11530
+ });
11531
+ await this.#assertNoKnownUnsupportedAccount(accountContext, 'Withdrawal');
11017
11532
  const exchangeClient = this.#clientService.getExchangeClient();
11018
11533
  this.#deps.debugLogger.log('HyperLiquidProvider: CLIENT READY');
11019
11534
  // Step 5: Validate amount against account balance
@@ -11063,6 +11578,7 @@ export class HyperLiquidProvider {
11063
11578
  endpoint: 'withdraw3',
11064
11579
  timestamp: new Date().toISOString(),
11065
11580
  });
11581
+ await this.#assertNoKnownUnsupportedAccount(accountContext, 'Withdrawal');
11066
11582
  const result = await exchangeClient.withdraw3({
11067
11583
  destination,
11068
11584
  amount: params.amount,
@@ -11089,7 +11605,11 @@ export class HyperLiquidProvider {
11089
11605
  // HyperLiquid's withdraw3 API doesn't return a transaction hash immediately
11090
11606
  };
11091
11607
  }
11092
- const errorMessage = `Withdrawal failed: ${String(result.status)}`;
11608
+ const rejection = new Error(`Withdrawal failed: ${JSON.stringify(result)}`);
11609
+ const isMultiSigRequired = this.#recordUnsupportedAccountFromError(rejection, accountContext);
11610
+ const errorMessage = isMultiSigRequired
11611
+ ? PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED
11612
+ : `Withdrawal failed: ${String(result.status)}`;
11093
11613
  this.#deps.debugLogger.log('HyperLiquidProvider: WITHDRAWAL FAILED', {
11094
11614
  error: errorMessage,
11095
11615
  status: result.status,
@@ -11102,13 +11622,18 @@ export class HyperLiquidProvider {
11102
11622
  };
11103
11623
  }
11104
11624
  catch (error) {
11625
+ const isMultiSigRequired = accountContext
11626
+ ? this.#recordUnsupportedAccountFromError(error, accountContext)
11627
+ : false;
11105
11628
  const signerFailure = await this.#handleSignerFailure(error, 'withdraw', {
11106
11629
  assetId: params.assetId,
11107
11630
  });
11108
11631
  if (signerFailure) {
11109
11632
  return createErrorResult(signerFailure, { success: false });
11110
11633
  }
11111
- const safeError = ensureError(error, 'HyperLiquidProvider.initiateWithdrawal');
11634
+ const safeError = isMultiSigRequired
11635
+ ? new Error(PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED)
11636
+ : ensureError(error, 'HyperLiquidProvider.initiateWithdrawal');
11112
11637
  this.#deps.debugLogger.log('HyperLiquidProvider: WITHDRAWAL EXCEPTION', {
11113
11638
  error: safeError.message,
11114
11639
  errorType: safeError.name,
@@ -11124,7 +11649,7 @@ export class HyperLiquidProvider {
11124
11649
  operation: PERPS_ERROR_OPERATION.FinancialOperations,
11125
11650
  action: PERPS_ERROR_ACTION.FinancialWithdrawal,
11126
11651
  }));
11127
- return createErrorResult(error, { success: false });
11652
+ return createErrorResult(safeError, { success: false });
11128
11653
  }
11129
11654
  }
11130
11655
  /**
@@ -11147,6 +11672,17 @@ export class HyperLiquidProvider {
11147
11672
  * });
11148
11673
  */
11149
11674
  async transferBetweenDexs(params) {
11675
+ return await this.#transferBetweenDexs(params);
11676
+ }
11677
+ /**
11678
+ * Transfer collateral with an optional context captured by a parent action.
11679
+ *
11680
+ * @param params - Transfer parameters.
11681
+ * @param expectedContext - Context captured by the parent action.
11682
+ * @returns The transfer result.
11683
+ */
11684
+ async #transferBetweenDexs(params, expectedContext) {
11685
+ let accountContext;
11150
11686
  try {
11151
11687
  this.#deps.debugLogger.log('HyperLiquidProvider: STARTING DEX TRANSFER', {
11152
11688
  params,
@@ -11159,14 +11695,13 @@ export class HyperLiquidProvider {
11159
11695
  if (params.sourceDex === params.destinationDex) {
11160
11696
  throw new Error('Source and destination DEX must be different');
11161
11697
  }
11162
- // Get user address
11163
- const userAddress = await this.#walletService.getUserAddressWithDefault();
11698
+ // Ensure client ready
11699
+ await this.#ensureReady();
11700
+ accountContext = await this.#assertAccountSupported(expectedContext);
11701
+ const { userAddress } = accountContext;
11164
11702
  this.#deps.debugLogger.log('HyperLiquidProvider: USER ADDRESS', {
11165
11703
  userAddress,
11166
11704
  });
11167
- // Ensure client ready
11168
- await this.#ensureReady();
11169
- const exchangeClient = this.#clientService.getExchangeClient();
11170
11705
  // Execute transfer using SDK sendAsset()
11171
11706
  // Note: SDK docs say "testnet-only" but it works on mainnet (verified via Phantom)
11172
11707
  this.#deps.debugLogger.log('HyperLiquidProvider: CALLING SEND_ASSET API', {
@@ -11174,6 +11709,8 @@ export class HyperLiquidProvider {
11174
11709
  destinationDex: params.destinationDex || '(main)',
11175
11710
  amount: params.amount,
11176
11711
  });
11712
+ await this.#assertNoKnownUnsupportedAccount(accountContext, 'DEX transfer');
11713
+ const exchangeClient = this.#clientService.getExchangeClient();
11177
11714
  const result = await exchangeClient.sendAsset({
11178
11715
  destination: userAddress,
11179
11716
  sourceDex: params.sourceDex,
@@ -11193,14 +11730,22 @@ export class HyperLiquidProvider {
11193
11730
  // User can verify transfer in explorer by timestamp
11194
11731
  };
11195
11732
  }
11733
+ if (this.#recordUnsupportedAccountFromError(new Error(`DEX transfer failed: ${JSON.stringify(result)}`), accountContext)) {
11734
+ throw new Error(PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED);
11735
+ }
11196
11736
  throw new Error(PERPS_ERROR_CODES.TRANSFER_FAILED);
11197
11737
  }
11198
11738
  catch (error) {
11739
+ const isMultiSigRequired = accountContext
11740
+ ? this.#recordUnsupportedAccountFromError(error, accountContext)
11741
+ : false;
11199
11742
  const signerFailure = await this.#handleSignerFailure(error, 'transferBetweenDexs', { sourceDex: params.sourceDex, destinationDex: params.destinationDex });
11200
11743
  if (signerFailure) {
11201
11744
  return { success: false, error: signerFailure.message };
11202
11745
  }
11203
- const safeError = ensureError(error, 'HyperLiquidProvider.transferToSpot');
11746
+ const safeError = isMultiSigRequired
11747
+ ? new Error(PERPS_ERROR_CODES.EXCHANGE_MULTI_SIG_REQUIRED)
11748
+ : ensureError(error, 'HyperLiquidProvider.transferToSpot');
11204
11749
  this.#deps.debugLogger.log('❌ HyperLiquidProvider: TRANSFER FAILED', {
11205
11750
  error: safeError.message,
11206
11751
  params,
@@ -11212,6 +11757,16 @@ export class HyperLiquidProvider {
11212
11757
  };
11213
11758
  }
11214
11759
  }
11760
+ /**
11761
+ * Transfer collateral using a context captured by a parent action.
11762
+ *
11763
+ * @param params - Transfer parameters.
11764
+ * @param accountContext - Original action context for the transfer.
11765
+ * @returns The transfer result.
11766
+ */
11767
+ async #transferBetweenDexsForContext(params, accountContext) {
11768
+ return await this.#transferBetweenDexs(params, accountContext);
11769
+ }
11215
11770
  /**
11216
11771
  * Subscribe to live price updates
11217
11772
  *
@@ -11494,6 +12049,11 @@ export class HyperLiquidProvider {
11494
12049
  try {
11495
12050
  const lifecycleGeneration = this.#lifecycleGeneration;
11496
12051
  const userAddress = await this.#walletService.getUserAddressWithDefault();
12052
+ const preparationContext = {
12053
+ lifecycleGeneration,
12054
+ network: this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet',
12055
+ userAddress,
12056
+ };
11497
12057
  // The result is only for the provider and account it started with.
11498
12058
  const assertPreparationCurrent = async () => {
11499
12059
  this.#assertProviderLifecycleCurrent(lifecycleGeneration, 'Trading wallet preparation');
@@ -11505,13 +12065,13 @@ export class HyperLiquidProvider {
11505
12065
  throw new Error(PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE);
11506
12066
  }
11507
12067
  };
11508
- await this.#ensureReadyForTrading({
12068
+ const { accountContext } = await this.#ensureReadyForTrading({
11509
12069
  requiresBuilderFee: false,
11510
12070
  recheckPendingReferral: true,
12071
+ accountContext: preparationContext,
11511
12072
  });
11512
- const network = this.#clientService.isTestnetMode()
11513
- ? 'testnet'
11514
- : 'mainnet';
12073
+ await assertPreparationCurrent();
12074
+ const { network } = accountContext;
11515
12075
  const isRegistered = await this.#isWalletOnHyperliquid(userAddress, network);
11516
12076
  await assertPreparationCurrent();
11517
12077
  // The venue rejects every write from a wallet with no HyperLiquid account
@@ -11527,7 +12087,7 @@ export class HyperLiquidProvider {
11527
12087
  await this.#ensureBuilderFeeSetup(undefined, {
11528
12088
  reportSignerFailure: true,
11529
12089
  reportRefusal: true,
11530
- });
12090
+ }, accountContext);
11531
12091
  // The builder fee setup ends quietly when the provider disconnects.
11532
12092
  await assertPreparationCurrent();
11533
12093
  if (!this.#walletService.isMainAccountSignerReady()) {
@@ -12171,6 +12731,7 @@ export class HyperLiquidProvider {
12171
12731
  // Clear session caches. Capability and fee reads use the lifecycle
12172
12732
  // generation above to discard any old response that resolves later.
12173
12733
  this.clearFeeCache();
12734
+ this.#accountSupportByContext.clear();
12174
12735
  this.#referralCheckCache.clear();
12175
12736
  this.#builderFeeCheckCache.clear();
12176
12737
  this.#builderFeeRefusals.clear();
@@ -12416,20 +12977,26 @@ export class HyperLiquidProvider {
12416
12977
  *
12417
12978
  * Note: This is network-specific - testnet and mainnet have separate referral states
12418
12979
  * Note: Non-blocking - failures are logged to Sentry but don't prevent trading
12980
+ *
12981
+ * @param accountContext - Account/network/lifecycle captured by the action
12982
+ * that is allowed to authorize a referral write.
12419
12983
  */
12420
- async #ensureReferralSet() {
12984
+ async #ensureReferralSet(accountContext) {
12421
12985
  this.#referralSetupNeedsRetry = false;
12422
12986
  this.#referralAwaitsBuilderCode = false;
12423
- const isTestnet = this.#clientService.isTestnetMode();
12424
- const network = isTestnet ? 'testnet' : 'mainnet';
12987
+ const network = accountContext?.network ??
12988
+ (this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet');
12989
+ const isTestnet = network === 'testnet';
12425
12990
  const expectedReferralCode = this.#getReferralCode(isTestnet);
12426
12991
  const referrerAddress = this.#getBuilderAddress(isTestnet);
12427
- let userAddress;
12428
- try {
12429
- userAddress = await this.#walletService.getUserAddressWithDefault();
12430
- }
12431
- catch {
12432
- return; // Can't proceed without address
12992
+ let userAddress = accountContext?.userAddress;
12993
+ if (!userAddress) {
12994
+ try {
12995
+ userAddress = await this.#walletService.getUserAddressWithDefault();
12996
+ }
12997
+ catch {
12998
+ return; // Can't proceed without address
12999
+ }
12433
13000
  }
12434
13001
  if (userAddress.toLowerCase() === referrerAddress.toLowerCase()) {
12435
13002
  this.#deps.debugLogger.log('[ensureReferralSet] User is builder, skipping', { network });
@@ -12471,6 +13038,9 @@ export class HyperLiquidProvider {
12471
13038
  return;
12472
13039
  }
12473
13040
  const codeStatus = await this.#getReferralCodeStatus();
13041
+ if (accountContext) {
13042
+ await this.#assertNoKnownUnsupportedAccount(accountContext, 'Referral code lookup');
13043
+ }
12474
13044
  if (codeStatus !== 'ready') {
12475
13045
  this.#deps.debugLogger.log('[ensureReferralSet] Builder referral not ready, skipping', { network, codeStatus });
12476
13046
  // Don't cache. A failed lookup (already logged) waits for the next
@@ -12480,7 +13050,10 @@ export class HyperLiquidProvider {
12480
13050
  return;
12481
13051
  }
12482
13052
  // Check if user already has a referral on-chain
12483
- const hasReferral = await this.#checkReferralSet();
13053
+ const hasReferral = await this.#checkReferralSet(userAddress);
13054
+ if (accountContext) {
13055
+ await this.#assertNoKnownUnsupportedAccount(accountContext, 'Referral account lookup');
13056
+ }
12484
13057
  if (hasReferral) {
12485
13058
  // Already has referral on-chain
12486
13059
  PerpsSigningCache.setReferral(network, userAddress, {
@@ -12491,7 +13064,7 @@ export class HyperLiquidProvider {
12491
13064
  }
12492
13065
  else {
12493
13066
  this.#deps.debugLogger.log('[ensureReferralSet] Setting referral (will show signing request)', { network, referralCode: expectedReferralCode });
12494
- const result = await this.#setReferralCode();
13067
+ const result = await this.#setReferralCode(accountContext);
12495
13068
  if (result) {
12496
13069
  this.#deps.debugLogger.log('[ensureReferralSet] Referral set successfully', { network });
12497
13070
  PerpsSigningCache.setReferral(network, userAddress, {
@@ -12519,6 +13092,11 @@ export class HyperLiquidProvider {
12519
13092
  completeInFlight();
12520
13093
  return;
12521
13094
  }
13095
+ if (accountContext &&
13096
+ this.#recordUnsupportedAccountFromError(error, accountContext)) {
13097
+ completeInFlight();
13098
+ return;
13099
+ }
12522
13100
  // Safety net: wallet looked registered but the SDK still rejects with
12523
13101
  // "User or API Wallet 0x... does not exist." Do not forward to Sentry.
12524
13102
  // The walletRegistered cache stores positive observations only, so no
@@ -12528,6 +13106,12 @@ export class HyperLiquidProvider {
12528
13106
  completeInFlight();
12529
13107
  return;
12530
13108
  }
13109
+ if (accountContext &&
13110
+ ensureError(error, 'HyperLiquidProvider.ensureReferralSet').message ===
13111
+ PERPS_ERROR_CODES.PROVIDER_LIFECYCLE_STALE) {
13112
+ completeInFlight();
13113
+ throw error;
13114
+ }
12531
13115
  // Cache failure to prevent retries
12532
13116
  PerpsSigningCache.setReferral(network, userAddress, {
12533
13117
  attempted: true,
@@ -12585,18 +13169,20 @@ export class HyperLiquidProvider {
12585
13169
  /**
12586
13170
  * Check if user has a referral code set with HyperLiquid
12587
13171
  *
13172
+ * @param userAddress - Account to inspect, or the currently selected account
13173
+ * when omitted.
12588
13174
  * @returns Promise resolving to true if referral is set, false otherwise
12589
13175
  */
12590
- async #checkReferralSet() {
13176
+ async #checkReferralSet(userAddress) {
12591
13177
  try {
12592
13178
  const infoClient = this.#clientService.getInfoClient();
12593
- const userAddress = await this.#walletService.getUserAddressWithDefault();
13179
+ const referralUserAddress = userAddress ?? (await this.#walletService.getUserAddressWithDefault());
12594
13180
  // Call HyperLiquid API to check if user has a referral set
12595
13181
  const referralData = await infoClient.referral({
12596
- user: userAddress,
13182
+ user: referralUserAddress,
12597
13183
  });
12598
13184
  this.#deps.debugLogger.log('Referral check result:', {
12599
- userAddress,
13185
+ userAddress: referralUserAddress,
12600
13186
  referralData,
12601
13187
  });
12602
13188
  return Boolean(referralData?.referredBy?.code);
@@ -12620,10 +13206,15 @@ export class HyperLiquidProvider {
12620
13206
  /**
12621
13207
  * Set MetaMask as the user's referrer on HyperLiquid
12622
13208
  *
13209
+ * @param accountContext - Account/network/lifecycle captured by the action
13210
+ * that is allowed to authorize this write.
12623
13211
  * @returns A promise that resolves to the boolean result.
12624
13212
  */
12625
- async #setReferralCode() {
13213
+ async #setReferralCode(accountContext) {
12626
13214
  try {
13215
+ if (accountContext) {
13216
+ await this.#assertNoKnownUnsupportedAccount(accountContext, 'Referral setup');
13217
+ }
12627
13218
  const exchangeClient = this.#clientService.getExchangeClient();
12628
13219
  const referralCode = this.#getReferralCode(this.#clientService.isTestnetMode());
12629
13220
  this.#deps.debugLogger.log('[setReferralCode] Setting referral code', {
@@ -12635,9 +13226,18 @@ export class HyperLiquidProvider {
12635
13226
  code: referralCode,
12636
13227
  });
12637
13228
  this.#deps.debugLogger.log('[setReferralCode] Referral code set result', result);
13229
+ if (result?.status !== 'ok' &&
13230
+ accountContext &&
13231
+ this.#recordUnsupportedAccountFromError(new Error(`Referral setup failed: ${JSON.stringify(result)}`), accountContext)) {
13232
+ return false;
13233
+ }
12638
13234
  return result?.status === 'ok';
12639
13235
  }
12640
13236
  catch (error) {
13237
+ if (accountContext &&
13238
+ this.#recordUnsupportedAccountFromError(error, accountContext)) {
13239
+ throw error;
13240
+ }
12641
13241
  // Retryable (a locked keyring, an unavailable agent, or one the venue
12642
13242
  // rejected): `#ensureReferralSet` retries at the next entry, so it is
12643
13243
  // not an error to report.