From b7f59c2cf73cd330efa42a1d9a1f487f81ee205a Mon Sep 17 00:00:00 2001 From: cmd-ob Date: Tue, 10 Mar 2026 18:06:48 +0000 Subject: [PATCH 01/11] test: make fixture builder type-safe for e2e tests (#27257) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Simplified the network controller setup by removing the nested providerConfig object in multiple test files. - Updated tests across various modules to directly set chainId, rpcUrl, type, nickname, and ticker properties. - Ensured consistency in the network configuration for local RPC and other custom networks. ## **Description** ## Summary - **Creates `tests/framework/fixtures/types.ts`** — single source of truth for all fixture-related TypeScript types. Imports `NetworkState`, `AccountsControllerState`, `PreferencesState`, and `AccountTreeControllerState` from MetaMask packages; writes minimal hand-crafted interfaces for the remaining controllers. Exports the composed `Fixture → FixtureState → EngineBackgroundState` hierarchy plus `ProviderConfig`, `UserKeyringState`, `UserSnapState`, `UserPermissionState`, and `DeepPartial`. - **Replaces `private fixture: any` with `private fixture!: Fixture`** in `FixtureBuilder.ts` — TypeScript now catches invalid fixture modifications at compile time. - **Changes `withNetworkController()` signature** from `withNetworkController({ providerConfig: {...} })` to `withNetworkController(providerConfig: ProviderConfig)` (flat, no wrapper). Updates all 53 call sites across `tests/smoke/` and `tests/regression/`. - **Types 8 previously-`any` methods:** `withPermissionController`, `withPreferencesController`, `withAccountTreeController`, `withSnapController`, `withUserProfileKeyRing`, `withUserProfileSnapUnencryptedState`, `withUserProfileSnapPermissions`, `withTokensForAllPopularNetworks`. Removes all 8 `eslint-disable @typescript-eslint/no-explicit-any` suppressions that guarded them. - **Removes `withState()`** callers should use the typed builder methods instead. - **Additional type-accuracy fixes** surfaced by the stricter typing: `BrowserTab.isArchived` added to interface, `withAsyncState` parameter tightened to `Record`, `RpcEndpointType.Custom` enum used in all `rpcEndpoints` type fields (was plain string `'custom'`), `networkConfigurationsByChainId` index assignments cast correctly. - **JSDoc cleanup:** removed redundant `{type}` from all `@param` and `@returns` tags (26 + 34 occurrences) — types are declared in TypeScript signatures, not JSDoc. ## Breaking change `withNetworkController()` call sites must be updated. Before: `.withNetworkController({ providerConfig: { chainId, rpcUrl, type } })`. After: `.withNetletworkController({ chainId, rpcUrl, type })`. All existing call sites in this repo are already updated. ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **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** > Primarily test-infrastructure refactors, but changes the fixture/network configuration API and fixture loading types across many tests, which could cause widespread E2E failures if any state shape assumptions are wrong. > > **Overview** > E2E fixture construction is made **type-safe** by introducing `tests/framework/fixtures/types.ts` and converting `FixtureBuilder`’s internal state from `any` to a strongly typed `Fixture`, plus tightening several builder APIs (e.g., `withPermissionController`, `withPreferencesController`, `withAccountTreeController`, token helpers) and removing the generic `withState()` escape hatch. > > `withNetworkController()` is simplified to accept a flat `ProviderConfig` (no nested `{ providerConfig: ... }` wrapper), and fixture/network endpoint typing is aligned to controller enums (e.g., `RpcEndpointType.Custom`). The fixture loading pipeline (`FixtureHelper`, `FixtureServer`, and `WithFixturesOptions`) is updated to accept either a `FixtureBuilder` or a pre-built `Fixture`, and a broad set of smoke/regression tests are updated accordingly. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 51b142a1013506f5e562d129077b783304c39c15. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- tests/framework/fixtures/FixtureBuilder.ts | 257 +++++++++-------- tests/framework/fixtures/FixtureHelper.ts | 27 +- tests/framework/fixtures/FixtureServer.ts | 3 +- tests/framework/fixtures/types.ts | 266 ++++++++++++++++++ tests/framework/types.ts | 4 +- .../error-boundary-srp-backup.spec.ts | 12 +- .../assets/import-custom-token.spec.ts | 12 +- .../import-tokens-via-asset-watcher.spec.ts | 12 +- tests/regression/assets/nft-details.spec.ts | 12 +- tests/regression/assets/transaction.spec.ts | 12 +- .../new-networks-signatures.spec.ts | 4 +- .../networks/add-custom-rpc.spec.ts | 2 +- .../ramps/onramp-parameters.spec.ts | 2 +- .../swap/swap-action-regression.spec.ts | 12 +- .../regression/swap/swap-token-chart.spec.ts | 12 +- tests/regression/swap/swap-token-rwa.spec.ts | 12 +- .../wallet/balance-privacy-toggle.spec.ts | 12 +- .../regression/wallet/send-ERC-token.spec.ts | 12 +- tests/smoke/card/card-button.spec.ts | 2 +- tests/smoke/card/card-home-add-funds.spec.ts | 2 +- .../smoke/card/card-home-manage-card.spec.ts | 2 +- .../send/send-erc20-token.spec.ts | 36 +-- .../send/send-native-token.spec.ts | 12 +- .../signatures/signatures-typed.spec.ts | 12 +- .../signatures/signatures.spec.ts | 12 +- .../7702/batch-transaction.spec.ts | 24 +- .../transactions/contract-deployment.spec.ts | 12 +- .../transactions/contract-interaction.spec.ts | 12 +- .../dapp-initiated-transfer.spec.ts | 12 +- .../gas-fee-tokens-eip-7702-sponsored.spec.ts | 12 +- .../gas-fee-tokens-eip-7702.spec.ts | 12 +- .../per-dapp-selected-network.spec.ts | 12 +- .../token-approve/approve.spec.ts | 24 +- .../token-approve/increase-allowance.spec.ts | 12 +- .../set-approval-for-all.spec.ts | 36 +-- .../identity/utils/withIdentityFixtures.ts | 3 +- .../permission-system-remove.failing.ts | 2 +- ...ion-system-dapp-chain-switch-grant.spec.js | 6 +- .../account-list/dismiss-account-list.spec.ts | 11 +- .../account-list/render-account-list.spec.ts | 28 +- .../network-list/dismiss-network-list.spec.ts | 11 +- .../network-list/render-network-list.spec.ts | 23 +- tests/smoke/perps/perps-add-funds.spec.ts | 12 +- tests/smoke/ramps/onramp-unified-buy.spec.ts | 4 +- .../smoke/snaps/test-snap-name-lookup.spec.ts | 12 +- tests/smoke/stake/stake-action-smoke.spec.ts | 12 +- tests/smoke/swap/bridge-action-smoke.spec.ts | 12 +- tests/smoke/swap/gasless-swap.spec.ts | 12 +- tests/smoke/swap/swap-action-smoke.spec.ts | 12 +- tests/smoke/swap/swap-deeplink-smoke.spec.ts | 36 +-- tests/smoke/swap/swap-trending-tokens.spec.ts | 12 +- tests/smoke/wallet/helpers/musd-fixture.ts | 12 +- .../wallet/incoming-transactions.spec.ts | 25 +- .../addressbook-send-add-contact.spec.ts | 12 +- 54 files changed, 708 insertions(+), 480 deletions(-) create mode 100644 tests/framework/fixtures/types.ts diff --git a/tests/framework/fixtures/FixtureBuilder.ts b/tests/framework/fixtures/FixtureBuilder.ts index 433486b0e6f..3278bcb4b66 100644 --- a/tests/framework/fixtures/FixtureBuilder.ts +++ b/tests/framework/fixtures/FixtureBuilder.ts @@ -44,7 +44,20 @@ import { MOCK_ENTROPY_SOURCE_3, } from '../../../app/util/test/keyringControllerTestUtils.ts'; import { NetworkEnablementControllerState } from '@metamask/network-enablement-controller'; +import { RpcEndpointType } from '@metamask/network-controller'; import { USDC_MAINNET, MUSD_MAINNET } from '../../constants/musd-mainnet.ts'; +import type { + Fixture, + ProviderConfig, + PermissionControllerState, + SnapControllerState, + TokenInfo, + UserKeyringState, + UserSnapState, + UserPermissionState, +} from './types.ts'; +import type { PreferencesState } from '@metamask/preferences-controller'; +import type { AccountTreeControllerState } from '@metamask/account-tree-controller'; export const DEFAULT_FIXTURE_ACCOUNT_CHECKSUM = '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3'; @@ -91,14 +104,12 @@ export interface MusdFixtureOptions { * FixtureBuilder class provides a fluent interface for building fixture data. */ class FixtureBuilder { - // We currently have no type representation of the whole fixture state - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private fixture: any; + private fixture!: Fixture; /** * Create a new instance of FixtureBuilder. - * @param {Object} options - Options for the fixture builder. - * @param {boolean} options.onboarding - Flag indicating if onboarding fixture should be used. + * @param options - Options for the fixture builder. + * @param options.onboarding - Flag indicating if onboarding fixture should be used. */ constructor({ onboarding = false } = {}) { // Initialize the fixture based on the onboarding flag @@ -109,27 +120,17 @@ class FixtureBuilder { /** * Set the asyncState property of the fixture. - * @param {any} asyncState - The value to set for asyncState. - * @returns {FixtureBuilder} - The FixtureBuilder instance for method chaining. + * @param asyncState - The value to set for asyncState. + * @returns - The FixtureBuilder instance for method chaining. */ - withAsyncState(asyncState: Record) { + withAsyncState(asyncState: Record) { this.fixture.asyncState = asyncState; return this; } - /** - * Set the state property of the fixture. - * @param {any} state - The value to set for state. - * @returns {FixtureBuilder} - The FixtureBuilder instance for method chaining. - */ - withState(state: Record) { - this.fixture.state = state; - return this; - } - /** * Ensures that the Solana feature modal is suppressed by adding the appropriate flag to asyncState. - * @returns {FixtureBuilder} - The FixtureBuilder instance for method chaining. + * @returns - The FixtureBuilder instance for method chaining. */ ensureSolanaModalSuppressed() { if (!this.fixture.asyncState) { @@ -141,11 +142,11 @@ class FixtureBuilder { /** * Ensures that the multichain accounts intro modal is suppressed by setting the appropriate flag. - * @returns {FixtureBuilder} - The FixtureBuilder instance for method chaining. + * @returns - The FixtureBuilder instance for method chaining. */ ensureMultichainIntroModalSuppressed() { if (!this.fixture?.state?.user) { - this.fixture.state.user = {}; + this.fixture.state.user = {} as typeof this.fixture.state.user; } this.fixture.state.user.multichainAccountsIntroModalSeen = true; return this; @@ -155,8 +156,7 @@ class FixtureBuilder { * Defines a Perps profile for E2E mocks. * The value is stored in the PerpsController state so that the mocks can read it. * @param profile Profile, e.g.: 'no-funds', 'default'. - * @returns {FixtureBuilder} - */ + * @returns */ withPerpsProfile(profile: string) { merge(this.fixture.state.engine.backgroundState.PerpsController, { // Field only for E2E; read by the mocks mixin @@ -195,7 +195,7 @@ class FixtureBuilder { /** * Set the showTestNetworks property of the fixture to false. - * @returns {FixtureBuilder} - The FixtureBuilder instance for method chaining. + * @returns - The FixtureBuilder instance for method chaining. */ withTestNetworksOff() { this.fixture.state.engine.backgroundState.PreferencesController.showTestNetworks = false; @@ -205,7 +205,7 @@ class FixtureBuilder { /** * Set the default fixture values. * Uses JSON-based fixture with runtime-injected dynamic values. - * @returns {FixtureBuilder} - The FixtureBuilder instance for method chaining. + * @returns - The FixtureBuilder instance for method chaining. */ withDefaultFixture() { // Deep clone the JSON fixture to avoid mutations @@ -237,7 +237,7 @@ class FixtureBuilder { { networkClientId, name: 'Localhost default RPC', - type: 'custom', + type: RpcEndpointType.Custom, url: '', }, ], @@ -251,56 +251,49 @@ class FixtureBuilder { /** * Merges provided data into the background state of the PermissionController. - * @param {object} data - Data to merge into the PermissionController's state. - * @returns {FixtureBuilder} - The FixtureBuilder instance for method chaining. + * @param data - Data to merge into the PermissionController's state. + * @returns - The FixtureBuilder instance for method chaining. */ - withPermissionController(data: Record) { + withPermissionController(data: Partial) { merge(this.fixture.state.engine.backgroundState.PermissionController, data); return this; } /** - * Merges provided data into the background state of the NetworkController. - * @param {object} data - Data to merge into the NetworkController's state. - * @returns {FixtureBuilder} - The FixtureBuilder instance for method chaining. + * Configures the NetworkController with a custom network using a provider config. + * @param providerConfig - The provider configuration for the new network. + * @returns - The FixtureBuilder instance for method chaining. */ - withNetworkController(data: Record) { + withNetworkController(providerConfig: ProviderConfig) { const networkController = this.fixture.state.engine.backgroundState.NetworkController; - // Extract providerConfig data - const { providerConfig } = data as { - providerConfig: Record; - }; - - // Generate a unique key for the new network client ID const newNetworkClientId = `networkClientId${ Object.keys(networkController.networkConfigurationsByChainId).length + 1 }`; - // Define the network configuration - const networkConfig = { + // NetworkConfiguration type is more specific than our ProviderConfig; cast is safe here + ( + networkController.networkConfigurationsByChainId as Record< + string, + unknown + > + )[providerConfig.chainId] = { chainId: providerConfig.chainId, rpcEndpoints: [ { networkClientId: newNetworkClientId, url: providerConfig.rpcUrl, - type: providerConfig.type, + type: providerConfig.type as 'custom' | 'infura', name: providerConfig.nickname, }, ], defaultRpcEndpointIndex: 0, blockExplorerUrls: [], - name: providerConfig.nickname, - nativeCurrency: providerConfig.ticker, + name: providerConfig.nickname ?? '', + nativeCurrency: providerConfig.ticker ?? 'ETH', }; - // Add the new network configuration to the object - networkController.networkConfigurationsByChainId[ - providerConfig.chainId as string - ] = networkConfig; - - // Update selectedNetworkClientId to the new network client ID networkController.selectedNetworkClientId = newNetworkClientId; return this; } @@ -308,8 +301,8 @@ class FixtureBuilder { /** * Private helper method to create permission controller configuration * @private - * @param {Object} additionalPermissions - Additional permissions to merge with permission - * @returns {Object} Permission controller configuration object + * @param additionalPermissions - Additional permissions to merge with permission + * @returns Permission controller configuration object */ createPermissionControllerConfig( additionalPermissions: Record = {}, @@ -373,8 +366,8 @@ class FixtureBuilder { /** * Connects the PermissionController to a test dapp with specific accounts permissions and origins. - * @param {Object} additionalPermissions - Additional permissions to merge. - * @returns {FixtureBuilder} - The FixtureBuilder instance for method chaining. + * @param additionalPermissions - Additional permissions to merge. + * @returns - The FixtureBuilder instance for method chaining. */ withPermissionControllerConnectedToTestDapp( additionalPermissions = {}, @@ -399,8 +392,8 @@ class FixtureBuilder { } /** - * @param {RampsRegion | null} region - The region to set, or null for default (Saint Lucia). - * @returns {FixtureBuilder} - The FixtureBuilder instance for method chaining. + * @param region - The region to set, or null for default (Saint Lucia). + * @returns - The FixtureBuilder instance for method chaining. * @example * new FixtureBuilder() * .withRampsSelectedRegion(RampsRegions[RampsRegionsEnum.UNITED_STATES]) @@ -495,7 +488,7 @@ class FixtureBuilder { /** * Sets the selected payment method for the fiat orders. - * @returns {FixtureBuilder} - The FixtureBuilder instance for method chaining. + * @returns - The FixtureBuilder instance for method chaining. */ withRampsSelectedPaymentMethod() { const paymentType = '/payments/debit-credit-card'; @@ -508,8 +501,8 @@ class FixtureBuilder { /** * Sets detected geolocation (e.g. for RWA/Stocks section visibility in Trending). * Use a non-restricted country code so RWA data is shown when not in __DEV__ (e.g. CI). - * @param {string} countryCode - ISO 3166-2 location code (e.g. 'AR', 'US-NY'). - * @returns {FixtureBuilder} - The FixtureBuilder instance for method chaining. + * @param countryCode - ISO country code (e.g. 'AR' for Argentina). + * @returns - The FixtureBuilder instance for method chaining. */ withDetectedGeolocation(countryCode: string) { merge(this.fixture.state.engine.backgroundState, { @@ -525,8 +518,8 @@ class FixtureBuilder { /** * Adds chain switching permission for specific chains. - * @param {string[]} chainIds - Array of chain IDs to permit (defaults to ['0x1']), other nexts like linea mainnet 0xe708 - * @returns {FixtureBuilder} - The FixtureBuilder instance for method chaining. + * @param chainIds - Array of chain IDs to permit (defaults to ['0x1']), other nexts like linea mainnet 0xe708 + * @returns - The FixtureBuilder instance for method chaining. */ withChainPermission(chainIds: `0x${string}`[] = ['0x1']) { const optionalScopes = chainIds @@ -573,7 +566,7 @@ class FixtureBuilder { /** * Adds Solana account permissions for default fixture account. - * @returns {FixtureBuilder} - The FixtureBuilder instance for method chaining. + * @returns - The FixtureBuilder instance for method chaining. */ withSolanaAccountPermission() { const caveatValue = { @@ -610,19 +603,23 @@ class FixtureBuilder { /** * Sets the user profile key ring in the fixture's background state. - * @param {object} userState - The user state to set. - * @returns {FixtureBuilder} - The FixtureBuilder instance for method chaining. + * @param userState - The user state to set. + * @returns - The FixtureBuilder instance for method chaining. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - withUserProfileKeyRing(userState: any) { + withUserProfileKeyRing(userState: UserKeyringState) { merge( this.fixture.state.engine.backgroundState.KeyringController, userState.KEYRING_CONTROLLER_STATE, ); // Add accounts controller with the first account selected - const firstAccountAddress = - userState.KEYRING_CONTROLLER_STATE.keyrings[0].accounts[0]; + const keyrings = userState.KEYRING_CONTROLLER_STATE.keyrings; + if (!keyrings?.[0]?.accounts?.[0]) { + throw new Error( + 'withUserProfileKeyRing: userState must contain at least one keyring with one account', + ); + } + const firstAccountAddress = keyrings[0].accounts[0]; const accountId = '4d7a5e0b-b261-4aed-8126-43972b0fa0a1'; merge(this.fixture.state.engine.backgroundState.AccountsController, { @@ -659,11 +656,10 @@ class FixtureBuilder { /** * Sets the user profile snap unencrypted state in the fixture's background state. - * @param {object} userState - The user state to set. - * @returns {FixtureBuilder} - The FixtureBuilder instance for method chaining. + * @param userState - The user state to set. + * @returns - The FixtureBuilder instance for method chaining. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - withUserProfileSnapUnencryptedState(userState: any) { + withUserProfileSnapUnencryptedState(userState: UserSnapState) { merge( this.fixture.state.engine.backgroundState.SnapController, userState.SNAPS_CONTROLLER_STATE, @@ -674,11 +670,10 @@ class FixtureBuilder { /** * Sets the user profile snap permissions in the fixture's background state. - * @param {object} userState - The user state to set. - * @returns {FixtureBuilder} - The FixtureBuilder instance for method chaining. + * @param userState - The user state to set. + * @returns - The FixtureBuilder instance for method chaining. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - withUserProfileSnapPermissions(userState: any) { + withUserProfileSnapPermissions(userState: UserPermissionState) { merge( this.fixture.state.engine.backgroundState.PermissionController, userState.PERMISSION_CONTROLLER_STATE, @@ -690,12 +685,11 @@ class FixtureBuilder { * Sets the tokens for all popular networks in the fixture's background state. * @param tokens - The tokens to set. * @param userState - The user state to set. - * @returns {FixtureBuilder} - The FixtureBuilder instance for method chaining. + * @returns - The FixtureBuilder instance for method chaining. */ withTokensForAllPopularNetworks( - tokens: Record[], - // eslint-disable-next-line @typescript-eslint/no-explicit-any - userState: any = null, + tokens: TokenInfo[], + userState: UserKeyringState | null = null, ) { // Get all popular network chain IDs using proper constants const popularChainIds = [ @@ -712,30 +706,27 @@ class FixtureBuilder { // Use userState accounts if provided, otherwise fall back to MULTIPLE_ACCOUNTS_ACCOUNTS_CONTROLLER let allAccountAddresses: string[] = []; - // eslint-disable-next-line @typescript-eslint/prefer-optional-chain - if (userState && userState.KEYRING_CONTROLLER_STATE) { + if (userState?.KEYRING_CONTROLLER_STATE) { // Extract all account addresses from the user state keyring - allAccountAddresses = userState.KEYRING_CONTROLLER_STATE.keyrings.flatMap( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (keyring: any) => keyring.accounts, - ); + allAccountAddresses = ( + userState.KEYRING_CONTROLLER_STATE.keyrings ?? [] + ).flatMap((keyring) => keyring.accounts); } else { // Fallback to the hardcoded accounts const accountsData = MULTIPLE_ACCOUNTS_ACCOUNTS_CONTROLLER.internalAccounts.accounts; allAccountAddresses = Object.values(accountsData).map( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (account: any) => account.address, + (account: { address: string }) => account.address, ); } // Create tokens object for all accounts - const accountTokens: Record[]> = {}; + const accountTokens: Record = {}; allAccountAddresses.forEach((address) => { accountTokens[address] = tokens; }); - const allTokens: Record> = {}; + const allTokens: Record> = {}; // Add tokens to each popular network popularChainIds.forEach((chainId) => { @@ -762,8 +753,7 @@ class FixtureBuilder { popularChainIds.forEach((chainId) => { tokenBalances[accountAddress][chainId] = {}; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - tokens.forEach((token: any, tokenIndex: number) => { + tokens.forEach((token, tokenIndex) => { // Generate realistic but varied balances for testing // Using different multipliers to create variety across accounts and tokens const baseBalance = (accountIndex + 1) * (tokenIndex + 1) * 1000; @@ -790,7 +780,7 @@ class FixtureBuilder { /** * Set the fixture to an empty object for onboarding. * Uses JSON-based fixture for consistency. - * @returns {FixtureBuilder} - The FixtureBuilder instance for method chaining. + * @returns - The FixtureBuilder instance for method chaining. */ withOnboardingFixture() { // Deep clone the JSON fixture to avoid mutations @@ -820,7 +810,7 @@ class FixtureBuilder { { networkClientId: newNetworkClientId, url: `http://localhost:${port}`, - type: 'custom', + type: RpcEndpointType.Custom, name: 'Localhost', }, ], @@ -832,8 +822,12 @@ class FixtureBuilder { }; // Add the new Ganache network configuration - fixtures.NetworkController.networkConfigurationsByChainId[chainId] = - ganacheNetworkConfig; + ( + fixtures.NetworkController.networkConfigurationsByChainId as Record< + string, + unknown + > + )[chainId] = ganacheNetworkConfig; // Update selectedNetworkClientId to the new network client ID fixtures.NetworkController.selectedNetworkClientId = newNetworkClientId; @@ -861,7 +855,7 @@ class FixtureBuilder { { networkClientId: newNetworkClientId, url: sepoliaConfig.rpcUrl, - type: 'custom', + type: RpcEndpointType.Custom, name: sepoliaConfig.nickname, }, ], @@ -872,9 +866,12 @@ class FixtureBuilder { }; // Add the new Sepolia network configuration - fixtures.NetworkController.networkConfigurationsByChainId[ - sepoliaConfig.chainId - ] = sepoliaNetworkConfig; + ( + fixtures.NetworkController.networkConfigurationsByChainId as Record< + string, + unknown + > + )[sepoliaConfig.chainId] = sepoliaNetworkConfig; // Update selectedNetworkClientId to the new network client ID fixtures.NetworkController.selectedNetworkClientId = newNetworkClientId; @@ -904,7 +901,7 @@ class FixtureBuilder { { networkClientId: newNetworkClientId, url: `http://localhost:${getMockServerPortForFixture()}/proxy?url=https://polygon-mainnet.infura.io/v3/${infuraProjectId}`, - type: 'custom', + type: RpcEndpointType.Custom, name: 'Polygon Localhost', }, ], @@ -915,8 +912,12 @@ class FixtureBuilder { nativeCurrency: 'MATIC', }; - fixtures.NetworkController.networkConfigurationsByChainId[chainId] = - polygonNetworkConfig; + ( + fixtures.NetworkController.networkConfigurationsByChainId as Record< + string, + unknown + > + )[chainId] = polygonNetworkConfig; fixtures.NetworkController.selectedNetworkClientId = newNetworkClientId; @@ -952,7 +953,7 @@ class FixtureBuilder { { networkClientId: newNetworkClientId, url: rpcTarget, - type: 'custom', + type: RpcEndpointType.Custom, name: nickname, }, ], @@ -963,7 +964,8 @@ class FixtureBuilder { }; // Add the new network configuration to the object - networkConfigurationsByChainId[chainId] = networkConfig; + (networkConfigurationsByChainId as Record)[chainId] = + networkConfig; } // Assign networkConfigurationsByChainId object to NetworkController in fixtures @@ -980,7 +982,7 @@ class FixtureBuilder { * Sets the privacy mode preferences in the fixture's asyncState. * This indicates that the user has agreed to MetaMetrics data collection. * - * @returns {FixtureBuilder} The current instance for method chaining. + * @returns The current instance for method chaining. */ withPrivacyModePreferences(privacyMode: boolean) { merge(this.fixture.state.engine.backgroundState.PreferencesController, { @@ -1000,7 +1002,12 @@ class FixtureBuilder { return this; } - withPreferencesController(data: Record) { + /** + * Merges provided data into the background state of the PreferencesController. + * @param data - Data to merge into the PreferencesController's state. + * @returns - The FixtureBuilder instance for method chaining. + */ + withPreferencesController(data: Partial) { merge( this.fixture.state.engine.backgroundState.PreferencesController, data, @@ -1012,8 +1019,9 @@ class FixtureBuilder { * Merges provided data into the KeyringController's state with a random imported account. * and also includes the default HD Key Tree fixture account. * - * @param {Object} account - ethers.Wallet object containing address and privateKey. - * @returns {FixtureBuilder} - The FixtureBuilder instance for method chaining. + * @param address - The account address to import. + * @param privateKey - The private key for the imported account. + * @returns - The FixtureBuilder instance for method chaining. */ withRandomImportedAccountKeyringController( address: string, @@ -1306,7 +1314,7 @@ class FixtureBuilder { /** * Enables profile syncing in the fixture. - * @returns {this} The current instance for method chaining. + * @returns The current instance for method chaining. */ withKeyringControllerOfMultipleAccounts() { merge(this.fixture.state.engine.backgroundState.KeyringController, { @@ -1390,7 +1398,7 @@ class FixtureBuilder { /** * Enables profile syncing in the fixture. - * @returns {this} The current instance for method chaining. + * @returns The current instance for method chaining. */ withProfileSyncingEnabled() { // Enable AuthenticationController - user must be signed in for profile syncing @@ -1417,7 +1425,7 @@ class FixtureBuilder { /** * Disables profile syncing in the fixture. - * @returns {this} The current instance for method chaining. + * @returns The current instance for method chaining. */ withProfileSyncingDisabled() { merge(this.fixture.state.engine.backgroundState.UserStorageController, { @@ -1485,7 +1493,7 @@ class FixtureBuilder { * and enables the AnalyticsController. * This indicates that the user has agreed to MetaMetrics data collection. * - * @returns {this} The current instance for method chaining. + * @returns The current instance for method chaining. */ withMetaMetricsOptIn() { if (!this.fixture.asyncState) { @@ -1505,8 +1513,8 @@ class FixtureBuilder { * Adds multiple test dapp tabs to the browser state. * This is intended to be used for testing multiple dapps concurrently. * The dapps are opened in the order they are added. - * @returns {FixtureBuilder} - The FixtureBuilder instance for method chaining. - * @param {number} extraTabs - The amount of extra tabs to open. + * @returns - The FixtureBuilder instance for method chaining. + * @param extraTabs - The amount of extra tabs to open. */ withExtraTabs(extraTabs = 1) { if (!this.fixture.state.browser.tabs) { @@ -1527,12 +1535,13 @@ class FixtureBuilder { /** * Sets ETH as the primary currency for both currency rate controller and settings. - * @returns {FixtureBuilder} - The FixtureBuilder instance for method chaining. + * @returns - The FixtureBuilder instance for method chaining. */ withETHAsPrimaryCurrency() { this.fixture.state.engine.backgroundState.CurrencyRateController.currentCurrency = 'ETH'; - this.fixture.state.settings.primaryCurrency = 'ETH'; + (this.fixture.state.settings as Record).primaryCurrency = + 'ETH'; return this; } @@ -1564,7 +1573,7 @@ class FixtureBuilder { * Disables the seedphraseBackedUp flag in the user state. * This is useful for testing scenarios where the user hasn't backed up their seedphrase. * - * @returns {FixtureBuilder} - The FixtureBuilder instance for method chaining + * @returns - The FixtureBuilder instance for method chaining */ withSeedphraseBackedUpDisabled() { this.fixture.state.user.seedphraseBackedUp = false; @@ -1577,10 +1586,10 @@ class FixtureBuilder { * with pre-defined grouping rules. Uses existing entropy sources (MOCK_ENTROPY_SOURCE), * real keyring types (KeyringTypes.hd, .qr, .simple), and actual Snap IDs from the codebase. * If custom wallets are provided, they completely replace the defaults. - * @param {object} data - Data to merge into the AccountTreeController's state. Optional. - * @returns {FixtureBuilder} - The FixtureBuilder instance for method chaining. + * @param data - Data to merge into the AccountTreeController's state. Optional. + * @returns - The FixtureBuilder instance for method chaining. */ - withAccountTreeController(data: Record = {}) { + withAccountTreeController(data: Partial = {}) { // Define a comprehensive default state following @metamask/account-tree-controller specs // Leverages existing keyring types, entropy sources (MOCK_ENTROPY_SOURCE*), and real Snap IDs from the codebase const defaultAccountTreeState = { @@ -1844,7 +1853,7 @@ class FixtureBuilder { return this; } - withSnapController(data: Record = {}) { + withSnapController(data: Partial = {}) { merge(this.fixture.state.engine.backgroundState.SnapController, data); return this; } @@ -1948,7 +1957,7 @@ class FixtureBuilder { * Call after withNetworkController, withTokensForAllPopularNetworks([ETH, USDC, MUSD?]), and withTokenRates. * * @param options - mUSD conversion options (education seen, USDC/MUSD balances). - * @returns {FixtureBuilder} - The FixtureBuilder instance for method chaining. + * @returns - The FixtureBuilder instance for method chaining. */ withMusdConversion(options: MusdFixtureOptions) { const USDC_DECIMALS = 6; @@ -2036,7 +2045,7 @@ class FixtureBuilder { /** * Build and return the fixture object. - * @returns {Object} - The built fixture object. + * @returns - The built fixture object. */ build() { return this.fixture; diff --git a/tests/framework/fixtures/FixtureHelper.ts b/tests/framework/fixtures/FixtureHelper.ts index 58e643d0e2a..7f48216e647 100644 --- a/tests/framework/fixtures/FixtureHelper.ts +++ b/tests/framework/fixtures/FixtureHelper.ts @@ -48,6 +48,7 @@ import { createLogger } from '../logger'; import { mockNotificationServices } from '../../smoke/notifications/utils/mocks'; import PortManager, { ResourceType } from '../PortManager'; import { DEFAULT_MOCKS } from '../../api-mocking/mock-responses/defaults'; +import type { Fixture } from './types'; import CommandQueueServer from './CommandQueueServer'; import DappServer from '../DappServer'; import { PlatformDetector } from '../PlatformLocator'; @@ -292,9 +293,7 @@ async function handleDappCleanup( * @param state - The fixture state to update * @returns The updated fixture state */ -function updateRpcUrlsWithAllocatedPorts( - state: FixtureBuilder['fixture'], -): FixtureBuilder { +function updateRpcUrlsWithAllocatedPorts(state: Fixture): Fixture { const portManager = PortManager.getInstance(); const actualAnvilPort = portManager.getPort(ResourceType.ANVIL); @@ -305,7 +304,7 @@ function updateRpcUrlsWithAllocatedPorts( ?.networkConfigurationsByChainId; if (networkConfigs) { for (const chainId of Object.keys(networkConfigs)) { - const config = networkConfigs[chainId]; + const config = networkConfigs[chainId as `0x${string}`]; if (config.rpcEndpoints) { for (const endpoint of config.rpcEndpoints) { if (endpoint.url) { @@ -334,9 +333,7 @@ function updateRpcUrlsWithAllocatedPorts( * Updates dapp URLs in PermissionController with actual allocated ports by index. * Replaces all occurrences of dapp URLs (by index) with their actual allocated ports. */ -function updateDappUrlsWithAllocatedPorts( - state: FixtureBuilder['fixture'], -): FixtureBuilder { +function updateDappUrlsWithAllocatedPorts(state: Fixture): Fixture { const portManager = PortManager.getInstance(); const permissionController = state.state?.engine?.backgroundState?.PermissionController; @@ -377,9 +374,7 @@ function updateDappUrlsWithAllocatedPorts( * Replaces all occurrences of localhost:8000 with the actual mock server port. * This affects browser tabs and RPC endpoints that proxy through mock server. */ -function updateMockServerUrlsInFixture( - state: FixtureBuilder['fixture'], -): FixtureBuilder { +function updateMockServerUrlsInFixture(state: Fixture): Fixture { const portManager = PortManager.getInstance(); const actualPort = portManager.getPort(ResourceType.MOCK_SERVER); @@ -405,11 +400,13 @@ function updateMockServerUrlsInFixture( */ export const loadFixture = async ( fixtureServer: FixtureServer, - { fixture }: { fixture: FixtureBuilder }, + { fixture }: { fixture: FixtureBuilder | Fixture }, ) => { - // If no fixture is provided, the `onboarding` option is set to `true` by default, which means - // the app will be loaded without any fixtures and will start and go through the onboarding process. - let state = fixture || new FixtureBuilder({ onboarding: true }).build(); + // Normalize FixtureBuilder → Fixture; fall back to onboarding fixture if nothing provided. + let state: Fixture = + fixture instanceof FixtureBuilder + ? fixture.build() + : (fixture ?? new FixtureBuilder({ onboarding: true }).build()); // Update RPC URLs with actual allocated ports from PortManager state = updateRpcUrlsWithAllocatedPorts(state); @@ -577,7 +574,7 @@ export async function withFixtures( mockServerInstance = mockServerResult.mockServerInstance; mockServerPort = mockServerResult.mockServerPort; // Resolve fixture after local nodes are started so dynamic ports are known - let resolvedFixture: FixtureBuilder; + let resolvedFixture: FixtureBuilder | Fixture; if (typeof fixtureOption === 'function') { resolvedFixture = await fixtureOption({ localNodes }); } else { diff --git a/tests/framework/fixtures/FixtureServer.ts b/tests/framework/fixtures/FixtureServer.ts index 76d761601c8..6bfd6e52eaa 100644 --- a/tests/framework/fixtures/FixtureServer.ts +++ b/tests/framework/fixtures/FixtureServer.ts @@ -2,6 +2,7 @@ import { getLocalHost } from './FixtureUtils.ts'; import Koa, { Context } from 'koa'; import { isObject, mapValues } from 'lodash'; import FixtureBuilder from './FixtureBuilder.ts'; +import type { Fixture } from './types.ts'; import { createLogger } from '../logger.ts'; import { Resource, ServerStatus } from '../types.ts'; import PortManager, { ResourceType } from '../PortManager.ts'; @@ -238,7 +239,7 @@ class FixtureServer implements Resource { } // Load JSON state into the server loadJsonState( - rawState: FixtureBuilder, + rawState: Fixture | FixtureBuilder, contractRegistry: ContractRegistry | null, ) { logger.debug('Loading JSON state...'); diff --git a/tests/framework/fixtures/types.ts b/tests/framework/fixtures/types.ts new file mode 100644 index 00000000000..1cd419c4a68 --- /dev/null +++ b/tests/framework/fixtures/types.ts @@ -0,0 +1,266 @@ +// ─── Layer 1: Imported from MetaMask packages (and re-exported) ─────────────── +// Imported for use in Layer 3 composed types; re-exported so consumers can +// import controller state types from this single file rather than each package. +import type { NetworkState } from '@metamask/network-controller'; +import type { AccountsControllerState } from '@metamask/accounts-controller'; +import type { PreferencesState } from '@metamask/preferences-controller'; +import type { AccountTreeControllerState } from '@metamask/account-tree-controller'; + +export type { + NetworkState, + AccountsControllerState, + PreferencesState, + AccountTreeControllerState, +}; + +// ─── Layer 2: Minimal hand-written interfaces ───────────────────────────────── +// Written for controllers that do not export a clean state type. +// These match the shape used in default-fixture.json. Keep them narrow — +// only include the fields FixtureBuilder methods actually read or write. + +export interface KeyringEntry { + type: string; + accounts: string[]; + metadata?: { id: string; name: string }; +} + +export interface KeyringControllerState { + keyrings: KeyringEntry[]; + vault?: string; + isUnlocked?: boolean; + encryptionKey?: string; + encryptionSalt?: string; +} + +// A single caveat inside a permission grant. +export interface PermissionCaveat { + type: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any; +} + +// One permission granted to an origin. +export interface PermissionEntry { + id: string; + parentCapability: string; + invoker: string; + caveats: PermissionCaveat[] | null; + date: number; +} + +// A subject (origin) that holds one or more permissions. +export interface PermissionSubject { + origin: string; + permissions: Record; +} + +export interface PermissionControllerState { + subjects: Record; +} + +// Snaps controller — only the fields we set in fixtures. +export interface SnapEntry { + id: string; + enabled: boolean; + blocked: boolean; + status: string; + version: string; + // Allow additional snap manifest fields without listing them all. + [key: string]: unknown; +} + +export interface SnapControllerState { + snaps?: Record; // absent in default fixture; injected at runtime +} + +// Token entry used in TokensController.allTokens +export interface TokenInfo { + address: string; + symbol: string; + decimals: number; + name?: string; + image?: string; + [key: string]: unknown; +} + +export interface TokensControllerState { + // allTokens[chainId][accountAddress] = TokenInfo[] + allTokens: Record>; +} + +export interface TokenBalancesControllerState { + // tokenBalances[accountAddress][chainId][tokenAddress] = hex string + tokenBalances: Record>>; +} + +export interface TokenRatesControllerState { + // marketData[chainId][tokenAddress] = { tokenAddress, price, ... } + marketData: Record>>; +} + +export interface CurrencyRateEntry { + conversionDate: number; + conversionRate: number; + usdConversionRate: number; +} + +export interface CurrencyRateControllerState { + currentCurrency: string; + currencyRates: Record; +} + +export interface AccountBalance { + balance: string; +} + +export interface AccountTrackerControllerState { + accounts?: Record; // legacy field; absent in current fixture + accountsByChainId: Record>; +} + +export interface RampsRegionCountry { + isoCode: string; + name: string; + flag: string; + currency?: string; + phone: { prefix: string; placeholder: string; template: string }; + supported: { buy: boolean; sell: boolean }; +} + +export interface RampsRegionState { + country: RampsRegionCountry; + state: { + stateId: string; + name: string; + supported: { buy: boolean; sell: boolean }; + } | null; + regionCode: string; +} + +export interface RampsControllerState { + userRegion: RampsRegionState | null; + [key: string]: unknown; +} + +// ─── Redux slice shapes ─────────────────────────────────────────────────────── + +export interface BrowserTab { + id: number; + url: string; + isArchived?: boolean; +} + +export interface BrowserState { + activeTab: number | null; + tabs: BrowserTab[]; + history: string[]; + favicons: unknown[]; + isFullscreen: boolean; + visitedDappsByHostname: Record; + whitelist: string[]; +} + +export interface UserState { + seedphraseBackedUp: boolean; + backUpSeedphraseVisible: boolean; + passwordSet: boolean; + importTime?: number; + musdConversionEducationSeen?: boolean; + [key: string]: unknown; +} + +export interface FiatOrdersState { + orders: unknown[]; + customOrderIds: unknown[]; + selectedRegionAgg?: unknown; + selectedPaymentMethodAgg?: string; + detectedGeolocation?: string; + rampRoutingDecision?: string; + networks?: unknown[]; + [key: string]: unknown; +} + +export interface LegalNoticesState { + newPrivacyPolicyToastShownDate: number; + isPna25Acknowledged?: boolean; + [key: string]: unknown; +} + +// ─── Layer 3: Composed types ────────────────────────────────────────────────── + +export interface EngineBackgroundState { + NetworkController: NetworkState; + AccountsController: AccountsControllerState; + PreferencesController: PreferencesState; + AccountTreeController: AccountTreeControllerState; + KeyringController: KeyringControllerState; + PermissionController: PermissionControllerState; + SnapController: SnapControllerState; + TokensController: TokensControllerState; + TokenBalancesController: TokenBalancesControllerState; + TokenRatesController: TokenRatesControllerState; + CurrencyRateController: CurrencyRateControllerState; + AccountTrackerController: AccountTrackerControllerState; + RampsController: RampsControllerState; + // PerpsController has an exotic shape that changes frequently; keep loose. + PerpsController: Record; + // Allow other controllers that fixtures may optionally set. + [key: string]: unknown; +} + +export interface FixtureState { + engine: { backgroundState: EngineBackgroundState }; + browser: BrowserState; + user: UserState; + fiatOrders: FiatOrdersState; + legalNotices: LegalNoticesState; + // Other Redux slices we don't need to narrow further. + [key: string]: unknown; +} + +export interface Fixture { + state: FixtureState; + asyncState: Record; +} + +// ─── Method parameter types ─────────────────────────────────────────────────── + +/** + * The network provider config passed to withNetworkController(). + * Matches the shape of providerConfig objects in tests/resources/networks.e2e.js. + */ +export interface ProviderConfig { + chainId: string; + rpcUrl: string; + type: string; + nickname?: string; + ticker?: string; +} + +/** + * Shape of user-profile objects exported from profile fixtures. + * Used by withUserProfileKeyRing(). + */ +export interface UserKeyringState { + KEYRING_CONTROLLER_STATE: Partial; +} + +/** + * Shape of user-profile objects containing snap controller state. + * Used by withUserProfileSnapUnencryptedState(). + */ +export interface UserSnapState { + SNAPS_CONTROLLER_STATE: Partial; +} + +/** + * Shape of user-profile objects containing permission controller state. + * Used by withUserProfileSnapPermissions(). + */ +export interface UserPermissionState { + PERMISSION_CONTROLLER_STATE: Partial; +} + +// ─── Utility ────────────────────────────────────────────────────────────────── + +export type DeepPartial = { [P in keyof T]?: DeepPartial }; diff --git a/tests/framework/types.ts b/tests/framework/types.ts index ae1355292e8..d8fe0aafe1c 100644 --- a/tests/framework/types.ts +++ b/tests/framework/types.ts @@ -7,6 +7,7 @@ import ContractAddressRegistry from '../../app/util/test/contract-address-regist import Ganache from '../../app/util/test/ganache'; import { Mockttp } from 'mockttp'; import FixtureBuilder from './fixtures/FixtureBuilder.ts'; +import type { Fixture } from './fixtures/types.ts'; import CommandQueueServer from './fixtures/CommandQueueServer.ts'; /* @@ -327,9 +328,10 @@ export type TestSpecificMock = (mockServer: Mockttp) => Promise; export interface WithFixturesOptions { fixture: | FixtureBuilder + | Fixture | ((ctx: { localNodes?: LocalNode[]; - }) => FixtureBuilder | Promise); + }) => FixtureBuilder | Fixture | Promise); restartDevice?: boolean; smartContracts?: string[]; disableLocalNodes?: boolean; diff --git a/tests/regression/accounts/error-boundary-srp-backup.spec.ts b/tests/regression/accounts/error-boundary-srp-backup.spec.ts index b9adc1f1b8e..8ac580623e6 100644 --- a/tests/regression/accounts/error-boundary-srp-backup.spec.ts +++ b/tests/regression/accounts/error-boundary-srp-backup.spec.ts @@ -50,13 +50,11 @@ describe(RegressionAccounts('Error Boundary Screen'), () => { return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: '0x539', - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Local RPC', - ticker: 'ETH', - }, + chainId: '0x539', + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', }) .withPermissionControllerConnectedToTestDapp( buildPermissions(['0x539']), diff --git a/tests/regression/assets/import-custom-token.spec.ts b/tests/regression/assets/import-custom-token.spec.ts index da5e7db79cc..81c9e71ee58 100644 --- a/tests/regression/assets/import-custom-token.spec.ts +++ b/tests/regression/assets/import-custom-token.spec.ts @@ -30,13 +30,11 @@ describe(RegressionAssets('Import custom token'), () => { return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: '0x539', - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Local RPC', - ticker: 'ETH', - }, + chainId: '0x539', + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', }) .withNetworkEnabledMap({ eip155: { '0x539': true }, diff --git a/tests/regression/assets/import-tokens-via-asset-watcher.spec.ts b/tests/regression/assets/import-tokens-via-asset-watcher.spec.ts index a064dfa2c87..0ea103038bc 100644 --- a/tests/regression/assets/import-tokens-via-asset-watcher.spec.ts +++ b/tests/regression/assets/import-tokens-via-asset-watcher.spec.ts @@ -62,13 +62,11 @@ describe(RegressionNetworkAbstractions('Asset Watch:'), () => { return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: '0x539', - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Local RPC', - ticker: 'ETH', - }, + chainId: '0x539', + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', }) .withPermissionControllerConnectedToTestDapp( buildERC20PermsForAddress(), diff --git a/tests/regression/assets/nft-details.spec.ts b/tests/regression/assets/nft-details.spec.ts index f1c24a584ac..874a72d9d2d 100644 --- a/tests/regression/assets/nft-details.spec.ts +++ b/tests/regression/assets/nft-details.spec.ts @@ -36,13 +36,11 @@ describe.skip(RegressionAssets('NFT Details page'), () => { return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: '0x539', - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Local RPC', - ticker: 'ETH', - }, + chainId: '0x539', + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', }) .withPermissionControllerConnectedToTestDapp( buildPermissions(['0x539']), diff --git a/tests/regression/assets/transaction.spec.ts b/tests/regression/assets/transaction.spec.ts index f705fa00439..4fb9823f75c 100644 --- a/tests/regression/assets/transaction.spec.ts +++ b/tests/regression/assets/transaction.spec.ts @@ -40,13 +40,11 @@ describe(RegressionAssets('Transaction'), () => { return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: '0x539', - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Local RPC', - ticker: 'ETH', - }, + chainId: '0x539', + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', }) .withNetworkEnabledMap({ eip155: { '0x539': true }, diff --git a/tests/regression/confirmations/new-networks-signatures.spec.ts b/tests/regression/confirmations/new-networks-signatures.spec.ts index 86a09cd7e01..af6f8d8b2e3 100644 --- a/tests/regression/confirmations/new-networks-signatures.spec.ts +++ b/tests/regression/confirmations/new-networks-signatures.spec.ts @@ -64,9 +64,7 @@ describe.skip(RegressionConfirmations('Signature Requests'), () => { }, ], fixture: new FixtureBuilder() - .withNetworkController({ - providerConfig: networkConfig.providerConfig, - }) + .withNetworkController(networkConfig.providerConfig) .withPermissionControllerConnectedToTestDapp( buildPermissions(networkConfig.permissions), ) diff --git a/tests/regression/networks/add-custom-rpc.spec.ts b/tests/regression/networks/add-custom-rpc.spec.ts index 71f66fe2460..85f8406a855 100644 --- a/tests/regression/networks/add-custom-rpc.spec.ts +++ b/tests/regression/networks/add-custom-rpc.spec.ts @@ -159,7 +159,7 @@ describe.skip(RegressionAssets('Custom RPC Tests'), () => { await withFixtures( { fixture: new FixtureBuilder() - .withNetworkController(CustomNetworks.Gnosis) + .withNetworkController(CustomNetworks.Gnosis.providerConfig) .build(), restartDevice: true, }, diff --git a/tests/regression/ramps/onramp-parameters.spec.ts b/tests/regression/ramps/onramp-parameters.spec.ts index 3c8322a3e4f..ce41d537690 100644 --- a/tests/regression/ramps/onramp-parameters.spec.ts +++ b/tests/regression/ramps/onramp-parameters.spec.ts @@ -30,7 +30,7 @@ const setupOnRampTest = async (testFn: () => Promise) => { await withFixtures( { fixture: new FixtureBuilder() - .withNetworkController(CustomNetworks.Tenderly.Mainnet) + .withNetworkController(CustomNetworks.Tenderly.Mainnet.providerConfig) .withRampsSelectedRegion(selectedRegion) .withMetaMetricsOptIn() .build(), diff --git a/tests/regression/swap/swap-action-regression.spec.ts b/tests/regression/swap/swap-action-regression.spec.ts index 901d9d34836..0ffe6002202 100644 --- a/tests/regression/swap/swap-action-regression.spec.ts +++ b/tests/regression/swap/swap-action-regression.spec.ts @@ -31,13 +31,11 @@ describe(RegressionTrade('Swap ETH <-> WETH from Actions'), (): void => { return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: '0x1', - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Localhost', - ticker: 'ETH', - }, + chainId: '0x1', + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Localhost', + ticker: 'ETH', }) .withDisabledSmartTransactions() .build(); diff --git a/tests/regression/swap/swap-token-chart.spec.ts b/tests/regression/swap/swap-token-chart.spec.ts index 4a550c59989..4026e88a93d 100644 --- a/tests/regression/swap/swap-token-chart.spec.ts +++ b/tests/regression/swap/swap-token-chart.spec.ts @@ -38,13 +38,11 @@ describe(RegressionTrade('Swap from Token view'), (): void => { return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId, - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Localhost', - ticker: 'ETH', - }, + chainId, + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Localhost', + ticker: 'ETH', }) .withDisabledSmartTransactions() .build(); diff --git a/tests/regression/swap/swap-token-rwa.spec.ts b/tests/regression/swap/swap-token-rwa.spec.ts index 01f4f7059b5..98c4adfb379 100644 --- a/tests/regression/swap/swap-token-rwa.spec.ts +++ b/tests/regression/swap/swap-token-rwa.spec.ts @@ -35,13 +35,11 @@ describe(RegressionTrade('Swap RWA'), (): void => { return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId, - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Localhost', - ticker: 'ETH', - }, + chainId, + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Localhost', + ticker: 'ETH', }) .withDisabledSmartTransactions() .build(); diff --git a/tests/regression/wallet/balance-privacy-toggle.spec.ts b/tests/regression/wallet/balance-privacy-toggle.spec.ts index c4d48bee38e..fe621dacec8 100644 --- a/tests/regression/wallet/balance-privacy-toggle.spec.ts +++ b/tests/regression/wallet/balance-privacy-toggle.spec.ts @@ -28,13 +28,11 @@ describe(RegressionWalletPlatform('Balance Privacy Toggle'), (): void => { return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: '0x539', - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Local RPC', - ticker: 'ETH', - }, + chainId: '0x539', + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', }) .withETHAsPrimaryCurrency() // Set primary currency to ETH .build(); diff --git a/tests/regression/wallet/send-ERC-token.spec.ts b/tests/regression/wallet/send-ERC-token.spec.ts index 9dbecde0220..e6998f3f1be 100644 --- a/tests/regression/wallet/send-ERC-token.spec.ts +++ b/tests/regression/wallet/send-ERC-token.spec.ts @@ -39,13 +39,11 @@ describe(RegressionWalletPlatform('Send ERC Token'), () => { return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: '0x539', - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Local RPC', - ticker: 'ETH', - }, + chainId: '0x539', + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', }) .withNetworkEnabledMap({ eip155: { '0x539': true }, diff --git a/tests/smoke/card/card-button.spec.ts b/tests/smoke/card/card-button.spec.ts index a714a2abbc2..a84a5fcc44d 100644 --- a/tests/smoke/card/card-button.spec.ts +++ b/tests/smoke/card/card-button.spec.ts @@ -21,7 +21,7 @@ describe(SmokeCard('Card NavBar Button'), () => { { fixture: new FixtureBuilder() .withMetaMetricsOptIn() - .withNetworkController(CustomNetworks.Tenderly.Linea) + .withNetworkController(CustomNetworks.Tenderly.Linea.providerConfig) .withAccountTreeController() .withTokens( [ diff --git a/tests/smoke/card/card-home-add-funds.spec.ts b/tests/smoke/card/card-home-add-funds.spec.ts index 68b42879d9b..786bafef50a 100644 --- a/tests/smoke/card/card-home-add-funds.spec.ts +++ b/tests/smoke/card/card-home-add-funds.spec.ts @@ -21,7 +21,7 @@ describe(SmokeCard('CardHome - Add Funds'), () => { { fixture: new FixtureBuilder() .withMetaMetricsOptIn() - .withNetworkController(CustomNetworks.Tenderly.Linea) + .withNetworkController(CustomNetworks.Tenderly.Linea.providerConfig) .withAccountTreeController() .withTokens( [ diff --git a/tests/smoke/card/card-home-manage-card.spec.ts b/tests/smoke/card/card-home-manage-card.spec.ts index 0d66717417d..0419257bdd8 100644 --- a/tests/smoke/card/card-home-manage-card.spec.ts +++ b/tests/smoke/card/card-home-manage-card.spec.ts @@ -21,7 +21,7 @@ describe(SmokeCard('CardHome - Manage Card'), () => { { fixture: new FixtureBuilder() .withMetaMetricsOptIn() - .withNetworkController(CustomNetworks.Tenderly.Linea) + .withNetworkController(CustomNetworks.Tenderly.Linea.providerConfig) .withAccountTreeController() .withTokens( [ diff --git a/tests/smoke/confirmations/send/send-erc20-token.spec.ts b/tests/smoke/confirmations/send/send-erc20-token.spec.ts index be6874b8b15..55bad325fd6 100644 --- a/tests/smoke/confirmations/send/send-erc20-token.spec.ts +++ b/tests/smoke/confirmations/send/send-erc20-token.spec.ts @@ -289,13 +289,11 @@ describe(SmokeConfirmations('Send ERC20 asset'), () => { return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: '0x539', - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Local RPC', - ticker: 'ETH', - }, + chainId: '0x539', + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', }) .withTokensForAllPopularNetworks([ { @@ -355,13 +353,11 @@ describe(SmokeConfirmations('Send ERC20 asset'), () => { return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: '0x539', - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Local RPC', - ticker: 'ETH', - }, + chainId: '0x539', + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', }) .withTokensForAllPopularNetworks([ { @@ -421,13 +417,11 @@ describe(SmokeConfirmations('Send ERC20 asset'), () => { return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: '0x539', - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Local RPC', - ticker: 'ETH', - }, + chainId: '0x539', + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', }) .withTokensForAllPopularNetworks([ { diff --git a/tests/smoke/confirmations/send/send-native-token.spec.ts b/tests/smoke/confirmations/send/send-native-token.spec.ts index 55fb48ce0d3..1d18facfabf 100644 --- a/tests/smoke/confirmations/send/send-native-token.spec.ts +++ b/tests/smoke/confirmations/send/send-native-token.spec.ts @@ -28,13 +28,11 @@ describe(SmokeConfirmations('Send native asset'), () => { ], fixture: new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: '0x539', - rpcUrl: LOCAL_NODE_RPC_URL, - type: 'custom', - nickname: 'Local RPC', - ticker: 'ETH', - }, + chainId: '0x539', + rpcUrl: LOCAL_NODE_RPC_URL, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', }) .withMetaMetricsOptIn() .withPreferencesController({}) diff --git a/tests/smoke/confirmations/signatures/signatures-typed.spec.ts b/tests/smoke/confirmations/signatures/signatures-typed.spec.ts index 9f427ab2aea..a7fd4abe88f 100644 --- a/tests/smoke/confirmations/signatures/signatures-typed.spec.ts +++ b/tests/smoke/confirmations/signatures/signatures-typed.spec.ts @@ -82,13 +82,11 @@ describe(SmokeConfirmations('Typed Signature Requests'), () => { return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: '0x539', - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Local RPC', - ticker: 'ETH', - }, + chainId: '0x539', + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', }) .withPermissionControllerConnectedToTestDapp( buildPermissions(['0x539']), diff --git a/tests/smoke/confirmations/signatures/signatures.spec.ts b/tests/smoke/confirmations/signatures/signatures.spec.ts index 252033438f5..d7495d40b1c 100644 --- a/tests/smoke/confirmations/signatures/signatures.spec.ts +++ b/tests/smoke/confirmations/signatures/signatures.spec.ts @@ -76,13 +76,11 @@ describe(SmokeConfirmations('Signature Requests'), () => { return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: '0x539', - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Local RPC', - ticker: 'ETH', - }, + chainId: '0x539', + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', }) .withPermissionControllerConnectedToTestDapp( buildPermissions(['0x539']), diff --git a/tests/smoke/confirmations/transactions/7702/batch-transaction.spec.ts b/tests/smoke/confirmations/transactions/7702/batch-transaction.spec.ts index 07b31f83e8d..7e1a312fb7a 100644 --- a/tests/smoke/confirmations/transactions/7702/batch-transaction.spec.ts +++ b/tests/smoke/confirmations/transactions/7702/batch-transaction.spec.ts @@ -119,13 +119,11 @@ describe(SmokeConfirmations('7702 - smart account'), () => { return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: '0x539', - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Local RPC', - ticker: 'ETH', - }, + chainId: '0x539', + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', }) .withPermissionControllerConnectedToTestDapp( buildPermissions(['0x539']), @@ -216,13 +214,11 @@ describe(SmokeConfirmations('7702 - smart account'), () => { return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: '0x539', - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Local RPC', - ticker: 'ETH', - }, + chainId: '0x539', + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', }) .build(); }, diff --git a/tests/smoke/confirmations/transactions/contract-deployment.spec.ts b/tests/smoke/confirmations/transactions/contract-deployment.spec.ts index abed4e7fe41..6b6e7ddb70b 100644 --- a/tests/smoke/confirmations/transactions/contract-deployment.spec.ts +++ b/tests/smoke/confirmations/transactions/contract-deployment.spec.ts @@ -59,13 +59,11 @@ describe(SmokeConfirmations('Contract Deployment'), () => { return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: '0x539', - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Local RPC', - ticker: 'ETH', - }, + chainId: '0x539', + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', }) .withPermissionControllerConnectedToTestDapp( buildPermissions(['0x539']), diff --git a/tests/smoke/confirmations/transactions/contract-interaction.spec.ts b/tests/smoke/confirmations/transactions/contract-interaction.spec.ts index 883b6a30f27..146f3bd74c4 100644 --- a/tests/smoke/confirmations/transactions/contract-interaction.spec.ts +++ b/tests/smoke/confirmations/transactions/contract-interaction.spec.ts @@ -60,13 +60,11 @@ describe(SmokeConfirmations('Contract Interaction'), () => { return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: '0x539', - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Local RPC', - ticker: 'ETH', - }, + chainId: '0x539', + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', }) .withPermissionControllerConnectedToTestDapp( buildPermissions(['0x539']), diff --git a/tests/smoke/confirmations/transactions/dapp-initiated-transfer.spec.ts b/tests/smoke/confirmations/transactions/dapp-initiated-transfer.spec.ts index 98ab85f07b1..c5f9cc12b0e 100644 --- a/tests/smoke/confirmations/transactions/dapp-initiated-transfer.spec.ts +++ b/tests/smoke/confirmations/transactions/dapp-initiated-transfer.spec.ts @@ -117,13 +117,11 @@ describe(SmokeConfirmations('DApp Initiated Transfer'), () => { ], fixture: new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: '0x539', - rpcUrl: `http://localhost:${DEFAULT_ANVIL_PORT}`, - type: 'custom', - nickname: 'Local RPC', - ticker: 'ETH', - }, + chainId: '0x539', + rpcUrl: `http://localhost:${DEFAULT_ANVIL_PORT}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', }) .withNetworkEnabledMap({ eip155: { '0x539': true }, diff --git a/tests/smoke/confirmations/transactions/gas-fee-tokens-eip-7702-sponsored.spec.ts b/tests/smoke/confirmations/transactions/gas-fee-tokens-eip-7702-sponsored.spec.ts index 57e2f772660..1bfae467617 100644 --- a/tests/smoke/confirmations/transactions/gas-fee-tokens-eip-7702-sponsored.spec.ts +++ b/tests/smoke/confirmations/transactions/gas-fee-tokens-eip-7702-sponsored.spec.ts @@ -130,13 +130,11 @@ const createFixture = ({ localNodes }: { localNodes?: LocalNode[] }) => { node instanceof AnvilManager ? (node.getPort() ?? AnvilPort()) : undefined; return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: '0x539', - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Local RPC', - ticker: 'ETH', - }, + chainId: '0x539', + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', }) .withDisabledSmartTransactions() .build(); diff --git a/tests/smoke/confirmations/transactions/gas-fee-tokens-eip-7702.spec.ts b/tests/smoke/confirmations/transactions/gas-fee-tokens-eip-7702.spec.ts index f9a86587c3a..84cfa3205ee 100644 --- a/tests/smoke/confirmations/transactions/gas-fee-tokens-eip-7702.spec.ts +++ b/tests/smoke/confirmations/transactions/gas-fee-tokens-eip-7702.spec.ts @@ -184,13 +184,11 @@ describe( : undefined; return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: '0x539', - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Local RPC', - ticker: 'ETH', - }, + chainId: '0x539', + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', }) .withDisabledSmartTransactions() .build(); diff --git a/tests/smoke/confirmations/transactions/per-dapp-selected-network.spec.ts b/tests/smoke/confirmations/transactions/per-dapp-selected-network.spec.ts index 3fffcf08f35..0d669c16f4e 100644 --- a/tests/smoke/confirmations/transactions/per-dapp-selected-network.spec.ts +++ b/tests/smoke/confirmations/transactions/per-dapp-selected-network.spec.ts @@ -60,13 +60,11 @@ describe(SmokeConfirmations('Dapp Network Switching'), () => { return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: LOCAL_CHAIN_ID, - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: LOCAL_CHAIN_NAME, - ticker: 'ETH', - }, + chainId: LOCAL_CHAIN_ID, + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: LOCAL_CHAIN_NAME, + ticker: 'ETH', }) .withPermissionControllerConnectedToTestDapp( buildPermissions([LOCAL_CHAIN_ID]), diff --git a/tests/smoke/confirmations/transactions/token-approve/approve.spec.ts b/tests/smoke/confirmations/transactions/token-approve/approve.spec.ts index ea2dce8b248..c23efe09c10 100644 --- a/tests/smoke/confirmations/transactions/token-approve/approve.spec.ts +++ b/tests/smoke/confirmations/transactions/token-approve/approve.spec.ts @@ -59,13 +59,11 @@ describe(SmokeConfirmations('Token Approve - approve method'), () => { return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: '0x539', - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Local RPC', - ticker: 'ETH', - }, + chainId: '0x539', + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', }) .withPermissionControllerConnectedToTestDapp( buildPermissions(['0x539']), @@ -155,13 +153,11 @@ describe(SmokeConfirmations('Token Approve - approve method'), () => { return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: '0x539', - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Local RPC', - ticker: 'ETH', - }, + chainId: '0x539', + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', }) .withPermissionControllerConnectedToTestDapp( buildPermissions(['0x539']), diff --git a/tests/smoke/confirmations/transactions/token-approve/increase-allowance.spec.ts b/tests/smoke/confirmations/transactions/token-approve/increase-allowance.spec.ts index b858e77c9da..3058906a814 100644 --- a/tests/smoke/confirmations/transactions/token-approve/increase-allowance.spec.ts +++ b/tests/smoke/confirmations/transactions/token-approve/increase-allowance.spec.ts @@ -58,13 +58,11 @@ describe(SmokeConfirmations('Token Approve - increaseAllowance method'), () => { return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: '0x539', - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Local RPC', - ticker: 'ETH', - }, + chainId: '0x539', + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', }) .withPermissionControllerConnectedToTestDapp( buildPermissions(['0x539']), diff --git a/tests/smoke/confirmations/transactions/token-approve/set-approval-for-all.spec.ts b/tests/smoke/confirmations/transactions/token-approve/set-approval-for-all.spec.ts index 27c53f98953..73fe3e1d659 100644 --- a/tests/smoke/confirmations/transactions/token-approve/set-approval-for-all.spec.ts +++ b/tests/smoke/confirmations/transactions/token-approve/set-approval-for-all.spec.ts @@ -59,13 +59,11 @@ describe(SmokeConfirmations('Token Approve - setApprovalForAll method'), () => { return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: '0x539', - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Local RPC', - ticker: 'ETH', - }, + chainId: '0x539', + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', }) .withPermissionControllerConnectedToTestDapp( buildPermissions(['0x539']), @@ -167,13 +165,11 @@ describe(SmokeConfirmations('Token Approve - setApprovalForAll method'), () => { return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: '0x539', - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Local RPC', - ticker: 'ETH', - }, + chainId: '0x539', + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', }) .withPermissionControllerConnectedToTestDapp( buildPermissions(['0x539']), @@ -244,13 +240,11 @@ describe(SmokeConfirmations('Token Approve - setApprovalForAll method'), () => { return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: '0x539', - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Local RPC', - ticker: 'ETH', - }, + chainId: '0x539', + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', }) .withPermissionControllerConnectedToTestDapp( buildPermissions(['0x539']), diff --git a/tests/smoke/identity/utils/withIdentityFixtures.ts b/tests/smoke/identity/utils/withIdentityFixtures.ts index 0f22f3c5a7b..e4876a7c6d0 100644 --- a/tests/smoke/identity/utils/withIdentityFixtures.ts +++ b/tests/smoke/identity/utils/withIdentityFixtures.ts @@ -1,5 +1,6 @@ import { withFixtures } from '../../../framework/fixtures/FixtureHelper'; import FixtureBuilder from '../../../framework/fixtures/FixtureBuilder'; +import type { Fixture } from '../../../framework/fixtures/types'; import { createUserStorageController, setupAccountMockedBalances, @@ -17,7 +18,7 @@ import { } from '@metamask/account-tree-controller'; export interface IdentityFixtureOptions { - fixture?: object; + fixture?: FixtureBuilder | Fixture; restartDevice?: boolean; userStorageFeatures?: (keyof typeof pathRegexps)[]; userStorageOverrides?: Partial< diff --git a/tests/smoke/multichain/permissions/accounts/permission-system-remove.failing.ts b/tests/smoke/multichain/permissions/accounts/permission-system-remove.failing.ts index 4f41882458e..bf6122297d5 100644 --- a/tests/smoke/multichain/permissions/accounts/permission-system-remove.failing.ts +++ b/tests/smoke/multichain/permissions/accounts/permission-system-remove.failing.ts @@ -37,7 +37,7 @@ describe(SmokeNetworkAbstractions('Chain Permission Management'), () => { }, ], fixture: new FixtureBuilder() - .withNetworkController(PopularNetworksList.Polygon) + .withNetworkController(PopularNetworksList.Polygon.providerConfig) .build(), restartDevice: true, }, diff --git a/tests/smoke/multichain/permissions/chains/permission-system-dapp-chain-switch-grant.spec.js b/tests/smoke/multichain/permissions/chains/permission-system-dapp-chain-switch-grant.spec.js index d086ba1a580..20043a1c79c 100644 --- a/tests/smoke/multichain/permissions/chains/permission-system-dapp-chain-switch-grant.spec.js +++ b/tests/smoke/multichain/permissions/chains/permission-system-dapp-chain-switch-grant.spec.js @@ -28,8 +28,10 @@ describe(SmokeNetworkAbstractions('Chain Permission System'), () => { }, ], fixture: new FixtureBuilder() - .withNetworkController(CustomNetworks.ElysiumTestnet) - .withNetworkController(CustomNetworks.EthereumMainCustom) + .withNetworkController(CustomNetworks.ElysiumTestnet.providerConfig) + .withNetworkController( + CustomNetworks.EthereumMainCustom.providerConfig, + ) .withPermissionController() .build(), restartDevice: true, diff --git a/tests/smoke/performance/account-list/dismiss-account-list.spec.ts b/tests/smoke/performance/account-list/dismiss-account-list.spec.ts index 2df06297f80..fc871d54f08 100644 --- a/tests/smoke/performance/account-list/dismiss-account-list.spec.ts +++ b/tests/smoke/performance/account-list/dismiss-account-list.spec.ts @@ -6,6 +6,11 @@ import Assertions from '../../../framework/Assertions'; import TestHelpers from '../../../helpers'; import FixtureBuilder from '../../../framework/fixtures/FixtureBuilder'; import { withFixtures } from '../../../framework/fixtures/FixtureHelper'; +import type { + UserKeyringState, + UserSnapState, + UserPermissionState, +} from '../../../framework/fixtures/types'; import { toChecksumAddress } from 'ethereumjs-util'; import { CORE_USER_STATE, @@ -65,9 +70,9 @@ describe(SmokePerformance('Switching Accounts to Dismiss Load Testing'), () => { await withFixtures( { fixture: new FixtureBuilder() - .withUserProfileKeyRing(_userState) - .withUserProfileSnapUnencryptedState(_userState) - .withUserProfileSnapPermissions(_userState) + .withUserProfileKeyRing(_userState as UserKeyringState) + .withUserProfileSnapUnencryptedState(_userState as UserSnapState) + .withUserProfileSnapPermissions(_userState as UserPermissionState) .withTokens(minimalTokens) .build(), restartDevice: true, diff --git a/tests/smoke/performance/account-list/render-account-list.spec.ts b/tests/smoke/performance/account-list/render-account-list.spec.ts index 3782e635150..d5bb16c27e7 100644 --- a/tests/smoke/performance/account-list/render-account-list.spec.ts +++ b/tests/smoke/performance/account-list/render-account-list.spec.ts @@ -6,6 +6,11 @@ import Assertions from '../../../framework/Assertions'; import TestHelpers from '../../../helpers'; import FixtureBuilder from '../../../framework/fixtures/FixtureBuilder'; import { withFixtures } from '../../../framework/fixtures/FixtureHelper'; +import type { + UserKeyringState, + UserSnapState, + UserPermissionState, +} from '../../../framework/fixtures/types'; import { toChecksumAddress } from 'ethereumjs-util'; import { CORE_USER_STATE, @@ -52,9 +57,9 @@ describe(SmokePerformance('Account List Load Testing'), () => { { fixture: new FixtureBuilder() .withPopularNetworks() - .withUserProfileKeyRing(userState) - .withUserProfileSnapUnencryptedState(userState) - .withUserProfileSnapPermissions(userState) + .withUserProfileKeyRing(userState as UserKeyringState) + .withUserProfileSnapUnencryptedState(userState as UserSnapState) + .withUserProfileSnapPermissions(userState as UserPermissionState) .build(), restartDevice: true, }, @@ -140,11 +145,14 @@ describe(SmokePerformance('Account List Load Testing'), () => { await withFixtures( { fixture: new FixtureBuilder() - .withUserProfileKeyRing(userState) - .withUserProfileSnapUnencryptedState(userState) - .withUserProfileSnapPermissions(userState) + .withUserProfileKeyRing(userState as UserKeyringState) + .withUserProfileSnapUnencryptedState(userState as UserSnapState) + .withUserProfileSnapPermissions(userState as UserPermissionState) .withPopularNetworks() - .withTokensForAllPopularNetworks(heavyTokenLoad, userState) + .withTokensForAllPopularNetworks( + heavyTokenLoad, + userState as UserKeyringState, + ) .build(), restartDevice: true, }, @@ -230,9 +238,9 @@ describe(SmokePerformance('Account List Load Testing'), () => { await withFixtures( { fixture: new FixtureBuilder() - .withUserProfileKeyRing(_userState) - .withUserProfileSnapUnencryptedState(_userState) - .withUserProfileSnapPermissions(_userState) + .withUserProfileKeyRing(_userState as UserKeyringState) + .withUserProfileSnapUnencryptedState(_userState as UserSnapState) + .withUserProfileSnapPermissions(_userState as UserPermissionState) .withTokens(minimalTokens) .build(), restartDevice: true, diff --git a/tests/smoke/performance/network-list/dismiss-network-list.spec.ts b/tests/smoke/performance/network-list/dismiss-network-list.spec.ts index 4b5fe7965d7..65b3ff2228e 100644 --- a/tests/smoke/performance/network-list/dismiss-network-list.spec.ts +++ b/tests/smoke/performance/network-list/dismiss-network-list.spec.ts @@ -5,6 +5,11 @@ import Assertions from '../../../framework/Assertions'; import TestHelpers from '../../../helpers'; import FixtureBuilder from '../../../framework/fixtures/FixtureBuilder'; import { withFixtures } from '../../../framework/fixtures/FixtureHelper'; +import type { + UserKeyringState, + UserSnapState, + UserPermissionState, +} from '../../../framework/fixtures/types'; import NetworkManager from '../../../page-objects/wallet/NetworkManager'; import { CORE_USER_STATE, @@ -62,9 +67,9 @@ describe(SmokePerformance('Network List Load Testing'), () => { fixture: new FixtureBuilder() .withTokens(minimalTokens) .withPopularNetworks() - .withUserProfileKeyRing(userState) - .withUserProfileSnapUnencryptedState(userState) - .withUserProfileSnapPermissions(userState) + .withUserProfileKeyRing(userState as UserKeyringState) + .withUserProfileSnapUnencryptedState(userState as UserSnapState) + .withUserProfileSnapPermissions(userState as UserPermissionState) .build(), restartDevice: true, }, diff --git a/tests/smoke/performance/network-list/render-network-list.spec.ts b/tests/smoke/performance/network-list/render-network-list.spec.ts index a073b388568..959ac6a5ac0 100644 --- a/tests/smoke/performance/network-list/render-network-list.spec.ts +++ b/tests/smoke/performance/network-list/render-network-list.spec.ts @@ -5,6 +5,11 @@ import Assertions from '../../../framework/Assertions'; import TestHelpers from '../../../helpers'; import FixtureBuilder from '../../../framework/fixtures/FixtureBuilder'; import { withFixtures } from '../../../framework/fixtures/FixtureHelper'; +import type { + UserKeyringState, + UserSnapState, + UserPermissionState, +} from '../../../framework/fixtures/types'; import NetworkManager from '../../../page-objects/wallet/NetworkManager'; import { toChecksumAddress } from 'ethereumjs-util'; import { @@ -53,9 +58,9 @@ describe(SmokePerformance('Network List Load Testing'), () => { { fixture: new FixtureBuilder() .withPopularNetworks() - .withUserProfileKeyRing(userState) - .withUserProfileSnapUnencryptedState(userState) - .withUserProfileSnapPermissions(userState) + .withUserProfileKeyRing(userState as UserKeyringState) + .withUserProfileSnapUnencryptedState(userState as UserSnapState) + .withUserProfileSnapPermissions(userState as UserPermissionState) .build(), restartDevice: true, }, @@ -146,9 +151,9 @@ describe(SmokePerformance('Network List Load Testing'), () => { { fixture: new FixtureBuilder() .withPopularNetworks() - .withUserProfileKeyRing(userState) - .withUserProfileSnapUnencryptedState(userState) - .withUserProfileSnapPermissions(userState) + .withUserProfileKeyRing(userState as UserKeyringState) + .withUserProfileSnapUnencryptedState(userState as UserSnapState) + .withUserProfileSnapPermissions(userState as UserPermissionState) // eslint-disable-next-line @typescript-eslint/no-explicit-any .withTokensForAllPopularNetworks(heavyTokenLoad, userState as any) .build(), @@ -230,9 +235,9 @@ describe(SmokePerformance('Network List Load Testing'), () => { fixture: new FixtureBuilder() .withTokens(minimalTokens) .withPopularNetworks() - .withUserProfileKeyRing(userState) - .withUserProfileSnapUnencryptedState(userState) - .withUserProfileSnapPermissions(userState) + .withUserProfileKeyRing(userState as UserKeyringState) + .withUserProfileSnapUnencryptedState(userState as UserSnapState) + .withUserProfileSnapPermissions(userState as UserPermissionState) .build(), restartDevice: true, }, diff --git a/tests/smoke/perps/perps-add-funds.spec.ts b/tests/smoke/perps/perps-add-funds.spec.ts index d1c284efcd9..498ad6a3af0 100644 --- a/tests/smoke/perps/perps-add-funds.spec.ts +++ b/tests/smoke/perps/perps-add-funds.spec.ts @@ -37,13 +37,11 @@ describe(SmokePerps('Perps - Add funds (has funds, not first time)'), () => { .withPerpsFirstTimeUser(false) .withKeyringControllerOfMultipleAccounts() .withNetworkController({ - providerConfig: { - type: 'rpc', - chainId: '0xa4b1', - rpcUrl: 'https://arb1.arbitrum.io/rpc', - nickname: 'Arbitrum One', - ticker: 'ETH', - }, + type: 'rpc', + chainId: '0xa4b1', + rpcUrl: 'https://arb1.arbitrum.io/rpc', + nickname: 'Arbitrum One', + ticker: 'ETH', }) .withTokensForAllPopularNetworks([ { diff --git a/tests/smoke/ramps/onramp-unified-buy.spec.ts b/tests/smoke/ramps/onramp-unified-buy.spec.ts index 21dfe6bd988..0495141f788 100644 --- a/tests/smoke/ramps/onramp-unified-buy.spec.ts +++ b/tests/smoke/ramps/onramp-unified-buy.spec.ts @@ -92,7 +92,7 @@ describe(SmokeRamps('Onramp Unified Buy'), () => { await withFixtures( { fixture: new FixtureBuilder() - .withNetworkController(CustomNetworks.Tenderly.Mainnet) + .withNetworkController(CustomNetworks.Tenderly.Mainnet.providerConfig) .withRampsSelectedRegion(selectedRegion) .withMetaMetricsOptIn() .build(), @@ -165,7 +165,7 @@ describe(SmokeRamps('Onramp Unified Buy'), () => { await withFixtures( { fixture: new FixtureBuilder() - .withNetworkController(CustomNetworks.Tenderly.Mainnet) + .withNetworkController(CustomNetworks.Tenderly.Mainnet.providerConfig) .withRampsSelectedRegion(selectedRegion) .withMetaMetricsOptIn() .build(), diff --git a/tests/smoke/snaps/test-snap-name-lookup.spec.ts b/tests/smoke/snaps/test-snap-name-lookup.spec.ts index d63c0292ad3..3a4c6a7ea7a 100644 --- a/tests/smoke/snaps/test-snap-name-lookup.spec.ts +++ b/tests/smoke/snaps/test-snap-name-lookup.spec.ts @@ -27,13 +27,11 @@ describe(FlaskBuildTests('Name Lookup Snap Tests'), () => { return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: '0x1', - rpcUrl: `http://localhost:${node.getPort() ?? AnvilPort()}`, - type: 'custom', - nickname: 'Local RPC', - ticker: 'ETH', - }, + chainId: '0x1', + rpcUrl: `http://localhost:${node.getPort() ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', }) .build(); }, diff --git a/tests/smoke/stake/stake-action-smoke.spec.ts b/tests/smoke/stake/stake-action-smoke.spec.ts index 20632a33f5c..808062ee5b3 100644 --- a/tests/smoke/stake/stake-action-smoke.spec.ts +++ b/tests/smoke/stake/stake-action-smoke.spec.ts @@ -40,13 +40,11 @@ describe(SmokeTrade('Stake from Actions'), (): void => { return new FixtureBuilder() .withPolygon() .withNetworkController({ - providerConfig: { - chainId, - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Localhost', - ticker: 'ETH', - }, + chainId, + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Localhost', + ticker: 'ETH', }) .build(); }, diff --git a/tests/smoke/swap/bridge-action-smoke.spec.ts b/tests/smoke/swap/bridge-action-smoke.spec.ts index 38a6bb7fb9a..941321f17b9 100644 --- a/tests/smoke/swap/bridge-action-smoke.spec.ts +++ b/tests/smoke/swap/bridge-action-smoke.spec.ts @@ -50,13 +50,11 @@ describe(SmokeTrade('Bridge functionality'), () => { return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId, - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Localhost', - ticker: 'ETH', - }, + chainId, + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Localhost', + ticker: 'ETH', }) .withDisabledSmartTransactions() .build(); diff --git a/tests/smoke/swap/gasless-swap.spec.ts b/tests/smoke/swap/gasless-swap.spec.ts index 22317040f83..8271e72e37b 100644 --- a/tests/smoke/swap/gasless-swap.spec.ts +++ b/tests/smoke/swap/gasless-swap.spec.ts @@ -33,13 +33,11 @@ describe(SmokeTrade('Gasless Swap - '), (): void => { return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId, - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Localhost', - ticker: 'ETH', - }, + chainId, + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Localhost', + ticker: 'ETH', }) .withMetaMetricsOptIn() .build(); diff --git a/tests/smoke/swap/swap-action-smoke.spec.ts b/tests/smoke/swap/swap-action-smoke.spec.ts index 2cbfd3d3c3a..6158818b62b 100644 --- a/tests/smoke/swap/swap-action-smoke.spec.ts +++ b/tests/smoke/swap/swap-action-smoke.spec.ts @@ -53,13 +53,11 @@ describe.skip(SmokeTrade('Swap from Actions'), (): void => { { fixture: new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: '0x1', - rpcUrl: `http://localhost:${DEFAULT_ANVIL_PORT}`, - type: 'custom', - nickname: 'Localhost', - ticker: 'ETH', - }, + chainId: '0x1', + rpcUrl: `http://localhost:${DEFAULT_ANVIL_PORT}`, + type: 'custom', + nickname: 'Localhost', + ticker: 'ETH', }) .withDisabledSmartTransactions() .withMetaMetricsOptIn() diff --git a/tests/smoke/swap/swap-deeplink-smoke.spec.ts b/tests/smoke/swap/swap-deeplink-smoke.spec.ts index 6dcd7da4dfc..266e82c85be 100644 --- a/tests/smoke/swap/swap-deeplink-smoke.spec.ts +++ b/tests/smoke/swap/swap-deeplink-smoke.spec.ts @@ -39,13 +39,11 @@ describe( return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId, - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Localhost', - ticker: 'ETH', - }, + chainId, + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Localhost', + ticker: 'ETH', }) .withMetaMetricsOptIn() .build(); @@ -106,13 +104,11 @@ describe( return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId, - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Localhost', - ticker: 'ETH', - }, + chainId, + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Localhost', + ticker: 'ETH', }) .withMetaMetricsOptIn() .build(); @@ -168,13 +164,11 @@ describe( return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId, - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Localhost', - ticker: 'ETH', - }, + chainId, + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Localhost', + ticker: 'ETH', }) .withMetaMetricsOptIn() .build(); diff --git a/tests/smoke/swap/swap-trending-tokens.spec.ts b/tests/smoke/swap/swap-trending-tokens.spec.ts index f2b92d929b3..b019d94eea4 100644 --- a/tests/smoke/swap/swap-trending-tokens.spec.ts +++ b/tests/smoke/swap/swap-trending-tokens.spec.ts @@ -133,13 +133,11 @@ const withBridgeFixtures = async (run: () => Promise) => { return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: '0x1', - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Localhost', - ticker: 'ETH', - }, + chainId: '0x1', + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Localhost', + ticker: 'ETH', }) .withDisabledSmartTransactions() .build(); diff --git a/tests/smoke/wallet/helpers/musd-fixture.ts b/tests/smoke/wallet/helpers/musd-fixture.ts index 7f95644f107..6141e1c3605 100644 --- a/tests/smoke/wallet/helpers/musd-fixture.ts +++ b/tests/smoke/wallet/helpers/musd-fixture.ts @@ -65,13 +65,11 @@ export async function createMusdFixture( return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: CHAIN_IDS.MAINNET, - rpcUrl: `http://localhost:${rpcPort}`, - type: 'custom', - nickname: 'Ethereum Mainnet', - ticker: 'ETH', - }, + chainId: CHAIN_IDS.MAINNET, + rpcUrl: `http://localhost:${rpcPort}`, + type: 'custom', + nickname: 'Ethereum Mainnet', + ticker: 'ETH', }) .withNetworkEnabledMap({ eip155: { [CHAIN_IDS.MAINNET]: true } }) .withMetaMetricsOptIn() diff --git a/tests/smoke/wallet/incoming-transactions.spec.ts b/tests/smoke/wallet/incoming-transactions.spec.ts index 34c680f04a2..63c5128a855 100644 --- a/tests/smoke/wallet/incoming-transactions.spec.ts +++ b/tests/smoke/wallet/incoming-transactions.spec.ts @@ -9,6 +9,7 @@ import FixtureBuilder, { DEFAULT_FIXTURE_ACCOUNT, ENTROPY_WALLET_1_ID, } from '../../framework/fixtures/FixtureBuilder'; +import type { AccountTreeControllerState } from '../../framework/fixtures/types'; import TabBarComponent from '../../page-objects/wallet/TabBarComponent'; import ToastModal from '../../page-objects/wallet/ToastModal'; import { MockApiEndpoint, TestSpecificMock } from '../../framework/types'; @@ -130,7 +131,9 @@ describe(SmokeWalletPlatform('Incoming Transactions'), () => { await withFixtures( { fixture: new FixtureBuilder() - .withAccountTreeController(EVM_ONLY_ACCOUNT_TREE) + .withAccountTreeController( + EVM_ONLY_ACCOUNT_TREE as unknown as Partial, + ) .withNetworkEnabledMap({ eip155: { '0x1': true } }) .withPrivacyModePreferences(false) .build(), @@ -152,7 +155,9 @@ describe(SmokeWalletPlatform('Incoming Transactions'), () => { await withFixtures( { fixture: new FixtureBuilder() - .withAccountTreeController(EVM_ONLY_ACCOUNT_TREE) + .withAccountTreeController( + EVM_ONLY_ACCOUNT_TREE as unknown as Partial, + ) .withNetworkEnabledMap({ eip155: { '0x1': true } }) .withTokens([ { @@ -181,7 +186,9 @@ describe(SmokeWalletPlatform('Incoming Transactions'), () => { await withFixtures( { fixture: new FixtureBuilder() - .withAccountTreeController(EVM_ONLY_ACCOUNT_TREE) + .withAccountTreeController( + EVM_ONLY_ACCOUNT_TREE as unknown as Partial, + ) .withNetworkEnabledMap({ eip155: { '0x1': true } }) .withPrivacyModePreferences(false) .build(), @@ -203,7 +210,9 @@ describe(SmokeWalletPlatform('Incoming Transactions'), () => { await withFixtures( { fixture: new FixtureBuilder() - .withAccountTreeController(EVM_ONLY_ACCOUNT_TREE) + .withAccountTreeController( + EVM_ONLY_ACCOUNT_TREE as unknown as Partial, + ) .withNetworkEnabledMap({ eip155: { '0x1': true } }) .withPrivacyModePreferences(true) .build(), @@ -223,7 +232,9 @@ describe(SmokeWalletPlatform('Incoming Transactions'), () => { await withFixtures( { fixture: new FixtureBuilder() - .withAccountTreeController(EVM_ONLY_ACCOUNT_TREE) + .withAccountTreeController( + EVM_ONLY_ACCOUNT_TREE as unknown as Partial, + ) .withNetworkEnabledMap({ eip155: { '0x1': true } }) .withTransactions([ { @@ -253,7 +264,9 @@ describe(SmokeWalletPlatform('Incoming Transactions'), () => { await withFixtures( { fixture: new FixtureBuilder() - .withAccountTreeController(EVM_ONLY_ACCOUNT_TREE) + .withAccountTreeController( + EVM_ONLY_ACCOUNT_TREE as unknown as Partial, + ) .withNetworkEnabledMap({ eip155: { '0x1': true } }) .build(), restartDevice: true, diff --git a/tests/smoke/wallet/settings/addressbook-send-add-contact.spec.ts b/tests/smoke/wallet/settings/addressbook-send-add-contact.spec.ts index eafa1d426ba..06b9ff053ac 100644 --- a/tests/smoke/wallet/settings/addressbook-send-add-contact.spec.ts +++ b/tests/smoke/wallet/settings/addressbook-send-add-contact.spec.ts @@ -82,13 +82,11 @@ describe(RegressionWalletPlatform('Addressbook Tests'), () => { return new FixtureBuilder() .withNetworkController({ - providerConfig: { - chainId: '0x539', - rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, - type: 'custom', - nickname: 'Local RPC', - ticker: 'ETH', - }, + chainId: '0x539', + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', }) .withNetworkEnabledMap({ eip155: { '0x539': true }, From 099a0b60b3dab8e82212f077f3b22bb8ea79a5a4 Mon Sep 17 00:00:00 2001 From: Brian August Nguyen Date: Tue, 10 Mar 2026 11:49:30 -0700 Subject: [PATCH 02/11] refactor: removed width and height from ai icon (#27286) ## **Description** Removes fixed `width` and `height` from the AI icon SVG so it scales correctly with the icon component (e.g. at different sizes). 1. **Reason for the change:** The AI icon had `width="24"` and `height="24"` on the root ``, which can prevent proper scaling when the icon is used at other sizes; the icon component typically controls dimensions. 2. **Improvement:** Removed `width` and `height` from the SVG; kept `viewBox="0 0 24 24"` so aspect ratio is preserved and the icon scales with its container. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/DSYS-553 ## **Manual testing steps** ```gherkin Feature: AI icon scaling Scenario: AI icon scales with container Given the app is open When user navigates to a screen that displays the AI icon (e.g. Predict, or component library / Storybook) Then the AI icon is displayed with correct aspect ratio And the icon scales correctly at different sizes (e.g. small and large) ``` ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/ab9f9755-d420-453a-b7c5-21bfc4855454 ## **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** > Single-asset SVG attribute change with minimal blast radius; risk is limited to potential visual sizing differences for the `ai` icon. > > **Overview** > Updates the `ai` icon asset to drop hardcoded `width` and `height` on the root ``, relying on `viewBox="0 0 24 24"` for correct scaling at different rendered sizes. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit c068b529fae8436a7d1d8d0526de9715b5e283cc. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/component-library/components/Icons/Icon/assets/ai.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/component-library/components/Icons/Icon/assets/ai.svg b/app/component-library/components/Icons/Icon/assets/ai.svg index be69d0b22f5..4c3e04053f3 100644 --- a/app/component-library/components/Icons/Icon/assets/ai.svg +++ b/app/component-library/components/Icons/Icon/assets/ai.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file From 1e1e10c59d51570bf97e4361faa7140b968e2eac Mon Sep 17 00:00:00 2001 From: George Gkasdrogkas Date: Tue, 10 Mar 2026 22:03:44 +0200 Subject: [PATCH 03/11] fix: increase touchable area of select quotes entry (#27267) ## **Description** increase touchable area of select quotes entry ## **Changelog** CHANGELOG entry: increase touchable area of select quotes entry ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/SWAPS-4246 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **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] > **Low Risk** > Low risk UI behavior change confined to the Bridge quote details card; main risk is unintentionally preventing users from opening the price impact modal if thresholds/feature flags are misconfigured. > > **Overview** > Improves the Bridge `QuoteDetailsCard` interaction by making the *entire rate value + arrow* area tappable (larger touch target) to open the quote selector. > > Adds a `priceImpactIsSafe` check (using `selectBridgeFeatureFlags.priceImpactThreshold.warning` with a fallback to `AppConstants`) so pressing the price impact value only navigates to the price impact modal when impact is at/above the warning threshold; tests and snapshots are updated and a new test asserts no navigation below the threshold. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 7666e16e05d8abcf6398dd39f756ed54cc923a0f. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../QuoteDetailsCard.test.tsx | 39 ++++ .../QuoteDetailsCard/QuoteDetailsCard.tsx | 99 ++++++---- .../QuoteDetailsCard.test.tsx.snap | 184 ++++++++---------- 3 files changed, 178 insertions(+), 144 deletions(-) diff --git a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx index 62a7fee2fb7..2ae3c5ccef7 100644 --- a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx +++ b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx @@ -602,6 +602,45 @@ describe('QuoteDetailsCard', () => { }); }); + it('does not navigate when price impact is below warning threshold', () => { + // priceImpact 0.04 < warning threshold 0.05 → priceImpactIsSafe = true → no navigation + const mockModule = jest.requireMock('../../hooks/useBridgeQuoteData'); + mockModule.useBridgeQuoteData.mockImplementationOnce(() => ({ + quoteFetchError: null, + activeQuote: { + ...mockQuotes[0], + quote: { + ...mockQuotes[0].quote, + priceData: { ...mockQuotes[0].quote.priceData, priceImpact: '0.04' }, + gasIncluded: false, + gasIncluded7702: false, + }, + }, + destTokenAmount: '24.44', + isLoading: false, + formattedQuoteData: { + networkFee: '0.01', + estimatedTime: '1 min', + rate: '1 ETH = 24.4 USDC', + priceImpact: '0.04%', + slippage: '0.5%', + }, + })); + + const { getByTestId } = renderScreen( + QuoteDetailsCardTestScreen, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + fireEvent.press(getByTestId('price-impact-info-button')); + + expect(mockNavigate).not.toHaveBeenCalledWith(Routes.BRIDGE.MODALS.ROOT, { + screen: Routes.BRIDGE.MODALS.PRICE_IMPACT_MODAL, + params: expect.anything(), + }); + }); + it('opens rate tooltip modal when rate info icon is pressed', () => { const { getByLabelText } = renderScreen( QuoteDetailsCardTestScreen, diff --git a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx index 278a8cc6896..d04f7956846 100644 --- a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx +++ b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from 'react'; -import { TouchableOpacity, Platform, UIManager, Pressable } from 'react-native'; +import { TouchableOpacity, Platform, UIManager } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { strings } from '../../../../../../locales/i18n'; import { useTheme } from '../../../../../util/theme'; @@ -26,6 +26,7 @@ import { selectSourceAmount, selectDestToken, selectSourceToken, + selectBridgeFeatureFlags, } from '../../../../../core/redux/slices/bridge'; import { getNativeSourceToken } from '../../utils/tokenUtils'; import { formatMinimumReceived } from '../../utils/currencyUtils'; @@ -53,6 +54,7 @@ import { PriceImpactModalType } from '../PriceImpactModal/constants'; import { formatPriceImpact } from '../../utils/formatPriceImpact'; import KeyValueRowLabel from '../../../../../component-library/components-temp/KeyValueRow/KeyValueLabel/KeyValueLabel'; import { usePriceImpactViewData } from '../../hooks/usePriceImpactViewData'; +import AppConstants from '../../../../../core/AppConstants'; if ( Platform.OS === 'android' && @@ -65,6 +67,7 @@ const QuoteDetailsCard: React.FC = ({ hasInsufficientBalance, location, }) => { + const bridgeFeatureFlags = useSelector(selectBridgeFeatureFlags); const tw = useTailwind(); const theme = useTheme(); const navigation = useNavigation(); @@ -90,6 +93,13 @@ const QuoteDetailsCard: React.FC = ({ isQuoteLoading, }); + const priceImpactIsSafe = + !activeQuote?.quote.priceData?.priceImpact || + Number(activeQuote.quote.priceData.priceImpact) <= + // @ts-expect-error TODO: remove comment after changes to core are published. + (bridgeFeatureFlags?.priceImpactThreshold?.warning ?? + AppConstants.BRIDGE.PRICE_IMPACT_WARNING_THRESHOLD); + const nativeTokenName = useMemo(() => { const chainId = sourceToken?.chainId; if (!chainId) return undefined; @@ -117,6 +127,10 @@ const QuoteDetailsCard: React.FC = ({ }; const handlePriceImpactPress = () => { + if (priceImpactIsSafe) { + return; + } + navigation.navigate(Routes.BRIDGE.MODALS.ROOT, { screen: Routes.BRIDGE.MODALS.PRICE_IMPACT_MODAL, params: { @@ -133,7 +147,7 @@ const QuoteDetailsCard: React.FC = ({ activeQuote?.minToTokenAmount?.amount || '0', ); - const priceImactViewData = usePriceImpactViewData( + const priceImpactViewData = usePriceImpactViewData( activeQuote?.quote.priceData?.priceImpact, ); @@ -178,28 +192,33 @@ const QuoteDetailsCard: React.FC = ({ iconName: IconNameLegacy.Info, }} /> - - - {formattedQuoteData?.rate} - - - - - + + + {formattedQuoteData?.rate} + + + + {shouldShowGasSponsored ? ( = ({ }} value={{ label: ( - - - {priceImactViewData.icon && ( + {priceImpactViewData.icon && ( )} - - - {formatPriceImpact(formattedQuoteData.priceImpact)} - - + + {formatPriceImpact(formattedQuoteData.priceImpact)} + + + ), }} /> diff --git a/app/components/UI/Bridge/components/QuoteDetailsCard/__snapshots__/QuoteDetailsCard.test.tsx.snap b/app/components/UI/Bridge/components/QuoteDetailsCard/__snapshots__/QuoteDetailsCard.test.tsx.snap index bb672526750..f846b5cff78 100644 --- a/app/components/UI/Bridge/components/QuoteDetailsCard/__snapshots__/QuoteDetailsCard.test.tsx.snap +++ b/app/components/UI/Bridge/components/QuoteDetailsCard/__snapshots__/QuoteDetailsCard.test.tsx.snap @@ -454,95 +454,70 @@ exports[`QuoteDetailsCard renders initial state 1`] = ` /> - - - 1 ETH = 24.4 USDC - - - - - + > + + 1 ETH = 24.4 USDC + + + + - - - - 0% - - + + 0% + + + From b61f74ddbbc39548679bc59bf95b0b6227dcc4ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Tani=C3=A7a?= Date: Tue, 10 Mar 2026 14:05:01 -0600 Subject: [PATCH 04/11] fix(confirmations): use address from tx and handle empty claimable positions (#27164) ## **Description** `PredictClaimFooter` was internally reading the selected account address via `selectSelectedInternalAccountAddress` selector, which could be stale or mismatched with the actual transaction address. This change: 1. Accepts `address` as a prop so the parent passes the address directly from the transaction context, ensuring consistency. 2. Adds an `onError` callback prop that fires when the component encounters no won positions to claim, allowing the parent to handle the error appropriately instead of silently rendering nothing. 3. Removes the internal `selectSelectedInternalAccountAddress` selector dependency and the `?? '0x0'` fallback. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/7093 ## **Manual testing steps** ```gherkin Feature: Predict claim confirmation Scenario: user claims winning prediction positions Given user has winning prediction positions for their account When user opens the claim confirmation Then the claim footer displays the correct positions and claim button Scenario: user attempts to claim with no winning positions Given user has no winning prediction positions for the given address When user opens the claim confirmation Then the onError callback is invoked with an error message ``` ## **Screenshots/Recordings** ### **Before** N/A - logic-only change ### **After** N/A - logic-only 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 - [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 confirmation flow for `predictClaim` by changing how the claim footer derives the address and how errors trigger rejection, which could affect whether a user can confirm or gets auto-rejected in edge cases. > > **Overview** > Updates the `predictClaim` confirmation footer to derive the wallet address from the current transaction (`txParams.from`) instead of the selected account, avoiding mismatches when the selected account is stale. > > Adds an `onError` callback to `PredictClaimFooter` and triggers it (while rendering `null`) when there is no transaction address or no won/claimable positions; the parent `Footer` wires this to `onReject` so empty claims are handled explicitly. Tests are updated to cover the new `onError` behavior and the no-positions path. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 76bc72f54b704b006edfd4683c625333e753713c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../components/footer/footer.tsx | 2 +- .../predict-claim-footer.test.tsx | 50 ++++++++++++------- .../predict-claim-footer.tsx | 26 +++++++--- 3 files changed, 53 insertions(+), 25 deletions(-) diff --git a/app/components/Views/confirmations/components/footer/footer.tsx b/app/components/Views/confirmations/components/footer/footer.tsx index 0ca2bc7a656..046843e0d0c 100644 --- a/app/components/Views/confirmations/components/footer/footer.tsx +++ b/app/components/Views/confirmations/components/footer/footer.tsx @@ -194,7 +194,7 @@ export const Footer = () => { transactionMetadata && hasTransactionType(transactionMetadata, [TransactionType.predictClaim]) ) { - return ; + return ; } return ( diff --git a/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.test.tsx b/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.test.tsx index a9d819e3f0e..44717775388 100644 --- a/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.test.tsx +++ b/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.test.tsx @@ -9,12 +9,17 @@ import { otherControllersMock, } from '../../../__mocks__/controllers/other-controllers-mock'; import { strings } from '../../../../../../../locales/i18n'; -import { fireEvent } from '@testing-library/react-native'; +import { fireEvent, waitFor } from '@testing-library/react-native'; function render({ onPress, + onError, singlePosition, -}: { onPress?: () => void; singlePosition?: boolean } = {}) { +}: { + onPress?: () => void; + onError?: (error?: Error) => void; + singlePosition?: boolean; +} = {}) { const state = merge( {}, simpleSendTransactionControllerMock, @@ -31,9 +36,12 @@ function render({ }; } - return renderWithProvider(, { - state, - }); + return renderWithProvider( + , + { + state, + }, + ); } describe('PredictClaimFooter', () => { @@ -67,9 +75,10 @@ describe('PredictClaimFooter', () => { expect(onPressMock).toHaveBeenCalled(); }); - it('uses fallback address when selectedAddress is undefined', () => { - // Arrange - state with no selected account address - const stateWithNoAddress = merge( + it('calls onError when there are no won positions', async () => { + // Arrange - state with transaction from address that has no claimable positions + const onErrorMock = jest.fn(); + const state = merge( {}, simpleSendTransactionControllerMock, transactionApprovalControllerMock, @@ -77,10 +86,12 @@ describe('PredictClaimFooter', () => { { engine: { backgroundState: { - AccountsController: { - internalAccounts: { - selectedAccount: undefined, - }, + TransactionController: { + transactions: [ + { + txParams: { from: '0xunknown' }, + }, + ], }, }, }, @@ -88,13 +99,18 @@ describe('PredictClaimFooter', () => { ); // Act - const { getByTestId } = renderWithProvider( - , - { state: stateWithNoAddress }, + const { queryByTestId } = renderWithProvider( + , + { state }, ); - // Assert - component renders without crashing - expect(getByTestId('predict-claim-footer')).toBeDefined(); + // Assert - component returns null and calls onError + expect(queryByTestId('predict-claim-footer')).toBeNull(); + await waitFor(() => { + expect(onErrorMock).toHaveBeenCalledWith( + new Error('Tried to claim but no positions were won'), + ); + }); }); it('renders extra info for single win', () => { diff --git a/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.tsx b/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.tsx index 5ad13515ff6..ba6d30405f0 100644 --- a/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.tsx +++ b/app/components/Views/confirmations/components/predict-confirmations/predict-claim-footer/predict-claim-footer.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { useSelector } from 'react-redux'; import { strings } from '../../../../../../../locales/i18n'; import Avatar, { @@ -15,31 +15,43 @@ import { Box } from '../../../../../UI/Box/Box'; import { PredictClaimConfirmationSelectorsIDs } from '../../../../../UI/Predict/Predict.testIds'; import styleSheet from './predict-claim-footer.styles'; import { selectPredictWonPositions } from '../../../../../UI/Predict/selectors/predictController'; -import { selectSelectedInternalAccountAddress } from '../../../../../../selectors/accountsController'; import { PredictPosition } from '../../../../../UI/Predict'; import { AlignItems, FlexDirection } from '../../../../../UI/Box/box.types'; import useFiatFormatter from '../../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'; import { BigNumber } from 'bignumber.js'; import ButtonHero from '../../../../../../component-library/components-temp/Buttons/ButtonHero'; import { ButtonBaseSize } from '@metamask/design-system-react-native'; +import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; export interface PredictClaimFooterProps { onPress: () => void; + onError: (error?: Error) => void; } -export function PredictClaimFooter({ onPress }: PredictClaimFooterProps) { +export function PredictClaimFooter({ + onPress, + onError, +}: PredictClaimFooterProps) { + const transactionMetadata = useTransactionMetadataRequest(); const { styles } = useStyles(styleSheet, {}); - const selectedAddress = - useSelector(selectSelectedInternalAccountAddress) ?? '0x0'; + const address = transactionMetadata?.txParams.from; const wonPositions = useSelector( selectPredictWonPositions({ - address: selectedAddress, + address: address ?? '0x', }), ); - if (!wonPositions?.length) { + const hasNoPositions = !address || !wonPositions?.length; + + useEffect(() => { + if (hasNoPositions) { + onError(new Error('Tried to claim but no positions were won')); + } + }, [hasNoPositions, onError]); + + if (hasNoPositions) { return null; } From ce040e3f98ddb0a83c627f602493a29711665d93 Mon Sep 17 00:00:00 2001 From: Yu-Gi-Oh! <54774811+imyugioh@users.noreply.github.com> Date: Wed, 11 Mar 2026 06:02:20 +0900 Subject: [PATCH 05/11] feat(ramps): Navigate back to token list when closing token not available modal (#27277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fixes the "Token not available with provider" modal so that pressing the close (`X`) button navigates the user back to the token selection screen instead of leaving them in a stuck state. Previously, closing the modal did nothing — the bottom sheet dismissed but no navigation occurred, leaving the user on a blank/unresponsive input screen. Now the close button behaves the same as the "Change token" button, sending the user back to the token list. ## **Changelog** CHANGELOG entry: Fixed a bug where closing the "Token not available" modal left the user in a stuck state instead of navigating back to the token selection screen. ## **Related issues** Refs: [TRAM-3329](https://consensyssoftware.atlassian.net/browse/TRAM-3329) ## **Manual testing steps** ```gherkin Feature: Token not available modal close behavior Scenario: Close button navigates back to token selection Given the user is in the Buy flow with a region set to US (e.g. Texas) And a provider is selected that does not support the chosen token (e.g. Ramp Network + UNI) When the "Not available" modal appears And the user presses the close (X) button Then the user is navigated back to the token selection screen And the token list is displayed correctly Scenario: Change token button still works Given the "Not available" modal is displayed When the user presses the "Change token" button Then the user is navigated back to the token selection screen Scenario: Change provider button still works Given the "Not available" modal is displayed When the user presses the "Change provider" button Then the provider selection modal is displayed ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/ff13e927-65e8-424f-9124-fbcd6a25e9a9 ### **After** https://github.com/user-attachments/assets/33d6767b-2596-461f-afa1-d8d3f7c8c357 https://github.com/user-attachments/assets/c4325a29-920e-4a12-9c8d-a3840505accd https://github.com/user-attachments/assets/64a6d24e-26f9-4612-9b89-edd78fb7c488 ## **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 - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] 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** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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** > Small UI navigation change scoped to a single modal, with updated test coverage and no sensitive data/auth logic involved. > > **Overview** > Fixes the `TokenNotAvailableModal` close (X) behavior so it now triggers the same flow as *Change token*: the bottom sheet closes and navigation returns to `Routes.RAMP.TOKEN_SELECTION` instead of simply dismissing. > > Updates the unit test to assert navigation to token selection on close, preventing regressions of the previously “stuck” state. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 89ca0e904a931ec0e6a9c7e3d13c6be6a73fa762. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../TokenNotAvailableModal.test.tsx | 30 +++++++++++++++++-- .../TokenNotAvailableModal.tsx | 17 +++++++---- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.test.tsx b/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.test.tsx index b2f21fb7b7d..a57443f4a32 100644 --- a/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.test.tsx +++ b/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.test.tsx @@ -53,6 +53,8 @@ const mockOnCloseBottomSheet = jest.fn((callback?: () => void) => { if (callback) callback(); }); +let capturedOnClose: ((hasPendingAction?: boolean) => void) | undefined; + jest.mock( '../../../../../../component-library/components/BottomSheets/BottomSheet', () => { @@ -61,11 +63,14 @@ jest.mock( ( { children, + onClose, }: { children: React.ReactNode; + onClose?: (hasPendingAction?: boolean) => void; }, ref: React.Ref<{ onCloseBottomSheet: (cb?: () => void) => void }>, ) => { + capturedOnClose = onClose; ReactActual.useImperativeHandle(ref, () => ({ onCloseBottomSheet: mockOnCloseBottomSheet, })); @@ -138,13 +143,34 @@ describe('TokenNotAvailableModal', () => { ); }); - it('closes the modal when the close button is pressed', () => { + it('navigates to token selection when the close button is pressed', () => { const { getByTestId } = render(TokenNotAvailableModal); const closeButton = getByTestId('bottomsheetheader-close-button'); fireEvent.press(closeButton); - expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1); + expect(mockOnCloseBottomSheet).toHaveBeenCalledWith(expect.any(Function)); + expect(mockNavigate).toHaveBeenCalledWith(Routes.RAMP.TOKEN_SELECTION, { + screen: Routes.RAMP.TOKEN_SELECTION, + }); + }); + + it('navigates to token selection when modal is dismissed without a pending action', () => { + render(TokenNotAvailableModal); + + capturedOnClose?.(false); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.RAMP.TOKEN_SELECTION, { + screen: Routes.RAMP.TOKEN_SELECTION, + }); + }); + + it('does not navigate on dismiss when there is a pending action', () => { + render(TokenNotAvailableModal); + + capturedOnClose?.(true); + + expect(mockNavigate).not.toHaveBeenCalled(); }); it('matches snapshot with missing provider and token names', () => { diff --git a/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.tsx b/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.tsx index 5f972a1222f..b7f39c491bf 100644 --- a/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.tsx +++ b/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.tsx @@ -83,19 +83,26 @@ function TokenNotAvailableModal() { createEventBuilder, ]); - const handleClose = useCallback(() => { - sheetRef.current?.onCloseBottomSheet(); - }, []); + const handleDismiss = useCallback( + (hasPendingAction?: boolean) => { + if (!hasPendingAction) { + navigation.navigate(Routes.RAMP.TOKEN_SELECTION, { + screen: Routes.RAMP.TOKEN_SELECTION, + }); + } + }, + [navigation], + ); return ( From 1e25a396a2357df40b4d1d3ef6b84b77433013bd Mon Sep 17 00:00:00 2001 From: Alexey Kureev Date: Tue, 10 Mar 2026 22:28:04 +0100 Subject: [PATCH 06/11] refactor(predict): migrate usePredictMarket to React Query (#26866) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Replace manual `useState`/`useEffect`/`useRef` data-fetching in `usePredictMarket` with React Query's `useQuery`, backed by a new `predictMarketOptions` query factory. **Why:** The existing implementation manually manages loading, error, and mounted-ref state which is error-prone and duplicates logic that React Query handles out of the box (caching, deduplication, stale-while-revalidate, automatic cleanup on unmount). **What changed:** - New `queries/market.ts` — query key factory and `queryOptions` for fetching a single market - `queries/index.ts` — registered `market` entry in the `predictQueries` object - `usePredictMarket.tsx` — replaced ~100 lines of manual state management with a single `useQuery` call - `usePredictMarket.test.tsx` — updated tests to use `QueryClientProvider` wrapper and `@testing-library/react-native` APIs (`waitFor` instead of `waitForNextUpdate`) ## **Changelog** ## **Related issues** ## **Manual testing steps** ```gherkin Feature: Predict market detail loading Scenario: user navigates to a market detail screen Given user is on the Predict markets list When user taps on a market card Then market detail screen loads with market data And loading indicator is shown while fetching And error state is shown if the fetch fails ``` ## **Screenshots/Recordings** N/A — no UI changes, internal refactor only. ### **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 - [ ] 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** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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** > Changes the data-fetching mechanism and hook return shape for market details, which can affect loading/error states and caching behavior across Predict screens despite being largely a refactor with updated test coverage. > > **Overview** > Migrates `usePredictMarket` from manual `useState`/`useEffect` fetching to React Query’s `useQuery`, introducing a new `predictQueries.market` query factory (`queries/market.ts`) with stable keys and a 10s `staleTime`. > > Updates Predict UI consumers (`PredictMarketSportCardWrapper`, `PredictMarketDetails`, and `PredictMarketDetailsActions`) and related tests to use React Query return fields (`data`, `isLoading`, `error`) and adjust loading/skeleton conditions accordingly, including minor test expectation tweaks (e.g., date formatting and async waits). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit c42adc2b3af708625a7803fee494d0f47060f3b7. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../PredictMarketSportCardWrapper.test.tsx | 64 +-- .../PredictMarketSportCardWrapper.tsx | 12 +- .../Predict/hooks/usePredictMarket.test.tsx | 490 +++++++----------- .../UI/Predict/hooks/usePredictMarket.tsx | 146 ++---- app/components/UI/Predict/queries/index.ts | 5 + app/components/UI/Predict/queries/market.ts | 25 + .../PredictMarketDetails.test.tsx | 19 +- .../PredictMarketDetails.tsx | 26 +- .../PredictMarketDetails.view.test.tsx | 8 +- .../PredictMarketDetailsActions.test.tsx | 6 +- .../PredictMarketDetailsActions.tsx | 6 +- 11 files changed, 312 insertions(+), 495 deletions(-) create mode 100644 app/components/UI/Predict/queries/market.ts diff --git a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCardWrapper.test.tsx b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCardWrapper.test.tsx index 296207ebf47..d5c6ef23660 100644 --- a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCardWrapper.test.tsx +++ b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCardWrapper.test.tsx @@ -129,11 +129,11 @@ describe('PredictMarketSportCardWrapper', () => { beforeEach(() => { jest.clearAllMocks(); mockUsePredictMarket.mockReturnValue({ - market: null, - isFetching: false, + data: null, + isLoading: false, error: null, refetch: jest.fn(), - }); + } as unknown as ReturnType); }); afterEach(() => { @@ -143,11 +143,11 @@ describe('PredictMarketSportCardWrapper', () => { describe('loading state', () => { it('returns null when fetching market data', () => { mockUsePredictMarket.mockReturnValue({ - market: null, - isFetching: true, + data: null, + isLoading: true, error: null, refetch: jest.fn(), - }); + } as unknown as ReturnType); const { toJSON } = renderWithProvider( , @@ -161,11 +161,11 @@ describe('PredictMarketSportCardWrapper', () => { describe('error state', () => { it('returns null when error occurs', () => { mockUsePredictMarket.mockReturnValue({ - market: null, - isFetching: false, - error: 'Failed to fetch market', + data: null, + isLoading: false, + error: new Error('Failed to fetch market'), refetch: jest.fn(), - }); + } as unknown as ReturnType); const { toJSON } = renderWithProvider( , @@ -179,11 +179,11 @@ describe('PredictMarketSportCardWrapper', () => { describe('no market data', () => { it('returns null when market is null', () => { mockUsePredictMarket.mockReturnValue({ - market: null, - isFetching: false, + data: null, + isLoading: false, error: null, refetch: jest.fn(), - }); + } as unknown as ReturnType); const { toJSON } = renderWithProvider( , @@ -197,11 +197,11 @@ describe('PredictMarketSportCardWrapper', () => { describe('successful render', () => { beforeEach(() => { mockUsePredictMarket.mockReturnValue({ - market: mockMarket, - isFetching: false, + data: mockMarket, + isLoading: false, error: null, refetch: jest.fn(), - }); + } as unknown as ReturnType); }); it('renders PredictMarketSportCard when market data is available', () => { @@ -334,11 +334,11 @@ describe('PredictMarketSportCardWrapper', () => { it('calls onLoad when market data is available', () => { const mockOnLoad = jest.fn(); mockUsePredictMarket.mockReturnValue({ - market: mockMarket, - isFetching: false, + data: mockMarket, + isLoading: false, error: null, refetch: jest.fn(), - }); + } as unknown as ReturnType); renderWithProvider( { it('does not call onLoad when fetching', () => { const mockOnLoad = jest.fn(); mockUsePredictMarket.mockReturnValue({ - market: null, - isFetching: true, + data: null, + isLoading: true, error: null, refetch: jest.fn(), - }); + } as unknown as ReturnType); renderWithProvider( { it('does not call onLoad when error occurs', () => { const mockOnLoad = jest.fn(); mockUsePredictMarket.mockReturnValue({ - market: null, - isFetching: false, - error: 'Failed to fetch', + data: null, + isLoading: false, + error: new Error('Failed to fetch'), refetch: jest.fn(), - }); + } as unknown as ReturnType); renderWithProvider( { it('does not call onLoad when market is null', () => { const mockOnLoad = jest.fn(); mockUsePredictMarket.mockReturnValue({ - market: null, - isFetching: false, + data: null, + isLoading: false, error: null, refetch: jest.fn(), - }); + } as unknown as ReturnType); renderWithProvider( { it('does not call onLoad when onLoad is not provided', () => { mockUsePredictMarket.mockReturnValue({ - market: mockMarket, - isFetching: false, + data: mockMarket, + isLoading: false, error: null, refetch: jest.fn(), - }); + } as unknown as ReturnType); renderWithProvider( , diff --git a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCardWrapper.tsx b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCardWrapper.tsx index 0b84ef2db27..5310e63cc3c 100644 --- a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCardWrapper.tsx +++ b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCardWrapper.tsx @@ -15,20 +15,24 @@ interface PredictMarketSportCardWrapperProps { const PredictMarketSportCardWrapper: React.FC< PredictMarketSportCardWrapperProps > = ({ marketId, testID, entryPoint, onDismiss, onLoad }) => { - const { market, isFetching, error } = usePredictMarket({ + const { + data: market, + isLoading, + error, + } = usePredictMarket({ id: marketId, enabled: Boolean(marketId), }); const hasCalledOnLoad = useRef(false); useEffect(() => { - if (!isFetching && !error && market && onLoad && !hasCalledOnLoad.current) { + if (!isLoading && !error && market && onLoad && !hasCalledOnLoad.current) { hasCalledOnLoad.current = true; onLoad(); } - }, [isFetching, error, market, onLoad]); + }, [isLoading, error, market, onLoad]); - if (isFetching || error || !market) { + if (isLoading || error || !market) { return null; } diff --git a/app/components/UI/Predict/hooks/usePredictMarket.test.tsx b/app/components/UI/Predict/hooks/usePredictMarket.test.tsx index a9a6088e671..2002060db02 100644 --- a/app/components/UI/Predict/hooks/usePredictMarket.test.tsx +++ b/app/components/UI/Predict/hooks/usePredictMarket.test.tsx @@ -1,245 +1,211 @@ -import { renderHook, act } from '@testing-library/react-hooks'; -import Engine from '../../../../core/Engine'; +import React from 'react'; +import { renderHook, waitFor, act } from '@testing-library/react-native'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { usePredictMarket } from './usePredictMarket'; import { PredictMarket, Recurrence } from '../types'; - import { POLYMARKET_PROVIDER_ID } from '../providers/polymarket/constants'; -// Mock dependencies + +jest.mock('../../../../util/Logger', () => ({ + __esModule: true, + default: { + error: jest.fn(), + }, +})); + +const mockGetMarket = jest.fn(); jest.mock('../../../../core/Engine', () => ({ context: { PredictController: { - getMarket: jest.fn(), + getMarket: (...args: unknown[]) => mockGetMarket(...args), }, }, })); -describe('usePredictMarket', () => { - const mockGetMarket = jest.fn(); - - const mockMarket: PredictMarket = { - id: 'market-1', - providerId: POLYMARKET_PROVIDER_ID, - slug: 'bitcoin-price-prediction', - title: 'Will Bitcoin reach $200k by end of 2025?', - description: 'Bitcoin price prediction market', - endDate: '2025-12-31T23:59:59Z', - image: 'https://example.com/btc.png', - status: 'open', - recurrence: Recurrence.NONE, - category: 'crypto', - tags: ['trending'], - outcomes: [ - { - id: 'outcome-1', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-1', - title: 'Yes', - description: 'Bitcoin will reach $200k', - image: '', - status: 'open', - tokens: [ - { - id: 'token-1', - title: 'Yes', - price: 0.65, - }, - ], - volume: 1000000, - groupItemTitle: 'Yes/No', - }, - { - id: 'outcome-2', - providerId: POLYMARKET_PROVIDER_ID, - marketId: 'market-1', - title: 'No', - description: 'Bitcoin will not reach $200k', - image: '', - status: 'open', - tokens: [ - { - id: 'token-2', - title: 'No', - price: 0.35, - }, - ], - volume: 1000000, - groupItemTitle: 'Yes/No', - }, - ], - liquidity: 1000000, - volume: 1000000, - }; +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const Wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + return { Wrapper, queryClient }; +}; + +const mockMarket: PredictMarket = { + id: 'market-1', + providerId: POLYMARKET_PROVIDER_ID, + slug: 'bitcoin-price-prediction', + title: 'Will Bitcoin reach $200k by end of 2025?', + description: 'Bitcoin price prediction market', + endDate: '2025-12-31T23:59:59Z', + image: 'https://example.com/btc.png', + status: 'open', + recurrence: Recurrence.NONE, + category: 'crypto', + tags: ['trending'], + outcomes: [ + { + id: 'outcome-1', + providerId: POLYMARKET_PROVIDER_ID, + marketId: 'market-1', + title: 'Yes', + description: 'Bitcoin will reach $200k', + image: '', + status: 'open', + tokens: [ + { + id: 'token-1', + title: 'Yes', + price: 0.65, + }, + ], + volume: 1000000, + groupItemTitle: 'Yes/No', + }, + { + id: 'outcome-2', + providerId: POLYMARKET_PROVIDER_ID, + marketId: 'market-1', + title: 'No', + description: 'Bitcoin will not reach $200k', + image: '', + status: 'open', + tokens: [ + { + id: 'token-2', + title: 'No', + price: 0.35, + }, + ], + volume: 1000000, + groupItemTitle: 'Yes/No', + }, + ], + liquidity: 1000000, + volume: 1000000, +}; +describe('usePredictMarket', () => { beforeEach(() => { jest.clearAllMocks(); - (Engine.context.PredictController.getMarket as jest.Mock) = mockGetMarket; }); afterEach(() => { - jest.clearAllMocks(); + jest.restoreAllMocks(); }); describe('initial state', () => { - it('returns null market and not fetching when no id provided', () => { - const { result } = renderHook(() => usePredictMarket()); - - expect(result.current.market).toBe(null); - expect(result.current.isFetching).toBe(false); - expect(result.current.error).toBe(null); - expect(typeof result.current.refetch).toBe('function'); - }); - - it('returns null market and not fetching when id is undefined', () => { - const { result } = renderHook(() => usePredictMarket({ id: undefined })); - - expect(result.current.market).toBe(null); - expect(result.current.isFetching).toBe(false); - expect(result.current.error).toBe(null); - }); - - it('returns null market and not fetching when id is empty string', () => { - const { result } = renderHook(() => usePredictMarket({ id: '' })); + it('does not fetch when id is empty string', () => { + const { Wrapper } = createWrapper(); + const { result } = renderHook(() => usePredictMarket({ id: '' }), { + wrapper: Wrapper, + }); - expect(result.current.market).toBe(null); + expect(result.current.data).toBeUndefined(); expect(result.current.isFetching).toBe(false); expect(result.current.error).toBe(null); }); }); describe('successful market fetching', () => { - it('fetches market data successfully with string id', async () => { + it('returns market data when given a valid id', async () => { + const { Wrapper } = createWrapper(); mockGetMarket.mockResolvedValue(mockMarket); - const { result, waitForNextUpdate } = renderHook(() => - usePredictMarket({ id: 'market-1' }), + const { result } = renderHook( + () => usePredictMarket({ id: 'market-1' }), + { wrapper: Wrapper }, ); - // Initially loading + // Initially fetching expect(result.current.isFetching).toBe(true); - expect(result.current.market).toBe(null); - expect(result.current.error).toBe(null); - - // Wait for data to load - await waitForNextUpdate(); - - expect(result.current.isFetching).toBe(false); - expect(result.current.market).toEqual(mockMarket); + expect(result.current.data).toBeUndefined(); expect(result.current.error).toBe(null); - expect(mockGetMarket).toHaveBeenCalledWith({ - marketId: 'market-1', - providerId: undefined, - }); - }); - - it('fetches market data successfully with number id', async () => { - mockGetMarket.mockResolvedValue(mockMarket); - - const { result, waitForNextUpdate } = renderHook(() => - usePredictMarket({ id: 123 }), - ); - - await waitForNextUpdate(); - expect(result.current.market).toEqual(mockMarket); - expect(mockGetMarket).toHaveBeenCalledWith({ - marketId: '123', + await waitFor(() => { + expect(result.current.isFetching).toBe(false); }); - }); - - it('fetches market data with string id', async () => { - mockGetMarket.mockResolvedValue(mockMarket); - - const { result, waitForNextUpdate } = renderHook(() => - usePredictMarket({ id: 'market-1' }), - ); - - await waitForNextUpdate(); - expect(result.current.market).toEqual(mockMarket); + expect(result.current.data).toEqual(mockMarket); + expect(result.current.error).toBe(null); expect(mockGetMarket).toHaveBeenCalledWith({ marketId: 'market-1', }); }); - it('handles null market response', async () => { + it('returns null data when API responds with null', async () => { + const { Wrapper } = createWrapper(); mockGetMarket.mockResolvedValue(null); - const { result, waitForNextUpdate } = renderHook(() => - usePredictMarket({ id: 'market-1' }), + const { result } = renderHook( + () => usePredictMarket({ id: 'market-1' }), + { wrapper: Wrapper }, ); - await waitForNextUpdate(); + await waitFor(() => { + expect(result.current.isFetching).toBe(false); + }); - expect(result.current.isFetching).toBe(false); - expect(result.current.market).toBe(null); + expect(result.current.data).toBe(null); expect(result.current.error).toBe(null); }); }); describe('error handling', () => { - it('handles API error with Error instance', async () => { + it('exposes Error instance when API rejects with Error', async () => { + const { Wrapper } = createWrapper(); const errorMessage = 'Network error occurred'; mockGetMarket.mockRejectedValue(new Error(errorMessage)); - const { result, waitForNextUpdate } = renderHook(() => - usePredictMarket({ id: 'market-1' }), + const { result } = renderHook( + () => usePredictMarket({ id: 'market-1' }), + { wrapper: Wrapper }, ); - await waitForNextUpdate(); + await waitFor(() => { + expect(result.current.isFetching).toBe(false); + }); - expect(result.current.isFetching).toBe(false); - expect(result.current.market).toBe(null); - expect(result.current.error).toBe(errorMessage); + expect(result.current.data).toBeUndefined(); + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error?.message).toBe(errorMessage); }); - it('handles API error with non-Error instance', async () => { + it('exposes raw value when API rejects with non-Error', async () => { + const { Wrapper } = createWrapper(); mockGetMarket.mockRejectedValue('String error'); - const { result, waitForNextUpdate } = renderHook(() => - usePredictMarket({ id: 'market-1' }), + const { result } = renderHook( + () => usePredictMarket({ id: 'market-1' }), + { wrapper: Wrapper }, ); - await waitForNextUpdate(); + await waitFor(() => { + expect(result.current.isFetching).toBe(false); + }); - expect(result.current.isFetching).toBe(false); - expect(result.current.market).toBe(null); - expect(result.current.error).toBe('Failed to fetch market'); + expect(result.current.data).toBeUndefined(); + expect(result.current.error).toBe('String error'); }); }); describe('enabled option', () => { it('does not fetch when enabled is false', () => { - renderHook(() => usePredictMarket({ id: 'market-1', enabled: false })); + const { Wrapper } = createWrapper(); + renderHook(() => usePredictMarket({ id: 'market-1', enabled: false }), { + wrapper: Wrapper, + }); expect(mockGetMarket).not.toHaveBeenCalled(); }); - it('clears state when disabled after being enabled', async () => { - mockGetMarket.mockResolvedValue(mockMarket); - - const { result, rerender, waitForNextUpdate } = renderHook( - ({ enabled }) => usePredictMarket({ id: 'market-1', enabled }), - { initialProps: { enabled: true } }, - ); - - await waitForNextUpdate(); - - expect(result.current.market).toEqual(mockMarket); - - // Disable the hook - rerender({ enabled: false }); - - expect(result.current.market).toBe(null); - expect(result.current.error).toBe(null); - expect(result.current.isFetching).toBe(false); - }); - it('fetches when enabled changes from false to true', async () => { + const { Wrapper } = createWrapper(); mockGetMarket.mockResolvedValue(mockMarket); - const { result, rerender, waitForNextUpdate } = renderHook( - ({ enabled }) => usePredictMarket({ id: 'market-1', enabled }), - { initialProps: { enabled: false } }, + const { result, rerender } = renderHook( + ({ enabled }: { enabled: boolean }) => + usePredictMarket({ id: 'market-1', enabled }), + { wrapper: Wrapper, initialProps: { enabled: false } }, ); expect(mockGetMarket).not.toHaveBeenCalled(); @@ -247,9 +213,11 @@ describe('usePredictMarket', () => { // Enable the hook rerender({ enabled: true }); - await waitForNextUpdate(); + await waitFor(() => { + expect(result.current.isFetching).toBe(false); + }); - expect(result.current.market).toEqual(mockMarket); + expect(result.current.data).toEqual(mockMarket); expect(mockGetMarket).toHaveBeenCalledWith({ marketId: 'market-1', }); @@ -258,13 +226,17 @@ describe('usePredictMarket', () => { describe('refetch functionality', () => { it('refetches data when calling refetch', async () => { + const { Wrapper } = createWrapper(); mockGetMarket.mockResolvedValue(mockMarket); - const { result, waitForNextUpdate } = renderHook(() => - usePredictMarket({ id: 'market-1' }), + const { result } = renderHook( + () => usePredictMarket({ id: 'market-1' }), + { wrapper: Wrapper }, ); - await waitForNextUpdate(); + await waitFor(() => { + expect(result.current.isFetching).toBe(false); + }); expect(mockGetMarket).toHaveBeenCalledTimes(1); @@ -276,69 +248,46 @@ describe('usePredictMarket', () => { expect(mockGetMarket).toHaveBeenCalledTimes(2); }); - it('maintains stable refetch function reference', () => { + it('maintains a callable refetch function across rerenders', async () => { + const { Wrapper } = createWrapper(); mockGetMarket.mockResolvedValue(mockMarket); - const { result, rerender } = renderHook(() => - usePredictMarket({ id: 'market-1' }), + const { result, rerender } = renderHook( + () => usePredictMarket({ id: 'market-1' }), + { wrapper: Wrapper }, ); - const firstRefetch = result.current.refetch; + await waitFor(() => { + expect(result.current.isFetching).toBe(false); + }); // Trigger a re-render - rerender(); + rerender({}); - expect(result.current.refetch).toBe(firstRefetch); - }); - - it('does not refetch when disabled', async () => { - const { result } = renderHook(() => - usePredictMarket({ id: 'market-1', enabled: false }), - ); + expect(typeof result.current.refetch).toBe('function'); + // Ensure refetch still works after rerender await act(async () => { await result.current.refetch(); }); - expect(mockGetMarket).not.toHaveBeenCalled(); + expect(mockGetMarket).toHaveBeenCalledTimes(2); }); }); describe('dependency changes', () => { it('refetches when id changes', async () => { + const { Wrapper } = createWrapper(); mockGetMarket.mockResolvedValue(mockMarket); - const { rerender, waitForNextUpdate } = renderHook( - ({ id }) => usePredictMarket({ id }), - { initialProps: { id: 'market-1' } }, + const { result, rerender } = renderHook( + ({ id }: { id: string }) => usePredictMarket({ id }), + { wrapper: Wrapper, initialProps: { id: 'market-1' } }, ); - await waitForNextUpdate(); - - expect(mockGetMarket).toHaveBeenCalledWith({ - marketId: 'market-1', - }); - - // Change id - rerender({ id: 'market-2' }); - - await waitForNextUpdate(); - - expect(mockGetMarket).toHaveBeenCalledWith({ - marketId: 'market-2', + await waitFor(() => { + expect(result.current.isFetching).toBe(false); }); - expect(mockGetMarket).toHaveBeenCalledTimes(2); - }); - - it('refetches when id changes across rerenders', async () => { - mockGetMarket.mockResolvedValue(mockMarket); - - const { rerender, waitForNextUpdate } = renderHook( - ({ id }) => usePredictMarket({ id }), - { initialProps: { id: 'market-1' } }, - ); - - await waitForNextUpdate(); expect(mockGetMarket).toHaveBeenCalledWith({ marketId: 'market-1', @@ -347,7 +296,9 @@ describe('usePredictMarket', () => { // Change id rerender({ id: 'market-2' }); - await waitForNextUpdate(); + await waitFor(() => { + expect(result.current.isFetching).toBe(false); + }); expect(mockGetMarket).toHaveBeenCalledWith({ marketId: 'market-2', @@ -356,119 +307,24 @@ describe('usePredictMarket', () => { }); }); - describe('component unmounting', () => { - it('does not update state after component unmounts', async () => { - mockGetMarket.mockImplementation( - () => - new Promise((resolve) => { - setTimeout(() => resolve(mockMarket), 100); - }), - ); - - const { result, unmount } = renderHook(() => - usePredictMarket({ id: 'market-1' }), - ); - - // Start the fetch - expect(result.current.isFetching).toBe(true); - - // Unmount before fetch completes - unmount(); - - // Wait for the promise to resolve - await new Promise((resolve) => setTimeout(resolve, 150)); - - // The hook should not have updated state after unmount - // We can't test this directly since the hook is unmounted, - // but we can verify the mock was called - expect(mockGetMarket).toHaveBeenCalled(); - }); - }); - - describe('edge cases', () => { - it('handles id conversion from number to string', async () => { - mockGetMarket.mockResolvedValue(mockMarket); - - const { waitForNextUpdate } = renderHook(() => - usePredictMarket({ id: 0 }), - ); - - await waitForNextUpdate(); - - expect(mockGetMarket).toHaveBeenCalledWith({ - marketId: '0', - }); - }); - - it('handles id conversion from negative number to string', async () => { - mockGetMarket.mockResolvedValue(mockMarket); - - const { waitForNextUpdate } = renderHook(() => - usePredictMarket({ id: -1 }), - ); - - await waitForNextUpdate(); - - expect(mockGetMarket).toHaveBeenCalledWith({ - marketId: '-1', - }); - }); - - it('handles multiple rapid id changes', async () => { - mockGetMarket.mockResolvedValue(mockMarket); - - const { rerender } = renderHook(({ id }) => usePredictMarket({ id }), { - initialProps: { id: 'market-1' }, - }); - - // Rapidly change id multiple times - rerender({ id: 'market-2' }); - rerender({ id: 'market-3' }); - rerender({ id: 'market-4' }); - - // Wait for all promises to settle - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - // Should have been called for each change - expect(mockGetMarket).toHaveBeenCalledTimes(4); - }); - }); - - describe('integration with controller', () => { + describe('integration', () => { it('calls getMarket with correct parameters', async () => { + const { Wrapper } = createWrapper(); mockGetMarket.mockResolvedValue(mockMarket); - const { waitForNextUpdate } = renderHook(() => - usePredictMarket({ - id: 'test-market-id', - }), + const { result } = renderHook( + () => usePredictMarket({ id: 'test-market-id' }), + { wrapper: Wrapper }, ); - await waitForNextUpdate(); + await waitFor(() => { + expect(result.current.isFetching).toBe(false); + }); expect(mockGetMarket).toHaveBeenCalledWith({ marketId: 'test-market-id', }); expect(mockGetMarket).toHaveBeenCalledTimes(1); }); - - it('handles controller method throwing synchronously', async () => { - mockGetMarket.mockImplementation(() => { - throw new Error('Synchronous error'); - }); - - const { result } = renderHook(() => usePredictMarket({ id: 'market-1' })); - - // Wait a bit for the error to be processed - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - - expect(result.current.isFetching).toBe(false); - expect(result.current.market).toBe(null); - expect(result.current.error).toBe('Synchronous error'); - }); }); }); diff --git a/app/components/UI/Predict/hooks/usePredictMarket.tsx b/app/components/UI/Predict/hooks/usePredictMarket.tsx index 849f8990dc0..063b4c121aa 100644 --- a/app/components/UI/Predict/hooks/usePredictMarket.tsx +++ b/app/components/UI/Predict/hooks/usePredictMarket.tsx @@ -1,126 +1,44 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import Engine from '../../../../core/Engine'; +import { useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; import Logger from '../../../../util/Logger'; import { PREDICT_CONSTANTS } from '../constants/errors'; import { ensureError } from '../utils/predictErrorHandler'; -import { PredictMarket } from '../types'; - -export interface UsePredictMarketOptions { - id?: string | number; - enabled?: boolean; -} - -export interface UsePredictMarketResult { - market: PredictMarket | null; - isFetching: boolean; - error: string | null; - refetch: () => Promise; -} +import { predictQueries } from '../queries'; /** * Hook to fetch detailed Predict market information */ -export const usePredictMarket = ( - options: UsePredictMarketOptions = {}, -): UsePredictMarketResult => { - const { id, enabled = true } = options; - const [market, setMarket] = useState(null); - const [isFetching, setIsFetching] = useState(false); - const [error, setError] = useState(null); - - const isMountedRef = useRef(true); - useEffect( - () => () => { - isMountedRef.current = false; - }, - [], - ); +export const usePredictMarket = ({ + id, + enabled = true, +}: { + id: string; + enabled?: boolean; +}) => { + const query = useQuery({ + ...predictQueries.market.options({ marketId: id }), + enabled: enabled && !!id, + }); useEffect(() => { - if (!enabled && isMountedRef.current) { - setMarket(null); - setError(null); - setIsFetching(false); - } - }, [enabled]); - - const fetchMarket = useCallback(async () => { - if (!enabled) { - return; - } - - const marketId = id !== undefined && id !== null ? String(id) : ''; - if (!marketId) { - if (isMountedRef.current) { - setMarket(null); - setError(null); - setIsFetching(false); - } - return; - } - - if (isMountedRef.current) { - setIsFetching(true); - setError(null); - } - - try { - if (!Engine || !Engine.context) { - throw new Error('Engine not initialized'); - } - - const controller = Engine.context.PredictController; - if (!controller) { - throw new Error('Predict controller not available'); - } - - const marketData = await controller.getMarket({ - marketId, - }); - - if (isMountedRef.current) { - setMarket(marketData ?? null); - } - } catch (err) { - const errorMessage = - err instanceof Error ? err.message : 'Failed to fetch market'; - - // Capture exception with market loading context - Logger.error(ensureError(err), { - tags: { - feature: PREDICT_CONSTANTS.FEATURE_NAME, - component: 'usePredictMarket', + if (!query.error) return; + + Logger.error(ensureError(query.error), { + tags: { + feature: PREDICT_CONSTANTS.FEATURE_NAME, + component: 'usePredictMarket', + }, + context: { + name: 'usePredictMarket', + data: { + method: 'queryFn', + action: 'market_load', + operation: 'data_fetching', + marketId: id, }, - context: { - name: 'usePredictMarket', - data: { - method: 'loadMarket', - action: 'market_load', - operation: 'data_fetching', - marketId: id, - }, - }, - }); - - if (isMountedRef.current) { - setError(errorMessage); - setMarket(null); - } - } finally { - if (isMountedRef.current) { - setIsFetching(false); - } - } - }, [enabled, id]); - - useEffect(() => { - fetchMarket(); - }, [fetchMarket]); + }, + }); + }, [query.error, id]); - return { - market, - isFetching, - error, - refetch: fetchMarket, - }; + return query; }; diff --git a/app/components/UI/Predict/queries/index.ts b/app/components/UI/Predict/queries/index.ts index 541f7618040..490802e6a86 100644 --- a/app/components/UI/Predict/queries/index.ts +++ b/app/components/UI/Predict/queries/index.ts @@ -1,5 +1,6 @@ import { predictActivityKeys, predictActivityOptions } from './activity'; import { predictBalanceKeys, predictBalanceOptions } from './balance'; +import { predictMarketKeys, predictMarketOptions } from './market'; import { predictOrderPreviewKeys, predictOrderPreviewOptions, @@ -19,6 +20,10 @@ export const predictQueries = { keys: predictBalanceKeys, options: predictBalanceOptions, }, + market: { + keys: predictMarketKeys, + options: predictMarketOptions, + }, orderPreview: { keys: predictOrderPreviewKeys, options: predictOrderPreviewOptions, diff --git a/app/components/UI/Predict/queries/market.ts b/app/components/UI/Predict/queries/market.ts new file mode 100644 index 00000000000..6df69c9239a --- /dev/null +++ b/app/components/UI/Predict/queries/market.ts @@ -0,0 +1,25 @@ +import { queryOptions } from '@tanstack/react-query'; +import Engine from '../../../../core/Engine'; +import type { PredictMarket } from '../types'; + +/** + * Query key factory for Predict single-market queries. + * + * - `all()` — prefix key for invalidating every market entry at once. + * - `detail(marketId)` — unique key for a specific market. + */ +export const predictMarketKeys = { + all: () => ['predict', 'market'] as const, + detail: (marketId: string) => [...predictMarketKeys.all(), marketId] as const, +}; + +export const predictMarketOptions = ({ marketId }: { marketId: string }) => + queryOptions({ + queryKey: predictMarketKeys.detail(marketId), + queryFn: async (): Promise => { + const controller = Engine.context.PredictController; + const marketData = await controller.getMarket({ marketId }); + return marketData ?? null; + }, + staleTime: 10_000, + }); diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx index eb3528509d7..bc0f90f0dcc 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx @@ -183,7 +183,8 @@ jest.mock('../../utils/format', () => ({ jest.mock('../../hooks/usePredictMarket', () => ({ usePredictMarket: jest.fn(() => ({ - market: null, + data: null, + isLoading: false, isFetching: false, refetch: jest.fn(), })), @@ -528,7 +529,8 @@ function setupPredictMarketDetailsTest( }); usePredictMarket.mockReturnValue({ - market: mockMarket, + data: mockMarket, + isLoading: false, isFetching: false, refetch: jest.fn(), ...hookOverrides.market, @@ -710,7 +712,7 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest( {}, {}, - { market: { isFetching: true, market: null } }, + { market: { isLoading: true, isFetching: true, data: null } }, ); // Check that skeleton loaders appear @@ -732,7 +734,7 @@ describe('PredictMarketDetails', () => { }); it('displays fallback title when market data is unavailable', () => { - setupPredictMarketDetailsTest({}, {}, { market: { market: null } }); + setupPredictMarketDetailsTest({}, {}, { market: { data: null } }); // Screen renders without a title; other sections may still show loading keys expect( @@ -772,7 +774,7 @@ describe('PredictMarketDetails', () => { setupPredictMarketDetailsTest( {}, {}, - { market: { isFetching: true, market: null } }, + { market: { isLoading: true, isFetching: true, data: null } }, ); expect( @@ -811,7 +813,9 @@ describe('PredictMarketDetails', () => { expect( screen.getByText('predict.market_details.end_date'), ).toBeOnTheScreen(); - expect(screen.getByText('12/31/2024')).toBeOnTheScreen(); + expect( + screen.getByText(new Date('2024-12-31T23:59:59Z').toLocaleDateString()), + ).toBeOnTheScreen(); }); it('displays resolution details information', () => { @@ -3499,7 +3503,8 @@ describe('PredictMarketDetails', () => { }); usePredictMarket.mockReturnValue({ - market: marketWithoutWaivedTag, + data: marketWithoutWaivedTag, + isLoading: false, isFetching: false, refetch: jest.fn(), }); diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx index 0c54e356e5d..3333bf0c62f 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx @@ -76,13 +76,15 @@ const PredictMarketDetails: React.FC = () => { }); const { - market, + data: marketData, + isLoading: isMarketLoading, isFetching: isMarketFetching, refetch: refetchMarket, } = usePredictMarket({ - id: resolvedMarketId, + id: resolvedMarketId ?? '', enabled: Boolean(resolvedMarketId), }); + const market = marketData ?? null; // Track screen load performance (market details + chart) usePredictMeasurement({ @@ -97,11 +99,11 @@ const PredictMarketDetails: React.FC = () => { // calculate sticky header indices based on content structure const stickyHeaderIndices = useMemo(() => { - if (isMarketFetching && !market) { + if (isMarketLoading) { return []; } return [1]; - }, [isMarketFetching, market]); + }, [isMarketLoading]); const titleLineCount = useMemo( () => estimateLineCount(title ?? market?.title), @@ -116,7 +118,7 @@ const PredictMarketDetails: React.FC = () => { } = usePredictPositions({ marketId: resolvedMarketId, claimable: false, - enabled: !isMarketFetching && Boolean(resolvedMarketId), + enabled: !isMarketLoading && Boolean(resolvedMarketId), }); // "claimable" positions @@ -127,7 +129,7 @@ const PredictMarketDetails: React.FC = () => { } = usePredictPositions({ marketId: resolvedMarketId, claimable: true, - enabled: !isMarketFetching && Boolean(resolvedMarketId), + enabled: !isMarketLoading && Boolean(resolvedMarketId), }); const feeCollectionConfig = useSelector(selectPredictFeeCollectionFlag); @@ -139,10 +141,10 @@ const PredictMarketDetails: React.FC = () => { // Tabs become ready when both market and positions queries have resolved const tabsReady = useMemo( () => - !isMarketFetching && + !isMarketLoading && !isActivePositionsLoading && !isClaimablePositionsLoading, - [isMarketFetching, isActivePositionsLoading, isClaimablePositionsLoading], + [isMarketLoading, isActivePositionsLoading, isClaimablePositionsLoading], ); const { @@ -362,7 +364,7 @@ const PredictMarketDetails: React.FC = () => { > = () => { {/* Show content skeleton while initial market data is fetching */} - {isMarketFetching && !market ? ( + {isMarketLoading ? ( @@ -423,7 +425,7 @@ const PredictMarketDetails: React.FC = () => { )} {/* Tab content - only show when market is loaded */} - {!isMarketFetching && market && ( + {!isMarketLoading && market && ( = () => { hasPositivePnl={hasPositivePnl} marketStatus={market?.status as PredictMarketStatus | undefined} singleOutcomeMarket={singleOutcomeMarket} - isMarketFetching={isMarketFetching} + isMarketLoading={isMarketLoading} market={market} openOutcomes={openOutcomes} yesPercentage={yesPercentage} diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.view.test.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.view.test.tsx index 272da826523..c66e46f96c1 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.view.test.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.view.test.tsx @@ -52,9 +52,11 @@ describe('PredictMarketDetails', () => { const screen = await findByTestId( PredictMarketDetailsSelectorsIDs.SCREEN, ); - expect( - within(screen).getByText(MOCK_PREDICT_MARKET.title), - ).toBeOnTheScreen(); + await waitFor(() => { + expect( + within(screen).getByText(MOCK_PREDICT_MARKET.title), + ).toBeOnTheScreen(); + }); expect(await findByText(/Yes.*¢/)).toBeOnTheScreen(); expect(await findByText(/No.*¢/)).toBeOnTheScreen(); diff --git a/app/components/UI/Predict/views/PredictMarketDetails/components/PredictMarketDetailsActions/PredictMarketDetailsActions.test.tsx b/app/components/UI/Predict/views/PredictMarketDetails/components/PredictMarketDetailsActions/PredictMarketDetailsActions.test.tsx index c5168678e78..af915968fef 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/components/PredictMarketDetailsActions/PredictMarketDetailsActions.test.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/components/PredictMarketDetailsActions/PredictMarketDetailsActions.test.tsx @@ -54,7 +54,7 @@ const createProps = ( hasPositivePnl: false, marketStatus: PredictMarketStatus.OPEN, singleOutcomeMarket: true, - isMarketFetching: false, + isMarketLoading: false, market: createMarket(), openOutcomes: [createOutcome()], yesPercentage: 65, @@ -130,7 +130,7 @@ describe('PredictMarketDetailsActions', () => { it('renders skeleton while market details are loading', () => { const props = createProps({ - isMarketFetching: true, + isMarketLoading: true, market: null, marketStatus: PredictMarketStatus.CLOSED, singleOutcomeMarket: false, @@ -149,7 +149,7 @@ describe('PredictMarketDetailsActions', () => { singleOutcomeMarket: false, hasPositivePnl: false, isClaimablePositionsLoading: false, - isMarketFetching: false, + isMarketLoading: false, }); renderWithProvider(); diff --git a/app/components/UI/Predict/views/PredictMarketDetails/components/PredictMarketDetailsActions/PredictMarketDetailsActions.tsx b/app/components/UI/Predict/views/PredictMarketDetails/components/PredictMarketDetailsActions/PredictMarketDetailsActions.tsx index 55914f7a6b7..4a7bc6d246c 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/components/PredictMarketDetailsActions/PredictMarketDetailsActions.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/components/PredictMarketDetailsActions/PredictMarketDetailsActions.tsx @@ -28,7 +28,7 @@ export interface PredictMarketDetailsActionsProps { hasPositivePnl: boolean; marketStatus: PredictMarketStatus | undefined; singleOutcomeMarket: boolean; - isMarketFetching: boolean; + isMarketLoading: boolean; market: PredictMarket | null; openOutcomes: PredictOutcome[]; yesPercentage: number; @@ -43,7 +43,7 @@ const PredictMarketDetailsActions = memo( hasPositivePnl, marketStatus, singleOutcomeMarket, - isMarketFetching, + isMarketLoading, market, openOutcomes, yesPercentage, @@ -125,7 +125,7 @@ const PredictMarketDetailsActions = memo( } // Show skeleton buttons while loading - if (isMarketFetching && !market) { + if (isMarketLoading) { return ; } From c22e4737d80fc699a84a9452eefd2a7123a97de2 Mon Sep 17 00:00:00 2001 From: infiniteflower <139582705+infiniteflower@users.noreply.github.com> Date: Wed, 11 Mar 2026 06:46:55 +0900 Subject: [PATCH 07/11] fix: swaps network selector not scrolling on android cp-7.69.0 (#27295) ## **Description** The "Select Network" bottom sheet in the Bridge token selector was not scrollable on Android. Tested on both iOS and Android. ## **Changelog** CHANGELOG entry: Fixed the Bridge "Select Network" bottom sheet not being scrollable when many networks are available on Android. ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/SWAPS-4252 Fixes: https://github.com/MetaMask/metamask-mobile/issues/27290 ## **Manual testing steps** ``` Feature: Bridge token selector network filter Scenario: user scrolls the network list in the Select Network bottom sheet Given the user is on the Bridge screen And many networks are available When user taps the network filter pill above the token list Then the Select Network bottom sheet opens When user attempts to scroll the network list Then the list scrolls smoothly without dismissing the sheet When user taps a network Then the sheet closes and the token list is filtered to that network ``` ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/006154c0-b0a3-482d-8c23-be4cfbb37085 ## **Pre-merge author checklist** - [ ] 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). - [ ] 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 - [ ] 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** > Small UI gesture-handling change limited to the Bridge network selector; no auth, security, or data-flow logic is modified. > > **Overview** > Fixes the Bridge token selector "Select Network" bottom sheet not scrolling on Android by switching the network list container to `ScrollView` from `react-native-gesture-handler`, which correctly handles scroll gestures inside the bottom sheet. > > Removes the unused `BridgeNetworkSelectorBase` component that previously wrapped children in a bottom sheet + `react-native` `ScrollView`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d3b24db18f455ec0017454d3a0a5669f8ea0e437. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../components/BridgeNetworkSelectorBase.tsx | 33 ------------------- .../BridgeTokenSelector/NetworkListModal.tsx | 2 +- 2 files changed, 1 insertion(+), 34 deletions(-) delete mode 100644 app/components/UI/Bridge/components/BridgeNetworkSelectorBase.tsx diff --git a/app/components/UI/Bridge/components/BridgeNetworkSelectorBase.tsx b/app/components/UI/Bridge/components/BridgeNetworkSelectorBase.tsx deleted file mode 100644 index c270c93c6b8..00000000000 --- a/app/components/UI/Bridge/components/BridgeNetworkSelectorBase.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import { ScrollView } from 'react-native'; - -import BottomSheetHeader from '../../../../component-library/components/BottomSheets/BottomSheetHeader'; -import BottomSheet from '../../../../component-library/components/BottomSheets/BottomSheet'; -import { strings } from '../../../../../locales/i18n'; - -import { useNavigation } from '@react-navigation/native'; - -interface BridgeNetworkSelectorBaseProps { - children: React.ReactNode; -} - -export const BridgeNetworkSelectorBase: React.FC< - BridgeNetworkSelectorBaseProps -> = ({ children }) => { - const navigation = useNavigation(); - - return ( - - navigation.goBack()} - closeButtonProps={{ - testID: 'bridge-network-selector-close-button', - }} - > - {strings('bridge.select_network')} - - - {children} - - ); -}; diff --git a/app/components/UI/Bridge/components/BridgeTokenSelector/NetworkListModal.tsx b/app/components/UI/Bridge/components/BridgeTokenSelector/NetworkListModal.tsx index 474a8048cf9..7509ecb510a 100644 --- a/app/components/UI/Bridge/components/BridgeTokenSelector/NetworkListModal.tsx +++ b/app/components/UI/Bridge/components/BridgeTokenSelector/NetworkListModal.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useRef } from 'react'; -import { ScrollView } from 'react-native'; +import { ScrollView } from 'react-native-gesture-handler'; // Must use this to make sure scroll works inside a bottom sheet on Android import { useSelector, useDispatch } from 'react-redux'; import { Icon, IconName, IconSize } from '@metamask/design-system-react-native'; import { IconName as ComponentLibraryIconName } from '../../../../../component-library/components/Icons/Icon'; From 05e25df35ab88de5adb95629481a7cea20129661 Mon Sep 17 00:00:00 2001 From: Yu-Gi-Oh! <54774811+imyugioh@users.noreply.github.com> Date: Wed, 11 Mar 2026 06:51:07 +0900 Subject: [PATCH 08/11] fix(ramp): Change provide clickable in loading (#27288) ## **Description** Fixes the "Change provider" link in the payment selection modal so it remains clickable while payment methods are loading. Previously, the "Change provider" text was rendered as plain, non-interactive text during the loading state (same grey color as surrounding text, no `onPress` handler). Now it stays styled as a tappable link and navigates to the provider selection screen even while payment methods are still loading. The link is still disabled when there is a payment method error. ## **Changelog** CHANGELOG entry: Fixed a bug where the "Change provider" link in the payment selection modal was not clickable while payment methods were loading. ## **Related issues** Refs: [TRAM-3291](https://consensyssoftware.atlassian.net/browse/TRAM-3291) ## **Manual testing steps** ```gherkin Feature: Change provider link clickable during loading Scenario: Change provider link is clickable while payment methods are loading Given the user is in the Buy flow And the user opens the "Pay with" payment selection modal And payment methods are still loading (skeleton placeholders visible) When the user taps the "Change provider" link at the bottom Then the provider selection modal is displayed Scenario: Change provider link is still clickable after payment methods load Given the payment selection modal is displayed And payment methods have finished loading When the user taps the "Change provider" link Then the provider selection modal is displayed Scenario: Change provider link is disabled on payment method error Given the payment selection modal is displayed And payment methods failed to load with an error When the user views the "Change provider" text Then it appears as non-interactive grey text ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/34a72fca-3899-4b94-a489-b214aa0233c9 ### **After** https://github.com/user-attachments/assets/6aca24e1-351f-45ed-8d8e-3c736d0ae00a ## **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 - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] 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** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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** > Small UI behavior change gated to a modal link; limited impact and covered by an updated unit test. > > **Overview** > Fixes the `PaymentSelectionModal` footer so **"Change provider" remains a tappable link while payment methods are loading**, and is only disabled/styled as non-interactive when `paymentMethodsError` is present. > > Updates the modal test to expect navigation to `RampProviderSelectionModal` even during the loading state (and ensures route `amount` is provided in that scenario). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 84a1450417b47ee72422d63bbd2813d58d39fa3c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../PaymentSelectionModal/PaymentSelectionModal.test.tsx | 7 ++++--- .../Modals/PaymentSelectionModal/PaymentSelectionModal.tsx | 6 ++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.test.tsx b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.test.tsx index 1eff1cfc055..1c8bb9c31e0 100644 --- a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.test.tsx +++ b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.test.tsx @@ -257,7 +257,7 @@ describe('PaymentSelectionModal', () => { }); }); - it('does not navigate to provider selection when change provider is pressed and payment methods are loading', async () => { + it('navigates to provider selection when change provider is pressed while payment methods are loading', () => { const loadingState = { ...defaultControllerReturn, selectedProvider: mockSelectedProvider, @@ -266,13 +266,14 @@ describe('PaymentSelectionModal', () => { selectedPaymentMethod: null, }; mockUseRampsController.mockImplementation(() => loadingState); + mockUseParams.mockReturnValue({ amount: 100 }); const { getByText } = renderWithProvider(PaymentSelectionModal); const changeProviderLink = getByText('fiat_on_ramp.change_provider'); fireEvent.press(changeProviderLink); - await waitFor(() => { - expect(getByText('fiat_on_ramp.pay_with')).toBeOnTheScreen(); + expect(mockNavigate).toHaveBeenCalledWith('RampProviderSelectionModal', { + amount: 100, }); }); diff --git a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.tsx b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.tsx index f283c5fba98..8a1faf7af30 100644 --- a/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.tsx +++ b/app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.tsx @@ -268,14 +268,12 @@ function PaymentSelectionModal() { {strings('fiat_on_ramp.change_provider')} From 63bd6d1c26c23b0499182021163314cb6024ae77 Mon Sep 17 00:00:00 2001 From: Yu-Gi-Oh! <54774811+imyugioh@users.noreply.github.com> Date: Wed, 11 Mar 2026 07:01:07 +0900 Subject: [PATCH 09/11] fix(ramp): Navigate to token list on close unavailable modal (#27297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fixes the "Token not available with provider" modal so that pressing the close (`X`) button navigates the user back to the token selection screen instead of leaving them in a stuck state. Previously, closing the modal did nothing — the bottom sheet dismissed but no navigation occurred, leaving the user on a blank/unresponsive input screen. Now the close button behaves the same as the "Change token" button, sending the user back to the token list. ## **Changelog** CHANGELOG entry: Fixed a bug where closing the "Token not available" modal left the user in a stuck state instead of navigating back to the token selection screen. ## **Related issues** Refs: [TRAM-3329](https://consensyssoftware.atlassian.net/browse/TRAM-3329) ## **Manual testing steps** ```gherkin Feature: Token not available modal close behavior Scenario: Dismiss provider selection returns to token selection Given the "Not available" modal is displayed And the user pressed "Change provider" When the user taps the deposit screen behind the provider selection modal Then the token selection screen is displayed Scenario: Dismiss token not available modal returns to token selection Given the "Not available" modal is displayed When the user taps the deposit screen behind the modal Then the token selection screen is displayed ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/ff13e927-65e8-424f-9124-fbcd6a25e9a9 ### **After** https://github.com/user-attachments/assets/199f4980-ce25-4873-ac68-e547c8d5950c ## **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 - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] 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** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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** > Small, isolated navigation change on modal dismissal with added test coverage; minimal impact outside the ramp modal flow. > > **Overview** > Fixes `ProviderSelectionModal` so dismissing the bottom sheet (e.g., tapping outside/close) triggers navigation back to `Routes.RAMP.TOKEN_SELECTION` when the modal is shown in `skipQuotes` mode (i.e., no pending post-action). > > Updates tests to mock `navigate`, capture the `BottomSheet` `onClose` callback, and assert navigation occurs only for `skipQuotes: true` and not for the normal quotes flow. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 69669e2aff582a91bffdd6a99f47e3bc9345f31c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../ProviderSelectionModal.test.tsx | 42 +++++++++++++++++-- .../ProviderSelectionModal.tsx | 13 +++++- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.test.tsx b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.test.tsx index a1c9c1d0c61..e970e514222 100644 --- a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.test.tsx +++ b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.test.tsx @@ -29,9 +29,11 @@ const mockNavigationState = { stale: false as const, }; +const mockNavigate = jest.fn(); + jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), - useNavigation: () => ({ goBack: mockGoBack, navigate: jest.fn() }), + useNavigation: () => ({ goBack: mockGoBack, navigate: mockNavigate }), useNavigationState: ( selector: (state: typeof mockNavigationState) => unknown, ) => selector(mockNavigationState), @@ -125,15 +127,26 @@ jest.mock('../../../hooks/useRampAccountAddress', () => ({ default: () => '0x123', })); +let capturedOnClose: ((hasPendingAction?: boolean) => void) | undefined; + jest.mock( '../../../../../../component-library/components/BottomSheets/BottomSheet', () => { const ReactActual = jest.requireActual('react'); return ReactActual.forwardRef( ( - { children }: { children: React.ReactNode }, + { + children, + onClose, + }: { + children: React.ReactNode; + onClose?: (hasPendingAction?: boolean) => void; + }, _ref: React.Ref, - ) => <>{children}, + ) => { + capturedOnClose = onClose; + return <>{children}; + }, ); }, ); @@ -261,4 +274,27 @@ describe('ProviderSelectionModal', () => { expect(getByText('MoonPay')).toBeOnTheScreen(); expect(queryByText('Other')).toBeNull(); }); + + it('navigates to token selection when dismissed without action and skipQuotes is true', () => { + mockUseParams.mockReturnValue({ + assetId: 'eip155:1/slip44:60', + skipQuotes: true, + }); + renderWithProvider(ProviderSelectionModal); + + capturedOnClose?.(false); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.RAMP.TOKEN_SELECTION, { + screen: Routes.RAMP.TOKEN_SELECTION, + }); + }); + + it('does not navigate to token selection when dismissed without action and skipQuotes is false', () => { + mockUseParams.mockReturnValue({ amount: 100 }); + renderWithProvider(ProviderSelectionModal); + + capturedOnClose?.(false); + + expect(mockNavigate).not.toHaveBeenCalled(); + }); }); diff --git a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.tsx b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.tsx index f6260452ae6..b6ef67590ed 100644 --- a/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.tsx +++ b/app/components/UI/Ramp/Views/Modals/ProviderSelectionModal/ProviderSelectionModal.tsx @@ -122,6 +122,17 @@ function ProviderSelectionModal() { error: quotesError, } = useRampsQuotes(quoteFetchParams); + const handleDismiss = useCallback( + (hasPendingAction?: boolean) => { + if (!hasPendingAction && skipQuotes) { + navigation.navigate(Routes.RAMP.TOKEN_SELECTION, { + screen: Routes.RAMP.TOKEN_SELECTION, + }); + } + }, + [navigation, skipQuotes], + ); + const handleBack = useCallback(() => { navigation.goBack(); }, [navigation]); @@ -151,7 +162,7 @@ function ProviderSelectionModal() { ); return ( - + Date: Wed, 11 Mar 2026 00:06:29 +0100 Subject: [PATCH 10/11] fix: try fixing X deeplinks by adding [GE-139] cp-7.69.0 (#27139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fix branch deeplinks not working with the X (Twitter) app by adding a `$deeplink_path` param in branch.io and consuming it in MetaMask Mobile. ### Context - Platform: happens only on iOS. Android behaves differently (no Deepview in our flow). - User taps a t.co link in X (Twitter) → redirects to https://metamask.app.link/1WkF6GmE40b (which is a link generated by X/Branch.io on Tweet posting) → Branch Deepview is shown → user taps “Get the app” → opens MetaMask via Universal Link: https://metamask-alternate.app.link/1WkF6GmE40b?__branch_flow_type=viewapp&__branch_flow_id=...&__branch_mobile_deepview_type=1. - Issue: The app opens correctly, but in-app we show a “page not found” (404). So the OS and Branch open the app, but we don’t know which in-app route corresponds to link ID 1WkF6GmE40b. ### What we found 1. No resolved URI from the SDK - The Branch iOS SDK (and react-native-branch) give us: - The raw URL that opened the app (e.g. https://metamask-alternate.app.link/1WkF6GmE40b?...), and - The params from the session (e.g. from getLatestReferringParams() / subscribe callback). - The SDK does not build a custom deeplink URI (e.g. metamask://swap) from link data; it only returns the params. So we have to do routing ourselves. 2. Implemented fix: - Added a $deeplink_path in Branch deeplinks (this must be added to every existing and future deeplinks that we want to post on X) - Read it from the params and build our deeplink ourselves (e.g. metamask://${params.$deeplink_path}) and then route inside the app. ## **Changelog** CHANGELOG entry: Fixed a bug that was causing deeplinks opened from X app to fail ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/GE-139 Fixes: https://github.com/MetaMask/metamask-mobile/issues/27140 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] 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). - [ ] 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 - [ ] 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 deeplink routing/host allowlists and Branch handling on app start, which can affect navigation from external links, but the change is narrowly scoped and covered by new unit tests. > > **Overview** > Fixes Branch short-link universal links (notably from X on iOS) by introducing `rewriteBranchUri`, which converts `metamask-alternate.app.link/` URLs into `https://link.metamask.io/<$deeplink_path>` while preserving query params. > > `DeeplinkManager.start()` now applies this rewrite for both cold-start Branch params (`~referring_link`) and `branch.subscribe` events, replacing the prior `getLatestReferringParams` fallback logic. > > Adds `MM_UNIVERSAL_LINK_HOST_ALTERNATE` (`metamask-alternate.app.link`) and includes it in universal-link host validation (`handleUniversalLink`) and MetaMask-host detection (`util/deeplinks`), with new tests covering rewrite and routing behavior. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit de4e3dc9341c4c0e69a6efa98f34690ff92bfb7a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/core/AppConstants.ts | 1 + .../DeeplinkManager/DeeplinkManager.test.ts | 123 +++++++++++++++++- app/core/DeeplinkManager/DeeplinkManager.ts | 49 ++++++- .../handlers/legacy/handleUniversalLink.ts | 2 + .../DeeplinkManager/util/deeplinks/index.ts | 2 + 5 files changed, 170 insertions(+), 7 deletions(-) diff --git a/app/core/AppConstants.ts b/app/core/AppConstants.ts index 595ba62d267..744f23ba4f9 100644 --- a/app/core/AppConstants.ts +++ b/app/core/AppConstants.ts @@ -95,6 +95,7 @@ export default { }, }, MM_UNIVERSAL_LINK_HOST: 'metamask.app.link', + MM_UNIVERSAL_LINK_HOST_ALTERNATE: 'metamask-alternate.app.link', MM_IO_UNIVERSAL_LINK_HOST: 'link.metamask.io', MM_IO_UNIVERSAL_LINK_TEST_HOST: 'link-test.metamask.io', MM_DEEP_ITMS_APP_LINK: 'https://metamask.app.link/skAH3BaF99', diff --git a/app/core/DeeplinkManager/DeeplinkManager.test.ts b/app/core/DeeplinkManager/DeeplinkManager.test.ts index 5aa1055957f..f77f417a66f 100644 --- a/app/core/DeeplinkManager/DeeplinkManager.test.ts +++ b/app/core/DeeplinkManager/DeeplinkManager.test.ts @@ -2,7 +2,11 @@ import { NavigationProp, ParamListBase } from '@react-navigation/native'; import { waitFor } from '@testing-library/react-native'; import FCMService from '../../util/notifications/services/FCMService'; import NavigationService from '../NavigationService'; -import SharedDeeplinkManager, { DeeplinkManager } from './DeeplinkManager'; +import SharedDeeplinkManager, { + DeeplinkManager, + rewriteBranchUri, +} from './DeeplinkManager'; +import type { BranchParams } from './types/deepLinkAnalytics.types'; import { handleDeeplink } from './handlers/legacy/handleDeeplink'; import switchNetwork from '../../util/networks/switchNetwork'; import parseDeeplink from './utils/parseDeeplink'; @@ -282,6 +286,34 @@ describe('SharedDeeplinkManager', () => { }); }); +describe('rewriteBranchUri', () => { + it('rewrites host and path to link.metamask.io and preserves query when +clicked_branch_link and $deeplink_path are set', () => { + const uri = + 'https://metamask-alternate.app.link/1WkF6GmE40b?amount=100&from=0x'; + const params: BranchParams = { + '+clicked_branch_link': true, + $deeplink_path: 'swap', + }; + expect(rewriteBranchUri(uri, params)).toBe( + 'https://link.metamask.io/swap?amount=100&from=0x', + ); + }); + + it('returns uri unchanged when +clicked_branch_link is false', () => { + const uri = 'https://metamask.app.link/swap'; + expect( + rewriteBranchUri(uri, { '+clicked_branch_link': false } as BranchParams), + ).toBe(uri); + }); + + it('returns uri unchanged when $deeplink_path is missing', () => { + const uri = 'https://metamask.app.link/swap'; + expect( + rewriteBranchUri(uri, { '+clicked_branch_link': true } as BranchParams), + ).toBe(uri); + }); +}); + describe('DeeplinkManager.start Branch deeplink handling', () => { beforeEach(() => { jest.clearAllMocks(); @@ -304,6 +336,31 @@ describe('DeeplinkManager.start Branch deeplink handling', () => { expect(handleDeeplink).toHaveBeenCalledWith({ uri: mockDeeplink }); }); + it('rewrites cold start Branch link using $deeplink_path from getLatestReferringParams', async () => { + (branch.getLatestReferringParams as jest.Mock).mockResolvedValue({ + '+clicked_branch_link': true, + $deeplink_path: 'swap', + '~referring_link': + 'https://metamask-alternate.app.link/1WkF6GmE40b?amount=500', + }); + DeeplinkManager.start(); + await new Promise((resolve) => setImmediate(resolve)); + expect(handleDeeplink).toHaveBeenCalledWith({ + uri: 'https://link.metamask.io/swap?amount=500', + }); + }); + + it('falls back to +non_branch_link on cold start when +clicked_branch_link is false', async () => { + const mockDeeplink = 'https://link.metamask.io/home'; + (branch.getLatestReferringParams as jest.Mock).mockResolvedValue({ + '+clicked_branch_link': false, + '+non_branch_link': mockDeeplink, + }); + DeeplinkManager.start(); + await new Promise((resolve) => setImmediate(resolve)); + expect(handleDeeplink).toHaveBeenCalledWith({ uri: mockDeeplink }); + }); + it('subscribes to Branch deeplink events', async () => { DeeplinkManager.start(); expect(branch.subscribe).toHaveBeenCalled(); @@ -318,4 +375,68 @@ describe('DeeplinkManager.start Branch deeplink handling', () => { await new Promise((resolve) => setImmediate(resolve)); expect(handleDeeplink).toHaveBeenCalledWith({ uri: mockUri }); }); + + it('rewrites Branch short link to link.metamask.io when +clicked_branch_link and $deeplink_path are present', async () => { + DeeplinkManager.start(); + const callback = (branch.subscribe as jest.Mock).mock.calls[0][0]; + + callback({ + uri: 'https://metamask-alternate.app.link/1WkF6GmE40b?amount=1000000&from=eip155%3A1%2Ferc20%3A0xabc', + params: { + '+clicked_branch_link': true, + $deeplink_path: 'swap', + }, + }); + + await new Promise((resolve) => setImmediate(resolve)); + expect(handleDeeplink).toHaveBeenCalledWith({ + uri: 'https://link.metamask.io/swap?amount=1000000&from=eip155%3A1%2Ferc20%3A0xabc', + }); + }); + + it('passes URI through unchanged when +clicked_branch_link is false', async () => { + DeeplinkManager.start(); + const callback = (branch.subscribe as jest.Mock).mock.calls[0][0]; + const mockUri = 'https://metamask.app.link/swap?amount=100'; + + callback({ + uri: mockUri, + params: { '+clicked_branch_link': false }, + }); + + await new Promise((resolve) => setImmediate(resolve)); + expect(handleDeeplink).toHaveBeenCalledWith({ uri: mockUri }); + }); + + it('passes URI through unchanged when $deeplink_path is missing', async () => { + DeeplinkManager.start(); + const callback = (branch.subscribe as jest.Mock).mock.calls[0][0]; + const mockUri = 'https://metamask.app.link/swap?amount=100'; + + callback({ + uri: mockUri, + params: { '+clicked_branch_link': true }, + }); + + await new Promise((resolve) => setImmediate(resolve)); + expect(handleDeeplink).toHaveBeenCalledWith({ uri: mockUri }); + }); + + it('strips leading slash from $deeplink_path when rewriting', async () => { + DeeplinkManager.start(); + const callback = (branch.subscribe as jest.Mock).mock.calls[0][0]; + + callback({ + uri: 'https://metamask-alternate.app.link/ABC123', + params: { + '+clicked_branch_link': true, + $deeplink_path: '/swap/token', + }, + }); + + await new Promise((resolve) => setImmediate(resolve)); + expect(handleDeeplink).toHaveBeenCalledWith({ + uri: 'https://link.metamask.io/swap/token', + }); + }); }); diff --git a/app/core/DeeplinkManager/DeeplinkManager.ts b/app/core/DeeplinkManager/DeeplinkManager.ts index 82592138411..1c145b39b6d 100644 --- a/app/core/DeeplinkManager/DeeplinkManager.ts +++ b/app/core/DeeplinkManager/DeeplinkManager.ts @@ -7,6 +7,33 @@ import Logger from '../../util/Logger'; import { handleDeeplink } from './handlers/legacy/handleDeeplink'; import FCMService from '../../util/notifications/services/FCMService'; import AppConstants from '../AppConstants'; +import { BranchParams } from './types/deepLinkAnalytics.types'; + +/** + * When Branch resolves a short link (e.g. metamask-alternate.app.link/1WkF6GmE40b), + * the URI path may be link ID, not an in-app route. If the resolved params indicate + * a clicked Branch link with a $deeplink_path, replace the host and path segment + * with link.metamask.io/$deeplink_path while preserving the original query string. + */ +export function rewriteBranchUri( + uri: string | undefined, + params: BranchParams | undefined, +): string | undefined { + try { + if (!uri || !params?.['+clicked_branch_link']) return uri; + const rawPath = params.$deeplink_path; + if (typeof rawPath !== 'string') return uri; + + const parsed = new URL(uri); + parsed.host = AppConstants.MM_IO_UNIVERSAL_LINK_HOST; + // Set the pathname to the sanitized $deeplink_path + parsed.pathname = `/${rawPath.replace(/^\//, '')}`; + return parsed.toString(); + } catch (error) { + Logger.error(error as Error, `Error rewriting Branch URI: ${uri}`); + return uri; + } +} export class DeeplinkManager { // singleton instance @@ -66,6 +93,17 @@ export class DeeplinkManager { try { const latestParams = await branch.getLatestReferringParams(); + + // Cold start: params may contain a resolved Branch link with $deeplink_path. + const rewritten = rewriteBranchUri( + latestParams?.['~referring_link'] as string | undefined, + latestParams as Record | undefined, + ); + if (rewritten) { + handleDeeplink({ uri: rewritten }); + return; + } + const deeplink = latestParams?.['+non_branch_link'] as string; if (deeplink) { handleDeeplink({ uri: deeplink }); @@ -117,12 +155,11 @@ export class DeeplinkManager { const branchError = new Error(error); Logger.error(branchError, 'Error subscribing to branch.'); } - getBranchDeeplink(opts.uri); - //TODO: that async call in the subscribe doesn't look good to me - branch.getLatestReferringParams().then((val) => { - const deeplink = opts.uri || (val['+non_branch_link'] as string); - handleDeeplink({ uri: deeplink }); - }); + const rewritten = rewriteBranchUri( + opts.uri, + opts.params as Record | undefined, + ); + getBranchDeeplink(rewritten ?? opts.uri); }); } } diff --git a/app/core/DeeplinkManager/handlers/legacy/handleUniversalLink.ts b/app/core/DeeplinkManager/handlers/legacy/handleUniversalLink.ts index 7a4a84d625c..e82b3472119 100644 --- a/app/core/DeeplinkManager/handlers/legacy/handleUniversalLink.ts +++ b/app/core/DeeplinkManager/handlers/legacy/handleUniversalLink.ts @@ -57,6 +57,7 @@ import Logger from '../../../../util/Logger'; const { MM_UNIVERSAL_LINK_HOST, + MM_UNIVERSAL_LINK_HOST_ALTERNATE, MM_IO_UNIVERSAL_LINK_HOST, MM_IO_UNIVERSAL_LINK_TEST_HOST, } = AppConstants; @@ -216,6 +217,7 @@ async function handleUniversalLink({ const isSupportedDomain = urlObj.hostname === MM_UNIVERSAL_LINK_HOST || + urlObj.hostname === MM_UNIVERSAL_LINK_HOST_ALTERNATE || urlObj.hostname === MM_IO_UNIVERSAL_LINK_HOST || urlObj.hostname === MM_IO_UNIVERSAL_LINK_TEST_HOST; diff --git a/app/core/DeeplinkManager/util/deeplinks/index.ts b/app/core/DeeplinkManager/util/deeplinks/index.ts index 86965010401..530628cb4e3 100644 --- a/app/core/DeeplinkManager/util/deeplinks/index.ts +++ b/app/core/DeeplinkManager/util/deeplinks/index.ts @@ -2,6 +2,7 @@ import AppConstants from '../../../AppConstants'; const { MM_UNIVERSAL_LINK_HOST, + MM_UNIVERSAL_LINK_HOST_ALTERNATE, MM_IO_UNIVERSAL_LINK_HOST, MM_IO_UNIVERSAL_LINK_TEST_HOST, } = AppConstants; @@ -10,6 +11,7 @@ const METAMASK_HOSTS = [ ...new Set( [ MM_UNIVERSAL_LINK_HOST || 'link.metamask.io', + MM_UNIVERSAL_LINK_HOST_ALTERNATE || 'metamask-alternate.app.link', MM_IO_UNIVERSAL_LINK_HOST || 'link.metamask.io', MM_IO_UNIVERSAL_LINK_TEST_HOST || 'link-test.metamask.io', 'metamask.app.link', From 06877a8b958e32a21d315cf68bb7a3fc3e351551 Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Tue, 10 Mar 2026 18:07:58 -0500 Subject: [PATCH 11/11] feat(ramps): add analytics events for token unavailable modal (#27287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds analytics tracking for all user interactions with the `TokenNotAvailableModal` — the bottom sheet shown in Unified Buy v2 when the selected token is not supported by the selected provider. Previously only the "Change Provider" button was tracked. This PR adds: - `Ramps Screen Viewed` on modal mount (`location: 'Token Unavailable Modal'`) - `Ramps Change Token Button Clicked` (new event) when user taps "Change Token" - `Ramps Close Button Clicked` when user taps X to dismiss This gives full funnel visibility into what users do when they hit a token-unavailability state. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TRAM-3311 ## **Manual testing steps** ```gherkin Feature: Token unavailable modal analytics Scenario: user sees the modal and taps Change Token Given the user is on the Amount Input screen with a provider selected And the selected token is not supported by that provider When the token unavailable modal appears Then a "Ramps Screen Viewed" event fires with location "Token Unavailable Modal" When user taps "Change Token" Then a "Ramps Change Token Button Clicked" event fires with current_provider set Scenario: user dismisses the modal Given the token unavailable modal is visible When user taps the X close button Then a "Ramps Close Button Clicked" event fires with location "Token Unavailable Modal" Scenario: user taps Change Provider (no regression) Given the token unavailable modal is visible When user taps "Change Provider" Then a "Ramps Change Provider Button Clicked" event fires (unchanged behaviour) ``` ## **Screenshots/Recordings** ### **Before** Only `Ramps Change Provider Button Clicked` fired from this modal. ### **After** All four interaction surfaces tracked. Verify via Segment debugger or analytics logs. ## **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** > Analytics-only changes plus a small close-button behavior tweak (close vs navigate) confined to a single modal and covered by updated tests. > > **Overview** > Adds MetaMetrics tracking to the Unified Buy v2 `TokenNotAvailableModal`, firing `RAMPS_SCREEN_VIEWED` on mount and logging button interactions for **Change token** and the modal **close (X)**. > > Introduces a new analytics event constant `RAMPS_CHANGE_TOKEN_BUTTON_CLICKED` in `MetaMetrics.events.ts`, wires the close button to a new `handleClose` (close only, no navigation), and updates/extends tests to mock `useAnalytics` and assert the new event payloads. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit cb0455f2f9326e08dbcb550b1a124dfc911a4718. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../TokenNotAvailableModal.test.tsx | 73 +++++++++++++++++-- .../TokenNotAvailableModal.tsx | 38 +++++++++- app/core/Analytics/MetaMetrics.events.ts | 4 + 3 files changed, 107 insertions(+), 8 deletions(-) diff --git a/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.test.tsx b/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.test.tsx index a57443f4a32..8196300ba7f 100644 --- a/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.test.tsx +++ b/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.test.tsx @@ -4,6 +4,22 @@ import TokenNotAvailableModal from './TokenNotAvailableModal'; import { renderScreen } from '../../../../../../util/test/renderWithProvider'; import { backgroundState } from '../../../../../../util/test/initial-root-state'; import Routes from '../../../../../../constants/navigation/Routes'; +import { MetaMetricsEvents } from '../../../../../../core/Analytics'; + +const mockTrackEvent = jest.fn(); +const mockAddProperties = jest.fn().mockReturnThis(); +const mockBuild = jest.fn(() => ({ name: 'test-event' })); +const mockCreateEventBuilder = jest.fn(() => ({ + addProperties: mockAddProperties, + build: mockBuild, +})); + +jest.mock('../../../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); const MOCK_ASSET_ID = 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; @@ -143,16 +159,13 @@ describe('TokenNotAvailableModal', () => { ); }); - it('navigates to token selection when the close button is pressed', () => { + it('closes the bottom sheet when the close button is pressed', () => { const { getByTestId } = render(TokenNotAvailableModal); const closeButton = getByTestId('bottomsheetheader-close-button'); fireEvent.press(closeButton); - expect(mockOnCloseBottomSheet).toHaveBeenCalledWith(expect.any(Function)); - expect(mockNavigate).toHaveBeenCalledWith(Routes.RAMP.TOKEN_SELECTION, { - screen: Routes.RAMP.TOKEN_SELECTION, - }); + expect(mockOnCloseBottomSheet).toHaveBeenCalled(); }); it('navigates to token selection when modal is dismissed without a pending action', () => { @@ -181,4 +194,54 @@ describe('TokenNotAvailableModal', () => { expect(toJSON()).toMatchSnapshot(); }); + + it('fires RAMPS_SCREEN_VIEWED analytics event on mount', () => { + render(TokenNotAvailableModal); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.RAMPS_SCREEN_VIEWED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + location: 'Token Unavailable Modal', + ramp_type: 'UNIFIED_BUY_2', + }); + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + }); + + it('fires RAMPS_CHANGE_TOKEN_BUTTON_CLICKED analytics event when Change token is pressed', () => { + const { getByText } = render(TokenNotAvailableModal); + mockTrackEvent.mockClear(); + mockCreateEventBuilder.mockClear(); + mockAddProperties.mockClear(); + + fireEvent.press(getByText('Change token')); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.RAMPS_CHANGE_TOKEN_BUTTON_CLICKED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + current_provider: 'Transak', + location: 'Token Unavailable Modal', + ramp_type: 'UNIFIED_BUY_2', + }); + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + }); + + it('fires RAMPS_CLOSE_BUTTON_CLICKED analytics event when close button is pressed', () => { + const { getByTestId } = render(TokenNotAvailableModal); + mockTrackEvent.mockClear(); + mockCreateEventBuilder.mockClear(); + mockAddProperties.mockClear(); + + fireEvent.press(getByTestId('bottomsheetheader-close-button')); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.RAMPS_CLOSE_BUTTON_CLICKED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + location: 'Token Unavailable Modal', + ramp_type: 'UNIFIED_BUY_2', + }); + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + }); }); diff --git a/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.tsx b/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.tsx index b7f39c491bf..7776134fd78 100644 --- a/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.tsx +++ b/app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useRef } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import { View } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import Text, { @@ -49,13 +49,33 @@ function TokenNotAvailableModal() { const tokenName = selectedToken?.name ?? ''; const providerName = selectedProvider?.name ?? ''; + useEffect(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.RAMPS_SCREEN_VIEWED) + .addProperties({ + location: 'Token Unavailable Modal', + ramp_type: 'UNIFIED_BUY_2', + }) + .build(), + ); + }, [trackEvent, createEventBuilder]); + const handleChangeToken = useCallback(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.RAMPS_CHANGE_TOKEN_BUTTON_CLICKED) + .addProperties({ + current_provider: selectedProvider?.name, + location: 'Token Unavailable Modal', + ramp_type: 'UNIFIED_BUY_2', + }) + .build(), + ); sheetRef.current?.onCloseBottomSheet(() => { navigation.navigate(Routes.RAMP.TOKEN_SELECTION, { screen: Routes.RAMP.TOKEN_SELECTION, }); }); - }, [navigation]); + }, [navigation, selectedProvider?.name, trackEvent, createEventBuilder]); const handleChangeProvider = useCallback(() => { trackEvent( @@ -83,6 +103,18 @@ function TokenNotAvailableModal() { createEventBuilder, ]); + const handleClose = useCallback(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.RAMPS_CLOSE_BUTTON_CLICKED) + .addProperties({ + location: 'Token Unavailable Modal', + ramp_type: 'UNIFIED_BUY_2', + }) + .build(), + ); + sheetRef.current?.onCloseBottomSheet(); + }, [trackEvent, createEventBuilder]); + const handleDismiss = useCallback( (hasPendingAction?: boolean) => { if (!hasPendingAction) { @@ -102,7 +134,7 @@ function TokenNotAvailableModal() { testID="token-unavailable-for-provider-modal" > diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index 832d8247937..53387cc656f 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -318,6 +318,7 @@ enum EVENT_NAME { RAMPS_PAYMENT_METHOD_SELECTOR_CLICKED = 'Ramps Payment Method Selector Clicked', RAMPS_QUICK_AMOUNT_CLICKED = 'Ramps Quick Amount Clicked', RAMPS_CHANGE_PROVIDER_BUTTON_CLICKED = 'Ramps Change Provider Button Clicked', + RAMPS_CHANGE_TOKEN_BUTTON_CLICKED = 'Ramps Change Token Button Clicked', RAMPS_PROVIDER_SELECTED = 'Ramps Provider Selected', RAMPS_CONTINUE_BUTTON_CLICKED = 'Ramps Continue Button Clicked', RAMPS_TERMS_CONSENT_CLICKED = 'Ramps Terms Consent Clicked', @@ -1117,6 +1118,9 @@ const events = { RAMPS_CHANGE_PROVIDER_BUTTON_CLICKED: generateOpt( EVENT_NAME.RAMPS_CHANGE_PROVIDER_BUTTON_CLICKED, ), + RAMPS_CHANGE_TOKEN_BUTTON_CLICKED: generateOpt( + EVENT_NAME.RAMPS_CHANGE_TOKEN_BUTTON_CLICKED, + ), RAMPS_PROVIDER_SELECTED: generateOpt(EVENT_NAME.RAMPS_PROVIDER_SELECTED), RAMPS_CONTINUE_BUTTON_CLICKED: generateOpt( EVENT_NAME.RAMPS_CONTINUE_BUTTON_CLICKED,