From 29d300597dd341cbcfe6b5623dd817ccb47cbc1d Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Fri, 6 Mar 2026 08:30:23 +0800 Subject: [PATCH 1/5] refactor(perps): address core PR #7941 review and cleanup (#27035) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Addresses all actionable review comments from the Core perps-controller PR ([MetaMask/core#7941](https://github.com/MetaMask/core/pull/7941)). **Bug fixes:** - Fix cancellation detection inconsistency — add `'User '` prefix to match the pattern used elsewhere - Fix memoization regression — pass `timestamp` as selector input so re-renders fire correctly - Resolve circular dependency in `perps-types.ts` - Extract hardcoded withdrawal fee and fee estimate to named constants **Refactors (per core review feedback):** - Replace 5 barrel `export *` wildcards with explicit named exports in `index.ts` - Extract 34 method action types to `PerpsController-method-action-types.ts` - Extract inline provider instantiation into private `#createProviders()` factory - Move `registerMethodActionHandlers` from constructor to `initialize()` with an idempotency guard - Extract magic numbers to config constants (`DEPOSIT_GAS_LIMIT`, `SPOT_ASSET_ID_OFFSET`, `MYX_TRADING_DEFAULTS`, `MYX_FEE_CONFIG`) - Add `fetchHistoricalCandles` to `PerpsProvider` interface (removes a type cast in `PerpsController`) ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Perps controller cleanup Scenario: user opens Perps and trades normally Given the app is running with Perps enabled When user navigates to Perps, connects, and places an order Then all perps features work as before — no regressions ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Touches Perps controller initialization/messenger wiring and public exports, which could cause runtime or build-time breakages if any consumers rely on previous timing or wildcard exports. Behavior changes are mostly bounded (constants, cancellation detection, candle fetching interface), but span multiple core perps modules. > > **Overview** > **Perps controller refactor and API cleanup.** Extracts `PerpsController` method action type definitions into `PerpsController-method-action-types.ts`, moves `registerMethodActionHandlers` from the constructor into `init()` with an idempotency guard, and factors provider instantiation/active-provider selection into a private `#createProviders()`. > > **Provider/API surface updates.** Replaces `export *` barrels in `controllers/perps/index.ts` with explicit named exports, adds optional `fetchHistoricalCandles` to the `PerpsProvider` interface and implements/tests it in `HyperLiquidProvider`, and removes a circular dependency by relocating `OrderType` into `types/perps-types.ts`. > > **Bug fixes and config/constants.** Updates deposit cancellation detection strings to match `User cancelled/canceled`, fixes a selector memoization ordering regression around `timestamp`, drops unsupported hex-wei handling in `convertPerpsAmountToUSD` (now returns `'$0'`), and centralizes fee/gas/ID constants (e.g., `DEPOSIT_CONFIG.EstimatedGasLimit`, `WITHDRAWAL_CONSTANTS.DefaultFeeAmount`, `ESTIMATED_FEE_RATE`, `SPOT_ASSET_ID_OFFSET`). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 29b3e693d7f8b6050ba895597721c09378a27eb4. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../UI/Perps/utils/amountConversion.test.ts | 4 +- .../UI/Perps/utils/amountConversion.ts | 16 +- .../PerpsController-method-action-types.ts | 207 +++++++++ app/controllers/perps/PerpsController.ts | 396 ++++++---------- .../perps/constants/hyperLiquidConfig.ts | 10 +- app/controllers/perps/index.ts | 430 +++++++++++++++++- .../providers/HyperLiquidProvider.test.ts | 57 +++ .../perps/providers/HyperLiquidProvider.ts | 22 +- app/controllers/perps/selectors.ts | 2 +- .../perps/services/AccountService.ts | 7 +- .../perps/services/DepositService.ts | 3 +- .../perps/services/MarketDataService.test.ts | 26 +- .../perps/services/MarketDataService.ts | 26 +- .../perps/services/TradingService.ts | 3 +- app/controllers/perps/types/index.ts | 16 +- app/controllers/perps/types/perps-types.ts | 25 +- 16 files changed, 911 insertions(+), 339 deletions(-) create mode 100644 app/controllers/perps/PerpsController-method-action-types.ts diff --git a/app/components/UI/Perps/utils/amountConversion.test.ts b/app/components/UI/Perps/utils/amountConversion.test.ts index 76571445c33..eae817e6a68 100644 --- a/app/components/UI/Perps/utils/amountConversion.test.ts +++ b/app/components/UI/Perps/utils/amountConversion.test.ts @@ -14,8 +14,8 @@ describe('convertPerpsAmountToUSD', () => { }); it('handles hex wei values correctly', () => { - // 1 ETH in wei (0xde0b6b3a7640000) - expect(convertPerpsAmountToUSD('0xde0b6b3a7640000')).toBe('$20,000'); + // Hex wei is not supported in Perps flow; all inputs return '$0' + expect(convertPerpsAmountToUSD('0xde0b6b3a7640000')).toBe('$0'); }); it('handles numeric strings correctly', () => { diff --git a/app/components/UI/Perps/utils/amountConversion.ts b/app/components/UI/Perps/utils/amountConversion.ts index d54aec01868..9751ae6f38a 100644 --- a/app/components/UI/Perps/utils/amountConversion.ts +++ b/app/components/UI/Perps/utils/amountConversion.ts @@ -1,5 +1,4 @@ import { formatPerpsFiat } from '../utils/formatUtils'; -import BN from 'bnjs4'; import { ensureError } from '../../../../util/errorUtils'; import { PERPS_CONSTANTS, type PerpsLogger } from '@metamask/perps-controller'; @@ -13,7 +12,7 @@ export type AmountConversionLogger = PerpsLogger | undefined; * Converts various amount formats to USD display string for Perps * Uses existing Perps formatting utilities for consistency * - * @param amount - Amount in various formats (USD string, hex wei, or numeric string) + * @param amount - Amount in various formats (USD string or numeric string) * @param logger - Optional logger for error reporting * @returns Formatted USD string using Perps formatting standards */ @@ -33,18 +32,9 @@ export const convertPerpsAmountToUSD = ( return formatPerpsFiat(numericValue); } - // Check if it's a hex value (starts with 0x) - treat as wei + // Hex wei input is not supported in the Perps flow (all deposits use ERC-20 USDC) if (amount.startsWith('0x')) { - const weiBN = new BN(amount, 16); - const ethBN = weiBN.div(new BN(10).pow(new BN(18))); - const ethValue = ethBN.toNumber(); - - // For now, use a placeholder ETH price since we don't have real-time price data - // In a real implementation, this should come from a price feed - const ethPriceUSD = 2000; // TODO: Replace with actual ETH price from price feed - const usdValue = ethValue * ethPriceUSD; - // Preserve decimals for converted amounts - return formatPerpsFiat(usdValue); + return '$0'; } // Otherwise, treat as a direct USD amount (e.g., "1.30" = $1.30) diff --git a/app/controllers/perps/PerpsController-method-action-types.ts b/app/controllers/perps/PerpsController-method-action-types.ts new file mode 100644 index 00000000000..f42cd24cbb3 --- /dev/null +++ b/app/controllers/perps/PerpsController-method-action-types.ts @@ -0,0 +1,207 @@ +import type { PerpsController } from './PerpsController'; + +export type PerpsControllerPlaceOrderAction = { + type: 'PerpsController:placeOrder'; + handler: PerpsController['placeOrder']; +}; + +export type PerpsControllerEditOrderAction = { + type: 'PerpsController:editOrder'; + handler: PerpsController['editOrder']; +}; + +export type PerpsControllerCancelOrderAction = { + type: 'PerpsController:cancelOrder'; + handler: PerpsController['cancelOrder']; +}; + +export type PerpsControllerCancelOrdersAction = { + type: 'PerpsController:cancelOrders'; + handler: PerpsController['cancelOrders']; +}; + +export type PerpsControllerClosePositionAction = { + type: 'PerpsController:closePosition'; + handler: PerpsController['closePosition']; +}; + +export type PerpsControllerClosePositionsAction = { + type: 'PerpsController:closePositions'; + handler: PerpsController['closePositions']; +}; + +export type PerpsControllerWithdrawAction = { + type: 'PerpsController:withdraw'; + handler: PerpsController['withdraw']; +}; + +export type PerpsControllerGetPositionsAction = { + type: 'PerpsController:getPositions'; + handler: PerpsController['getPositions']; +}; + +export type PerpsControllerGetOrderFillsAction = { + type: 'PerpsController:getOrderFills'; + handler: PerpsController['getOrderFills']; +}; + +export type PerpsControllerGetOrdersAction = { + type: 'PerpsController:getOrders'; + handler: PerpsController['getOrders']; +}; + +export type PerpsControllerGetOpenOrdersAction = { + type: 'PerpsController:getOpenOrders'; + handler: PerpsController['getOpenOrders']; +}; + +export type PerpsControllerGetFundingAction = { + type: 'PerpsController:getFunding'; + handler: PerpsController['getFunding']; +}; + +export type PerpsControllerGetAccountStateAction = { + type: 'PerpsController:getAccountState'; + handler: PerpsController['getAccountState']; +}; + +export type PerpsControllerGetMarketsAction = { + type: 'PerpsController:getMarkets'; + handler: PerpsController['getMarkets']; +}; + +export type PerpsControllerRefreshEligibilityAction = { + type: 'PerpsController:refreshEligibility'; + handler: PerpsController['refreshEligibility']; +}; + +export type PerpsControllerToggleTestnetAction = { + type: 'PerpsController:toggleTestnet'; + handler: PerpsController['toggleTestnet']; +}; + +export type PerpsControllerDisconnectAction = { + type: 'PerpsController:disconnect'; + handler: PerpsController['disconnect']; +}; + +export type PerpsControllerCalculateFeesAction = { + type: 'PerpsController:calculateFees'; + handler: PerpsController['calculateFees']; +}; + +export type PerpsControllerMarkTutorialCompletedAction = { + type: 'PerpsController:markTutorialCompleted'; + handler: PerpsController['markTutorialCompleted']; +}; + +export type PerpsControllerMarkFirstOrderCompletedAction = { + type: 'PerpsController:markFirstOrderCompleted'; + handler: PerpsController['markFirstOrderCompleted']; +}; + +export type PerpsControllerGetHistoricalPortfolioAction = { + type: 'PerpsController:getHistoricalPortfolio'; + handler: PerpsController['getHistoricalPortfolio']; +}; + +export type PerpsControllerResetFirstTimeUserStateAction = { + type: 'PerpsController:resetFirstTimeUserState'; + handler: PerpsController['resetFirstTimeUserState']; +}; + +export type PerpsControllerClearPendingTransactionRequestsAction = { + type: 'PerpsController:clearPendingTransactionRequests'; + handler: PerpsController['clearPendingTransactionRequests']; +}; + +export type PerpsControllerSaveTradeConfigurationAction = { + type: 'PerpsController:saveTradeConfiguration'; + handler: PerpsController['saveTradeConfiguration']; +}; + +export type PerpsControllerGetTradeConfigurationAction = { + type: 'PerpsController:getTradeConfiguration'; + handler: PerpsController['getTradeConfiguration']; +}; + +export type PerpsControllerSaveMarketFilterPreferencesAction = { + type: 'PerpsController:saveMarketFilterPreferences'; + handler: PerpsController['saveMarketFilterPreferences']; +}; + +export type PerpsControllerGetMarketFilterPreferencesAction = { + type: 'PerpsController:getMarketFilterPreferences'; + handler: PerpsController['getMarketFilterPreferences']; +}; + +export type PerpsControllerSavePendingTradeConfigurationAction = { + type: 'PerpsController:savePendingTradeConfiguration'; + handler: PerpsController['savePendingTradeConfiguration']; +}; + +export type PerpsControllerGetPendingTradeConfigurationAction = { + type: 'PerpsController:getPendingTradeConfiguration'; + handler: PerpsController['getPendingTradeConfiguration']; +}; + +export type PerpsControllerClearPendingTradeConfigurationAction = { + type: 'PerpsController:clearPendingTradeConfiguration'; + handler: PerpsController['clearPendingTradeConfiguration']; +}; + +export type PerpsControllerGetOrderBookGroupingAction = { + type: 'PerpsController:getOrderBookGrouping'; + handler: PerpsController['getOrderBookGrouping']; +}; + +export type PerpsControllerSaveOrderBookGroupingAction = { + type: 'PerpsController:saveOrderBookGrouping'; + handler: PerpsController['saveOrderBookGrouping']; +}; + +export type PerpsControllerSetSelectedPaymentTokenAction = { + type: 'PerpsController:setSelectedPaymentToken'; + handler: PerpsController['setSelectedPaymentToken']; +}; + +export type PerpsControllerResetSelectedPaymentTokenAction = { + type: 'PerpsController:resetSelectedPaymentToken'; + handler: PerpsController['resetSelectedPaymentToken']; +}; + +export type PerpsControllerMethodActions = + | PerpsControllerPlaceOrderAction + | PerpsControllerEditOrderAction + | PerpsControllerCancelOrderAction + | PerpsControllerCancelOrdersAction + | PerpsControllerClosePositionAction + | PerpsControllerClosePositionsAction + | PerpsControllerWithdrawAction + | PerpsControllerGetPositionsAction + | PerpsControllerGetOrderFillsAction + | PerpsControllerGetOrdersAction + | PerpsControllerGetOpenOrdersAction + | PerpsControllerGetFundingAction + | PerpsControllerGetAccountStateAction + | PerpsControllerGetMarketsAction + | PerpsControllerRefreshEligibilityAction + | PerpsControllerToggleTestnetAction + | PerpsControllerDisconnectAction + | PerpsControllerCalculateFeesAction + | PerpsControllerMarkTutorialCompletedAction + | PerpsControllerMarkFirstOrderCompletedAction + | PerpsControllerGetHistoricalPortfolioAction + | PerpsControllerResetFirstTimeUserStateAction + | PerpsControllerClearPendingTransactionRequestsAction + | PerpsControllerSaveTradeConfigurationAction + | PerpsControllerGetTradeConfigurationAction + | PerpsControllerSaveMarketFilterPreferencesAction + | PerpsControllerGetMarketFilterPreferencesAction + | PerpsControllerSavePendingTradeConfigurationAction + | PerpsControllerGetPendingTradeConfigurationAction + | PerpsControllerClearPendingTradeConfigurationAction + | PerpsControllerGetOrderBookGroupingAction + | PerpsControllerSaveOrderBookGroupingAction + | PerpsControllerSetSelectedPaymentTokenAction + | PerpsControllerResetSelectedPaymentTokenAction; diff --git a/app/controllers/perps/PerpsController.ts b/app/controllers/perps/PerpsController.ts index 128346b071b..f1289646190 100644 --- a/app/controllers/perps/PerpsController.ts +++ b/app/controllers/perps/PerpsController.ts @@ -23,6 +23,7 @@ import { PROVIDER_CONFIG, } from './constants/perpsConfig'; import type { SortOptionId } from './constants/perpsConfig'; +import type { PerpsControllerMethodActions } from './PerpsController-method-action-types'; import { PERPS_ERROR_CODES } from './perpsErrorCodes'; import { AggregatedPerpsProvider } from './providers/AggregatedPerpsProvider'; import { HyperLiquidProvider } from './providers/HyperLiquidProvider'; @@ -562,142 +563,7 @@ export type PerpsControllerEvents = ControllerStateChangeEvent< */ export type PerpsControllerActions = | ControllerGetStateAction<'PerpsController', PerpsControllerState> - | { - type: 'PerpsController:placeOrder'; - handler: PerpsController['placeOrder']; - } - | { - type: 'PerpsController:editOrder'; - handler: PerpsController['editOrder']; - } - | { - type: 'PerpsController:cancelOrder'; - handler: PerpsController['cancelOrder']; - } - | { - type: 'PerpsController:cancelOrders'; - handler: PerpsController['cancelOrders']; - } - | { - type: 'PerpsController:closePosition'; - handler: PerpsController['closePosition']; - } - | { - type: 'PerpsController:closePositions'; - handler: PerpsController['closePositions']; - } - | { - type: 'PerpsController:withdraw'; - handler: PerpsController['withdraw']; - } - | { - type: 'PerpsController:getPositions'; - handler: PerpsController['getPositions']; - } - | { - type: 'PerpsController:getOrderFills'; - handler: PerpsController['getOrderFills']; - } - | { - type: 'PerpsController:getOrders'; - handler: PerpsController['getOrders']; - } - | { - type: 'PerpsController:getOpenOrders'; - handler: PerpsController['getOpenOrders']; - } - | { - type: 'PerpsController:getFunding'; - handler: PerpsController['getFunding']; - } - | { - type: 'PerpsController:getAccountState'; - handler: PerpsController['getAccountState']; - } - | { - type: 'PerpsController:getMarkets'; - handler: PerpsController['getMarkets']; - } - | { - type: 'PerpsController:refreshEligibility'; - handler: PerpsController['refreshEligibility']; - } - | { - type: 'PerpsController:toggleTestnet'; - handler: PerpsController['toggleTestnet']; - } - | { - type: 'PerpsController:disconnect'; - handler: PerpsController['disconnect']; - } - | { - type: 'PerpsController:calculateFees'; - handler: PerpsController['calculateFees']; - } - | { - type: 'PerpsController:markTutorialCompleted'; - handler: PerpsController['markTutorialCompleted']; - } - | { - type: 'PerpsController:markFirstOrderCompleted'; - handler: PerpsController['markFirstOrderCompleted']; - } - | { - type: 'PerpsController:getHistoricalPortfolio'; - handler: PerpsController['getHistoricalPortfolio']; - } - | { - type: 'PerpsController:resetFirstTimeUserState'; - handler: PerpsController['resetFirstTimeUserState']; - } - | { - type: 'PerpsController:clearPendingTransactionRequests'; - handler: PerpsController['clearPendingTransactionRequests']; - } - | { - type: 'PerpsController:saveTradeConfiguration'; - handler: PerpsController['saveTradeConfiguration']; - } - | { - type: 'PerpsController:getTradeConfiguration'; - handler: PerpsController['getTradeConfiguration']; - } - | { - type: 'PerpsController:saveMarketFilterPreferences'; - handler: PerpsController['saveMarketFilterPreferences']; - } - | { - type: 'PerpsController:getMarketFilterPreferences'; - handler: PerpsController['getMarketFilterPreferences']; - } - | { - type: 'PerpsController:savePendingTradeConfiguration'; - handler: PerpsController['savePendingTradeConfiguration']; - } - | { - type: 'PerpsController:getPendingTradeConfiguration'; - handler: PerpsController['getPendingTradeConfiguration']; - } - | { - type: 'PerpsController:clearPendingTradeConfiguration'; - handler: PerpsController['clearPendingTradeConfiguration']; - } - | { - type: 'PerpsController:getOrderBookGrouping'; - handler: PerpsController['getOrderBookGrouping']; - } - | { - type: 'PerpsController:saveOrderBookGrouping'; - handler: PerpsController['saveOrderBookGrouping']; - } - | { - type: 'PerpsController:setSelectedPaymentToken'; - handler: PerpsController['setSelectedPaymentToken']; - } - | { - type: 'PerpsController:resetSelectedPaymentToken'; - handler: PerpsController['resetSelectedPaymentToken']; - }; + | PerpsControllerMethodActions; /** * PerpsController messenger constraints. @@ -874,6 +740,8 @@ export class PerpsController extends BaseController< */ #standaloneProvider: HyperLiquidProvider | null = null; + #handlersRegistered = false; + #standaloneProviderIsTestnet: boolean | null = null; #standaloneProviderHip3Version: number | null = null; @@ -987,11 +855,6 @@ export class PerpsController extends BaseController< // Migrate old persisted data without accountAddress this.#migrateRequestsIfNeeded(); - - this.messenger.registerMethodActionHandlers( - this, - MESSENGER_EXPOSED_METHODS, - ); } // ============================================================================ @@ -1506,6 +1369,14 @@ export class PerpsController extends BaseController< * @returns A promise that resolves when the operation completes. */ async init(): Promise { + if (!this.#handlersRegistered) { + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + this.#handlersRegistered = true; + } + if (this.isInitialized) { return undefined; } @@ -1559,123 +1430,7 @@ export class PerpsController extends BaseController< this.providers.clear(); await this.#cleanupStandaloneProvider(); - const { activeProvider } = this.state; - - this.#debugLog( - 'PerpsController: Creating provider with HIP-3 configuration', - { - hip3Enabled: this.#hip3Enabled, - hip3AllowlistMarkets: this.#hip3AllowlistMarkets, - hip3BlocklistMarkets: this.#hip3BlocklistMarkets, - hip3ConfigSource: this.#hip3ConfigSource, - isTestnet: this.state.isTestnet, - activeProvider, - }, - ); - - // Always create HyperLiquid provider as the base provider - const hyperLiquidProvider = new HyperLiquidProvider({ - isTestnet: this.state.isTestnet, - hip3Enabled: this.#hip3Enabled, - allowlistMarkets: this.#hip3AllowlistMarkets, - blocklistMarkets: this.#hip3BlocklistMarkets, - platformDependencies: this.#options.infrastructure, - messenger: this.messenger, - }); - this.providers.set('hyperliquid', hyperLiquidProvider); - - // Register MYX provider if enabled via feature flag - const isMYXEnabled = this.#isMYXProviderEnabled(); - if (isMYXEnabled) { - const myxIsTestnet = - PROVIDER_CONFIG.MYX_TESTNET_ONLY || this.state.isTestnet; - const config = this.#options.clientConfig ?? {}; - // When on mainnet, fall back to testnet credentials if mainnet ones are empty. - // Uses firstNonEmpty because env vars default to '' (not null/undefined), - // so ?? would not fall through on empty strings. - const firstNonEmpty = (...vals: (string | undefined)[]): string => - vals.find( - (val) => val !== null && val !== undefined && val !== '', - ) ?? ''; - const myxAppId = myxIsTestnet - ? (config.myxAppIdTestnet ?? '') - : firstNonEmpty(config.myxAppIdMainnet, config.myxAppIdTestnet); - const myxApiSecret = myxIsTestnet - ? (config.myxApiSecretTestnet ?? '') - : firstNonEmpty( - config.myxApiSecretMainnet, - config.myxApiSecretTestnet, - ); - const myxBrokerAddress = myxIsTestnet - ? (config.myxBrokerAddressTestnet ?? '') - : firstNonEmpty( - config.myxBrokerAddressMainnet, - config.myxBrokerAddressTestnet, - ); - const myxProvider = new MYXProvider({ - isTestnet: myxIsTestnet, - platformDependencies: this.#options.infrastructure, - messenger: this.messenger, - myxAuthConfig: { - appId: myxAppId, - apiSecret: myxApiSecret, - brokerAddress: myxBrokerAddress, - }, - }); - this.providers.set('myx', myxProvider); - this.#debugLog('PerpsController: MYX provider registered', { - isTestnet: myxIsTestnet, - }); - } - - // Set up active provider based on activeProvider value in state - // 'aggregated' is treated as just another provider that wraps others - if (activeProvider === 'aggregated') { - // Aggregated mode: wrap in AggregatedPerpsProvider for multi-provider support - this.activeProviderInstance = new AggregatedPerpsProvider({ - providers: this.providers, - defaultProvider: 'hyperliquid', - infrastructure: this.#options.infrastructure, - }); - this.#debugLog( - 'PerpsController: Using aggregated provider (multi-provider)', - { registeredProviders: Array.from(this.providers.keys()) }, - ); - } else if (activeProvider === 'hyperliquid') { - // Direct provider mode: use HyperLiquid provider directly - this.activeProviderInstance = hyperLiquidProvider; - this.#debugLog( - `PerpsController: Using direct provider (${activeProvider})`, - ); - } else if (activeProvider === 'myx') { - // MYX provider mode - const myxProvider = this.providers.get('myx'); - if (myxProvider) { - this.activeProviderInstance = myxProvider; - } else { - // MYX feature flag is disabled — fall back to HyperLiquid - this.#debugLog( - 'PerpsController: MYX provider not available (feature flag disabled), falling back to hyperliquid', - ); - this.activeProviderInstance = hyperLiquidProvider; - this.update((state) => { - state.activeProvider = 'hyperliquid'; - }); - } - this.#debugLog( - `PerpsController: Using direct provider (${this.activeProviderInstance === hyperLiquidProvider ? 'hyperliquid' : activeProvider})`, - ); - } else { - // Unsupported provider - throw error to prevent silent misconfiguration - throw new Error( - `Unsupported provider: ${String(activeProvider)}. Currently only 'hyperliquid', 'myx', and 'aggregated' are supported.`, - ); - } - - // Future providers can be added here with their own authentication patterns: - // - Some might use API keys: new BinanceProvider({ apiKey, apiSecret }) - // - Some might use different wallet patterns: new GMXProvider({ signer }) - // - Some might not need auth at all: new DydxProvider() + this.#createProviders(); // Wait for WebSocket transport to be ready before marking as initialized await wait(PERPS_CONSTANTS.ReconnectionCleanupDelayMs); @@ -1688,7 +1443,7 @@ export class PerpsController extends BaseController< this.#debugLog('PerpsController: Providers initialized successfully', { providerCount: this.providers.size, - activeProvider, + activeProvider: this.state.activeProvider, timestamp: new Date().toISOString(), attempts: attempt, }); @@ -1735,6 +1490,125 @@ export class PerpsController extends BaseController< }); } + /** + * Instantiate provider instances based on current state and register them. + * Selects and assigns the active provider instance from the registry. + * Future providers can be added here with their own authentication patterns: + * - Some might use API keys: new BinanceProvider({ apiKey, apiSecret }) + * - Some might use different wallet patterns: new GMXProvider({ signer }) + * - Some might not need auth at all: new DydxProvider() + */ + #createProviders(): void { + const { activeProvider } = this.state; + + this.#debugLog( + 'PerpsController: Creating provider with HIP-3 configuration', + { + hip3Enabled: this.#hip3Enabled, + hip3AllowlistMarkets: this.#hip3AllowlistMarkets, + hip3BlocklistMarkets: this.#hip3BlocklistMarkets, + hip3ConfigSource: this.#hip3ConfigSource, + isTestnet: this.state.isTestnet, + activeProvider, + }, + ); + + // Always create HyperLiquid provider as the base provider + const hyperLiquidProvider = new HyperLiquidProvider({ + isTestnet: this.state.isTestnet, + hip3Enabled: this.#hip3Enabled, + allowlistMarkets: this.#hip3AllowlistMarkets, + blocklistMarkets: this.#hip3BlocklistMarkets, + platformDependencies: this.#options.infrastructure, + messenger: this.messenger, + }); + this.providers.set('hyperliquid', hyperLiquidProvider); + + // Register MYX provider if enabled via feature flag + const isMYXEnabled = this.#isMYXProviderEnabled(); + if (isMYXEnabled) { + const myxIsTestnet = + PROVIDER_CONFIG.MYX_TESTNET_ONLY || this.state.isTestnet; + const config = this.#options.clientConfig ?? {}; + // When on mainnet, fall back to testnet credentials if mainnet ones are empty. + // Uses firstNonEmpty because env vars default to '' (not null/undefined), + // so ?? would not fall through on empty strings. + const firstNonEmpty = (...vals: (string | undefined)[]): string => + vals.find((val) => val !== null && val !== undefined && val !== '') ?? + ''; + const myxAppId = myxIsTestnet + ? (config.myxAppIdTestnet ?? '') + : firstNonEmpty(config.myxAppIdMainnet, config.myxAppIdTestnet); + const myxApiSecret = myxIsTestnet + ? (config.myxApiSecretTestnet ?? '') + : firstNonEmpty(config.myxApiSecretMainnet, config.myxApiSecretTestnet); + const myxBrokerAddress = myxIsTestnet + ? (config.myxBrokerAddressTestnet ?? '') + : firstNonEmpty( + config.myxBrokerAddressMainnet, + config.myxBrokerAddressTestnet, + ); + const myxProvider = new MYXProvider({ + isTestnet: myxIsTestnet, + platformDependencies: this.#options.infrastructure, + messenger: this.messenger, + myxAuthConfig: { + appId: myxAppId, + apiSecret: myxApiSecret, + brokerAddress: myxBrokerAddress, + }, + }); + this.providers.set('myx', myxProvider); + this.#debugLog('PerpsController: MYX provider registered', { + isTestnet: myxIsTestnet, + }); + } + + // Set up active provider based on activeProvider value in state + // 'aggregated' is treated as just another provider that wraps others + if (activeProvider === 'aggregated') { + // Aggregated mode: wrap in AggregatedPerpsProvider for multi-provider support + this.activeProviderInstance = new AggregatedPerpsProvider({ + providers: this.providers, + defaultProvider: 'hyperliquid', + infrastructure: this.#options.infrastructure, + }); + this.#debugLog( + 'PerpsController: Using aggregated provider (multi-provider)', + { registeredProviders: Array.from(this.providers.keys()) }, + ); + } else if (activeProvider === 'hyperliquid') { + // Direct provider mode: use HyperLiquid provider directly + this.activeProviderInstance = hyperLiquidProvider; + this.#debugLog( + `PerpsController: Using direct provider (${activeProvider})`, + ); + } else if (activeProvider === 'myx') { + // MYX provider mode + const myxProvider = this.providers.get('myx'); + if (myxProvider) { + this.activeProviderInstance = myxProvider; + } else { + // MYX feature flag is disabled — fall back to HyperLiquid + this.#debugLog( + 'PerpsController: MYX provider not available (feature flag disabled), falling back to hyperliquid', + ); + this.activeProviderInstance = hyperLiquidProvider; + this.update((state) => { + state.activeProvider = 'hyperliquid'; + }); + } + this.#debugLog( + `PerpsController: Using direct provider (${this.activeProviderInstance === hyperLiquidProvider ? 'hyperliquid' : activeProvider})`, + ); + } else { + // Unsupported provider - throw error to prevent silent misconfiguration + throw new Error( + `Unsupported provider: ${String(activeProvider)}. Currently only 'hyperliquid', 'myx', and 'aggregated' are supported.`, + ); + } + } + /** * Generate standard error context for Logger.error calls with searchable tags and context. * Enables Sentry dashboard filtering by feature, provider, and network. @@ -2302,8 +2176,8 @@ export class PerpsController extends BaseController< const isCancellation = errorMessage.includes('User denied') || errorMessage.includes('User rejected') || - errorMessage.includes('cancelled') || - errorMessage.includes('canceled'); + errorMessage.includes('User cancelled') || + errorMessage.includes('User canceled'); this.update((state) => { const requestToUpdate = state.depositRequests.find( (req) => req.id === currentDepositId, diff --git a/app/controllers/perps/constants/hyperLiquidConfig.ts b/app/controllers/perps/constants/hyperLiquidConfig.ts index 8188143b2d5..9e56137b13b 100644 --- a/app/controllers/perps/constants/hyperLiquidConfig.ts +++ b/app/controllers/perps/constants/hyperLiquidConfig.ts @@ -189,7 +189,7 @@ export const REFERRAL_CONFIG = { // Deposit constants export const DEPOSIT_CONFIG = { - EstimatedGasLimit: 150000, // Estimated gas limit for bridge deposit + EstimatedGasLimit: 100000, // Estimated gas limit for bridge deposit DefaultSlippage: 1, // 1% default slippage for bridge quotes BridgeQuoteTimeout: 1000, // 1 second timeout for bridge quotes RefreshRate: 30000, // 30 seconds quote refresh rate @@ -201,6 +201,7 @@ export const DEPOSIT_CONFIG = { // Withdrawal constants (HyperLiquid-specific) export const HYPERLIQUID_WITHDRAWAL_MINUTES = 5; // HyperLiquid withdrawal processing time in minutes +export const ESTIMATED_FEE_RATE = 0.0009; // 0.09% taker fee estimate for flip operations (close + open) // Type helpers export type SupportedAsset = keyof typeof HYPERLIQUID_ASSET_CONFIGS; @@ -278,6 +279,13 @@ export const HIP3_ASSET_ID_CONFIG = { */ export const BASIS_POINTS_DIVISOR = 10000; +/** + * Offset added to spot market pair index to derive the spot asset ID + * used in HyperLiquid order routing. + * Per HyperLiquid protocol: spotAssetId = SPOT_ASSET_ID_OFFSET + pairIndex + */ +export const SPOT_ASSET_ID_OFFSET = 10000; + /** * HIP-3 asset market type classifications (PRODUCTION DEFAULT) * diff --git a/app/controllers/perps/index.ts b/app/controllers/perps/index.ts index 3baaacc2ba3..e77bb525248 100644 --- a/app/controllers/perps/index.ts +++ b/app/controllers/perps/index.ts @@ -44,20 +44,430 @@ export type { // Provider interfaces and implementations export { HyperLiquidProvider } from './providers/HyperLiquidProvider'; -// All type definitions -export * from './types'; +// Type definitions (explicit named exports) +export { WebSocketConnectionState, PerpsAnalyticsEvent } from './types'; +export type { + RawLedgerUpdate, + UserHistoryItem, + GetUserHistoryParams, + TradeConfiguration, + OrderType, + MarketType, + MarketTypeFilter, + InputMethod, + TradeAction, + TrackingData, + TPSLTrackingData, + OrderParams, + OrderResult, + Position, + AccountState, + ClosePositionParams, + ClosePositionsParams, + ClosePositionsResult, + UpdateMarginParams, + MarginResult, + FlipPositionParams, + InitializeResult, + ReadyToTradeResult, + DisconnectResult, + MarketInfo, + PerpsMarketData, + ToggleTestnetResult, + AssetRoute, + SwitchProviderResult, + CancelOrderParams, + CancelOrderResult, + BatchCancelOrdersParams, + CancelOrdersParams, + CancelOrdersResult, + EditOrderParams, + DepositParams, + DepositWithConfirmationParams, + DepositResult, + DepositStatus, + DepositFlowType, + DepositStepInfo, + WithdrawParams, + WithdrawResult, + TransferBetweenDexsParams, + TransferBetweenDexsResult, + GetHistoricalPortfolioParams, + HistoricalPortfolioResult, + LiveDataConfig, + PerpsControllerConfig, + PriceUpdate, + OrderFill, + CheckEligibilityParams, + GetPositionsParams, + GetAccountStateParams, + GetOrderFillsParams, + GetOrFetchFillsParams, + GetOrdersParams, + GetFundingParams, + GetSupportedPathsParams, + GetAvailableDexsParams, + GetMarketsParams, + SubscribePricesParams, + SubscribePositionsParams, + SubscribeOrderFillsParams, + SubscribeOrdersParams, + SubscribeAccountParams, + SubscribeOICapsParams, + SubscribeCandlesParams, + OrderBookLevel, + OrderBookData, + SubscribeOrderBookParams, + LiquidationPriceParams, + MaintenanceMarginParams, + FeeCalculationParams, + FeeCalculationResult, + UpdatePositionTPSLParams, + Order, + Funding, + PerpsProvider, + PerpsProviderType, + PerpsActiveProviderMode, + AggregationMode, + RoutingStrategy, + AggregatedProviderConfig, + ProviderError, + AggregatedAccountState, + PerpsLogger, + PerpsTraceName, + PerpsTraceValue, + PerpsAnalyticsProperties, + PerpsMetrics, + PerpsDebugLogger, + PerpsStreamManager, + PerpsPerformance, + PerpsTracer, + PerpsTypedMessageParams, + PerpsTransactionParams, + PerpsAddTransactionOptions, + PerpsInternalAccount, + PerpsRemoteFeatureFlagState, + PerpsPlatformDependencies, + PerpsCacheType, + InvalidateCacheParams, + PerpsCacheInvalidator, + MarketDataFormatters, + PaymentToken, + PerpsSelectedPaymentToken, + VersionGatedFeatureFlag, +} from './types'; +export { + PerpsTraceNames, + PerpsTraceOperations, + isVersionGatedFeatureFlag, +} from './types'; -// All constants -export * from './constants'; +// Types from sub-modules (re-exported via types/index.ts) +export type { + TestResultStatus, + TestResult, + SDKTestType, + HyperliquidAsset, + CandleStick, + CandleData, + OrderFormState, + OrderDirection, + ReconnectOptions, + ExtendedAssetMeta, + ExtendedPerpDex, +} from './types'; +export type { + BaseTransactionResult, + LastTransactionResult, + TransactionStatus, + TransactionRecord, +} from './types'; +export { isTransactionRecord, isLastTransactionResult } from './types'; +export type { + AssetPosition, + SpotBalance, + PerpsUniverse, + PerpsAssetCtx, + PredictedFunding, + FrontendOrder, + SDKOrderParams, + ClearinghouseStateResponse, + SpotClearinghouseStateResponse, + MetaResponse, + FrontendOpenOrdersResponse, + AllMidsResponse, + MetaAndAssetCtxsResponse, + PredictedFundingsResponse, + SpotMetaResponse, +} from './types'; +export type { + HyperLiquidEndpoints, + AssetNetworkConfig, + HyperLiquidAssetConfigs, + BridgeContractConfig, + HyperLiquidBridgeContracts, + TransportReconnectConfig, + TransportKeepAliveConfig, + HyperLiquidTransportConfig, + TradingAmountConfig, + TradingDefaultsConfig, + FeeRatesConfig, + HyperLiquidNetwork, +} from './types'; +export type { PerpsToken } from './types'; -// All utilities -export * from './utils'; +// Constants (explicit named exports) +export { + CandlePeriod, + TimeDuration, + ChartInterval, + MAX_CANDLE_COUNT, + DURATION_CANDLE_PERIODS, + CANDLE_PERIODS, + DEFAULT_CANDLE_PERIOD, + getCandlePeriodsForDuration, + getDefaultCandlePeriodForDuration, + calculateCandleCount, +} from './constants'; +export { PERPS_EVENT_PROPERTY, PERPS_EVENT_VALUE } from './constants'; +export { DETAILED_ORDER_TYPES, isTPSLOrder } from './constants'; +export { PERPS_TRANSACTIONS_HISTORY_CONSTANTS } from './constants'; +export { + ARBITRUM_MAINNET_CHAIN_ID_HEX, + ARBITRUM_MAINNET_CHAIN_ID, + ARBITRUM_TESTNET_CHAIN_ID, + ARBITRUM_MAINNET_CAIP_CHAIN_ID, + ARBITRUM_TESTNET_CAIP_CHAIN_ID, + HYPERLIQUID_MAINNET_CHAIN_ID, + HYPERLIQUID_TESTNET_CHAIN_ID, + HYPERLIQUID_MAINNET_CAIP_CHAIN_ID, + HYPERLIQUID_TESTNET_CAIP_CHAIN_ID, + HYPERLIQUID_NETWORK_NAME, + USDC_SYMBOL, + USDC_NAME, + USDC_DECIMALS, + TOKEN_DECIMALS, + ZERO_ADDRESS, + ZERO_BALANCE, + ARBITRUM_SEPOLIA_CHAIN_ID, + USDC_ETHEREUM_MAINNET_ADDRESS, + USDC_ARBITRUM_MAINNET_ADDRESS, + USDC_ARBITRUM_TESTNET_ADDRESS, + USDC_TOKEN_ICON_URL, + HYPERLIQUID_ENDPOINTS, + HYPERLIQUID_ASSET_ICONS_BASE_URL, + METAMASK_PERPS_ICONS_BASE_URL, + HYPERLIQUID_ASSET_CONFIGS, + HYPERLIQUID_BRIDGE_CONTRACTS, + HYPERLIQUID_TRANSPORT_CONFIG, + TRADING_DEFAULTS, + FEE_RATES, + HIP3_FEE_CONFIG, + BUILDER_FEE_CONFIG, + REFERRAL_CONFIG, + DEPOSIT_CONFIG, + HYPERLIQUID_WITHDRAWAL_MINUTES, + getWebSocketEndpoint, + getChainId, + getCaipChainId, + getBridgeInfo, + getSupportedAssets, + CAIP_ASSET_NAMESPACES, + HYPERLIQUID_CONFIG, + HIP3_ASSET_ID_CONFIG, + BASIS_POINTS_DIVISOR, + SPOT_ASSET_ID_OFFSET, + HIP3_ASSET_MARKET_TYPES, + TESTNET_HIP3_CONFIG, + MAINNET_HIP3_CONFIG, + HIP3_MARGIN_CONFIG, + USDH_CONFIG, + INITIAL_AMOUNT_UI_PROGRESS, + WITHDRAWAL_PROGRESS_STAGES, + PROGRESS_BAR_COMPLETION_DELAY_MS, +} from './constants'; +export type { SupportedAsset } from './constants'; +export { PerpsMeasurementName } from './constants'; +export { + MYX_MAINNET_CHAIN_ID, + MYX_TESTNET_CHAIN_ID, + MYX_MAINNET_CAIP_CHAIN_ID, + MYX_TESTNET_CAIP_CHAIN_ID, + getMYXChainId, + MYX_ENDPOINTS, + getMYXHttpEndpoint, + MYX_PRICE_DECIMALS, + MYX_SIZE_DECIMALS, + MYX_COLLATERAL_DECIMALS, + USDT_BNB_TESTNET, + USDT_BNB_MAINNET, + MYX_ASSET_CONFIGS, + fromMYXPrice, + toMYXPrice, + fromMYXSize, + toMYXSize, + fromMYXCollateral, + MYX_PRICE_POLLING_INTERVAL_MS, + MYX_HTTP_TIMEOUT_MS, + MYX_MAX_RETRIES, + MYX_MAX_LEVERAGE, + MYX_FEE_RATE, + MYX_PROTOCOL_FEE_RATE, + MYX_DEFAULT_SLIPPAGE_BPS, + MYX_MINIMUM_ORDER_SIZE_USD, + MYX_EXECUTION_FEE_TOKEN, +} from './constants'; +export { + PERPS_CONSTANTS, + WITHDRAWAL_CONSTANTS, + VALIDATION_THRESHOLDS, + ORDER_SLIPPAGE_CONFIG, + PERFORMANCE_CONFIG, + TP_SL_CONFIG, + HYPERLIQUID_ORDER_LIMITS, + CLOSE_POSITION_CONFIG, + MARGIN_ADJUSTMENT_CONFIG, + DATA_LAKE_API_CONFIG, + DECIMAL_PRECISION_CONFIG, + MARKET_SORTING_CONFIG, + PROVIDER_CONFIG, +} from './constants'; +export type { SortOptionId } from './constants'; -// Error codes -export * from './perpsErrorCodes'; +// Utilities (explicit named exports) +export { + findEvmAccount, + getEvmAccountFromAccountGroup, + getSelectedEvmAccount, + calculateWeightedReturnOnEquity, + aggregateAccountStates, +} from './utils'; +export type { ReturnOnEquityInput } from './utils'; +export { ensureError } from './utils'; +export type { + OrderBookCacheEntry, + ProcessL2BookDataParams, + ProcessBboDataParams, +} from './utils'; +export { processL2BookData, processBboData } from './utils'; +export type { ValidationDebugLogger } from './utils'; +export { + createErrorResult, + validateWithdrawalParams, + validateDepositParams, + validateAssetSupport, + validateBalance, + applyPathFilters, + getSupportedPaths, + getMaxOrderValue, + validateOrderParams, + validateCoinExists, +} from './utils'; +export { + generatePerpsId, + generateDepositId, + generateWithdrawalId, + generateOrderId, + generateTransactionId, +} from './utils'; +export { + calculateOpenInterestUSD, + transformMarketData, + formatChange, +} from './utils'; +export type { HyperLiquidMarketData } from './utils'; +export { + adaptMarketFromMYX, + adaptPriceFromMYX, + adaptMarketDataFromMYX, + filterMYXExclusiveMarkets, + isOverlappingMarket, + buildPoolSymbolMap, + buildSymbolPoolsMap, + extractSymbolFromPoolId, +} from './utils'; +export { + MAX_MARKET_PATTERN_LENGTH, + escapeRegex, + validateMarketPattern, + compileMarketPattern, + matchesMarketPattern, + shouldIncludeMarket, + getPerpsDisplaySymbol, + getPerpsDexFromSymbol, + calculateFundingCountdown, + calculate24hHighLow, + filterMarketsByQuery, +} from './utils'; +export type { MarketPatternMatcher, CompiledMarketPattern } from './utils'; +export type { + OrderCalculationsDebugLogger, + CalculateFinalPositionSizeParams, + CalculateFinalPositionSizeResult, + CalculateOrderPriceAndSizeParams, + CalculateOrderPriceAndSizeResult, + BuildOrdersArrayParams, + BuildOrdersArrayResult, +} from './utils'; +export { + calculatePositionSize, + calculateMarginRequired, + getMaxAllowedAmount, + calculateFinalPositionSize, + calculateOrderPriceAndSize, + buildOrdersArray, +} from './utils'; +export { + formatAccountToCaipAccountId, + isCaipAccountId, + handleRewardsError, +} from './utils'; +export { + countSignificantFigures, + hasExceededSignificantFigures, + roundToSignificantFigures, +} from './utils'; +export type { SortField, SortDirection, SortMarketsParams } from './utils'; +export { parseVolume, sortMarkets } from './utils'; +export type { StandaloneInfoClientOptions } from './utils'; +export { + createStandaloneInfoClient, + queryStandaloneClearinghouseStates, + queryStandaloneOpenOrders, +} from './utils'; +export { stripQuotes, parseCommaSeparatedString } from './utils'; +export { generateERC20TransferData } from './utils'; +export { wait } from './utils'; +export { + adaptOrderToSDK, + adaptPositionFromSDK, + adaptOrderFromSDK, + adaptMarketFromSDK, + adaptAccountStateFromSDK, + buildAssetMapping, + formatHyperLiquidPrice, + formatHyperLiquidSize, + calculateHip3AssetId, + parseAssetName, + adaptHyperLiquidLedgerUpdateToUserHistoryItem, +} from './utils'; +export { getEnvironment } from './utils'; + +// Error codes (explicit named exports) +export { PERPS_ERROR_CODES } from './perpsErrorCodes'; +export type { PerpsErrorCode } from './perpsErrorCodes'; -// Selectors -export * from './selectors'; +// Selectors (explicit named exports) +export { + selectIsFirstTimeUser, + selectHasPlacedFirstOrder, + selectWatchlistMarkets, + selectIsWatchlistMarket, + selectTradeConfiguration, + selectPendingTradeConfiguration, + selectMarketFilterPreferences, + selectOrderBookGrouping, +} from './selectors'; // Services (only externally consumed items) export { TradingReadinessCache } from './services/TradingReadinessCache'; diff --git a/app/controllers/perps/providers/HyperLiquidProvider.test.ts b/app/controllers/perps/providers/HyperLiquidProvider.test.ts index 0a4e43d5460..47f1c14a38c 100644 --- a/app/controllers/perps/providers/HyperLiquidProvider.test.ts +++ b/app/controllers/perps/providers/HyperLiquidProvider.test.ts @@ -4,6 +4,7 @@ import { createMockInfrastructure, createMockMessenger, } from '../../../components/UI/Perps/__mocks__/serviceMocks'; +import { CandlePeriod } from '../constants/chartConfig'; import { REFERRAL_CONFIG } from '../constants/hyperLiquidConfig'; import { PERPS_ERROR_CODES } from '../perpsErrorCodes'; import { HyperLiquidClientService } from '../services/HyperLiquidClientService'; @@ -8547,6 +8548,62 @@ describe('HyperLiquidProvider', () => { }); }); + describe('fetchHistoricalCandles', () => { + const options = { + symbol: 'BTC', + interval: CandlePeriod.OneHour, + limit: 100, + }; + + it('returns candle data from clientService', async () => { + // Arrange + const mockCandles = { + symbol: 'BTC', + interval: CandlePeriod.OneHour, + candles: [ + { + open: '50000', + close: '51000', + high: '51500', + low: '49500', + volume: '100', + time: 1000, + }, + ], + }; + mockClientService.fetchHistoricalCandles = jest + .fn() + .mockResolvedValue(mockCandles); + + // Act + const result = await provider.fetchHistoricalCandles(options); + + // Assert + expect(mockClientService.ensureInitialized).toHaveBeenCalled(); + expect(mockClientService.fetchHistoricalCandles).toHaveBeenCalledWith( + options, + ); + expect(result).toStrictEqual(mockCandles); + }); + + it('returns empty candles when clientService returns null', async () => { + // Arrange + mockClientService.fetchHistoricalCandles = jest + .fn() + .mockResolvedValue(null); + + // Act + const result = await provider.fetchHistoricalCandles(options); + + // Assert + expect(result).toStrictEqual({ + symbol: options.symbol, + interval: options.interval, + candles: [], + }); + }); + }); + describe('buildAssetMapping with perpDexs network failure', () => { it('completes asset mapping using fallback when perpDexs throws', async () => { // Arrange — perpDexs throws, so getValidatedDexs falls back to [null] diff --git a/app/controllers/perps/providers/HyperLiquidProvider.ts b/app/controllers/perps/providers/HyperLiquidProvider.ts index 57d45f64b56..5e9c057ffa9 100644 --- a/app/controllers/perps/providers/HyperLiquidProvider.ts +++ b/app/controllers/perps/providers/HyperLiquidProvider.ts @@ -2,6 +2,7 @@ import { CaipAccountId } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; import { v4 as uuidv4 } from 'uuid'; +import type { CandlePeriod } from '../constants/chartConfig'; import { BASIS_POINTS_DIVISOR, BUILDER_FEE_CONFIG, @@ -14,6 +15,7 @@ import { HYPERLIQUID_WITHDRAWAL_MINUTES, MAINNET_HIP3_CONFIG, REFERRAL_CONFIG, + SPOT_ASSET_ID_OFFSET, TESTNET_HIP3_CONFIG, TRADING_DEFAULTS, USDC_DECIMALS, @@ -45,6 +47,7 @@ import type { CancelOrderParams, CancelOrderResult, CancelOrdersResult, + CandleData, ClosePositionParams, ClosePositionsParams, ClosePositionsResult, @@ -1700,7 +1703,7 @@ export class HyperLiquidProvider implements PerpsProvider { return { success: false, error: PERPS_ERROR_CODES.SPOT_PAIR_NOT_FOUND }; } - const spotAssetId = 10000 + usdhUsdcPair.index; + const spotAssetId = SPOT_ASSET_ID_OFFSET + usdhUsdcPair.index; this.#deps.debugLogger.log( 'HyperLiquidProvider: Found USDH/USDC spot pair', @@ -7497,6 +7500,23 @@ export class HyperLiquidProvider implements PerpsProvider { } } + async fetchHistoricalCandles(options: { + symbol: string; + interval: CandlePeriod; + limit?: number; + endTime?: number; + }): Promise { + this.#clientService.ensureInitialized(); + const result = await this.#clientService.fetchHistoricalCandles(options); + return ( + result ?? { + symbol: options.symbol, + interval: options.interval, + candles: [], + } + ); + } + /** * Get block explorer URL for an address or just the base URL * diff --git a/app/controllers/perps/selectors.ts b/app/controllers/perps/selectors.ts index b290cb39c82..a7e0b1cf4ab 100644 --- a/app/controllers/perps/selectors.ts +++ b/app/controllers/perps/selectors.ts @@ -142,8 +142,8 @@ export const selectPendingTradeConfiguration = createSelector( } // Check if config has expired (5 minutes = 300,000 milliseconds) - const FIVE_MINUTES_MS = 5 * 60 * 1000; const now = Date.now(); + const FIVE_MINUTES_MS = 5 * 60 * 1000; const age = now - config.timestamp; if (age > FIVE_MINUTES_MS) { diff --git a/app/controllers/perps/services/AccountService.ts b/app/controllers/perps/services/AccountService.ts index f6c121daa1e..d60f8bc9bc4 100644 --- a/app/controllers/perps/services/AccountService.ts +++ b/app/controllers/perps/services/AccountService.ts @@ -5,7 +5,10 @@ import { PERPS_EVENT_VALUE, } from '../constants/eventNames'; import { USDC_SYMBOL } from '../constants/hyperLiquidConfig'; -import { PERPS_CONSTANTS } from '../constants/perpsConfig'; +import { + PERPS_CONSTANTS, + WITHDRAWAL_CONSTANTS, +} from '../constants/perpsConfig'; import { PERPS_ERROR_CODES } from '../perpsErrorCodes'; import { PerpsAnalyticsEvent, @@ -117,7 +120,7 @@ export class AccountService { // Calculate net amount after fees const grossAmount = parseFloat(params.amount); - const feeAmount = 1.0; // HyperLiquid withdrawal fee is $1 USDC + const feeAmount = WITHDRAWAL_CONSTANTS.DefaultFeeAmount; const netAmount = Math.max(0, grossAmount - feeAmount); // Get current account address via messenger diff --git a/app/controllers/perps/services/DepositService.ts b/app/controllers/perps/services/DepositService.ts index a4ffe31e519..860964db75d 100644 --- a/app/controllers/perps/services/DepositService.ts +++ b/app/controllers/perps/services/DepositService.ts @@ -2,6 +2,7 @@ import { toHex } from '@metamask/controller-utils'; import { parseCaipAssetId } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; +import { DEPOSIT_CONFIG } from '../constants/hyperLiquidConfig'; import type { PerpsProvider, PerpsPlatformDependencies, @@ -13,7 +14,7 @@ import { generateDepositId } from '../utils/idUtils'; import { generateERC20TransferData } from '../utils/transferData'; // Temporary to avoid estimation failures due to insufficient balance -const DEPOSIT_GAS_LIMIT = toHex(100000); +const DEPOSIT_GAS_LIMIT = toHex(DEPOSIT_CONFIG.EstimatedGasLimit); /** * DepositService diff --git a/app/controllers/perps/services/MarketDataService.test.ts b/app/controllers/perps/services/MarketDataService.test.ts index 4727870cc54..da45aa5a025 100644 --- a/app/controllers/perps/services/MarketDataService.test.ts +++ b/app/controllers/perps/services/MarketDataService.test.ts @@ -876,14 +876,9 @@ describe('MarketDataService', () => { }; it('fetches historical candles successfully', async () => { - const hyperLiquidProvider = mockProvider as unknown as { - clientService: { - fetchHistoricalCandles: jest.Mock; - }; - }; - hyperLiquidProvider.clientService = { - fetchHistoricalCandles: jest.fn().mockResolvedValue(mockCandleData), - }; + mockProvider.fetchHistoricalCandles = jest + .fn() + .mockResolvedValue(mockCandleData); const result = await marketDataService.fetchHistoricalCandles({ provider: mockProvider, @@ -894,9 +889,7 @@ describe('MarketDataService', () => { }); expect(result).toEqual(mockCandleData); - expect( - hyperLiquidProvider.clientService.fetchHistoricalCandles, - ).toHaveBeenCalledWith({ + expect(mockProvider.fetchHistoricalCandles).toHaveBeenCalledWith({ symbol: 'BTC', interval: '1h', limit: 100, @@ -934,15 +927,10 @@ describe('MarketDataService', () => { }); it('updates error state on failure', async () => { - const hyperLiquidProvider = mockProvider as unknown as { - clientService: { - fetchHistoricalCandles: jest.Mock; - }; - }; const mockError = new Error('Network timeout'); - hyperLiquidProvider.clientService = { - fetchHistoricalCandles: jest.fn().mockRejectedValue(mockError), - }; + mockProvider.fetchHistoricalCandles = jest + .fn() + .mockRejectedValue(mockError); await expect( marketDataService.fetchHistoricalCandles({ diff --git a/app/controllers/perps/services/MarketDataService.ts b/app/controllers/perps/services/MarketDataService.ts index cdf8b03938d..3da87699504 100644 --- a/app/controllers/perps/services/MarketDataService.ts +++ b/app/controllers/perps/services/MarketDataService.ts @@ -760,28 +760,16 @@ export class MarketDataService { }, }); - // Check if provider supports historical candles via clientService - const hyperLiquidProvider = provider as { - clientService?: { - fetchHistoricalCandles?: (options: { - symbol: string; - interval: CandlePeriod; - limit?: number; - endTime?: number; - }) => Promise; - }; - }; - if (!hyperLiquidProvider.clientService?.fetchHistoricalCandles) { + if (!provider.fetchHistoricalCandles) { throw new Error('Historical candles not supported by provider'); } - const result = - await hyperLiquidProvider.clientService.fetchHistoricalCandles({ - symbol, - interval, - limit, - endTime, - }); + const result = await provider.fetchHistoricalCandles({ + symbol, + interval, + limit, + endTime, + }); traceData = { success: true }; return result; diff --git a/app/controllers/perps/services/TradingService.ts b/app/controllers/perps/services/TradingService.ts index db5dc1b950f..a3e72ef8398 100644 --- a/app/controllers/perps/services/TradingService.ts +++ b/app/controllers/perps/services/TradingService.ts @@ -6,6 +6,7 @@ import { PERPS_EVENT_PROPERTY, PERPS_EVENT_VALUE, } from '../constants/eventNames'; +import { ESTIMATED_FEE_RATE } from '../constants/hyperLiquidConfig'; import { isTPSLOrder } from '../constants/orderTypes'; import { PerpsMeasurementName } from '../constants/performanceMetrics'; import { PERPS_CONSTANTS } from '../constants/perpsConfig'; @@ -1894,7 +1895,7 @@ export class TradingService { const entryPrice = parseFloat(position.entryPrice); const flipSize = positionSize * 2; const notionalValue = flipSize * entryPrice; - const estimatedFees = notionalValue * 0.0009; + const estimatedFees = notionalValue * ESTIMATED_FEE_RATE; if (estimatedFees > availableBalance) { throw new Error( diff --git a/app/controllers/perps/types/index.ts b/app/controllers/perps/types/index.ts index 3fb6e4efe1e..5e53d8584aa 100644 --- a/app/controllers/perps/types/index.ts +++ b/app/controllers/perps/types/index.ts @@ -5,7 +5,7 @@ import type { Hex, } from '@metamask/utils'; -import type { CandleData } from './perps-types'; +import type { CandleData, OrderType } from './perps-types'; import type { CandlePeriod, TimeDuration } from '../constants/chartConfig'; /** @@ -72,9 +72,6 @@ export type TradeConfiguration = { }; }; -// Order type enumeration -export type OrderType = 'market' | 'limit'; - // Market asset type classification (reusable across components) export type MarketType = 'crypto' | 'equity' | 'commodity' | 'forex'; @@ -1066,6 +1063,17 @@ export type PerpsProvider = { * @returns Array of DEX names (empty string '' represents main DEX) */ getAvailableDexs?(params?: GetAvailableDexsParams): Promise; + + /** + * Fetch historical OHLCV candle data for a symbol. + * Optional: only providers that support historical candles need to implement this. + */ + fetchHistoricalCandles?(options: { + symbol: string; + interval: CandlePeriod; + limit?: number; + endTime?: number; + }): Promise; }; // ============================================================================ diff --git a/app/controllers/perps/types/perps-types.ts b/app/controllers/perps/types/perps-types.ts index 3e0deb1ead4..4b30618788c 100644 --- a/app/controllers/perps/types/perps-types.ts +++ b/app/controllers/perps/types/perps-types.ts @@ -1,9 +1,11 @@ /** * Test result states for SDK validation */ -import { OrderType } from '.'; import { CandlePeriod } from '../constants/chartConfig'; +// Order type enumeration +export type OrderType = 'market' | 'limit'; + export type TestResultStatus = | 'idle' | 'loading' @@ -55,9 +57,24 @@ export type CandleData = { candles: CandleStick[]; }; -// Export all configuration types directly -export type * from './config'; -export type * from './token'; +// Configuration types +export type { + HyperLiquidEndpoints, + AssetNetworkConfig, + HyperLiquidAssetConfigs, + BridgeContractConfig, + HyperLiquidBridgeContracts, + TransportReconnectConfig, + TransportKeepAliveConfig, + HyperLiquidTransportConfig, + TradingAmountConfig, + TradingDefaultsConfig, + FeeRatesConfig, + HyperLiquidNetwork, +} from './config'; + +// Token types +export type { PerpsToken } from './token'; /** * Order form state for the Perps order view From 0ce62c3b274b3ee20c907f72d1819abc6fb525d2 Mon Sep 17 00:00:00 2001 From: CW Date: Thu, 5 Mar 2026 17:04:08 -0800 Subject: [PATCH 2/5] test: remove offramp-token-amount e2e test covered by unit tests (#27033) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Removes `offramp-token-amount.spec.ts` e2e test. All behaviors tested in this e2e test are already covered by existing unit tests in `BuildQuote.test.tsx`. Coverage mapping: | E2E Behavior | Unit Test in BuildQuote.test.tsx | |---|---| | Enter amount via keypad | `updates the amount input` | | Quick amount buttons (25%, MAX) | `updates the amount input with quick amount buttons` | | MAX capped by gas for native token | `updates the amount input up to the max considering gas for native asset` | | Percentage capped by gas (75%, 50%) | `updates the amount input up to the percentage considering gas` | | Freshly opened keyboard clears amount | `clears the amount when the keyboard is freshly opened` | This is part of the effort to convert ramps e2e tests to unit/component tests (MMQA-1492). ### Why not a separate component-view test? `BuildQuote.test.tsx` already **is** a component-view test — it uses `renderScreen()` which renders the full `BuildQuote` component inside a real navigation stack with Redux state. It simulates user interactions via `fireEvent.press` on keypad buttons, asserts displayed text, and tests quick amount buttons (25%, 50%, 75%, MAX) with gas capping. Creating a separate component-view test would duplicate existing coverage. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: [MMQA-1520](https://consensyssoftware.atlassian.net/browse/MMQA-1520) ## **Manual testing steps** ```gherkin Feature: Offramp token amount test removal Scenario: Existing unit tests cover all e2e behaviors Given the BuildQuote.test.tsx unit tests exist And they cover keypad entry, quick amounts, gas capping, and validation When the offramp-token-amount.spec.ts e2e test is removed Then no test coverage is lost And the unit test suite continues to pass ``` ## **Screenshots/Recordings** ### **Before** N/A — test removal, not a UI change. ### **After** N/A — test removal, not a UI change. ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. Co-authored-by: Claude Opus 4.6 --- .../smoke/ramps/offramp-token-amount.spec.ts | 51 ------------------- 1 file changed, 51 deletions(-) delete mode 100644 tests/smoke/ramps/offramp-token-amount.spec.ts diff --git a/tests/smoke/ramps/offramp-token-amount.spec.ts b/tests/smoke/ramps/offramp-token-amount.spec.ts deleted file mode 100644 index 01a0dbf8999..00000000000 --- a/tests/smoke/ramps/offramp-token-amount.spec.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { loginToApp } from '../../flows/wallet.flow'; -import WalletView from '../../page-objects/wallet/WalletView'; -import FundActionMenu from '../../page-objects/UI/FundActionMenu'; -import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; -import { withFixtures } from '../../framework/fixtures/FixtureHelper'; -import { SmokeRamps } from '../../tags'; -import { CustomNetworks } from '../../resources/networks.e2e'; -import BuildQuoteView from '../../page-objects/Ramps/BuildQuoteView'; -import Assertions from '../../framework/Assertions'; -import { RampsRegions, RampsRegionsEnum } from '../../framework/Constants'; -import { Mockttp } from 'mockttp'; -import { setupRegionAwareOnRampMocks } from '../../api-mocking/mock-responses/ramps/ramps-mocks'; - -describe(SmokeRamps('Off-ramp token amounts'), () => { - beforeEach(async () => { - jest.setTimeout(150000); - }); - it('should change token amounts directly and by percentage', async () => { - const selectedRegion = RampsRegions[RampsRegionsEnum.FRANCE]; - - await withFixtures( - { - fixture: new FixtureBuilder() - .withNetworkController(CustomNetworks.Tenderly.Mainnet) - .withRampsSelectedRegion(selectedRegion) - .withRampsSelectedPaymentMethod() - .build(), - restartDevice: true, - testSpecificMock: async (mockServer: Mockttp) => { - await setupRegionAwareOnRampMocks(mockServer, selectedRegion); - }, - }, - async () => { - await loginToApp(); - await WalletView.tapWalletBuyButton(); - await FundActionMenu.tapSellButton(); - await BuildQuoteView.enterAmount('5'); - await Assertions.expectTextDisplayed('5 ETH'); - await BuildQuoteView.tapKeypadDeleteButton(1); - await BuildQuoteView.tapQuickAmount25(); - await Assertions.expectTextDisplayed('64 ETH'); - await BuildQuoteView.tapQuickAmount50(); - await Assertions.expectTextDisplayed('128 ETH'); - await BuildQuoteView.tapQuickAmount75(); - await Assertions.expectTextDisplayed('192 ETH'); - await BuildQuoteView.tapQuickAmountMax(); - await Assertions.expectTextNotDisplayed('192 ETH'); - }, - ); - }); -}); From 1a22eb48108101ea8682d00d27dfd84fe73b4f4a Mon Sep 17 00:00:00 2001 From: Pedro Pablo Aste Kompen Date: Fri, 6 Mar 2026 01:33:52 -0300 Subject: [PATCH 3/5] fix(TMCU-513): update View More card styling and Perps routing (#27078) ## **Description** Updates the shared "View more" card in the homepage carousels to match the updated design, and changes the Perps "View more" entrypoint to route to the market list page instead of the main Perps home. Changes: - Added `rounded-xl bg-background-muted` background to the ViewMoreCard outer Box - Removed the circular bubble wrapper around the ArrowRight icon - Made Predictions ViewMoreCard use `flex-1` and default `BodyMd` text size to match Perps - Split Perps navigation: section title still goes to Perps home, View more card now goes to the market list page ## **Changelog** CHANGELOG entry: Updated View more card styling with background color and updated Perps View more to navigate to market list ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TMCU-513 ## **Manual testing steps** ```gherkin Feature: View more card styling and routing Scenario: View more cards display with background Given user is on the homepage And Perps or Predictions sections show a carousel with a View more card When the user views the View more card Then it has a rounded grey background (bg-background-muted) And the arrow icon has no circular bubble around it Scenario: Perps View more navigates to market list Given user is on the homepage with Perps section visible When user taps the View more card in the Perps carousel Then the app navigates to the Perps market list page Scenario: Perps section title navigates to Perps home Given user is on the homepage with Perps section visible When user taps the Perpetuals section title Then the app navigates to the Perps home page ``` ## **Screenshots/Recordings** Verified on device -- both Perps and Predictions View more cards now show consistent styling. ### **Before** View more card had no background and a circular bubble around the arrow icon. ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Low risk UI and navigation tweak limited to homepage carousels; main risk is an incorrect route/params causing the Perps "View more" CTA to land on the wrong screen. > > **Overview** > Updates the shared homepage `ViewMoreCard` to match new design by moving the muted background and rounded corners to the outer card and removing the circular icon bubble. > > Changes the Perps homepage carousel "View more" CTA to navigate to `Routes.PERPS.MARKET_LIST` (while the section title still routes to `Routes.PERPS.PERPS_HOME`) and updates the Perps unit test accordingly. Aligns the Predictions carousel `ViewMoreCard` sizing/typography usage with Perps by using `flex-1` and default text variant. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit a2cb634c527dfcf26e0a57891c7a39440682d21e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Sections/Perpetuals/PerpsSection.test.tsx | 2 +- .../Sections/Perpetuals/PerpsSection.tsx | 9 ++++++++- .../Predictions/PredictionsSection.tsx | 5 ++--- .../components/ViewMoreCard/ViewMoreCard.tsx | 20 +++++++------------ 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx index 5b339468646..9c5ee0a1197 100644 --- a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx +++ b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx @@ -811,7 +811,7 @@ describe('PerpsSection', () => { fireEvent.press(screen.getByTestId('perps-view-more-card')); expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { - screen: Routes.PERPS.PERPS_HOME, + screen: Routes.PERPS.MARKET_LIST, params: { source: 'home_section' }, }); }); diff --git a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.tsx b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.tsx index 791c1a73caa..680f000973c 100644 --- a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.tsx +++ b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.tsx @@ -212,6 +212,13 @@ const PerpsSection = forwardRef( }); }, [navigation]); + const handleViewMorePerps = useCallback(() => { + navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_LIST, + params: { source: PERPS_EVENT_VALUE.SOURCE.HOME_SECTION }, + }); + }, [navigation]); + const handlePositionPress = useCallback( (position: Position) => { track(MetaMetricsEvents.PERPS_UI_INTERACTION, { @@ -330,7 +337,7 @@ const PerpsSection = forwardRef( /> ))} diff --git a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx index ae8ae3b378b..f13926e0516 100644 --- a/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx +++ b/app/components/Views/Homepage/Sections/Predictions/PredictionsSection.tsx @@ -8,7 +8,7 @@ import { ScrollView, View } from 'react-native'; import { useNavigation, NavigationProp } from '@react-navigation/native'; import { useSelector } from 'react-redux'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { Box, TextVariant } from '@metamask/design-system-react-native'; +import { Box } from '@metamask/design-system-react-native'; import SectionTitle from '../../components/SectionTitle'; import ErrorState from '../../components/ErrorState'; import FadingScrollContainer from '../../components/FadingScrollContainer'; @@ -276,8 +276,7 @@ const PredictionsSection = forwardRef< ))} )} diff --git a/app/components/Views/Homepage/components/ViewMoreCard/ViewMoreCard.tsx b/app/components/Views/Homepage/components/ViewMoreCard/ViewMoreCard.tsx index 1f5e893bbe4..3a387b2c270 100644 --- a/app/components/Views/Homepage/components/ViewMoreCard/ViewMoreCard.tsx +++ b/app/components/Views/Homepage/components/ViewMoreCard/ViewMoreCard.tsx @@ -26,7 +26,7 @@ interface ViewMoreCardProps { /** * Shared "View more" card shown at the end of a horizontal carousel. - * Renders a circular ArrowRight icon above a label, blending with the background. + * Renders an ArrowRight icon above a label on a muted background. */ const ViewMoreCard: React.FC = ({ onPress, @@ -41,22 +41,16 @@ const ViewMoreCard: React.FC = ({ testID={testID} > - - - + Date: Fri, 6 Mar 2026 01:34:27 -0300 Subject: [PATCH 4/5] fix(TMCU-539): add horizontal padding to NFT skeleton in full view (#27077) ## **Description** The NFT grid skeleton loading state had minimal padding (`p-1`) regardless of context, while the actual NFT grid `FlatList` uses `px-4` horizontal padding in full view mode. This caused an inconsistent layout jump when loading finished and the skeleton was replaced by real content. The fix threads `isFullView` from `NftGrid` through `NftGridContent` to `NftGridSkeleton`, so the skeleton conditionally applies `px-4` in full view and `px-1` in tab/homepage view -- matching the padding of the content it replaces in each context. ## **Changelog** CHANGELOG entry: Fixed missing horizontal padding on NFT skeleton loading state in full view ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TMCU-539 ## **Manual testing steps** ```gherkin Feature: NFT skeleton padding in full view Scenario: user sees skeleton with correct padding in NFTs full view Given user navigates to the NFTs full view And NFTs are being fetched When the skeleton loading state is displayed Then the skeleton has the same horizontal padding as the NFT grid content Scenario: skeleton padding is unchanged in tab/homepage view Given user is on the homepage with the NFTs section visible And NFTs are being fetched When the skeleton loading state is displayed Then the skeleton retains minimal horizontal padding (matching tab layout) ``` ## **Screenshots/Recordings** N/A -- padding-only fix, verified via tests. ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Low risk UI-only change that adjusts loading-state layout; no business logic, data handling, or navigation behavior is modified. > > **Overview** > Fixes a layout jump in the NFT grid loading state by **matching `NftGridSkeleton` horizontal padding to the rendered grid**. > > Threads `isFullView` from `NftGrid` through `NftGridContent` into `NftGridSkeleton`, where the container now applies `px-4` in full view and `px-1` otherwise (replacing the previous fixed `p-1`). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit afdbe3638f6d30521962501a194be71191146f5c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/components/UI/NftGrid/NftGrid.tsx | 5 ++++- app/components/UI/NftGrid/NftGridSkeleton.tsx | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/components/UI/NftGrid/NftGrid.tsx b/app/components/UI/NftGrid/NftGrid.tsx index a2a1c2645f8..5ab8946d327 100644 --- a/app/components/UI/NftGrid/NftGrid.tsx +++ b/app/components/UI/NftGrid/NftGrid.tsx @@ -54,11 +54,13 @@ const NftGridContent = ({ nftRowList, goToAddCollectible, isAddNFTEnabled, + isFullView = false, }: { allFilteredCollectibles: Nft[]; nftRowList: React.ReactNode; goToAddCollectible: () => void; isAddNFTEnabled: boolean; + isFullView?: boolean; }) => { const isNftFetchingProgress = useSelector(isNftFetchingProgressSelector); @@ -67,7 +69,7 @@ const NftGridContent = ({ } if (isNftFetchingProgress) { - return ; + return ; } return ( @@ -300,6 +302,7 @@ const NftGrid = forwardRef( nftRowList={nftRowList} goToAddCollectible={goToAddCollectible} isAddNFTEnabled={isAddNFTEnabled} + isFullView={isFullView} /> {/* View all NFTs button - shown when there are more items than maxItems */} diff --git a/app/components/UI/NftGrid/NftGridSkeleton.tsx b/app/components/UI/NftGrid/NftGridSkeleton.tsx index ebc5fa372a3..faba279c9c4 100644 --- a/app/components/UI/NftGrid/NftGridSkeleton.tsx +++ b/app/components/UI/NftGrid/NftGridSkeleton.tsx @@ -4,12 +4,12 @@ import SkeletonPlaceholder from 'react-native-skeleton-placeholder'; import { useTheme } from '../../../util/theme'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -const NftGridSkeleton = () => { +const NftGridSkeleton = ({ isFullView = false }: { isFullView?: boolean }) => { const { colors } = useTheme(); const tw = useTailwind(); return ( - + Date: Fri, 6 Mar 2026 01:34:50 -0300 Subject: [PATCH 5/5] fix(TMCU-538): update error state icon to new no-connection illustration (#27070) ## **Description** Replaces the flat `IconName.WifiOff` design system icon in the shared `ErrorState` component with themed PNG illustrations matching Vinay's new "No connection" design. Uses `useAssetFromTheme` to switch between light and dark variants, following the same pattern as `CollectiblesEmptyState`. This change affects all 4 homepage sections that render error states: Tokens, Predictions, Perpetuals, and DeFi. ## **Changelog** CHANGELOG entry: Updated the error state icon on the homepage to a new no-connection illustration ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TMCU-538 ## **Manual testing steps** ```gherkin Feature: Updated error state icon on homepage sections Scenario: user sees error state in light mode Given user is on the homepage in light mode And a section fails to load (e.g., Tokens, Predictions) When the error state is displayed Then the new no-connection illustration is shown (light variant) And the Retry button is visible below the illustration Scenario: user sees error state in dark mode Given user is on the homepage in dark mode And a section fails to load When the error state is displayed Then the new no-connection illustration is shown (dark variant) ``` ## **Screenshots/Recordings** Verified on device in both light and dark modes. ### **Before** Flat `WifiOff` icon from the design system. ### **After** New themed no-connection illustration (72x72) with light/dark variants. w ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Low risk UI-only change that swaps a design-system icon for themed PNG assets in the shared homepage `ErrorState` component. Main risk is limited to missing/incorrect asset bundling or sizing regressions across sections that reuse this component. > > **Overview** > Updates the shared homepage `ErrorState` UI to render a themed no-connection PNG illustration (light/dark via `useAssetFromTheme`) instead of the design-system `WifiOff` icon. > > Adds `react-native` `Image` rendering with Tailwind-based sizing (72x72) and removes the unused icon imports, affecting all homepage sections that reuse `ErrorState`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d58c424b896f89a4eca060bdb1f43c010a5ac403. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../components/ErrorState/ErrorState.tsx | 22 +++++++++++------- app/images/error-state-no-connection-dark.png | Bin 0 -> 5164 bytes .../error-state-no-connection-light.png | Bin 0 -> 5236 bytes 3 files changed, 13 insertions(+), 9 deletions(-) create mode 100644 app/images/error-state-no-connection-dark.png create mode 100644 app/images/error-state-no-connection-light.png diff --git a/app/components/Views/Homepage/components/ErrorState/ErrorState.tsx b/app/components/Views/Homepage/components/ErrorState/ErrorState.tsx index cf996c68751..a9c31fdd975 100644 --- a/app/components/Views/Homepage/components/ErrorState/ErrorState.tsx +++ b/app/components/Views/Homepage/components/ErrorState/ErrorState.tsx @@ -1,11 +1,8 @@ import React from 'react'; +import { Image } from 'react-native'; import { Box, Text, - Icon, - IconName, - IconSize, - IconColor, TextVariant, TextColor, BoxAlignItems, @@ -13,7 +10,11 @@ import { ButtonVariant, ButtonSize, } from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { useAssetFromTheme } from '../../../../../util/theme'; import { strings } from '../../../../../../locales/i18n'; +import errorStateLight from '../../../../../images/error-state-no-connection-light.png'; +import errorStateDark from '../../../../../images/error-state-no-connection-dark.png'; interface ErrorStateProps { /** Text describing what failed to load (e.g., "Unable to load predictions") */ @@ -24,9 +25,12 @@ interface ErrorStateProps { /** * Generic error state for homepage sections. - * Shows a wifi-off icon, error message, and a retry button. + * Shows a no-connection illustration, error message, and a retry button. */ const ErrorState: React.FC = ({ title, onRetry }) => { + const tw = useTailwind(); + const noConnectionImage = useAssetFromTheme(errorStateLight, errorStateDark); + const handleRetry = () => { try { const result = onRetry(); @@ -42,10 +46,10 @@ const ErrorState: React.FC = ({ title, onRetry }) => { return ( - zC(9|Hq}NJAZH_{drR0r%PCh)~_?cqBX@b#s3V z3@VQQfT>}~e)>3x>2Ii}gi$}maPVkgIVarF?-7_*^e+iz+1 zFM>F%fkB|Rnv8@bJcG(vA>W^x<*FV6E2~?_P4$r=$iq5jq_| zIZCiIKYcPnH%~6EG~0_`n%IFk~Mu^e~(Q-g=J%nc7Aw+3fPw! zXW`YrFMNJng0A~+a&x4CI)anRhTlZP27?b?t@lMBuO~_Do9gnw8V|6;c{x76*;9oynA^r{8HGd1mohSbdv;X?aI6JG z6aLZ)fr3JMtt0uG!t%hHCJLmfRH>~{C5RaPcLgg3UL_n*91Ztx$=EWYrlwPF+xwn1 zr(3!E-=s@2GS>>Wc30*ZY!juBk7*0_!^9kYFPn^)3{L9G9n*CxKLKD2MlTJ_)YJ+d zL*GCVwxq-n`mTRhMS z)LSn94&LY$92Zs*U{2nX(Q)TeAvwok3_$yn;$-a4WRs<+_QQmORR3g#E0cU1Vd!V) z+ufxcX*Q5*FBFI_*!xFKvsJUTa{oq;CR2tqrd<7up^aiTrMTqgi=sb z=!Q+`wb%Ps`T&EiA zlD9`L-2)I;I(=z7Kz^^7ti!{@PkUo3JJ-`?sB*Y{Gdn(6{fdf}?U~W##ZSH>$(WCO z0t7_()(Wb}YMFtYE#jmaI}&BH>GY-t#FkKd3@ZFse16Z$!ey0NMzxL;fUg~NwedjB zV7hpGJ_`I@W;$Ixgla4*p8i2ckr#1fSnLTd^Ur4SgDK-_EU| zBAeUG&b0>;&M4qB_KMx#>^uc+Zp9)Z&y-9GxZ+3F{Qi7F|CU}lh+c0v7j;BqteDM?x$GfK$sw9uZppQ=n0NvumQ--Y8Ls0xy6NDVf{xu~Ve zD)fT2w#!K_R{LU!U{}cQ>esORVx-KD641`HJuRNj_Rz~mSWIPS=N_4MC(HhXg~I5`ODWIr{-V4&G_K6M<${FuBqX40PqNLq+>(U`&%HsCstcuB=HxkgSdkY7NJ zlIg{v0)ZQq3ST%Si}+cu94`TNtV``iFVf=3YRj;M*J}M{B$In+tBI4MK&;-j|KMLo z*12=KA1|}hgrQQQsv4hc>Aeyl{cHUQWoIaa zcILsmoVP60Z=EuO#o1yfDXL1^g@ z?S%$)i7lmmIx;=L5NhZ^I#Kx^{uMR))#C`c;d;T-|lE>OX1DDIx>S7?vpZa4A$4pS(%@{j<2 zDn}bMf&9z&Q0HXa(D#A5JXxAX#ZEV&T#K%4%E{F=&EuD7s(Eg|U3-i&_m}SR%WGxc z_wKw6A3Y{V#P@4e?5vUrQ%8}1wfc2Yt9x_EESl6I4ErDo1jc-}$@Omg8YJj~VL z9M9IdKz6HkBXOf_v63i(0*P=i)6kb;V3>JOkZ5F|}<=+b$Q^2SR1watO64OoBcNa6iMA$Fc=V zO)8FOXlq>XtvEwLduu+unmgw->9U(yj1nbSyfeLLq%xw%y@39P{5qm z=Zbw{l}!WQ?+Qj98!4YwavO$INIGyZWOK1r8!@v4bHU21br-gTe`*F=fs64XC7rxy zHfGC_!mm5L>gp>BOSvnKMNl}4*Bk+M7Bg^i0{i~g--JkbUOL6BW zD>t!~FCf+o~#;Bh!4Iv-&pcK6&LlxoqNJW>$&{`}x7= zI>E6#NKbfV3s*GU=3-(2{RmC5)93Z}ybrL`#~qd>jJcG!A# zNRVS!)aYR*RLA!Lj9!KOR7@dHC(S8Jk^ah@IB&Y1X7aHVrL_D?KZNZhV3+%0A>%U- z8nkt%+)99icfpRwRm;AN%0pJnbT@}&>`s<6LF?NK%hS|%Fj%RZxpK|c)v?gs-?H$R zqZ0l<{~RU4>HmJNk+ni5ZyesSt8w7xFNzGacwY~!U7I+9gP*YqPNB7Tz^mE>mW@7V z3etgG#=kyy{21E|bC_H%_tlr&%p02E!scRTbJ)cf1B!O02lI}8w(ZEV7$D}2!py@a z^?lP=#EvO>K6YK-4&0;hMt|6Ko3DIMv`}Bqz~cMO?b}hb`FJMtR>~hvykvBilFzcr6ZZ}jP6N%| z#KY?BWmqALG80kaeBFA8r$&Ube&%raR)q2^msg)7J*4bedwT^mBn1EOYa} zXvG>1z@bY?hI4~0is~GE%#!8ne(bq4Ivn;T0>@5_B=EpI?b`V*~KY|t6=buxINk5ArU4#HYm_~_mF|4kHMLZl_%+;!*6^XkqM#-U`9-KOq^^f~# zHqxmkE2Vy6Nq~ccSp=Sj#E7;bt&e~T+ z6C%c9x7WK%HIskJIP+R4s>>u+{65(PCM~+kLp!T)IaXZl&Q@-y;hwlPHC@s)W?elZ za~F*-8wxbjV?7Wd03cuTwCU2wTaSLR9pXNeGU~WhX#c14m8V$Z*TsX~%c@J8ur9%z z)P9M8&=;>RX3o|w>F>9en@1=oX;L5oF;qoXx|?bJx8t0IdHamgB4$}K{7y<^oa7YM zA)5>o7%Z(zIdsi;P^dOR%U$8mXGN~d$lnMxrg_)|M}WSoYPS?bgqEM zK0y*YtnnHokrbwNR;i_dKg#XQZoEESEM5B2YJpNr2mulb($rO$x1f2vV^t?vp!wSg;|A39){G!SO5`U%Xt(ah1GUPmc>fiBCCf9g`1OI)X*m{rcSNK)9pk) z>=!it_q!iH!EfK}(i146s;5^5d|7-AswQ^lsyIUnpK7K)UpWg7bNE=MV6XtM$bFM3y$DA;! zlF(I`;s$R~H@9I?4Y4A?AM(UHX9RHeCI!??$3sqj8atHRsr5k&fxKo3aH@dSp!+gxIk2hDf0o4IRR`8)u*0>A4 z&w!fX$BGrsa!B7&0ud6`;S;)#YxGIpqyYo0b&l=9)~pr=D{IScD}>&u^;Q(7v#oNu z@)EJB7?Xx6(%CMOBQ7vgSIf>|-Ka@;HA>czvO8RSvrC|ck=(SGWAU_4Rw|uoHa`NLyX1eSP zV8Q`d^1%$+ZjV86;8+0#WU-Ov)XzG-&5NEgD1>Whffh2H4{yQ$v&QxR4t|47nD|?O Vk>E07+ea}ELqkOuSg&Lo`+pex^eO-V literal 0 HcmV?d00001 diff --git a/app/images/error-state-no-connection-light.png b/app/images/error-state-no-connection-light.png new file mode 100644 index 0000000000000000000000000000000000000000..4b2fa643440307444b821d0fb30abfbe9f782b6f GIT binary patch literal 5236 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