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{Cr
z6BI;Vfo~a+Z+fv7X;c0Nuo&r(<|{QI4jsIP6=qcJ=1&HVXW`;K_a?>$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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