From 16263b4c6e957103ecf0d4a3c480c16cc5ebf783 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Tani=C3=A7a?= Date: Wed, 3 Dec 2025 10:37:12 -0700 Subject: [PATCH 1/8] chore(predict): setup codeowners (#23612) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Set up CODEOWNERS file for Predict team. ## **Changelog** CHANGELOG entry: null ## **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** - [ ] 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] > Adds CODEOWNERS entries assigning the Predict team to predict-related components, controllers, messengers, deeplink handler, and glob patterns. > > - **CODEOWNERS**: > - Add Predict team ownership for: > - `app/components/UI/Predict/` > - `app/core/Engine/controllers/predict-controller` > - `app/core/Engine/messengers/predict-controller-messenger` > - `app/core/DeeplinkManager/handlers/legacy/handlePredictUrl.ts` > - `**/Predict/**`, `**/predict/**` > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit bc5491e11bb14af5c1e7c3d08d7a6bb6733e6635. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .github/CODEOWNERS | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 011ad38767b..60026e0a877 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -151,6 +151,14 @@ app/core/DeeplinkManager/Handlers/handlePerpsUrl.ts @MetaMask/perps **/Perps/** @MetaMask/perps **/perps/** @MetaMask/perps +# Predict Team +app/components/UI/Predict/ @MetaMask/predict +app/core/Engine/controllers/predict-controller @MetaMask/predict +app/core/Engine/messengers/predict-controller-messenger @MetaMask/predict +app/core/DeeplinkManager/handlers/legacy/handlePredictUrl.ts @MetaMask/predict +**/Predict/** @MetaMask/predict +**/predict/** @MetaMask/predict + # Assets Team app/components/hooks/useIsOriginalNativeTokenSymbol @MetaMask/metamask-assets app/components/hooks/useTokenBalancesController @MetaMask/metamask-assets From 29043c01645d6561405555bc44fb63c382880a5f Mon Sep 17 00:00:00 2001 From: Aslau Mario-Daniel Date: Wed, 3 Dec 2025 17:55:53 +0000 Subject: [PATCH 2/8] feat: Create installation deferred deep linking feature for Mobile (#23349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Implementation of Deferred Deep Linking: Deferred deep linking is a technique that allows users to be directed to specific content within an app, even if the app is The first version of deep linking in Mobile doesn't support deferred deep linking. This means that if mobile users who don't have the MetaMask Mobile app installed click a deep link, they are redirected to the iOS/Android store to download and install the app. However, after installation, they are not redirected to the specific page the deep link was intended for. Deferred deep linking will enable this seamless redirection to the intended screen after installation. ## **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] ``` Important note for testing on Android in Dev If testing deep link on Android via Expo, when the link opens Expo to open the app, the deep link passed to the app itself will be changed by Expo into something seen bellow ![IMG_0168](https://github.com/user-attachments/assets/73dc66ff-2fbf-41d7-97c3-24b92598616f) instead of https://link.metamask.io/perps, which makes the deeplinking/deferral system work but it never gets the right link to process, resulting in the "This page doesn't exist" ![IMG_0167](https://github.com/user-attachments/assets/fa832a9a-f46d-4a25-86d5-626cef75bc33) ## **Screenshots/Recordings** **iOS** https://github.com/user-attachments/assets/d7692309-09e5-4e50-80fe-a39aca9e926d https://github.com/user-attachments/assets/0f440e22-511b-4ccf-92e9-277ff72339a8 **Android** https://github.com/user-attachments/assets/32c092b9-5136-40a2-9bc1-0db726383d42 ### **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 - [ ] 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** - [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] > Enables Branch pasteboard check on iOS app launch and refactors onboarding completion hook tests with clearer setup/teardown and assertions. > > - **iOS**: > - Enable deferred deep linking support by calling `RNBranch.branch.checkPasteboardOnInstall` in `ios/MetaMask/AppDelegate.m` before `initSessionWithLaunchOptions`. > - **Tests**: > - Refactor `useCompletedOnboardingEffect` tests: > - Add `beforeEach`/`afterEach` to manage Jest mocks; remove inline `jest.clearAllMocks` in `arrangeMocks`. > - Rename tests for clarity and keep assertions verifying when `setCompletedOnboarding(true)` is called or skipped. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 017e90e5d3e1414b697f075c3bffc8c86f29f0b1. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: metamaskbot --- .../useCompletedOnboardingEffect.test.ts | 37 ++++++++++++++----- ios/MetaMask/AppDelegate.m | 1 + 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/app/util/onboarding/hooks/useCompletedOnboardingEffect/useCompletedOnboardingEffect.test.ts b/app/util/onboarding/hooks/useCompletedOnboardingEffect/useCompletedOnboardingEffect.test.ts index 64d4c26c8bd..44d2baa3778 100644 --- a/app/util/onboarding/hooks/useCompletedOnboardingEffect/useCompletedOnboardingEffect.test.ts +++ b/app/util/onboarding/hooks/useCompletedOnboardingEffect/useCompletedOnboardingEffect.test.ts @@ -25,7 +25,6 @@ const arrangeMockState = ( }); const arrangeMocks = (stateOverrides: ArrangeMocksMetamaskStateOverrides) => { - jest.clearAllMocks(); const state = arrangeMockState(stateOverrides); const mockSetCompletedOnboarding = jest.spyOn( @@ -40,71 +39,91 @@ const arrangeMocks = (stateOverrides: ArrangeMocksMetamaskStateOverrides) => { }; describe('useCompletedOnboardingEffect', () => { - it('sets completedOnboarding to true if conditions are met', async () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('completes onboarding when vault exists but onboarding incomplete', async () => { + // Arrange const { state, mockSetCompletedOnboarding } = arrangeMocks({ vault: 'mock-vault-data', completedOnboarding: false, }); + + // Act const { rerender } = renderHookWithProvider( () => useCompletedOnboardingEffect(), { state }, ); - await act(async () => { rerender({}); }); + // Assert expect(mockSetCompletedOnboarding).toHaveBeenCalledWith(true); }); - it('does not set completedOnboarding if vault is empty', async () => { + it('skips onboarding completion when vault is missing', async () => { + // Arrange const { state, mockSetCompletedOnboarding } = arrangeMocks({ vault: undefined, completedOnboarding: false, }); + + // Act const { rerender } = renderHookWithProvider( () => useCompletedOnboardingEffect(), { state }, ); - await act(async () => { rerender({}); }); + // Assert expect(mockSetCompletedOnboarding).not.toHaveBeenCalled(); }); - it('does not set completedOnboarding if it is already true', async () => { + it('skips onboarding completion when already completed', async () => { + // Arrange const { state, mockSetCompletedOnboarding } = arrangeMocks({ vault: 'mock-vault-data', completedOnboarding: true, }); + + // Act const { rerender } = renderHookWithProvider( () => useCompletedOnboardingEffect(), { state }, ); - await act(async () => { rerender({}); }); + // Assert expect(mockSetCompletedOnboarding).not.toHaveBeenCalled(); }); - it('does not set completedOnboarding if vault is undefined and completedOnboarding is true', async () => { + it('skips onboarding completion when vault missing with completed status', async () => { + // Arrange const { state, mockSetCompletedOnboarding } = arrangeMocks({ vault: undefined, completedOnboarding: true, }); + + // Act const { rerender } = renderHookWithProvider( () => useCompletedOnboardingEffect(), { state }, ); - await act(async () => { rerender({}); }); + // Assert expect(mockSetCompletedOnboarding).not.toHaveBeenCalled(); }); }); diff --git a/ios/MetaMask/AppDelegate.m b/ios/MetaMask/AppDelegate.m index 83dd3f94689..4988e93c41c 100644 --- a/ios/MetaMask/AppDelegate.m +++ b/ios/MetaMask/AppDelegate.m @@ -23,6 +23,7 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( foxCode = @"debug"; } + [RNBranch.branch checkPasteboardOnInstall]; // Uncomment this line to use the test key instead of the live one. // [RNBranch useTestInstance]; [RNBranch initSessionWithLaunchOptions:launchOptions isReferrable:YES]; From fedb55ee6ba3e5891a254a663d55ef3a212fb79e Mon Sep 17 00:00:00 2001 From: Nick Gambino <35090461+gambinish@users.noreply.github.com> Date: Wed, 3 Dec 2025 10:01:14 -0800 Subject: [PATCH 3/8] chore: pending withdraw/deposit should only display for selected evm account (#23497) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Currently when a perps withdrawal/deposit is initiated, it is visible across multiple MetaMask accounts. This is wrong, and we should only show the pending withdraw for the selectedEvmAccount of the MetaMask perps account that initiated the withdrawal. This PR introduces the accountId of the initiator to enable filtering of the currently selected account, to show the pending withdraw for the correct account. ## **Changelog** CHANGELOG entry: Fix to account specific pending perps withdraws ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TAT-1889 ## **Manual testing steps** ```gherkin Feature: Per-Account Pending Withdrawal Tracking Scenario: user initiates withdrawal and sees pending progress Given user is on Perps home screen with Account A selected And Account A has a balance of 100 USDC When user initiates a withdrawal of 50 USDC Then user sees pending withdrawal progress indicator for Account A And progress indicator shows withdrawal amount of 49 USDC (after $1 fee) Scenario: user switches accounts and does not see previous account's pending withdrawal Given user has initiated a withdrawal from Account A And Account A shows pending withdrawal progress indicator When user switches to Account B in the account picker Then user does not see any pending withdrawal progress indicator And Account B shows its own balance state without Account A's withdrawal Scenario: user switches back to account with pending withdrawal and sees progress resume Given user has initiated a withdrawal from Account A And user has switched to Account B And Account B does not show any withdrawal progress ``` ## **Screenshots/Recordings** https://github.com/user-attachments/assets/0beb0f45-0b5d-4979-8d1c-346dc550e79b ## **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] > Scopes perps deposit/withdraw tracking to the currently selected EVM account and migrates persisted requests to include account attribution. > > - **Perps UI/Hooks**: > - Filter `withdrawalRequests` and `depositRequests` by selected EVM account via `selectSelectedInternalAccountByScope('eip155:1')` in `PerpsMarketBalanceActions`, `useWithdrawalRequests`, and `useDepositRequests`. > - Add detailed logging and guard for no selected address; only show relevant in-progress states and amounts. > - **Controller**: > - Extend `PerpsController` state: add `accountAddress` to `withdrawalRequests`/`depositRequests`; initialize arrays; default `withdrawalProgress` shape. > - Add migration to drop legacy requests missing `accountAddress`. > - `depositWithConfirmation`: include initiating `accountAddress` in new `depositRequests`. > - **Services**: > - `AccountService.withdraw`: capture selected `accountAddress`, include in new `withdrawalRequests`, and log context. > - **Tests/Fixtures**: > - Update tests to assert account-scoped filtering and behaviors; add account addresses to mocks; use `renderHookWithProvider`. > - Update snapshots and `initial-background-state.json` to new array shapes and `withdrawalProgress` defaults. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 49b5d79de8e362fe92d51d522d26c1d7c94c65d0. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../PerpsMarketBalanceActions.tsx | 42 ++- .../PerpsProgressBar.test.tsx | 2 + .../Perps/controllers/PerpsController.test.ts | 13 + .../UI/Perps/controllers/PerpsController.ts | 30 ++ .../services/AccountService.test.ts | 6 + .../controllers/services/AccountService.ts | 13 + .../UI/Perps/hooks/useDepositRequests.test.ts | 288 +++++++++++++++-- .../UI/Perps/hooks/useDepositRequests.ts | 76 ++++- .../Perps/hooks/useWithdrawalRequests.test.ts | 305 +++++++++++++++--- .../UI/Perps/hooks/useWithdrawalRequests.ts | 59 +++- .../logs/__snapshots__/index.test.ts.snap | 20 +- app/util/test/initial-background-state.json | 10 +- 12 files changed, 747 insertions(+), 117 deletions(-) diff --git a/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.tsx b/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.tsx index c900f94be2e..00c04400e63 100644 --- a/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.tsx +++ b/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.tsx @@ -47,6 +47,7 @@ import { Skeleton } from '../../../../../component-library/components/Skeleton'; import DevLogger from '../../../../../core/SDKConnect/utils/DevLogger'; import { PerpsProgressBar } from '../PerpsProgressBar'; import { RootState } from '../../../../../reducers'; +import { selectSelectedInternalAccountByScope } from '../../../../../selectors/multichainAccounts/accounts'; interface PerpsMarketBalanceActionsProps { positions?: Position[]; @@ -81,11 +82,42 @@ const PerpsMarketBalanceActions: React.FC = ({ const navigation = useNavigation>(); const { isDepositInProgress } = usePerpsDepositProgress(); - // Get withdrawal requests from controller state - const withdrawalRequests = useSelector( - (state: RootState) => - state.engine.backgroundState.PerpsController?.withdrawalRequests || [], - ); + // Get current selected account address + const selectedAddress = useSelector(selectSelectedInternalAccountByScope)( + 'eip155:1', + )?.address; + + // Get withdrawal requests from controller state and filter by current account + const withdrawalRequests = useSelector((state: RootState) => { + const allWithdrawals = + state.engine.backgroundState.PerpsController?.withdrawalRequests || []; + + // If no selected address, return empty array (don't show potentially wrong account's data) + if (!selectedAddress) { + DevLogger.log( + 'PerpsMarketBalanceActions: No selected address, returning empty array', + { totalCount: allWithdrawals.length }, + ); + return []; + } + + // Filter by current account, normalizing addresses for comparison + const filtered = allWithdrawals.filter( + (req) => + req.accountAddress?.toLowerCase() === selectedAddress.toLowerCase(), + ); + + DevLogger.log( + 'PerpsMarketBalanceActions: Filtered withdrawals by account', + { + selectedAddress, + totalCount: allWithdrawals.length, + filteredCount: filtered.length, + }, + ); + + return filtered; + }); // State for transaction amount const [transactionAmountWei, setTransactionAmountWei] = useState< diff --git a/app/components/UI/Perps/components/PerpsProgressBar/PerpsProgressBar.test.tsx b/app/components/UI/Perps/components/PerpsProgressBar/PerpsProgressBar.test.tsx index 8f90cfdd411..76228f45647 100644 --- a/app/components/UI/Perps/components/PerpsProgressBar/PerpsProgressBar.test.tsx +++ b/app/components/UI/Perps/components/PerpsProgressBar/PerpsProgressBar.test.tsx @@ -64,6 +64,7 @@ describe('PerpsProgressBar', () => { timestamp: 1640995200000, amount: '100', asset: 'USDC', + accountAddress: '0x1234567890123456789012345678901234567890', txHash: '0x123', status: 'pending' as const, destination: '0x456', @@ -74,6 +75,7 @@ describe('PerpsProgressBar', () => { timestamp: 1640995201000, amount: '200', asset: 'USDC', + accountAddress: '0x1234567890123456789012345678901234567890', txHash: '0x789', status: 'completed' as const, destination: '0xabc', diff --git a/app/components/UI/Perps/controllers/PerpsController.test.ts b/app/components/UI/Perps/controllers/PerpsController.test.ts index 0110e85cf79..e0bcda1cb2b 100644 --- a/app/components/UI/Perps/controllers/PerpsController.test.ts +++ b/app/components/UI/Perps/controllers/PerpsController.test.ts @@ -95,9 +95,19 @@ jest.mock('../../../../core/Engine', () => { }), }; + const mockAccountTreeController = { + getAccountsFromSelectedAccountGroup: jest.fn().mockReturnValue([ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]), + }; + const mockEngineContext = { RewardsController: mockRewardsController, NetworkController: mockNetworkController, + AccountTreeController: mockAccountTreeController, TransactionController: {}, }; @@ -2513,6 +2523,7 @@ describe('PerpsController', () => { timestamp: Date.now(), amount: '50', asset: 'USDC', + accountAddress: '0x1234567890123456789012345678901234567890', success: false, status: 'pending', source: 'hyperliquid', @@ -2583,6 +2594,7 @@ describe('PerpsController', () => { timestamp: Date.now(), amount: '75', asset: 'USDC', + accountAddress: '0x1234567890123456789012345678901234567890', success: false, status: 'pending', source: 'hyperliquid', @@ -2618,6 +2630,7 @@ describe('PerpsController', () => { timestamp: Date.now(), amount: '100', asset: 'USDC', + accountAddress: '0x1234567890123456789012345678901234567890', success: false, status: 'pending', source: 'hyperliquid', diff --git a/app/components/UI/Perps/controllers/PerpsController.ts b/app/components/UI/Perps/controllers/PerpsController.ts index 00587eae072..f1cbf62a4ce 100644 --- a/app/components/UI/Perps/controllers/PerpsController.ts +++ b/app/components/UI/Perps/controllers/PerpsController.ts @@ -25,6 +25,7 @@ import { MetaMetrics } from '../../../../core/Analytics'; import { ensureError } from '../utils/perpsErrorHandler'; import type { CandleData } from '../types/perps-types'; import { CandlePeriod } from '../constants/chartConfig'; +import { getEvmAccountFromSelectedAccountGroup } from '../utils/accountUtils'; import { PERPS_CONSTANTS, MARKET_SORTING_CONFIG, @@ -162,6 +163,7 @@ export type PerpsControllerState = { id: string; amount: string; asset: string; + accountAddress: string; // Account that initiated this withdrawal txHash?: string; timestamp: number; success: boolean; @@ -185,6 +187,7 @@ export type PerpsControllerState = { id: string; amount: string; asset: string; + accountAddress: string; // Account that initiated this deposit txHash?: string; timestamp: number; success: boolean; @@ -740,6 +743,28 @@ export class PerpsController extends BaseController< ); this.providers = new Map(); + + // Migrate old persisted data without accountAddress + this.migrateRequestsIfNeeded(); + } + + /** + * Clean up old withdrawal/deposit requests that don't have accountAddress + * These are from before the accountAddress field was added and can't be displayed + * in the UI (which filters by account), so we discard them + */ + private migrateRequestsIfNeeded(): void { + this.update((state) => { + // Remove withdrawal requests without accountAddress - they can't be attributed to any account + state.withdrawalRequests = state.withdrawalRequests.filter( + (req) => !!req.accountAddress, + ); + + // Remove deposit requests without accountAddress - they can't be attributed to any account + state.depositRequests = state.depositRequests.filter( + (req) => !!req.accountAddress, + ); + }); } protected setBlockedRegionList( @@ -1337,12 +1362,17 @@ export class PerpsController extends BaseController< this.update((state) => { state.lastDepositResult = null; + // Get current account address + const evmAccount = getEvmAccountFromSelectedAccountGroup(); + const accountAddress = evmAccount?.address || 'unknown'; + // Add deposit request to tracking const depositRequest = { id: currentDepositId, timestamp: Date.now(), amount: amount || '0', // Use provided amount or default to '0' asset: USDC_SYMBOL, + accountAddress, // Track which account initiated deposit success: false, // Will be updated when transaction completes txHash: undefined, status: 'pending' as TransactionStatus, diff --git a/app/components/UI/Perps/controllers/services/AccountService.test.ts b/app/components/UI/Perps/controllers/services/AccountService.test.ts index e7ec9610047..b2b3b7772f7 100644 --- a/app/components/UI/Perps/controllers/services/AccountService.test.ts +++ b/app/components/UI/Perps/controllers/services/AccountService.test.ts @@ -54,6 +54,11 @@ jest.mock('../../../../../core/SDKConnect/utils/DevLogger', () => ({ log: jest.fn(), }, })); +jest.mock('../../utils/accountUtils', () => ({ + getEvmAccountFromSelectedAccountGroup: jest.fn().mockReturnValue({ + address: '0x1234567890123456789012345678901234567890', + }), +})); describe('AccountService', () => { let mockProvider: jest.Mocked; @@ -392,6 +397,7 @@ describe('AccountService', () => { success: false, amount: '100', asset: 'USDC', + accountAddress: expect.any(String) as string, timestamp: Date.now(), }, ], diff --git a/app/components/UI/Perps/controllers/services/AccountService.ts b/app/components/UI/Perps/controllers/services/AccountService.ts index 19f96717595..ccdabc6ff04 100644 --- a/app/components/UI/Perps/controllers/services/AccountService.ts +++ b/app/components/UI/Perps/controllers/services/AccountService.ts @@ -20,6 +20,7 @@ import { import { USDC_SYMBOL } from '../../constants/hyperLiquidConfig'; import { PERPS_ERROR_CODES } from '../perpsErrorCodes'; import { DevLogger } from '../../../../../core/SDKConnect/utils/DevLogger'; +import { getEvmAccountFromSelectedAccountGroup } from '../../utils/accountUtils'; /** * AccountService @@ -103,12 +104,24 @@ export class AccountService { const feeAmount = 1.0; // HyperLiquid withdrawal fee is $1 USDC const netAmount = Math.max(0, grossAmount - feeAmount); + // Get current account address + const evmAccount = getEvmAccountFromSelectedAccountGroup(); + const accountAddress = evmAccount?.address || 'unknown'; + + DevLogger.log('AccountService: Creating withdrawal request', { + accountAddress, + hasEvmAccount: !!evmAccount, + evmAccountAddress: evmAccount?.address, + amount: netAmount.toString(), + }); + // Add withdrawal request to tracking const withdrawalRequest = { id: currentWithdrawalId, timestamp: Date.now(), amount: netAmount.toString(), // Use net amount (after fees) asset: USDC_SYMBOL, + accountAddress, // Track which account initiated withdrawal success: false, // Will be updated when transaction completes txHash: undefined, status: 'pending' as TransactionStatus, diff --git a/app/components/UI/Perps/hooks/useDepositRequests.test.ts b/app/components/UI/Perps/hooks/useDepositRequests.test.ts index f0ec4740715..f5c929f1522 100644 --- a/app/components/UI/Perps/hooks/useDepositRequests.test.ts +++ b/app/components/UI/Perps/hooks/useDepositRequests.test.ts @@ -1,13 +1,27 @@ -import { renderHook, act } from '@testing-library/react-native'; +import { act } from '@testing-library/react-native'; +import { renderHookWithProvider } from '../../../../util/test/renderWithProvider'; import Engine from '../../../../core/Engine'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; import { useDepositRequests } from './useDepositRequests'; import { usePerpsSelector } from './usePerpsSelector'; +import type { PerpsControllerState } from '../controllers/PerpsController'; +import type { RootState } from '../../../../reducers'; +import { + createMockInternalAccount, + createMockUuidFromAddress, +} from '../../../../util/test/accountsControllerTestUtils'; +import { useSelector } from 'react-redux'; // Mock dependencies jest.mock('../../../../core/Engine'); jest.mock('../../../../core/SDKConnect/utils/DevLogger'); jest.mock('./usePerpsSelector'); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +const mockUseSelector = useSelector as jest.MockedFunction; const mockEngine = Engine as jest.Mocked; const mockDevLogger = DevLogger as jest.Mocked; @@ -16,6 +30,13 @@ const mockUsePerpsSelector = usePerpsSelector as jest.MockedFunction< >; describe('useDepositRequests', () => { + const mockAddress = '0x1234567890123456789012345678901234567890'; + const mockAccountId = createMockUuidFromAddress(mockAddress.toLowerCase()); + const mockInternalAccount = createMockInternalAccount( + mockAddress.toLowerCase(), + 'Account 1', + ); + let mockController: { getActiveProvider: jest.MockedFunction<() => unknown>; }; @@ -31,7 +52,9 @@ describe('useDepositRequests', () => { timestamp: 1640995200000, amount: '100', asset: 'USDC', + accountAddress: mockAddress, status: 'pending' as const, + success: false, txHash: undefined, source: 'arbitrum', depositId: 'deposit1', @@ -41,7 +64,9 @@ describe('useDepositRequests', () => { timestamp: 1640995201000, amount: '200', asset: 'USDC', + accountAddress: mockAddress, status: 'bridging' as const, + success: false, txHash: '0x123', source: 'ethereum', depositId: 'deposit2', @@ -84,6 +109,42 @@ describe('useDepositRequests', () => { }, ]; + // Helper to create mock Redux state with account + const createMockState = () => + ({ + engine: { + backgroundState: { + AccountTreeController: { + accountTree: { + selectedAccountGroup: 'keyring:wallet1/1', + wallets: { + 'keyring:wallet1': { + id: 'keyring:wallet1', + name: 'Wallet 1', + type: 'hd', + groups: [ + { + id: 'keyring:wallet1/1', + name: 'Account 1', + accounts: [mockAccountId], + }, + ], + }, + }, + }, + }, + AccountsController: { + internalAccounts: { + accounts: { + [mockAccountId]: mockInternalAccount, + }, + selectedAccount: mockAccountId, + }, + }, + }, + }, + }) as unknown as RootState; + beforeEach(() => { jest.clearAllMocks(); @@ -106,13 +167,31 @@ describe('useDepositRequests', () => { PerpsController: mockController, }; - // Mock usePerpsSelector - mockUsePerpsSelector.mockReturnValue(mockPendingDeposits); + // Mock usePerpsSelector to execute the selector function with mock state + mockUsePerpsSelector.mockImplementation((selector) => + selector({ + depositRequests: mockPendingDeposits, + } as Partial as PerpsControllerState), + ); + + // Mock useSelector to return the mock account for selectSelectedInternalAccountByScope + mockUseSelector.mockImplementation((selector) => { + // Check if this is the selectSelectedInternalAccountByScope selector + // It returns a function that takes a scope + const result = selector(createMockState()); + if (typeof result === 'function') { + // This is selectSelectedInternalAccountByScope, return the mock account + return () => mockInternalAccount; + } + return result; + }); }); describe('initial state', () => { it('returns initial state correctly', () => { - const { result } = renderHook(() => useDepositRequests()); + const { result } = renderHookWithProvider(() => useDepositRequests(), { + state: createMockState(), + }); expect(result.current.depositRequests).toEqual([]); expect(result.current.isLoading).toBe(true); @@ -121,8 +200,11 @@ describe('useDepositRequests', () => { }); it('skips initial fetch when skipInitialFetch is true', () => { - const { result } = renderHook(() => - useDepositRequests({ skipInitialFetch: true }), + const { result } = renderHookWithProvider( + () => useDepositRequests({ skipInitialFetch: true }), + { + state: createMockState(), + }, ); expect(result.current.isLoading).toBe(false); @@ -134,7 +216,9 @@ describe('useDepositRequests', () => { describe('fetchCompletedDeposits', () => { it('fetches completed deposits successfully', async () => { - const { result } = renderHook(() => useDepositRequests()); + const { result } = renderHookWithProvider(() => useDepositRequests(), { + state: createMockState(), + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -151,7 +235,9 @@ describe('useDepositRequests', () => { it('uses provided startTime', async () => { const startTime = 1640995200000; - renderHook(() => useDepositRequests({ startTime })); + renderHookWithProvider(() => useDepositRequests({ startTime }), { + state: createMockState(), + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -164,7 +250,9 @@ describe('useDepositRequests', () => { }); it('uses start of today when no startTime provided', async () => { - renderHook(() => useDepositRequests()); + renderHookWithProvider(() => useDepositRequests(), { + state: createMockState(), + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -180,7 +268,9 @@ describe('useDepositRequests', () => { }); it('filters only deposit transactions', async () => { - const { result } = renderHook(() => useDepositRequests()); + const { result } = renderHookWithProvider(() => useDepositRequests(), { + state: createMockState(), + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -192,7 +282,9 @@ describe('useDepositRequests', () => { }); it('transforms ledger updates to deposit requests', async () => { - const { result } = renderHook(() => useDepositRequests()); + const { result } = renderHookWithProvider(() => useDepositRequests(), { + state: createMockState(), + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -203,7 +295,9 @@ describe('useDepositRequests', () => { expect(deposit.timestamp).toBe(1640995202000); expect(deposit.amount).toBe('500'); expect(deposit.asset).toBe('USDC'); + expect(deposit.accountAddress).toBe(mockAddress); expect(deposit.txHash).toBe('0x456'); + expect(deposit.success).toBe(true); expect(deposit.status).toBe('completed'); expect(deposit.depositId).toBe('123'); }); @@ -213,7 +307,9 @@ describe('useDepositRequests', () => { mockEngine.context as unknown as { PerpsController: unknown } ).PerpsController = undefined; - const { result } = renderHook(() => useDepositRequests()); + const { result } = renderHookWithProvider(() => useDepositRequests(), { + state: createMockState(), + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -226,7 +322,9 @@ describe('useDepositRequests', () => { it('handles no active provider', async () => { mockController.getActiveProvider.mockReturnValue(undefined); - const { result } = renderHook(() => useDepositRequests()); + const { result } = renderHookWithProvider(() => useDepositRequests(), { + state: createMockState(), + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -240,7 +338,9 @@ describe('useDepositRequests', () => { mockProvider = {} as unknown as typeof mockProvider; mockController.getActiveProvider.mockReturnValue(mockProvider); - const { result } = renderHook(() => useDepositRequests()); + const { result } = renderHookWithProvider(() => useDepositRequests(), { + state: createMockState(), + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -257,7 +357,9 @@ describe('useDepositRequests', () => { new Error('Provider error'), ); - const { result } = renderHook(() => useDepositRequests()); + const { result } = renderHookWithProvider(() => useDepositRequests(), { + state: createMockState(), + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -272,7 +374,9 @@ describe('useDepositRequests', () => { 'String error', ); - const { result } = renderHook(() => useDepositRequests()); + const { result } = renderHookWithProvider(() => useDepositRequests(), { + state: createMockState(), + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -284,10 +388,97 @@ describe('useDepositRequests', () => { }); describe('deposit filtering and combining', () => { + it('filters deposits by current account address', () => { + const depositsFromMultipleAccounts = [ + { + id: 'deposit1', + timestamp: 1640995200000, + amount: '100', + asset: 'USDC', + accountAddress: mockAddress, // Current account + status: 'pending' as const, + success: false, + source: 'arbitrum', + }, + { + id: 'deposit2', + timestamp: 1640995201000, + amount: '200', + asset: 'USDC', + accountAddress: '0xdifferentaccount000000000000000000000000', // Different account + status: 'pending' as const, + success: false, + source: 'ethereum', + }, + ]; + + mockUsePerpsSelector.mockImplementation((selector) => + selector({ + depositRequests: depositsFromMultipleAccounts, + } as Partial as PerpsControllerState), + ); + + renderHookWithProvider(() => useDepositRequests(), { + state: createMockState(), + }); + + // Should only return deposits for the current account + expect(mockDevLogger.log).toHaveBeenCalledWith( + 'useDepositRequests: Filtered deposits by account', + expect.objectContaining({ + selectedAddress: mockAddress, + totalCount: 2, + filteredCount: 1, + }), + ); + }); + + it('returns empty array when no selected address', () => { + const stateWithoutAccount = { + engine: { + backgroundState: { + AccountsController: { + internalAccounts: { + accounts: {}, + selectedAccount: undefined, + }, + }, + }, + }, + }; + + // Override mock to return undefined for this test + mockUseSelector.mockImplementation((selector) => { + const result = selector(stateWithoutAccount); + if (typeof result === 'function') { + // This is selectSelectedInternalAccountByScope, return undefined + return () => undefined; + } + return result; + }); + + renderHookWithProvider(() => useDepositRequests(), { + state: stateWithoutAccount, + }); + + expect(mockDevLogger.log).toHaveBeenCalledWith( + 'useDepositRequests: No selected address, returning empty array', + expect.objectContaining({ + totalCount: 2, + }), + ); + }); + it('combines pending and completed deposits', async () => { - mockUsePerpsSelector.mockReturnValue(mockPendingDeposits); + mockUsePerpsSelector.mockImplementation((selector) => + selector({ + depositRequests: mockPendingDeposits, + } as Partial as PerpsControllerState), + ); - const { result } = renderHook(() => useDepositRequests()); + const { result } = renderHookWithProvider(() => useDepositRequests(), { + state: createMockState(), + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -300,7 +491,9 @@ describe('useDepositRequests', () => { }); it('filters out deposits with zero amounts', async () => { - const { result } = renderHook(() => useDepositRequests()); + const { result } = renderHookWithProvider(() => useDepositRequests(), { + state: createMockState(), + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -330,7 +523,9 @@ describe('useDepositRequests', () => { zeroAmountLedgerUpdates, ); - const { result } = renderHook(() => useDepositRequests()); + const { result } = renderHookWithProvider(() => useDepositRequests(), { + state: createMockState(), + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -358,7 +553,9 @@ describe('useDepositRequests', () => { noTxHashLedgerUpdates, ); - const { result } = renderHook(() => useDepositRequests()); + const { result } = renderHookWithProvider(() => useDepositRequests(), { + state: createMockState(), + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -397,7 +594,9 @@ describe('useDepositRequests', () => { multipleDeposits, ); - const { result } = renderHook(() => useDepositRequests()); + const { result } = renderHookWithProvider(() => useDepositRequests(), { + state: createMockState(), + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -410,7 +609,9 @@ describe('useDepositRequests', () => { describe('refetch functionality', () => { it('refetches completed deposits when refetch is called', async () => { - const { result } = renderHook(() => useDepositRequests()); + const { result } = renderHookWithProvider(() => useDepositRequests(), { + state: createMockState(), + }); // Initial fetch await act(async () => { @@ -439,7 +640,9 @@ describe('useDepositRequests', () => { new Error('Refetch error'), ); - const { result } = renderHook(() => useDepositRequests()); + const { result } = renderHookWithProvider(() => useDepositRequests(), { + state: createMockState(), + }); await act(async () => { await result.current.refetch(); @@ -451,13 +654,17 @@ describe('useDepositRequests', () => { }); describe('logging', () => { - it('logs pending deposits from controller state', () => { - renderHook(() => useDepositRequests()); + it('logs filtered deposits by account', () => { + renderHookWithProvider(() => useDepositRequests(), { + state: createMockState(), + }); expect(mockDevLogger.log).toHaveBeenCalledWith( - 'Pending deposits from controller state:', + 'useDepositRequests: Filtered deposits by account', expect.objectContaining({ - count: 2, + selectedAddress: mockAddress, + totalCount: 2, + filteredCount: 2, deposits: expect.arrayContaining([ expect.objectContaining({ id: 'pending1', @@ -465,6 +672,7 @@ describe('useDepositRequests', () => { amount: '100', asset: 'USDC', status: 'pending', + accountAddress: mockAddress, }), ]), }), @@ -472,7 +680,9 @@ describe('useDepositRequests', () => { }); it('logs final combined deposits', async () => { - renderHook(() => useDepositRequests()); + renderHookWithProvider(() => useDepositRequests(), { + state: createMockState(), + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -501,7 +711,9 @@ describe('useDepositRequests', () => { it('handles empty ledger updates', async () => { mockProvider.getUserNonFundingLedgerUpdates.mockResolvedValue([]); - const { result } = renderHook(() => useDepositRequests()); + const { result } = renderHookWithProvider(() => useDepositRequests(), { + state: createMockState(), + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -514,7 +726,9 @@ describe('useDepositRequests', () => { it('handles undefined ledger updates', async () => { mockProvider.getUserNonFundingLedgerUpdates.mockResolvedValue(undefined); - const { result } = renderHook(() => useDepositRequests()); + const { result } = renderHookWithProvider(() => useDepositRequests(), { + state: createMockState(), + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -527,7 +741,9 @@ describe('useDepositRequests', () => { it('handles null ledger updates', async () => { mockProvider.getUserNonFundingLedgerUpdates.mockResolvedValue(null); - const { result } = renderHook(() => useDepositRequests()); + const { result } = renderHookWithProvider(() => useDepositRequests(), { + state: createMockState(), + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -555,7 +771,9 @@ describe('useDepositRequests', () => { ledgerUpdatesWithoutCoin, ); - const { result } = renderHook(() => useDepositRequests()); + const { result } = renderHookWithProvider(() => useDepositRequests(), { + state: createMockState(), + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -583,7 +801,9 @@ describe('useDepositRequests', () => { ledgerUpdatesWithoutNonce, ); - const { result } = renderHook(() => useDepositRequests()); + const { result } = renderHookWithProvider(() => useDepositRequests(), { + state: createMockState(), + }); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); diff --git a/app/components/UI/Perps/hooks/useDepositRequests.ts b/app/components/UI/Perps/hooks/useDepositRequests.ts index 71d24ae921d..e6b6d5cc86f 100644 --- a/app/components/UI/Perps/hooks/useDepositRequests.ts +++ b/app/components/UI/Perps/hooks/useDepositRequests.ts @@ -1,14 +1,18 @@ import { useCallback, useEffect, useState, useMemo } from 'react'; +import { useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; import { usePerpsSelector } from './usePerpsSelector'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; +import { selectSelectedInternalAccountByScope } from '../../../../selectors/multichainAccounts/accounts'; export interface DepositRequest { id: string; timestamp: number; amount: string; asset: string; + accountAddress: string; // Account that initiated this deposit txHash?: string; + success: boolean; status: 'pending' | 'bridging' | 'completed' | 'failed'; source?: string; depositId?: string; @@ -45,20 +49,48 @@ export const useDepositRequests = ( ): UseDepositRequestsResult => { const { startTime, skipInitialFetch = false } = options; - // Get pending/bridging deposits from controller state (real-time) - const pendingDeposits = usePerpsSelector( - (state) => state?.depositRequests || [], - ); + // Get current selected account address + const selectedAddress = useSelector(selectSelectedInternalAccountByScope)( + 'eip155:1', + )?.address; + + // Get pending/bridging deposits from controller state and filter by current account + const pendingDeposits = usePerpsSelector((state) => { + const allDeposits = state?.depositRequests || []; + + // If no selected address, return empty array (don't show potentially wrong account's data) + if (!selectedAddress) { + DevLogger.log( + 'useDepositRequests: No selected address, returning empty array', + { + totalCount: allDeposits.length, + }, + ); + return []; + } + + // Filter by current account, normalizing addresses for comparison + const filtered = allDeposits.filter((req) => { + const match = + req.accountAddress?.toLowerCase() === selectedAddress.toLowerCase(); + return match; + }); - DevLogger.log('Pending deposits from controller state:', { - count: pendingDeposits.length, - deposits: pendingDeposits.map((d) => ({ - id: d.id, - timestamp: new Date(d.timestamp).toISOString(), - amount: d.amount, - asset: d.asset, - status: d.status, - })), + DevLogger.log('useDepositRequests: Filtered deposits by account', { + selectedAddress, + totalCount: allDeposits.length, + filteredCount: filtered.length, + deposits: filtered.map((d) => ({ + id: d.id, + timestamp: new Date(d.timestamp).toISOString(), + amount: d.amount, + asset: d.asset, + status: d.status, + accountAddress: d.accountAddress, + })), + }); + + return filtered; }); const [completedDeposits, setCompletedDeposits] = useState( @@ -72,6 +104,15 @@ export const useDepositRequests = ( setIsLoading(true); setError(null); + // Skip fetch if no selected address - can't attribute deposits to unknown account + if (!selectedAddress) { + DevLogger.log( + 'fetchCompletedDeposits: No selected address, skipping fetch', + ); + setIsLoading(false); + return; + } + const controller = Engine.context.PerpsController; if (!controller) { throw new Error('PerpsController not available'); @@ -111,6 +152,11 @@ export const useDepositRequests = ( // Handle cases where updates might be undefined or null const updatesArray = Array.isArray(updates) ? updates : []; + // Get current account address for completed deposits + // Since we're fetching deposits for the current account, all completed deposits belong to it + // Note: selectedAddress is guaranteed to exist due to early return above + const currentAccountAddress = selectedAddress; + const depositData = ( updatesArray as { delta: { @@ -133,7 +179,9 @@ export const useDepositRequests = ( timestamp: update.time, amount: Math.abs(parseFloat(update.delta.usdc)).toString(), asset: update.delta.coin || 'USDC', // Default to USDC if coin is not specified + accountAddress: currentAccountAddress, // Completed deposits belong to current account txHash: update.hash, + success: true, // Completed deposits from ledger are successful status: 'completed' as const, // HyperLiquid ledger updates are completed transactions source: undefined, // Not available in ledger updates depositId: update.delta.nonce?.toString(), // Use nonce as deposit ID if available @@ -150,7 +198,7 @@ export const useDepositRequests = ( } finally { setIsLoading(false); } - }, [startTime]); + }, [selectedAddress, startTime]); // Combine pending and completed deposits const allDeposits = useMemo(() => { diff --git a/app/components/UI/Perps/hooks/useWithdrawalRequests.test.ts b/app/components/UI/Perps/hooks/useWithdrawalRequests.test.ts index 17ad7900272..5bba68e51aa 100644 --- a/app/components/UI/Perps/hooks/useWithdrawalRequests.test.ts +++ b/app/components/UI/Perps/hooks/useWithdrawalRequests.test.ts @@ -1,21 +1,41 @@ -import { renderHook, act } from '@testing-library/react-native'; +import { act } from '@testing-library/react-native'; +import { renderHookWithProvider } from '../../../../util/test/renderWithProvider'; import { useWithdrawalRequests } from './useWithdrawalRequests'; import Engine from '../../../../core/Engine'; import { usePerpsSelector } from './usePerpsSelector'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; +import type { PerpsControllerState } from '../controllers/PerpsController'; +import type { RootState } from '../../../../reducers'; +import { + createMockInternalAccount, + createMockUuidFromAddress, +} from '../../../../util/test/accountsControllerTestUtils'; +import { useSelector } from 'react-redux'; // Mock dependencies jest.mock('../../../../core/Engine'); jest.mock('./usePerpsSelector'); jest.mock('../../../../core/SDKConnect/utils/DevLogger'); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); const mockEngine = Engine as jest.Mocked; const mockUsePerpsSelector = usePerpsSelector as jest.MockedFunction< typeof usePerpsSelector >; const mockDevLogger = DevLogger as jest.Mocked; +const mockUseSelector = useSelector as jest.MockedFunction; describe('useWithdrawalRequests', () => { + const mockAddress = '0x1234567890123456789012345678901234567890'; + const mockAccountId = createMockUuidFromAddress(mockAddress.toLowerCase()); + const mockInternalAccount = createMockInternalAccount( + mockAddress.toLowerCase(), + 'Account 1', + ); + let mockController: { getActiveProvider: jest.MockedFunction<() => unknown>; updateWithdrawalStatus: jest.MockedFunction< @@ -34,6 +54,7 @@ describe('useWithdrawalRequests', () => { timestamp: 1640995200000, amount: '100', asset: 'USDC', + accountAddress: mockAddress, status: 'pending' as const, destination: '0x123', }, @@ -42,6 +63,7 @@ describe('useWithdrawalRequests', () => { timestamp: 1640995201000, amount: '200', asset: 'USDC', + accountAddress: mockAddress, status: 'bridging' as const, destination: '0x456', txHash: '0xabc', @@ -84,9 +106,46 @@ describe('useWithdrawalRequests', () => { }, ]; + // Helper to create mock Redux state with account + const createMockState = () => + ({ + engine: { + backgroundState: { + AccountTreeController: { + accountTree: { + selectedAccountGroup: 'keyring:wallet1/1', + wallets: { + 'keyring:wallet1': { + id: 'keyring:wallet1', + name: 'Wallet 1', + type: 'hd', + groups: [ + { + id: 'keyring:wallet1/1', + name: 'Account 1', + accounts: [mockAccountId], + }, + ], + }, + }, + }, + }, + AccountsController: { + internalAccounts: { + accounts: { + [mockAccountId]: mockInternalAccount, + }, + selectedAccount: mockAccountId, + }, + }, + }, + }, + }) as unknown as RootState; + beforeEach(() => { jest.clearAllMocks(); - jest.useFakeTimers(); + jest.useRealTimers(); // Clear any existing fake timers first + jest.useFakeTimers(); // Then install fresh fake timers // Mock controller mockController = { @@ -106,8 +165,24 @@ describe('useWithdrawalRequests', () => { PerpsController: mockController, }; - // Mock usePerpsSelector - mockUsePerpsSelector.mockReturnValue(mockPendingWithdrawals); + // Mock usePerpsSelector to execute the selector function with mock state + mockUsePerpsSelector.mockImplementation((selector) => + selector({ + withdrawalRequests: mockPendingWithdrawals, + } as Partial as PerpsControllerState), + ); + + // Mock useSelector to return the mock account for selectSelectedInternalAccountByScope + mockUseSelector.mockImplementation((selector) => { + // Check if this is the selectSelectedInternalAccountByScope selector + // It returns a function that takes a scope + const result = selector(createMockState()); + if (typeof result === 'function') { + // This is selectSelectedInternalAccountByScope, return the mock account + return () => mockInternalAccount; + } + return result; + }); // Mock provider methods mockController.getActiveProvider.mockReturnValue(mockProvider); @@ -122,7 +197,9 @@ describe('useWithdrawalRequests', () => { describe('initial state', () => { it('returns initial state with pending withdrawals', () => { - const { result } = renderHook(() => useWithdrawalRequests()); + const { result } = renderHookWithProvider(() => useWithdrawalRequests(), { + state: createMockState(), + }); expect(result.current.withdrawalRequests).toEqual( expect.arrayContaining([ @@ -142,8 +219,9 @@ describe('useWithdrawalRequests', () => { }); it('skips initial fetch when skipInitialFetch is true', () => { - const { result } = renderHook(() => - useWithdrawalRequests({ skipInitialFetch: true }), + const { result } = renderHookWithProvider( + () => useWithdrawalRequests({ skipInitialFetch: true }), + { state: createMockState() }, ); expect(result.current.isLoading).toBe(false); @@ -154,7 +232,10 @@ describe('useWithdrawalRequests', () => { it('uses custom startTime when provided', async () => { const customStartTime = 1640995000000; - renderHook(() => useWithdrawalRequests({ startTime: customStartTime })); + renderHookWithProvider( + () => useWithdrawalRequests({ startTime: customStartTime }), + { state: createMockState() }, + ); await act(async () => { jest.advanceTimersByTime(0); @@ -170,7 +251,9 @@ describe('useWithdrawalRequests', () => { const mockNow = new Date('2024-01-01T12:00:00Z'); jest.spyOn(global, 'Date').mockImplementation(() => mockNow); - renderHook(() => useWithdrawalRequests()); + renderHookWithProvider(() => useWithdrawalRequests(), { + state: createMockState(), + }); await act(async () => { jest.advanceTimersByTime(0); @@ -193,7 +276,9 @@ describe('useWithdrawalRequests', () => { describe('fetching completed withdrawals', () => { it('fetches completed withdrawals successfully', async () => { - const { result } = renderHook(() => useWithdrawalRequests()); + const { result } = renderHookWithProvider(() => useWithdrawalRequests(), { + state: createMockState(), + }); await act(async () => { jest.advanceTimersByTime(0); @@ -227,7 +312,9 @@ describe('useWithdrawalRequests', () => { it('handles provider errors gracefully', async () => { mockController.getActiveProvider.mockReturnValue(null); - const { result } = renderHook(() => useWithdrawalRequests()); + const { result } = renderHookWithProvider(() => useWithdrawalRequests(), { + state: createMockState(), + }); await act(async () => { jest.advanceTimersByTime(0); @@ -240,7 +327,9 @@ describe('useWithdrawalRequests', () => { it('handles controller errors gracefully', async () => { (mockEngine as unknown as { context: unknown }).context = {}; - const { result } = renderHook(() => useWithdrawalRequests()); + const { result } = renderHookWithProvider(() => useWithdrawalRequests(), { + state: createMockState(), + }); await act(async () => { jest.advanceTimersByTime(0); @@ -254,7 +343,9 @@ describe('useWithdrawalRequests', () => { const providerWithoutMethod = {}; mockController.getActiveProvider.mockReturnValue(providerWithoutMethod); - const { result } = renderHook(() => useWithdrawalRequests()); + const { result } = renderHookWithProvider(() => useWithdrawalRequests(), { + state: createMockState(), + }); await act(async () => { jest.advanceTimersByTime(0); @@ -270,7 +361,9 @@ describe('useWithdrawalRequests', () => { const apiError = new Error('API Error'); mockProvider.getUserNonFundingLedgerUpdates.mockRejectedValue(apiError); - const { result } = renderHook(() => useWithdrawalRequests()); + const { result } = renderHookWithProvider(() => useWithdrawalRequests(), { + state: createMockState(), + }); await act(async () => { jest.advanceTimersByTime(0); @@ -285,7 +378,9 @@ describe('useWithdrawalRequests', () => { 'String error', ); - const { result } = renderHook(() => useWithdrawalRequests()); + const { result } = renderHookWithProvider(() => useWithdrawalRequests(), { + state: createMockState(), + }); await act(async () => { jest.advanceTimersByTime(0); @@ -302,7 +397,9 @@ describe('useWithdrawalRequests', () => { null as unknown as unknown[], ); - const { result } = renderHook(() => useWithdrawalRequests()); + const { result } = renderHookWithProvider(() => useWithdrawalRequests(), { + state: createMockState(), + }); await act(async () => { jest.advanceTimersByTime(0); @@ -316,7 +413,9 @@ describe('useWithdrawalRequests', () => { describe('withdrawal data transformation', () => { it('transforms ledger updates to withdrawal requests correctly', async () => { - const { result } = renderHook(() => useWithdrawalRequests()); + const { result } = renderHookWithProvider(() => useWithdrawalRequests(), { + state: createMockState(), + }); await act(async () => { jest.advanceTimersByTime(0); @@ -376,7 +475,9 @@ describe('useWithdrawalRequests', () => { updatesWithoutCoin as unknown as unknown[], ); - const { result } = renderHook(() => useWithdrawalRequests()); + const { result } = renderHookWithProvider(() => useWithdrawalRequests(), { + state: createMockState(), + }); await act(async () => { jest.advanceTimersByTime(0); @@ -409,7 +510,9 @@ describe('useWithdrawalRequests', () => { updatesWithoutNonce as unknown as unknown[], ); - const { result } = renderHook(() => useWithdrawalRequests()); + const { result } = renderHookWithProvider(() => useWithdrawalRequests(), { + state: createMockState(), + }); await act(async () => { jest.advanceTimersByTime(0); @@ -443,7 +546,9 @@ describe('useWithdrawalRequests', () => { updatesWithNegativeAmount as unknown as unknown[], ); - const { result } = renderHook(() => useWithdrawalRequests()); + const { result } = renderHookWithProvider(() => useWithdrawalRequests(), { + state: createMockState(), + }); await act(async () => { jest.advanceTimersByTime(0); @@ -467,11 +572,16 @@ describe('useWithdrawalRequests', () => { timestamp: 1640995200000, amount: '100', asset: 'USDC', + accountAddress: mockAddress, status: 'pending' as const, destination: '0x123', }; - mockUsePerpsSelector.mockReturnValue([matchingPendingWithdrawal]); + mockUsePerpsSelector.mockImplementation((selector) => + selector({ + withdrawalRequests: [matchingPendingWithdrawal], + } as Partial as PerpsControllerState), + ); mockProvider.getUserNonFundingLedgerUpdates.mockResolvedValue([ { delta: { @@ -486,7 +596,9 @@ describe('useWithdrawalRequests', () => { }, ] as unknown as unknown[]); - const { result } = renderHook(() => useWithdrawalRequests()); + const { result } = renderHookWithProvider(() => useWithdrawalRequests(), { + state: createMockState(), + }); await act(async () => { jest.advanceTimersByTime(0); @@ -502,6 +614,7 @@ describe('useWithdrawalRequests', () => { timestamp: 1640995200000, amount: '100', asset: 'USDC', + accountAddress: mockAddress, status: 'completed', destination: '0x123', txHash: '0xledger1', @@ -521,11 +634,16 @@ describe('useWithdrawalRequests', () => { timestamp: 1640995200000, amount: '100', asset: 'USDC', + accountAddress: mockAddress, status: 'pending' as const, destination: '0x123', }; - mockUsePerpsSelector.mockReturnValue([pendingWithdrawal]); + mockUsePerpsSelector.mockImplementation((selector) => + selector({ + withdrawalRequests: [pendingWithdrawal], + } as Partial as PerpsControllerState), + ); mockProvider.getUserNonFundingLedgerUpdates.mockResolvedValue([ { delta: { @@ -540,7 +658,9 @@ describe('useWithdrawalRequests', () => { }, ] as unknown as unknown[]); - const { result } = renderHook(() => useWithdrawalRequests()); + const { result } = renderHookWithProvider(() => useWithdrawalRequests(), { + state: createMockState(), + }); await act(async () => { jest.advanceTimersByTime(0); @@ -561,11 +681,16 @@ describe('useWithdrawalRequests', () => { timestamp: 1640995200000, amount: '100', asset: 'USDC', + accountAddress: mockAddress, status: 'pending' as const, destination: '0x123', }; - mockUsePerpsSelector.mockReturnValue([pendingWithdrawal]); + mockUsePerpsSelector.mockImplementation((selector) => + selector({ + withdrawalRequests: [pendingWithdrawal], + } as Partial as PerpsControllerState), + ); mockProvider.getUserNonFundingLedgerUpdates.mockResolvedValue([ { delta: { @@ -580,7 +705,9 @@ describe('useWithdrawalRequests', () => { }, ] as unknown as unknown[]); - const { result } = renderHook(() => useWithdrawalRequests()); + const { result } = renderHookWithProvider(() => useWithdrawalRequests(), { + state: createMockState(), + }); await act(async () => { jest.advanceTimersByTime(0); @@ -601,11 +728,16 @@ describe('useWithdrawalRequests', () => { timestamp: 1640995200000, amount: '100', asset: 'USDC', + accountAddress: mockAddress, status: 'pending' as const, destination: '0x123', }; - mockUsePerpsSelector.mockReturnValue([pendingWithdrawal]); + mockUsePerpsSelector.mockImplementation((selector) => + selector({ + withdrawalRequests: [pendingWithdrawal], + } as Partial as PerpsControllerState), + ); mockProvider.getUserNonFundingLedgerUpdates.mockResolvedValue([ { delta: { @@ -620,7 +752,9 @@ describe('useWithdrawalRequests', () => { }, ] as unknown as unknown[]); - const { result } = renderHook(() => useWithdrawalRequests()); + const { result } = renderHookWithProvider(() => useWithdrawalRequests(), { + state: createMockState(), + }); await act(async () => { jest.advanceTimersByTime(0); @@ -641,11 +775,16 @@ describe('useWithdrawalRequests', () => { timestamp: 1640995200000, amount: '100.00', asset: 'USDC', + accountAddress: mockAddress, status: 'pending' as const, destination: '0x123', }; - mockUsePerpsSelector.mockReturnValue([pendingWithdrawal]); + mockUsePerpsSelector.mockImplementation((selector) => + selector({ + withdrawalRequests: [pendingWithdrawal], + } as Partial as PerpsControllerState), + ); mockProvider.getUserNonFundingLedgerUpdates.mockResolvedValue([ { delta: { @@ -660,7 +799,9 @@ describe('useWithdrawalRequests', () => { }, ] as unknown as unknown[]); - const { result } = renderHook(() => useWithdrawalRequests()); + const { result } = renderHookWithProvider(() => useWithdrawalRequests(), { + state: createMockState(), + }); await act(async () => { jest.advanceTimersByTime(0); @@ -681,11 +822,16 @@ describe('useWithdrawalRequests', () => { timestamp: 1640995200000, amount: '100', asset: 'USDC', + accountAddress: mockAddress, status: 'pending' as const, destination: '0x123', }; - mockUsePerpsSelector.mockReturnValue([pendingWithdrawal]); + mockUsePerpsSelector.mockImplementation((selector) => + selector({ + withdrawalRequests: [pendingWithdrawal], + } as Partial as PerpsControllerState), + ); mockProvider.getUserNonFundingLedgerUpdates.mockResolvedValue([ { delta: { @@ -700,7 +846,9 @@ describe('useWithdrawalRequests', () => { }, ] as unknown as unknown[]); - const { result } = renderHook(() => useWithdrawalRequests()); + const { result } = renderHookWithProvider(() => useWithdrawalRequests(), { + state: createMockState(), + }); await act(async () => { jest.advanceTimersByTime(0); @@ -718,7 +866,11 @@ describe('useWithdrawalRequests', () => { describe('sorting and ordering', () => { it('sorts withdrawals by timestamp descending', async () => { - mockUsePerpsSelector.mockReturnValue([]); + mockUsePerpsSelector.mockImplementation((selector) => + selector({ + withdrawalRequests: [], + } as Partial as PerpsControllerState), + ); mockProvider.getUserNonFundingLedgerUpdates.mockResolvedValue([ { delta: { @@ -744,7 +896,9 @@ describe('useWithdrawalRequests', () => { }, ] as unknown as unknown[]); - const { result } = renderHook(() => useWithdrawalRequests()); + const { result } = renderHookWithProvider(() => useWithdrawalRequests(), { + state: createMockState(), + }); await act(async () => { jest.advanceTimersByTime(0); @@ -765,14 +919,21 @@ describe('useWithdrawalRequests', () => { timestamp: 1640995200000, amount: '100', asset: 'USDC', + accountAddress: mockAddress, status: 'pending' as const, destination: '0x123', }, ]; - mockUsePerpsSelector.mockReturnValue(activeWithdrawals); + mockUsePerpsSelector.mockImplementation((selector) => + selector({ + withdrawalRequests: activeWithdrawals, + } as Partial as PerpsControllerState), + ); - renderHook(() => useWithdrawalRequests()); + renderHookWithProvider(() => useWithdrawalRequests(), { + state: createMockState(), + }); await act(async () => { jest.advanceTimersByTime(0); @@ -801,14 +962,21 @@ describe('useWithdrawalRequests', () => { timestamp: 1640995200000, amount: '100', asset: 'USDC', + accountAddress: mockAddress, status: 'completed' as const, txHash: '0x123', }, ]; - mockUsePerpsSelector.mockReturnValue(completedWithdrawals); + mockUsePerpsSelector.mockImplementation((selector) => + selector({ + withdrawalRequests: completedWithdrawals, + } as Partial as PerpsControllerState), + ); - renderHook(() => useWithdrawalRequests()); + renderHookWithProvider(() => useWithdrawalRequests(), { + state: createMockState(), + }); await act(async () => { jest.advanceTimersByTime(0); @@ -837,14 +1005,22 @@ describe('useWithdrawalRequests', () => { timestamp: 1640995200000, amount: '100', asset: 'USDC', + accountAddress: mockAddress, status: 'pending' as const, destination: '0x123', }, ]; - mockUsePerpsSelector.mockReturnValue(activeWithdrawals); + mockUsePerpsSelector.mockImplementation((selector) => + selector({ + withdrawalRequests: activeWithdrawals, + } as Partial as PerpsControllerState), + ); - const { unmount } = renderHook(() => useWithdrawalRequests()); + const { unmount } = renderHookWithProvider( + () => useWithdrawalRequests(), + { state: createMockState() }, + ); await act(async () => { jest.advanceTimersByTime(0); @@ -866,7 +1042,9 @@ describe('useWithdrawalRequests', () => { describe('refetch functionality', () => { it('refetches data when refetch is called', async () => { - const { result } = renderHook(() => useWithdrawalRequests()); + const { result } = renderHookWithProvider(() => useWithdrawalRequests(), { + state: createMockState(), + }); await act(async () => { jest.advanceTimersByTime(0); @@ -889,7 +1067,9 @@ describe('useWithdrawalRequests', () => { }); it('handles refetch errors gracefully', async () => { - const { result } = renderHook(() => useWithdrawalRequests()); + const { result } = renderHookWithProvider(() => useWithdrawalRequests(), { + state: createMockState(), + }); await act(async () => { jest.advanceTimersByTime(0); @@ -910,7 +1090,9 @@ describe('useWithdrawalRequests', () => { describe('logging', () => { it('logs pending withdrawals from controller state', () => { - renderHook(() => useWithdrawalRequests()); + renderHookWithProvider(() => useWithdrawalRequests(), { + state: createMockState(), + }); expect(mockDevLogger.log).toHaveBeenCalledWith( 'Pending withdrawals from controller state:', @@ -932,9 +1114,15 @@ describe('useWithdrawalRequests', () => { describe('edge cases', () => { it('handles empty pending withdrawals', () => { - mockUsePerpsSelector.mockReturnValue([]); + mockUsePerpsSelector.mockImplementation((selector) => + selector({ + withdrawalRequests: [], + } as Partial as PerpsControllerState), + ); - const { result } = renderHook(() => useWithdrawalRequests()); + const { result } = renderHookWithProvider(() => useWithdrawalRequests(), { + state: createMockState(), + }); expect(result.current.withdrawalRequests).toEqual([]); }); @@ -942,7 +1130,9 @@ describe('useWithdrawalRequests', () => { it('handles empty completed withdrawals', async () => { mockProvider.getUserNonFundingLedgerUpdates.mockResolvedValue([]); - const { result } = renderHook(() => useWithdrawalRequests()); + const { result } = renderHookWithProvider(() => useWithdrawalRequests(), { + state: createMockState(), + }); await act(async () => { jest.advanceTimersByTime(0); @@ -962,14 +1152,21 @@ describe('useWithdrawalRequests', () => { timestamp: 1640995200000, amount: '100', asset: 'USDC', + accountAddress: mockAddress, status: 'failed' as const, destination: '0x123', }, ]; - mockUsePerpsSelector.mockReturnValue(failedWithdrawals); + mockUsePerpsSelector.mockImplementation((selector) => + selector({ + withdrawalRequests: failedWithdrawals, + } as Partial as PerpsControllerState), + ); - const { result } = renderHook(() => useWithdrawalRequests()); + const { result } = renderHookWithProvider(() => useWithdrawalRequests(), { + state: createMockState(), + }); const failedWithdrawal = result.current.withdrawalRequests.find( (w) => w.id === 'withdrawal-failed', @@ -985,6 +1182,7 @@ describe('useWithdrawalRequests', () => { timestamp: 1640995200000, amount: '100', asset: 'USDC', + accountAddress: mockAddress, status: 'pending' as const, destination: '0x123', }, @@ -993,14 +1191,21 @@ describe('useWithdrawalRequests', () => { timestamp: 1640995200000, amount: '200', asset: 'USDC', + accountAddress: mockAddress, status: 'pending' as const, destination: '0x456', }, ]; - mockUsePerpsSelector.mockReturnValue(sameTimestampWithdrawals); + mockUsePerpsSelector.mockImplementation((selector) => + selector({ + withdrawalRequests: sameTimestampWithdrawals, + } as Partial as PerpsControllerState), + ); - const { result } = renderHook(() => useWithdrawalRequests()); + const { result } = renderHookWithProvider(() => useWithdrawalRequests(), { + state: createMockState(), + }); expect(result.current.withdrawalRequests).toHaveLength(2); expect(result.current.withdrawalRequests[0].id).toBe('withdrawal-1'); diff --git a/app/components/UI/Perps/hooks/useWithdrawalRequests.ts b/app/components/UI/Perps/hooks/useWithdrawalRequests.ts index bea4c60c4f6..a62fb31348f 100644 --- a/app/components/UI/Perps/hooks/useWithdrawalRequests.ts +++ b/app/components/UI/Perps/hooks/useWithdrawalRequests.ts @@ -1,13 +1,16 @@ import { useCallback, useEffect, useState, useMemo } from 'react'; +import { useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; import { usePerpsSelector } from './usePerpsSelector'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; +import { selectSelectedInternalAccountByScope } from '../../../../selectors/multichainAccounts/accounts'; export interface WithdrawalRequest { id: string; timestamp: number; amount: string; asset: string; + accountAddress: string; // Account that initiated this withdrawal txHash?: string; status: 'pending' | 'bridging' | 'completed' | 'failed'; destination?: string; @@ -45,10 +48,46 @@ export const useWithdrawalRequests = ( ): UseWithdrawalRequestsResult => { const { startTime, skipInitialFetch = false } = options; - // Get pending withdrawals from controller state (real-time) - const pendingWithdrawals = usePerpsSelector( - (state) => state?.withdrawalRequests || [], - ); + // Get current selected account address + const selectedAddress = useSelector(selectSelectedInternalAccountByScope)( + 'eip155:1', + )?.address; + + // Get pending withdrawals from controller state and filter by current account + const pendingWithdrawals = usePerpsSelector((state) => { + const allWithdrawals = state?.withdrawalRequests || []; + + // If no selected address, return empty array (don't show potentially wrong account's data) + if (!selectedAddress) { + DevLogger.log( + 'useWithdrawalRequests: No selected address, returning empty array', + { + totalCount: allWithdrawals.length, + }, + ); + return []; + } + + // Filter by current account, normalizing addresses for comparison + const filtered = allWithdrawals.filter((req) => { + const match = + req.accountAddress?.toLowerCase() === selectedAddress.toLowerCase(); + return match; + }); + + DevLogger.log('useWithdrawalRequests: Filtered withdrawals by account', { + selectedAddress, + totalCount: allWithdrawals.length, + filteredCount: filtered.length, + withdrawals: filtered.map((w) => ({ + id: w.id, + accountAddress: w.accountAddress, + status: w.status, + })), + }); + + return filtered; + }); DevLogger.log('Pending withdrawals from controller state:', { count: pendingWithdrawals.length, @@ -71,6 +110,15 @@ export const useWithdrawalRequests = ( setIsLoading(true); setError(null); + // Skip fetch if no selected address - can't attribute withdrawals to unknown account + if (!selectedAddress) { + DevLogger.log( + 'fetchCompletedWithdrawals: No selected address, skipping fetch', + ); + setIsLoading(false); + return; + } + const controller = Engine.context.PerpsController; if (!controller) { throw new Error('PerpsController not available'); @@ -132,6 +180,7 @@ export const useWithdrawalRequests = ( timestamp: update.time, amount: Math.abs(parseFloat(update.delta.usdc)).toString(), asset: update.delta.coin || 'USDC', // Default to USDC if coin is not specified + accountAddress: selectedAddress, // selectedAddress is guaranteed to exist due to early return above txHash: update.hash, status: 'completed' as const, // HyperLiquid ledger updates are completed transactions destination: undefined, // Not available in ledger updates @@ -149,7 +198,7 @@ export const useWithdrawalRequests = ( } finally { setIsLoading(false); } - }, [startTime]); + }, [startTime, selectedAddress]); // Combine pending and completed withdrawals const allWithdrawals = useMemo(() => { diff --git a/app/util/logs/__snapshots__/index.test.ts.snap b/app/util/logs/__snapshots__/index.test.ts.snap index 6129331ef9e..6170b6d9a4b 100644 --- a/app/util/logs/__snapshots__/index.test.ts.snap +++ b/app/util/logs/__snapshots__/index.test.ts.snap @@ -465,7 +465,7 @@ exports[`logs :: generateStateLogs Sanitized SeedlessOnboardingController State "activeProvider": "hyperliquid", "connectionStatus": "disconnected", "depositInProgress": false, - "depositRequests": {}, + "depositRequests": [], "hasPlacedFirstOrder": { "mainnet": false, "testnet": false, @@ -491,8 +491,12 @@ exports[`logs :: generateStateLogs Sanitized SeedlessOnboardingController State "tradeConfigurations": {}, "watchlistMarkets": [], "withdrawInProgress": false, - "withdrawalProgress": {}, - "withdrawalRequests": {}, + "withdrawalProgress": { + "activeWithdrawalId": null, + "lastUpdated": 0, + "progress": 0, + }, + "withdrawalRequests": [], }, "PreferencesController": { "dismissSmartAccountSuggestionEnabled": false, @@ -1212,7 +1216,7 @@ exports[`logs :: generateStateLogs generates a valid json export 1`] = ` "activeProvider": "hyperliquid", "connectionStatus": "disconnected", "depositInProgress": false, - "depositRequests": {}, + "depositRequests": [], "hasPlacedFirstOrder": { "mainnet": false, "testnet": false, @@ -1238,8 +1242,12 @@ exports[`logs :: generateStateLogs generates a valid json export 1`] = ` "tradeConfigurations": {}, "watchlistMarkets": [], "withdrawInProgress": false, - "withdrawalProgress": {}, - "withdrawalRequests": {}, + "withdrawalProgress": { + "activeWithdrawalId": null, + "lastUpdated": 0, + "progress": 0, + }, + "withdrawalRequests": [], }, "PreferencesController": { "dismissSmartAccountSuggestionEnabled": false, diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index 6b1a42ae055..5ca8b737202 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -475,12 +475,16 @@ "positions": [], "accountState": null, "depositInProgress": false, - "depositRequests": {}, + "depositRequests": [], "lastDepositTransactionId": null, "lastDepositResult": null, "withdrawInProgress": false, - "withdrawalRequests": {}, - "withdrawalProgress": {}, + "withdrawalRequests": [], + "withdrawalProgress": { + "progress": 0, + "lastUpdated": 0, + "activeWithdrawalId": null + }, "lastWithdrawResult": null, "lastError": null, "lastUpdateTimestamp": 0, From c424c876f255f2154c2b72b6bd68672405f2342b Mon Sep 17 00:00:00 2001 From: Nicholas Smith Date: Wed, 3 Dec 2025 12:08:34 -0600 Subject: [PATCH 4/8] feat: lend in one click bundling erc 20 approval and deposit (#23154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** 1. What is the reason for the change? We want to deposit in Earn lending in 1 click 2. What is the improvement/solution? We can now deposit into Earn lending in 1 click using the redesigned confirmations ## **Changelog** CHANGELOG entry: Adds 1-click approval/deposit transaction confirmation flow for Earn lending ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MUSD-93 ## **Manual testing steps** ```gherkin Feature: stablecoin lending 1-click flow Scenario: user is on token home page Given user clicks on a lending cta When user goes to the lending deposit input Then clicks on the action for deposit, there will be a 1-click confirmation instead of 2 clicks ``` ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/80ba6e8a-811f-4a0d-9875-e7d3b74eaad5 ## **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] > Enables 1‑click Earn lending by batching ERC‑20 approval + deposit with redesigned confirmations, adds batch-aware gas/confirmation behavior, removes legacy flag gating, and updates tests/i18n. > > - **Earn (Input/Flow)**: > - Implement 1‑click stablecoin lending: create transaction batch with `tokenMethodApprove` + `lendingDeposit` and navigate to `REDESIGNED_CONFIRMATIONS` when `staking_confirmations` is enabled. > - Use `selectDefaultEndpointByChainId` to derive `networkClientId`; remove env-gated redesign util and legacy UI bits (e.g., estimated rewards card). > - Add guards/logging (missing `selectedAccount`, missing market data, undefined `attemptDepositTransaction`). > - **Confirmations/Approvals**: > - Treat `ApprovalType.TransactionBatch` as non-blocking (`waitForResult: false`) and navigate to `TransactionsView` after confirm. > - Add `TransactionType.lendingDeposit` to full-screen confirmations/contract interaction sets. > - **Gas UI (Batch-aware)**: > - `GasFeeTokenIcon`/`SelectedGasFeeToken`: resolve `chainId` from single transaction or batch metadata; fallback handling added. > - `GasFeeTokenToast`: default `chainId` when undefined; add close action. > - `GasFeesDetailsRow`: distinct loading for batch vs single; support fee display using batch estimates without simulation. > - **Env/Utils/i18n**: > - Remove `MM_STABLECOIN_LENDING_UI_ENABLED_REDESIGNED` and delete `EarnInputView/utils.ts`. > - Add `earn.rewards` strings for rewards tag/tooltips. > - **Tests/Snapshots**: > - Extensive updates and new cases for lending batch flow, confirmations behavior, gas components (batch metadata), and snapshots reflecting UI changes. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 7c133f94298896d590848d20dae70513e295cfa4. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Matthew Walsh --- .js.env.example | 2 - .../EarnInputView/EarnInputView.test.tsx | 212 ++++++++++++++---- .../Views/EarnInputView/EarnInputView.tsx | 65 +++--- .../__snapshots__/EarnInputView.test.tsx.snap | 210 +---------------- .../UI/Earn/Views/EarnInputView/utils.ts | 4 - .../gas-fee-token-icon.test.tsx | 72 ++++++ .../gas-fee-token-icon/gas-fee-token-icon.tsx | 6 +- .../gas-fee-token-toast.test.tsx | 38 ++++ .../gas-fee-token-toast.tsx | 2 +- .../selected-gas-fee-token.test.tsx | 128 +++++++++++ .../selected-gas-fee-token.tsx | 6 +- .../gas-fee-details-row.test.tsx | 137 ++++++++++- .../gas-fee-details-row.tsx | 9 +- .../confirmations/constants/confirmations.ts | 1 + .../hooks/useConfirmActions.test.ts | 118 ++++++++++ .../confirmations/hooks/useConfirmActions.ts | 10 +- locales/languages/en.json | 9 + 17 files changed, 733 insertions(+), 296 deletions(-) delete mode 100644 app/components/UI/Earn/Views/EarnInputView/utils.ts diff --git a/.js.env.example b/.js.env.example index 440709aacab..99c3657447b 100644 --- a/.js.env.example +++ b/.js.env.example @@ -107,8 +107,6 @@ export MM_PERMISSIONS_SETTINGS_V1_ENABLED="" ## Stablecoin Lending export MM_STABLECOIN_LENDING_UI_ENABLED="true" export MM_STABLE_COIN_SERVICE_INTERRUPTION_BANNER_ENABLED="true" -### Redesigned stablecoin lending -export MM_STABLECOIN_LENDING_UI_ENABLED_REDESIGNED="true" ## Pooled-Staking export MM_POOLED_STAKING_ENABLED="true" export MM_POOLED_STAKING_SERVICE_INTERRUPTION_BANNER_ENABLED="true" diff --git a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.test.tsx b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.test.tsx index 60803c1ce71..60337e15cf5 100644 --- a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.test.tsx +++ b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.test.tsx @@ -45,6 +45,8 @@ import usePoolStakedDeposit from '../../../Stake/hooks/usePoolStakedDeposit'; import Engine from '../../../../../core/Engine'; // eslint-disable-next-line import/no-namespace import * as useEarnGasFee from '../../../Earn/hooks/useEarnGasFee'; +// eslint-disable-next-line import/no-namespace +import * as multichainAccountsSelectors from '../../../../../selectors/multichainAccounts/accounts'; import { createMockToken, getCreateMockTokenOptions, @@ -57,14 +59,11 @@ import { selectStablecoinLendingEnabledFlag } from '../../selectors/featureFlags import EarnInputView from './EarnInputView'; import { EarnInputViewProps } from './EarnInputView.types'; import { Stake } from '../../../Stake/sdk/stakeSdkProvider'; -import { getIsRedesignedStablecoinLendingScreenEnabled } from './utils'; import { selectConversionRate } from '../../../../../selectors/currencyRateController'; import { trace, TraceName } from '../../../../../util/trace'; import { MAINNET_DISPLAY_NAME } from '../../../../../core/Engine/constants'; import { selectTrxStakingEnabled } from '../../../../../selectors/featureFlagController/trxStakingEnabled'; -jest.mock('./utils'); - jest.mock('lodash', () => { const actual = jest.requireActual('lodash'); return { @@ -292,11 +291,6 @@ jest.mock('../../../Stake/hooks/usePoolStakedDeposit', () => ({ default: jest.fn(), })); -jest.mock('./utils', () => ({ - __esModule: true, - getIsRedesignedStablecoinLendingScreenEnabled: jest.fn(() => false), -})); - jest.mock('../../utils/tempLending', () => ({ generateLendingAllowanceIncreaseTransaction: jest.fn(() => ({ txParams: { @@ -380,9 +374,6 @@ describe('EarnInputView', () => { jest.useFakeTimers(); // Reset the mocked function to default value - ( - getIsRedesignedStablecoinLendingScreenEnabled as jest.Mock - ).mockReturnValue(false); selectConfirmationRedesignFlagsMock.mockReturnValue({ staking_confirmations: false, } as unknown as ConfirmationRedesignRemoteFlags); @@ -470,7 +461,7 @@ describe('EarnInputView', () => { }); afterEach(() => { - (getIsRedesignedStablecoinLendingScreenEnabled as jest.Mock).mockClear(); + jest.clearAllMocks(); }); function render( @@ -702,16 +693,6 @@ describe('EarnInputView', () => { }); }); - describe('when calculating rewards', () => { - it('calculates estimated annual rewards based on input', () => { - const { getByText } = renderComponent(); - - fireEvent.press(getByText('1')); - - expect(getByText('0.5 ETH')).toBeTruthy(); - }); - }); - describe('quick amount buttons', () => { it('handles 25% quick amount button press correctly', () => { const { getByText } = renderComponent(); @@ -742,17 +723,6 @@ describe('EarnInputView', () => { fireEvent.press(getByText('4')); expect(queryAllByText('Not enough ETH')).toHaveLength(2); }); - - it('navigates to Learn more modal when learn icon is pressed', () => { - const { getByLabelText } = renderComponent(); - fireEvent.press(getByLabelText('Learn More')); - expect(mockNavigate).toHaveBeenCalledWith('StakeModals', { - screen: Routes.STAKING.MODALS.LEARN_MORE, - params: { - chainId: CHAIN_IDS.MAINNET, - }, - }); - }); }); describe('navigates to ', () => { @@ -1057,10 +1027,10 @@ describe('EarnInputView', () => { // Enable stablecoin lending feature flag selectStablecoinLendingEnabledFlagMock.mockReturnValue(true); - // Mock the function to return true for this test - ( - getIsRedesignedStablecoinLendingScreenEnabled as jest.Mock - ).mockReturnValue(true); + // Enable redesigned staking confirmations flag + selectConfirmationRedesignFlagsMock.mockReturnValue({ + staking_confirmations: true, + } as unknown as ConfirmationRedesignRemoteFlags); const getErc20SpendingLimitSpy = jest .spyOn(Engine.context.EarnController, 'getLendingTokenAllowance') @@ -1156,9 +1126,6 @@ describe('EarnInputView', () => { type: 'lendingDeposit', }, ], - disable7702: true, - disableHook: true, - disableSequential: false, requireApproval: true, }); @@ -1684,4 +1651,169 @@ describe('EarnInputView', () => { }); }); }); + + describe('Additional edge cases for coverage', () => { + it('navigates to MAX_INPUT modal for staking when max button pressed', () => { + const { getByText } = renderComponent(); + + const maxButton = getByText('Max'); + fireEvent.press(maxButton); + + expect(mockNavigate).toHaveBeenCalledWith( + 'StakeModals', + expect.objectContaining({ + screen: Routes.STAKING.MODALS.MAX_INPUT, + }), + ); + }); + + it('handles missing selectedAccount address gracefully in lending flow', async () => { + selectStablecoinLendingEnabledFlagMock.mockReturnValue(true); + selectConversionRateMock.mockReturnValue(1); + + (useEarnTokens as jest.Mock).mockReturnValue({ + getEarnToken: jest.fn(() => ({ + ...MOCK_USDC_MAINNET_ASSET, + chainId: CHAIN_IDS.MAINNET, + address: '0x123232', + balance: '100', + balanceFiat: '$100', + balanceWei: new BN4('100000000'), + balanceMinimalUnit: '100000000', + balanceFiatNumber: 100, + tokenUsdExchangeRate: 1, + experience: { + type: EARN_EXPERIENCES.STABLECOIN_LENDING, + market: { + protocol: 'AAVE v3', + underlying: { + address: MOCK_USDC_MAINNET_ASSET.address, + }, + }, + }, + })), + getOutputToken: jest.fn(() => ({ + ...MOCK_USDC_MAINNET_ASSET, + chainId: CHAIN_IDS.MAINNET, + })), + }); + + // Mock selector to return undefined account + jest + .spyOn( + multichainAccountsSelectors, + 'selectSelectedInternalAccountByScope', + ) + .mockReturnValue(() => undefined); + + const { getByText } = render(EarnInputView, { + params: { token: MOCK_USDC_MAINNET_ASSET }, + key: Routes.STAKING.STAKE, + name: 'params', + }); + + await act(async () => { + fireEvent.press(getByText('1')); + }); + + await act(async () => { + fireEvent.press(getByText('Review')); + }); + + // Should not navigate when selectedAccount is undefined + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('handles missing earnToken experience market data gracefully', async () => { + selectStablecoinLendingEnabledFlagMock.mockReturnValue(true); + selectConversionRateMock.mockReturnValue(1); + + (useEarnTokens as jest.Mock).mockReturnValue({ + getEarnToken: jest.fn(() => ({ + ...MOCK_USDC_MAINNET_ASSET, + chainId: CHAIN_IDS.MAINNET, + address: '0x123232', + balance: '100', + balanceFiat: '$100', + balanceWei: new BN4('100000000'), + balanceMinimalUnit: '100000000', + balanceFiatNumber: 100, + tokenUsdExchangeRate: 1, + experience: { + type: EARN_EXPERIENCES.STABLECOIN_LENDING, + // Missing market data + }, + })), + getOutputToken: jest.fn(() => ({ + ...MOCK_USDC_MAINNET_ASSET, + chainId: CHAIN_IDS.MAINNET, + })), + }); + + const { getByText } = render(EarnInputView, { + params: { token: MOCK_USDC_MAINNET_ASSET }, + key: Routes.STAKING.STAKE, + name: 'params', + }); + + await act(async () => { + fireEvent.press(getByText('1')); + }); + + await act(async () => { + fireEvent.press(getByText('Review')); + }); + + // Should not navigate when market data is missing + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('handles pooled staking when attemptDepositTransaction is undefined', async () => { + usePoolStakedDepositMock.mockReturnValue({ + attemptDepositTransaction: undefined, + }); + + selectConfirmationRedesignFlagsMock.mockReturnValue({ + staking_confirmations: true, + } as unknown as ConfirmationRedesignRemoteFlags); + + const { getByText } = renderComponent(); + + await act(async () => { + fireEvent.press(getByText('1')); + }); + + await act(async () => { + fireEvent.press(getByText('Review')); + }); + + // Should not navigate when attemptDepositTransaction is undefined + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('tracks staking events when shouldLogStakingEvent returns true', async () => { + selectStablecoinLendingEnabledFlagMock.mockReturnValue(false); + + const { getByText } = renderComponent(); + + mockTrackEvent.mockClear(); + + await act(async () => { + fireEvent.press(getByText('25%')); + }); + + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Stake Input Quick Amount Clicked', + properties: expect.objectContaining({ + location: 'EarnInputView', + amount: 0.25, + is_max: false, + mode: 'native', + experience: EARN_EXPERIENCES.POOLED_STAKING, + }), + }), + ); + }); + }); }); diff --git a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx index f2712ce3f3d..5c6d7323a4e 100644 --- a/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx +++ b/app/components/UI/Earn/Views/EarnInputView/EarnInputView.tsx @@ -28,10 +28,9 @@ import Engine from '../../../../../core/Engine'; import { RootState } from '../../../../../reducers'; import { selectSelectedInternalAccountByScope } from '../../../../../selectors/multichainAccounts/accounts'; import { selectConversionRate } from '../../../../../selectors/currencyRateController'; -import { selectConfirmationRedesignFlags } from '../../../../../selectors/featureFlagController/confirmations'; import { selectNetworkConfigurationByChainId, - selectNetworkClientId, + selectDefaultEndpointByChainId, } from '../../../../../selectors/networkController'; import { selectContractExchangeRatesByChainId } from '../../../../../selectors/tokenRatesController'; import { getDecimalChainId } from '../../../../../util/networks'; @@ -41,12 +40,10 @@ import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics'; import { useStyles } from '../../../../hooks/useStyles'; import { getStakingNavbar } from '../../../Navbar'; import ScreenLayout from '../../../Ramp/Aggregator/components/ScreenLayout'; -import EstimatedAnnualRewardsCard from '../../../Stake/components/EstimatedAnnualRewardsCard'; import QuickAmounts from '../../../Stake/components/QuickAmounts'; import { EVENT_PROVIDERS } from '../../../Stake/constants/events'; import { EVENT_LOCATIONS } from '../../constants/events'; import usePoolStakedDeposit from '../../../Stake/hooks/usePoolStakedDeposit'; -import { withMetaMetrics } from '../../../Stake/utils/metaMetrics/withMetaMetrics'; import EarnTokenSelector from '../../components/EarnTokenSelector'; import InputDisplay from '../../components/InputDisplay'; import { EARN_EXPERIENCES } from '../../constants/experiences'; @@ -67,13 +64,13 @@ import { EarnInputViewProps, } from './EarnInputView.types'; import { InternalAccount } from '@metamask/keyring-internal-api'; -import { getIsRedesignedStablecoinLendingScreenEnabled } from './utils'; import { useEarnAnalyticsEventLogging } from '../../hooks/useEarnEventAnalyticsLogging'; import { doesTokenRequireAllowanceReset } from '../../utils'; import { ScrollView } from 'react-native-gesture-handler'; import { trace, TraceName } from '../../../../../util/trace'; import { useEndTraceOnMount } from '../../../../hooks/useEndTraceOnMount'; import { EVM_SCOPE } from '../../constants/networks'; +import { selectConfirmationRedesignFlags } from '../../../../../selectors/featureFlagController/confirmations'; ///: BEGIN:ONLY_INCLUDE_IF(tron) import useTronStake from '../../hooks/useTronStake'; import TronStakePreview from '../../components/Tron/StakePreview/TronStakePreview'; @@ -96,12 +93,6 @@ const EarnInputView = () => { setIsSubmittingStakeDepositTransaction, ] = useState(false); - const confirmationRedesignFlags = useSelector( - selectConfirmationRedesignFlags, - ); - - const isStakingDepositRedesignedEnabled = - confirmationRedesignFlags?.staking_confirmations; const selectedAccount = useSelector(selectSelectedInternalAccountByScope)( EVM_SCOPE, ); @@ -149,7 +140,12 @@ const EarnInputView = () => { const earnToken = getEarnToken(token); - const networkClientId = useSelector(selectNetworkClientId); + const endpoint = useSelector((state: RootState) => + selectDefaultEndpointByChainId(state, earnToken?.chainId as Hex), + ); + + const networkClientId = endpoint?.networkClientId; + const { isFiat, currentCurrency, @@ -164,11 +160,9 @@ const EarnInputView = () => { handleQuickAmountPress, handleKeypadChange, calculateEstimatedAnnualRewards, - estimatedAnnualRewards, annualRewardsToken, annualRewardsFiat, annualRewardRate, - isLoadingEarnMetadata, handleMax, balanceValue, isHighGasCostImpact, @@ -276,6 +270,13 @@ const EarnInputView = () => { ], ); + const confirmationRedesignFlags = useSelector( + selectConfirmationRedesignFlags, + ); + + const isStakingDepositRedesignedEnabled = + confirmationRedesignFlags?.staking_confirmations; + const handleLendingFlow = useCallback(async () => { if ( !selectedAccount?.address || @@ -349,6 +350,13 @@ const EarnInputView = () => { _earnToken: EarnTokenDetails, _activeAccount: InternalAccount, ) => { + if (!networkClientId) { + console.error( + 'Cannot create lending deposit confirmation - networkClientId is undefined', + ); + return; + } + const approveTxParams = generateLendingAllowanceIncreaseTransaction( amountTokenMinimalUnit.toString(), _activeAccount.address, @@ -399,9 +407,6 @@ const EarnInputView = () => { networkClientId, origin: ORIGIN_METAMASK, transactions: [approveTx, lendingDepositTx], - disable7702: true, - disableHook: true, - disableSequential: false, requireApproval: true, }); @@ -433,9 +438,7 @@ const EarnInputView = () => { }); }; - const isRedesignedStablecoinLendingScreenEnabled = - getIsRedesignedStablecoinLendingScreenEnabled(); - if (isRedesignedStablecoinLendingScreenEnabled) { + if (isStakingDepositRedesignedEnabled) { createRedesignedLendingDepositConfirmation(earnToken, selectedAccount); } else { createLegacyLendingDepositConfirmation( @@ -461,6 +464,7 @@ const EarnInputView = () => { annualRewardsToken, annualRewardsFiat, annualRewardRate, + isStakingDepositRedesignedEnabled, ]); const handlePooledStakingFlow = useCallback(async () => { @@ -510,7 +514,9 @@ const EarnInputView = () => { // start trace between user initiating deposit and the redesigned confirmation screen loading trace({ name: TraceName.EarnDepositConfirmationScreen, - data: { experience: EARN_EXPERIENCES.POOLED_STAKING }, + data: { + experience: earnToken?.experience?.type ?? '', + }, }); // this prevents the user from adding the transaction deposit into the @@ -575,6 +581,7 @@ const EarnInputView = () => { createEventBuilder, earnToken?.chainId, earnToken?.isETH, + earnToken?.experience?.type, estimatedGasFeeWei, getDepositTxGasPercentage, isHighGasCostImpact, @@ -924,21 +931,7 @@ const EarnInputView = () => { action={EARN_INPUT_VIEW_ACTIONS.DEPOSIT} /> - ) : ( - - ))} + ) : null)} { diff --git a/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap b/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap index f8e257e3b01..e089ca754ab 100644 --- a/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap +++ b/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap @@ -562,110 +562,7 @@ exports[`EarnInputView render matches snapshot 1`] = ` "paddingBottom": 8, } } - > - - - - - MetaMask Pool - - - - - - - - 50% - - - Estimated annual rewards - - - - - + /> - - - - - MetaMask Pool - - - - - - - - 50% - - - Estimated annual rewards - - - - - + /> - process.env.MM_STABLECOIN_LENDING_UI_ENABLED_REDESIGNED === 'true'; - -export { getIsRedesignedStablecoinLendingScreenEnabled }; diff --git a/app/components/Views/confirmations/components/gas/gas-fee-token-icon/gas-fee-token-icon.test.tsx b/app/components/Views/confirmations/components/gas/gas-fee-token-icon/gas-fee-token-icon.test.tsx index 8817094b15f..f5169d4df73 100644 --- a/app/components/Views/confirmations/components/gas/gas-fee-token-icon/gas-fee-token-icon.test.tsx +++ b/app/components/Views/confirmations/components/gas/gas-fee-token-icon/gas-fee-token-icon.test.tsx @@ -5,18 +5,29 @@ import { NATIVE_TOKEN_ADDRESS } from '../../../constants/tokens'; import { GasFeeTokenIcon } from './gas-fee-token-icon'; import { transferTransactionStateMock } from '../../../__mocks__/transfer-transaction-mock'; import { useTokenWithBalance } from '../../../hooks/tokens/useTokenWithBalance'; +import { useTransactionBatchesMetadata } from '../../../hooks/transactions/useTransactionBatchesMetadata'; +import { merge } from 'lodash'; +import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; jest.mock('../../../hooks/transactions/useTransactionMetadataRequest'); +jest.mock('../../../hooks/transactions/useTransactionBatchesMetadata'); jest.mock('../../../hooks/useNetworkInfo'); jest.mock('../../../hooks/tokens/useTokenWithBalance', () => ({ useTokenWithBalance: jest .fn() .mockReturnValue({ asset: { logo: 'logo.png' } }), })); +jest.mock('../../../hooks/transactions/useTransactionMetadataRequest'); describe('GasFeeTokenIcon', () => { const mockUseNetworkInfo = jest.mocked(useNetworkInfo); const mockUseTokenWithBalance = jest.mocked(useTokenWithBalance); + const mockUseTransactionBatchesMetadata = jest.mocked( + useTransactionBatchesMetadata, + ); + const mockUseTransactionMetadataRequest = jest.mocked( + useTransactionMetadataRequest, + ); beforeEach(() => { mockUseNetworkInfo.mockReturnValue({ @@ -24,6 +35,12 @@ describe('GasFeeTokenIcon', () => { networkNativeCurrency: 'ETH', networkName: 'Ethereum', }); + mockUseTransactionBatchesMetadata.mockReturnValue(undefined); + mockUseTransactionMetadataRequest.mockReturnValue({ + chainId: '0x1', + } as Partial< + ReturnType + > as ReturnType); jest.clearAllMocks(); }); @@ -60,4 +77,59 @@ describe('GasFeeTokenIcon', () => { expect(getByTestId('native-icon')).toBeOnTheScreen(); }); + + describe('Batch Transactions', () => { + it('uses chainId from batch metadata when transaction metadata is unavailable', () => { + const batchChainId = '0xe708'; + mockUseTransactionBatchesMetadata.mockReturnValue({ + chainId: batchChainId, + } as Partial< + ReturnType + > as ReturnType); + mockUseTransactionMetadataRequest.mockReturnValue(undefined); + + // Create state without transaction metadata + const stateWithoutTransactionMeta = merge( + {}, + transferTransactionStateMock, + { + engine: { + backgroundState: { + TransactionController: { + transactions: [], + }, + }, + }, + }, + ); + + const { getByTestId } = renderWithProvider( + , + { state: stateWithoutTransactionMeta }, + ); + + expect(getByTestId('native-icon')).toBeOnTheScreen(); + expect(mockUseNetworkInfo).toHaveBeenCalledWith(batchChainId); + }); + + it('prefers transaction metadata chainId over batch metadata chainId', () => { + const batchChainId = '0xe708'; + const transactionChainId = '0x1'; + + mockUseTransactionBatchesMetadata.mockReturnValue({ + chainId: batchChainId, + } as Partial< + ReturnType + > as ReturnType); + + // State has transaction metadata with chainId + renderWithProvider( + , + { state: transferTransactionStateMock }, + ); + + // Should use transaction chainId (0x1 from transferTransactionStateMock) + expect(mockUseNetworkInfo).toHaveBeenCalledWith(transactionChainId); + }); + }); }); diff --git a/app/components/Views/confirmations/components/gas/gas-fee-token-icon/gas-fee-token-icon.tsx b/app/components/Views/confirmations/components/gas/gas-fee-token-icon/gas-fee-token-icon.tsx index 500e92b6d8d..e74848fae21 100644 --- a/app/components/Views/confirmations/components/gas/gas-fee-token-icon/gas-fee-token-icon.tsx +++ b/app/components/Views/confirmations/components/gas/gas-fee-token-icon/gas-fee-token-icon.tsx @@ -16,6 +16,7 @@ import Badge, { } from '../../../../../../component-library/components/Badges/Badge'; import NetworkAssetLogo from '../../../../../UI/NetworkAssetLogo'; import { useTokenWithBalance } from '../../../hooks/tokens/useTokenWithBalance'; +import { useTransactionBatchesMetadata } from '../../../hooks/transactions/useTransactionBatchesMetadata'; export enum GasFeeTokenIconSize { Sm = 'sm', @@ -30,7 +31,10 @@ export function GasFeeTokenIcon({ tokenAddress: Hex; }) { const transactionMeta = useTransactionMetadataRequest(); - const { chainId } = transactionMeta || {}; + const transactionBatchesMetadata = useTransactionBatchesMetadata(); + const { chainId: chainIdSingle } = transactionMeta || {}; + const { chainId: chainIdBatch } = transactionBatchesMetadata || {}; + const chainId = chainIdSingle ?? chainIdBatch; const token = useTokenWithBalance(tokenAddress, chainId as Hex); const { networkImage, diff --git a/app/components/Views/confirmations/components/gas/gas-fee-token-toast/gas-fee-token-toast.test.tsx b/app/components/Views/confirmations/components/gas/gas-fee-token-toast/gas-fee-token-toast.test.tsx index 4368960f944..93675585eac 100644 --- a/app/components/Views/confirmations/components/gas/gas-fee-token-toast/gas-fee-token-toast.test.tsx +++ b/app/components/Views/confirmations/components/gas/gas-fee-token-toast/gas-fee-token-toast.test.tsx @@ -166,4 +166,42 @@ describe('GasFeeTokenToast', () => { }), ); }); + + it('calls closeToast when close button is pressed', () => { + (useSelectedGasFeeToken as jest.Mock).mockReturnValue(GAS_FEE_TOKEN_MOCK); + + renderToastHook(TOKENS_CONTROLLER_STATE, { + gasFeeToken: GAS_FEE_TOKEN_USDC_MOCK, + }); + + expect(mockShowToast).toHaveBeenCalledTimes(1); + + const closeButtonOptions = + mockShowToast.mock.calls[0][0].closeButtonOptions; + expect(closeButtonOptions).toBeDefined(); + + closeButtonOptions.onPress(); + + expect(mockCloseToast).toHaveBeenCalledTimes(1); + }); + + it('uses default chainId when chainId is undefined', () => { + (useGasFeeToken as jest.Mock).mockReturnValue(GAS_FEE_TOKEN_MOCK); + (useSelectedGasFeeToken as jest.Mock).mockReturnValue( + GAS_FEE_TOKEN_USDC_MOCK, + ); + (useTransactionMetadataRequest as jest.Mock).mockReturnValue({ + chainId: undefined, + }); + + renderWithProvider( + + + , + { state: TOKENS_CONTROLLER_STATE }, + ); + + // The component should still work with undefined chainId, defaulting to '0x1' + expect(mockShowToast).toHaveBeenCalledTimes(1); + }); }); diff --git a/app/components/Views/confirmations/components/gas/gas-fee-token-toast/gas-fee-token-toast.tsx b/app/components/Views/confirmations/components/gas/gas-fee-token-toast/gas-fee-token-toast.tsx index 16b5bba01b9..ed8b6aabc7d 100644 --- a/app/components/Views/confirmations/components/gas/gas-fee-token-toast/gas-fee-token-toast.tsx +++ b/app/components/Views/confirmations/components/gas/gas-fee-token-toast/gas-fee-token-toast.tsx @@ -32,7 +32,7 @@ export function GasFeeTokenToast() { chainId as Hex, ); const networkImageSource = getNetworkImageSource({ - chainId: chainId as Hex, + chainId: chainId ?? '0x1', }); useEffect(() => { diff --git a/app/components/Views/confirmations/components/gas/selected-gas-fee-token/selected-gas-fee-token.test.tsx b/app/components/Views/confirmations/components/gas/selected-gas-fee-token/selected-gas-fee-token.test.tsx index 069e1127876..5ef0eb32e8c 100644 --- a/app/components/Views/confirmations/components/gas/selected-gas-fee-token/selected-gas-fee-token.test.tsx +++ b/app/components/Views/confirmations/components/gas/selected-gas-fee-token/selected-gas-fee-token.test.tsx @@ -11,12 +11,16 @@ import { merge } from 'lodash'; import { NATIVE_TOKEN_ADDRESS } from '../../../constants/tokens'; import { Alert } from '../../../types/alerts'; import { GasFeeToken } from '@metamask/transaction-controller'; +import { useTransactionBatchesMetadata } from '../../../hooks/transactions/useTransactionBatchesMetadata'; +import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; jest.mock('../../../hooks/alerts/useInsufficientBalanceAlert'); jest.mock('../../../hooks/gas/useGasFeeToken'); jest.mock('../../../hooks/gas/useIsGaslessSupported'); jest.mock('../../../hooks/useNetworkInfo'); jest.mock('../../../hooks/tokens/useTokenWithBalance'); +jest.mock('../../../hooks/transactions/useTransactionBatchesMetadata'); +jest.mock('../../../hooks/transactions/useTransactionMetadataRequest'); describe('SelectedGasFeeToken', () => { const mockUseInsufficientBalanceAlert = jest.mocked( @@ -25,6 +29,12 @@ describe('SelectedGasFeeToken', () => { const mockUseSelectedGasFeeToken = jest.mocked(useSelectedGasFeeToken); const mockUseIsGaslessSupported = jest.mocked(useIsGaslessSupported); const mockUseNetworkInfo = jest.mocked(useNetworkInfo); + const mockUseTransactionBatchesMetadata = jest.mocked( + useTransactionBatchesMetadata, + ); + const mockUseTransactionMetadataRequest = jest.mocked( + useTransactionMetadataRequest, + ); const setupTest = ({ insufficientBalance = [], @@ -32,12 +42,16 @@ describe('SelectedGasFeeToken', () => { gaslessSupported = false, isSmartTransaction = false, gasFeeTokens = [], + transactionMetadata, }: { insufficientBalance?: Alert[]; selectedGasFeeToken?: ReturnType; gaslessSupported?: boolean; isSmartTransaction?: boolean; gasFeeTokens?: GasFeeToken[]; + transactionMetadata?: ReturnType< + typeof useTransactionMetadataRequest + > | null; expectModal?: boolean; } = {}) => { mockUseInsufficientBalanceAlert.mockReturnValue(insufficientBalance); @@ -50,6 +64,29 @@ describe('SelectedGasFeeToken', () => { networkNativeCurrency: 'ETH', } as ReturnType); + // Set transaction metadata mock + // - If explicitly set to null, mock as undefined + // - If explicitly provided (even undefined), use that value + // - Otherwise, create default based on gasFeeTokens + if (transactionMetadata === null) { + mockUseTransactionMetadataRequest.mockReturnValue(undefined); + } else if (transactionMetadata !== undefined) { + mockUseTransactionMetadataRequest.mockReturnValue(transactionMetadata); + } else if (gasFeeTokens.length > 0) { + mockUseTransactionMetadataRequest.mockReturnValue({ + chainId: '0x1', + gasFeeTokens, + } as Partial< + ReturnType + > as ReturnType); + } else { + mockUseTransactionMetadataRequest.mockReturnValue({ + chainId: '0x1', + } as Partial< + ReturnType + > as ReturnType); + } + const state = gasFeeTokens.length > 0 ? merge({}, transferTransactionStateMock, { @@ -90,6 +127,8 @@ describe('SelectedGasFeeToken', () => { beforeEach(() => { jest.clearAllMocks(); + // Set default mock return values + mockUseTransactionBatchesMetadata.mockReturnValue(undefined); }); it('renders the gas fee token button with the native token symbol', () => { @@ -224,4 +263,93 @@ describe('SelectedGasFeeToken', () => { expectModalToOpen(); }); }); + + describe('Batch Transactions', () => { + it('uses chainId from batch metadata when transaction metadata is unavailable', () => { + const batchChainId = '0xe708'; + + mockUseTransactionBatchesMetadata.mockReturnValue({ + chainId: batchChainId, + } as Partial< + ReturnType + > as ReturnType); + + // Create state without transaction metadata + const stateWithoutTransactionMeta = merge( + {}, + transferTransactionStateMock, + { + engine: { + backgroundState: { + TransactionController: { + transactions: [], + }, + }, + }, + }, + ); + + setupTest({ transactionMetadata: null }); + const { getByTestId } = renderWithProvider(, { + state: stateWithoutTransactionMeta, + }); + + expect(getByTestId('selected-gas-fee-token')).toBeOnTheScreen(); + expect(mockUseNetworkInfo).toHaveBeenCalledWith(batchChainId); + }); + + it('prefers transaction metadata chainId over batch metadata chainId', () => { + const batchChainId = '0xe708'; + const transactionChainId = '0x1'; + + mockUseTransactionBatchesMetadata.mockReturnValue({ + chainId: batchChainId, + } as Partial< + ReturnType + > as ReturnType); + + setupTest({ + transactionMetadata: { + chainId: transactionChainId, + } as Partial< + ReturnType + > as ReturnType, + }); + + expect(mockUseNetworkInfo).toHaveBeenCalledWith(transactionChainId); + }); + + it('renders correctly with batch metadata chainId', () => { + const batchChainId = '0xe708'; + + mockUseTransactionBatchesMetadata.mockReturnValue({ + chainId: batchChainId, + } as Partial< + ReturnType + > as ReturnType); + + const stateWithoutTransactionMeta = merge( + {}, + transferTransactionStateMock, + { + engine: { + backgroundState: { + TransactionController: { + transactions: [], + }, + }, + }, + }, + ); + + setupTest({ transactionMetadata: null }); + const { getByTestId, getByText } = renderWithProvider( + , + { state: stateWithoutTransactionMeta }, + ); + + expect(getByTestId('selected-gas-fee-token')).toBeOnTheScreen(); + expect(getByText('ETH')).toBeOnTheScreen(); + }); + }); }); diff --git a/app/components/Views/confirmations/components/gas/selected-gas-fee-token/selected-gas-fee-token.tsx b/app/components/Views/confirmations/components/gas/selected-gas-fee-token/selected-gas-fee-token.tsx index ff647e6b3d4..f67ba77cfcc 100644 --- a/app/components/Views/confirmations/components/gas/selected-gas-fee-token/selected-gas-fee-token.tsx +++ b/app/components/Views/confirmations/components/gas/selected-gas-fee-token/selected-gas-fee-token.tsx @@ -15,11 +15,15 @@ import { useSelectedGasFeeToken } from '../../../hooks/gas/useGasFeeToken'; import { useIsGaslessSupported } from '../../../hooks/gas/useIsGaslessSupported'; import { GasFeeTokenModal } from '../gas-fee-token-modal'; import { useIsInsufficientBalance } from '../../../hooks/useIsInsufficientBalance'; +import { useTransactionBatchesMetadata } from '../../../hooks/transactions/useTransactionBatchesMetadata'; export function SelectedGasFeeToken() { const [isModalOpen, setIsModalOpen] = useState(false); const transactionMetadata = useTransactionMetadataRequest(); - const { chainId, gasFeeTokens } = transactionMetadata || {}; + const transactionBatchesMetadata = useTransactionBatchesMetadata(); + const { chainId: chainIdSingle, gasFeeTokens } = transactionMetadata || {}; + const { chainId: chainIdBatch } = transactionBatchesMetadata || {}; + const chainId = chainIdSingle ?? chainIdBatch; const hasGasFeeTokens = Boolean(gasFeeTokens?.length); const { styles } = useStyles(styleSheet, { diff --git a/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.test.tsx b/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.test.tsx index 4b1ebad0551..e4f6a33d0a2 100644 --- a/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.test.tsx +++ b/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.test.tsx @@ -9,7 +9,12 @@ import { useConfirmationMetricEvents } from '../../../../hooks/metrics/useConfir import { TOOLTIP_TYPES } from '../../../../../../../core/Analytics/events/confirmations'; import GasFeesDetailsRow from './gas-fee-details-row'; import { toHex } from '@metamask/controller-utils'; -import { SimulationData } from '@metamask/transaction-controller'; +import { + GasFeeEstimateLevel, + GasFeeEstimateType, + SimulationData, + TransactionStatus, +} from '@metamask/transaction-controller'; import { useSelectedGasFeeToken } from '../../../../hooks/gas/useGasFeeToken'; import { useIsGaslessSupported } from '../../../../hooks/gas/useIsGaslessSupported'; import { useInsufficientBalanceAlert } from '../../../../hooks/alerts/useInsufficientBalanceAlert'; @@ -84,6 +89,57 @@ const createStateWithSimulationData = ( return stateWithSimulation; }; +const createStateWithBatchTransaction = ( + baseState = stakingDepositConfirmationState, +) => { + const stateWithBatch = cloneDeep(baseState); + const batchId = 'test-batch-id'; + + // Add batch metadata + stateWithBatch.engine.backgroundState.TransactionController.transactionBatches = + [ + { + id: batchId, + chainId: '0x1', + from: '0x1234567890123456789012345678901234567890', + networkClientId: 'mainnet', + gas: '0x5208', + gasFeeEstimates: { + type: GasFeeEstimateType.FeeMarket, + [GasFeeEstimateLevel.Low]: { + maxFeePerGas: '0x59682f00', + maxPriorityFeePerGas: '0x59682f00', + }, + [GasFeeEstimateLevel.Medium]: { + maxFeePerGas: '0x59682f00', + maxPriorityFeePerGas: '0x59682f00', + }, + [GasFeeEstimateLevel.High]: { + maxFeePerGas: '0x59682f00', + maxPriorityFeePerGas: '0x59682f00', + }, + }, + status: TransactionStatus.unapproved, + transactions: [], + }, + ]; + + // Create approval for the batch + // @ts-expect-error Adding dynamic batch approval to test state + stateWithBatch.engine.backgroundState.ApprovalController.pendingApprovals[ + batchId + ] = { + id: batchId, + type: 'transaction_batch', + time: Date.now(), + origin: 'metamask', + requestData: { txBatchId: batchId }, + }; + stateWithBatch.engine.backgroundState.ApprovalController.pendingApprovalCount = 2; + + return stateWithBatch; +}; + describe('GasFeesDetailsRow', () => { const useConfirmationMetricEventsMock = jest.mocked( useConfirmationMetricEvents, @@ -300,4 +356,83 @@ describe('GasFeesDetailsRow', () => { ); expect(getByText('Includes $0.25 fee')).toBeDefined(); }); + + describe('Batch Transactions', () => { + it('displays gas fee for batch transaction with fee estimates', () => { + mockUseSelectedGasFeeToken.mockReturnValue(GAS_FEE_TOKEN_MOCK); + + const { getByText, getByTestId } = renderWithProvider( + , + { + state: createStateWithBatchTransaction(), + }, + ); + + expect(getByText('Network fee')).toBeDefined(); + expect(getByTestId('gas-fees-details')).toBeOnTheScreen(); + // Batch transaction renders even without simulationData when fee estimates exist + }); + + it('shows loading skeleton for batch without fee calculations', () => { + const stateWithBatch = createStateWithBatchTransaction(); + // Remove gas fee estimates to simulate loading state + stateWithBatch.engine.backgroundState.TransactionController.transactionBatches[0].gasFeeEstimates = + undefined; + + const { getByTestId } = renderWithProvider(, { + state: stateWithBatch, + }); + + // Should show skeleton when fee calculations are not ready + expect(getByTestId('gas-fees-details')).toBeOnTheScreen(); + }); + + it('does not require simulationData for batch transactions', () => { + // This test verifies that batches don't need simulationData to display fees + const stateWithBatch = createStateWithBatchTransaction(); + + // Ensure no simulationData exists (batches don't have it) + expect( + stateWithBatch.engine.backgroundState.TransactionController + .transactions?.[0]?.simulationData, + ).toBeUndefined(); + + const { getByText } = renderWithProvider(, { + state: stateWithBatch, + }); + + // Should still display network fee without simulationData + expect(getByText('Network fee')).toBeDefined(); + }); + + it('uses different loading logic for batch vs single transactions', () => { + // Single transaction without simulationData should show loading + const stateWithoutSim = cloneDeep(stakingDepositConfirmationState); + stateWithoutSim.engine.backgroundState.TransactionController.transactions[0].simulationData = + undefined; + + // Batch transaction without simulationData but with fee estimates should NOT show loading + const batchState = createStateWithBatchTransaction(); + + // Single transaction without simulationData should show loading + const { getByTestId: getByTestIdSingle } = renderWithProvider( + , + { + state: stateWithoutSim, + }, + ); + + expect(getByTestIdSingle('gas-fees-details')).toBeOnTheScreen(); + + // Batch transaction without simulationData but with fee estimates should still render + const { getByTestId: getByTestIdBatch } = renderWithProvider( + , + { + state: batchState, + }, + ); + + expect(getByTestIdBatch('gas-fees-details')).toBeOnTheScreen(); + }); + }); }); diff --git a/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.tsx b/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.tsx index 446df01237e..c604712a73f 100644 --- a/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.tsx +++ b/app/components/Views/confirmations/components/rows/transactions/gas-fee-details-row/gas-fee-details-row.tsx @@ -56,6 +56,7 @@ const EstimationInfo = ({ feeCalculations, fiatOnly, isGasFeeSponsored, + isBatch = false, }: { hideFiatForTestnet: boolean; feeCalculations: @@ -63,6 +64,7 @@ const EstimationInfo = ({ | ReturnType; fiatOnly: boolean; isGasFeeSponsored?: boolean; + isBatch?: boolean; }) => { const gasFeeToken = useSelectedGasFeeToken(); const { styles } = useStyles(styleSheet, {}); @@ -76,6 +78,7 @@ const EstimationInfo = ({ hideFiatForTestnet || !fiatValue ? styles.primaryValue : styles.secondaryValue; + const transactionMetadata = useTransactionMetadataRequest(); const { chainId, simulationData, networkClientId } = (transactionMetadata as TransactionMeta) ?? {}; @@ -84,7 +87,9 @@ const EstimationInfo = ({ simulationData, networkClientId, }); - const isSimulationLoading = !simulationData || balanceChangesResult.pending; + + const isSimulationLoading = + !isBatch && (!simulationData || balanceChangesResult.pending); return ( @@ -139,6 +144,7 @@ const BatchEstimateInfo = ({ const feeCalculations = useFeeCalculationsTransactionBatch( transactionBatchesMetadata as TransactionBatchMeta, ); + const isBatch = Boolean(transactionBatchesMetadata); return ( ); }; diff --git a/app/components/Views/confirmations/constants/confirmations.ts b/app/components/Views/confirmations/constants/confirmations.ts index 3c86654d019..27156624f87 100644 --- a/app/components/Views/confirmations/constants/confirmations.ts +++ b/app/components/Views/confirmations/constants/confirmations.ts @@ -52,6 +52,7 @@ export const REDESIGNED_CONTRACT_INTERACTION_TYPES = [ ]; export const FULL_SCREEN_CONFIRMATIONS = [ + TransactionType.lendingDeposit, TransactionType.musdConversion, TransactionType.perpsDeposit, TransactionType.predictDeposit, diff --git a/app/components/Views/confirmations/hooks/useConfirmActions.test.ts b/app/components/Views/confirmations/hooks/useConfirmActions.test.ts index 0c6a9f6058e..23e608f4208 100644 --- a/app/components/Views/confirmations/hooks/useConfirmActions.test.ts +++ b/app/components/Views/confirmations/hooks/useConfirmActions.test.ts @@ -1,4 +1,5 @@ import { useNavigation } from '@react-navigation/native'; +import { TransactionType } from '@metamask/transaction-controller'; import Engine from '../../../../core/Engine'; import { renderHookWithProvider } from '../../../../util/test/renderWithProvider'; @@ -205,4 +206,121 @@ describe('useConfirmAction', () => { result?.current?.onReject(undefined, true); expect(goBackSpy).not.toHaveBeenCalled(); }); + + it('sets waitForResult to false when approvalType is TransactionBatch', async () => { + const mockOpenLedgerSignModal = jest.fn(); + createUseLedgerContextSpy({ openLedgerSignModal: mockOpenLedgerSignModal }); + + const transactionBatchState = { + engine: { + backgroundState: { + ...stakingDepositConfirmationState.engine.backgroundState, + ApprovalController: { + pendingApprovals: { + 'batch-approval-id': { + id: 'batch-approval-id', + origin: 'metamask', + type: 'transaction_batch', + time: 1738825814816, + requestData: { batchId: '0x123456789abcdef' }, + requestState: null, + expectsResult: false, + }, + }, + pendingApprovalCount: 1, + approvalFlows: [], + }, + }, + }, + }; + + const { result } = renderHookWithProvider(() => useConfirmActions(), { + state: transactionBatchState, + }); + + result?.current?.onConfirm(); + expect(Engine.acceptPendingApproval).toHaveBeenCalledTimes(1); + const callArgs = (Engine.acceptPendingApproval as jest.Mock).mock.calls[0]; + expect(callArgs[0]).toBe('batch-approval-id'); + expect(callArgs[2]).toEqual({ + waitForResult: false, + deleteAfterResult: true, + handleErrors: false, + }); + await flushPromises(); + }); + + it('sets waitForResult to true when approvalType is not TransactionBatch', async () => { + const mockOpenLedgerSignModal = jest.fn(); + createUseLedgerContextSpy({ openLedgerSignModal: mockOpenLedgerSignModal }); + + const { result } = renderHookWithProvider(() => useConfirmActions(), { + state: personalSignatureConfirmationState, + }); + + result?.current?.onConfirm(); + expect(Engine.acceptPendingApproval).toHaveBeenCalledTimes(1); + const callArgs = (Engine.acceptPendingApproval as jest.Mock).mock.calls[0]; + expect(callArgs[0]).toBe('76b33b40-7b5c-11ef-bc0a-25bce29dbc09'); + expect(callArgs[2]).toEqual({ + waitForResult: true, + deleteAfterResult: true, + handleErrors: false, + }); + await flushPromises(); + }); + + it('navigates to transactions view when confirming batch transaction', async () => { + const mockOpenLedgerSignModal = jest.fn(); + createUseLedgerContextSpy({ openLedgerSignModal: mockOpenLedgerSignModal }); + + const lendingBatchId = 'lending-batch-id'; + const lendingDepositBatchState = { + engine: { + backgroundState: { + ...stakingDepositConfirmationState.engine.backgroundState, + ApprovalController: { + pendingApprovals: { + [lendingBatchId]: { + id: lendingBatchId, + origin: 'metamask', + type: 'transaction_batch', + time: 1738825814816, + requestData: {}, + requestState: null, + expectsResult: false, + }, + }, + pendingApprovalCount: 1, + approvalFlows: [], + }, + TransactionController: { + transactions: [], + transactionBatches: [ + { + id: lendingBatchId, + chainId: '0x1' as `0x${string}`, + origin: 'metamask', + from: '0x935e73edb9ff52e23bac7f7e043a1ecd06d05477', + transactions: [ + { type: TransactionType.contractInteraction }, + { type: TransactionType.lendingDeposit }, + ], + }, + ], + }, + }, + }, + }; + + const { result } = renderHookWithProvider(() => useConfirmActions(), { + state: lendingDepositBatchState, + }); + + result?.current?.onConfirm(); + await flushPromises(); + + expect(navigateMock).toHaveBeenCalledTimes(1); + expect(navigateMock).toHaveBeenCalledWith('TransactionsView'); + }); }); diff --git a/app/components/Views/confirmations/hooks/useConfirmActions.ts b/app/components/Views/confirmations/hooks/useConfirmActions.ts index 3e416537242..f6bba6d256a 100644 --- a/app/components/Views/confirmations/hooks/useConfirmActions.ts +++ b/app/components/Views/confirmations/hooks/useConfirmActions.ts @@ -74,12 +74,19 @@ export const useConfirmActions = () => { return; } + const waitForResult = approvalType !== ApprovalType.TransactionBatch; + await onRequestConfirm({ - waitForResult: true, + waitForResult, deleteAfterResult: true, handleErrors: false, }); + if (approvalType === ApprovalType.TransactionBatch) { + navigation.navigate(Routes.TRANSACTIONS_VIEW); + return; + } + navigation.goBack(); if (isSignatureReq) { @@ -97,6 +104,7 @@ export const useConfirmActions = () => { setScannerVisible, onTransactionConfirm, captureSignatureMetrics, + approvalType, ]); return { onConfirm, onReject }; diff --git a/locales/languages/en.json b/locales/languages/en.json index 5fb0267c72a..23188c0446f 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -5674,6 +5674,15 @@ "earn_points_daily": "Earn points daily", "buy_musd": "Buy mUSD", "get_musd": "Get mUSD" + }, + "rewards": { + "rewards_tag_label": "Rewards", + "tooltip_title": "Earn rewards with mUSD", + "tooltip_points_suffix": "per $100", + "tooltip_description": "Convert your USDC, USDT, or DAI for mUSD, MetaMask's dollar-backed stablecoin.\nEarn points every time you convert.", + "tooltip_opted_in_footer": "Points will be automatically added to your account.", + "tooltip_not_opted_in_footer": "Opt-in to rewards to receive your points.", + "tooltip_close": "Close" } }, "stake": { From 0c5df827a038730ce35d3e6dd4e73decf76cad04 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Wed, 3 Dec 2025 19:15:27 +0100 Subject: [PATCH 5/8] fix: hide trending section if empty (#23586) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR hides trending tokens section if empty. The UI needs to show again the tokens section if it gets data on refresh To test this; i updated the useTrendingRequest hook: ``` const isFirstCall = currentRequestId === 1; const resultsToStore = isFirstCall ? [] : await getTrendingTokens({ chainIds: stableChainIds, sortBy, minLiquidity, minVolume24hUsd, maxVolume24hUsd, minMarketCap, maxMarketCap, }); ``` Result: https://github.com/user-attachments/assets/9247e4ba-fc3e-458d-a5a7-206bdd427de4 ## **Changelog** CHANGELOG entry: Hides trending section if empty ## **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** https://github.com/user-attachments/assets/8010c17a-4079-4de3-a4d2-14ca887176bc ## **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] > Hides empty Explore sections and shows an empty error state for Trending Tokens, while defaulting trending fetch to loading with 24h trending sort. > > - **Trending View (Explore)**: > - Hide sections with empty data via `emptySections` state; keep components mounted to detect new data. > - Pass `toggleSectionEmptyState` to each section (`SectionCard`, `SectionCarrousel`) and wire through `sections.config`. > - Update `QuickActions` to only show visible sections. > - **Trending Tokens Full View**: > - Show skeletons only while `isLoading`; when no results, render `EmptyErrorTrendingState` with retry. > - Disable Price Change control when no results. > - **Hook**: > - `useTrendingRequest`: default `sortBy` to `h24_trending`; initial `isLoading` set to `true`. > - **New Component**: > - `EmptyErrorTrendingState` with tests and localized strings. > - **Tests & i18n**: > - Update tests to use `useTrendingSearch` and validate new empty/error state and controls. > - Add `trending.empty_error_trending_state.*` strings. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit e96b2f05c1f6f4b047a4e5c703b08f1168c0ca97. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../useTrendingRequest/useTrendingRequest.ts | 4 +- .../TrendingTokensFullView.test.tsx | 22 ++++---- .../TrendingTokensFullView.tsx | 14 ++++- .../Views/TrendingView/TrendingView.tsx | 52 +++++++++++++++---- .../EmptyErrorTrendingState.test.tsx | 32 ++++++++++++ .../EmptyErrorTrendingState.tsx | 48 +++++++++++++++++ .../components/QuickActions/QuickActions.tsx | 15 ++++-- .../components/SectionCard/SectionCard.tsx | 10 ++++ .../SectionCarrousel/SectionCarrousel.tsx | 9 ++++ .../TrendingView/config/sections.config.tsx | 32 +++++++++--- locales/languages/en.json | 7 ++- 11 files changed, 206 insertions(+), 39 deletions(-) create mode 100644 app/components/Views/TrendingView/components/EmptyErrorState/EmptyErrorTrendingState.test.tsx create mode 100644 app/components/Views/TrendingView/components/EmptyErrorState/EmptyErrorTrendingState.tsx diff --git a/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.ts b/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.ts index 6ef157e8a09..125628f77a0 100644 --- a/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.ts +++ b/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.ts @@ -22,7 +22,7 @@ export const useTrendingRequest = (options: { }) => { const { chainIds: providedChainIds = [], - sortBy, + sortBy = 'h24_trending', minLiquidity = 0, minVolume24hUsd = 0, maxVolume24hUsd, @@ -48,7 +48,7 @@ export const useTrendingRequest = (options: { Awaited> >([]); - const [isLoading, setIsLoading] = useState(false); + const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); diff --git a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx index b4cca82ca08..ce069065a38 100644 --- a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx +++ b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx @@ -317,11 +317,10 @@ describe('TrendingTokensFullView', () => { }); it('displays skeleton loader when loading', () => { - mockUseTrendingRequest.mockReturnValue({ - results: [], + mockUseTrendingSearch.mockReturnValue({ + data: [], isLoading: true, - error: null, - fetch: jest.fn(), + refetch: jest.fn(), }); const { queryAllByTestId } = renderWithProvider( @@ -335,23 +334,20 @@ describe('TrendingTokensFullView', () => { expect(skeletons[0]).toBeOnTheScreen(); }); - it('displays skeleton loader when results are empty', () => { - mockUseTrendingRequest.mockReturnValue({ - results: [], + it('displays empty error state when results are empty', () => { + mockUseTrendingSearch.mockReturnValue({ + data: [], isLoading: false, - error: null, - fetch: jest.fn(), + refetch: jest.fn(), }); - const { queryAllByTestId } = renderWithProvider( + const { getByText } = renderWithProvider( , { state: mockState }, false, ); - const skeletons = queryAllByTestId('trending-tokens-skeleton'); - expect(skeletons.length).toBeGreaterThan(0); - expect(skeletons[0]).toBeOnTheScreen(); + expect(getByText('Trending tokens is not available')).toBeOnTheScreen(); }); it('displays trending tokens list when data is loaded', () => { diff --git a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx index bf0112676f1..42beee2ffc6 100644 --- a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx +++ b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx @@ -41,6 +41,7 @@ import { } from '../../../UI/Trending/components/TrendingTokensBottomSheet'; import { sortTrendingTokens } from '../../../UI/Trending/utils/sortTrendingTokens'; import { useTrendingSearch } from '../../../UI/Trending/hooks/useTrendingSearch/useTrendingSearch'; +import EmptyErrorTrendingState from '../../TrendingView/components/EmptyErrorState/EmptyErrorTrendingState'; interface TrendingTokensNavigationParamList { [key: string]: undefined | object; @@ -113,6 +114,9 @@ const createStyles = (theme: Theme) => lineHeight: 19.6, // 140% of 14px fontStyle: 'normal', }, + controlButtonDisabled: { + opacity: 0.5, + }, }); const TrendingTokensFullView = () => { @@ -312,8 +316,12 @@ const TrendingTokensFullView = () => { @@ -366,12 +374,14 @@ const TrendingTokensFullView = () => { ) : null} - {isLoading || (searchResults as TrendingAsset[]).length === 0 ? ( + {isLoading ? ( {Array.from({ length: 12 }).map((_, index) => ( ))} + ) : (searchResults as TrendingAsset[]).length === 0 ? ( + ) : ( { const [refreshing, setRefreshing] = useState(false); const [refreshTrigger, setRefreshTrigger] = useState(0); + // Track which sections have empty data + const [emptySections, setEmptySections] = useState>(new Set()); + // Update state when returning to TrendingFeed useEffect(() => { const unsubscribe = navigation.addListener('focus', () => { @@ -58,6 +61,24 @@ const TrendingFeed: React.FC = () => { const isBasicFunctionalityEnabled = useSelector( selectBasicFunctionalityEnabled, ); + + const sectionCallbacks = useMemo(() => { + const callbacks = {} as Record void>; + HOME_SECTIONS_ARRAY.forEach((section) => { + callbacks[section.id] = (isEmpty: boolean) => { + setEmptySections((prev) => { + const next = new Set(prev); + if (isEmpty) { + next.add(section.id); + } else { + next.delete(section.id); + } + return next; + }); + }; + }); + return callbacks; + }, []); const handleBrowserPress = useCallback(() => { updateLastTrendingScreen('TrendingBrowser'); navigation.navigate('TrendingBrowser', { @@ -138,14 +159,25 @@ const TrendingFeed: React.FC = () => { /> } > - - - {HOME_SECTIONS_ARRAY.map((section) => ( - - - - - ))} + + + {HOME_SECTIONS_ARRAY.map((section) => { + // Hide section visually but keep mounted so it can report when data arrives + const isHidden = emptySections.has(section.id); + + return ( + + + + + ); + })} ) : ( diff --git a/app/components/Views/TrendingView/components/EmptyErrorState/EmptyErrorTrendingState.test.tsx b/app/components/Views/TrendingView/components/EmptyErrorState/EmptyErrorTrendingState.test.tsx new file mode 100644 index 00000000000..0f9c83d107d --- /dev/null +++ b/app/components/Views/TrendingView/components/EmptyErrorState/EmptyErrorTrendingState.test.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import EmptyErrorTrendingState from './EmptyErrorTrendingState'; + +describe('EmptyErrorTrendingState', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders empty state', () => { + const { getByText } = render( + true} />, + ); + + expect(getByText('Trending tokens is not available')).toBeDefined(); + expect(getByText("We can't fetch this page right now")).toBeDefined(); + expect(getByText('Try again')).toBeDefined(); + }); + + it('calls onRetry when button is pressed', () => { + const mockOnRetry = jest.fn(); + const { getByText } = render( + , + ); + + const retryButton = getByText('Try again'); + + fireEvent.press(retryButton); + + expect(mockOnRetry).toHaveBeenCalled(); + }); +}); diff --git a/app/components/Views/TrendingView/components/EmptyErrorState/EmptyErrorTrendingState.tsx b/app/components/Views/TrendingView/components/EmptyErrorState/EmptyErrorTrendingState.tsx new file mode 100644 index 00000000000..2509eb094eb --- /dev/null +++ b/app/components/Views/TrendingView/components/EmptyErrorState/EmptyErrorTrendingState.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { + Box, + Text, + TextVariant, + Button, + ButtonVariant, +} from '@metamask/design-system-react-native'; +import { strings } from '../../../../../../locales/i18n'; + +interface EmptyErrorTrendingStateProps { + onRetry?: () => void; +} + +const EmptyErrorTrendingState: React.FC = ({ + onRetry, +}) => ( + + + + {strings('trending.empty_error_trending_state.title')} + + + {strings('trending.empty_error_trending_state.description')} + + {onRetry && ( + + )} + + +); + +export default EmptyErrorTrendingState; diff --git a/app/components/Views/TrendingView/components/QuickActions/QuickActions.tsx b/app/components/Views/TrendingView/components/QuickActions/QuickActions.tsx index 488d4caca38..7f2d76f07f7 100644 --- a/app/components/Views/TrendingView/components/QuickActions/QuickActions.tsx +++ b/app/components/Views/TrendingView/components/QuickActions/QuickActions.tsx @@ -10,22 +10,31 @@ import { TextVariant, } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { SECTIONS_ARRAY } from '../../config/sections.config'; +import { SECTIONS_ARRAY, SectionId } from '../../config/sections.config'; + +interface QuickActionsProps { + /** Set of section IDs that have empty data and should be hidden */ + emptySections: Set; +} /** * A dynamic component that automatically generates action buttons based on the * centralized sections configuration. When a new section is added to SECTIONS_CONFIG, * a corresponding button will automatically appear here. */ -const QuickActions: React.FC = () => { +const QuickActions: React.FC = ({ emptySections }) => { const navigation = useNavigation(); const tw = useTailwind(); + const visibleSections = SECTIONS_ARRAY.filter( + (s) => !emptySections.has(s.id), + ); + return ( - {SECTIONS_ARRAY.map((section) => ( + {visibleSections.map((section) => ( section.viewAllAction(navigation)} diff --git a/app/components/Views/TrendingView/components/SectionCard/SectionCard.tsx b/app/components/Views/TrendingView/components/SectionCard/SectionCard.tsx index b8e561a9a17..f898ddc2523 100644 --- a/app/components/Views/TrendingView/components/SectionCard/SectionCard.tsx +++ b/app/components/Views/TrendingView/components/SectionCard/SectionCard.tsx @@ -21,11 +21,14 @@ const createStyles = (theme: Theme) => interface SectionCardProps { sectionId: SectionId; refreshTrigger?: number; + /** Callback when data empty state changes (only called after loading completes) */ + toggleSectionEmptyState?: (isEmpty: boolean) => void; } const SectionCard: React.FC = ({ sectionId, refreshTrigger, + toggleSectionEmptyState, }) => { const navigation = useNavigation(); const theme = useAppThemeFromContext(); @@ -34,6 +37,13 @@ const SectionCard: React.FC = ({ const section = SECTIONS_CONFIG[sectionId]; const { data, isLoading, refetch } = section.useSectionData(); + // Notify parent when data empty state changes (only after loading completes) + useEffect(() => { + if (!isLoading && toggleSectionEmptyState) { + toggleSectionEmptyState(data.length === 0); + } + }, [data.length, isLoading, toggleSectionEmptyState]); + useEffect(() => { if (refreshTrigger && refreshTrigger > 0 && refetch) { refetch(); diff --git a/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.tsx b/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.tsx index 98b770f0095..df23914830a 100644 --- a/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.tsx +++ b/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.tsx @@ -14,11 +14,13 @@ const CARD_HEIGHT = 220; export interface SectionCarrouselProps { sectionId: SectionId; refreshTrigger?: number; + toggleSectionEmptyState?: (isEmpty: boolean) => void; } const SectionCarrousel: React.FC = ({ sectionId, refreshTrigger, + toggleSectionEmptyState, }) => { const navigation = useNavigation(); const tw = useTailwind(); @@ -27,6 +29,13 @@ const SectionCarrousel: React.FC = ({ const section = SECTIONS_CONFIG[sectionId]; const { data, isLoading, refetch } = section.useSectionData(); + // Notify parent when data empty state changes (only after loading completes) + useEffect(() => { + if (!isLoading && toggleSectionEmptyState) { + toggleSectionEmptyState(data.length === 0); + } + }, [data.length, isLoading, toggleSectionEmptyState]); + useEffect(() => { if (refreshTrigger && refreshTrigger > 0 && refetch) { refetch(); diff --git a/app/components/Views/TrendingView/config/sections.config.tsx b/app/components/Views/TrendingView/config/sections.config.tsx index 62a4b56c160..3303255dad4 100644 --- a/app/components/Views/TrendingView/config/sections.config.tsx +++ b/app/components/Views/TrendingView/config/sections.config.tsx @@ -47,7 +47,10 @@ interface SectionConfig { navigation: NavigationProp; }>; Skeleton: React.ComponentType; - Section: React.ComponentType<{ refreshTrigger?: number }>; + Section: React.ComponentType<{ + refreshTrigger?: number; + toggleSectionEmptyState?: (isEmpty: boolean) => void; + }>; useSectionData: (searchQuery?: string) => { data: unknown[]; isLoading: boolean; @@ -83,8 +86,12 @@ export const SECTIONS_CONFIG: Record = { ), Skeleton: () => , - Section: ({ refreshTrigger }) => ( - + Section: ({ refreshTrigger, toggleSectionEmptyState }) => ( + ), useSectionData: (searchQuery) => { const { data, isLoading, refetch } = useTrendingSearch(searchQuery); @@ -120,10 +127,14 @@ export const SECTIONS_CONFIG: Record = { ), // Using trending skeleton cause PerpsMarketRowSkeleton has too much spacing Skeleton: () => , - Section: ({ refreshTrigger }) => ( + Section: ({ refreshTrigger, toggleSectionEmptyState }) => ( - + ), @@ -159,10 +170,11 @@ export const SECTIONS_CONFIG: Record = { ), Skeleton: () => , - Section: ({ refreshTrigger }) => ( + Section: ({ refreshTrigger, toggleSectionEmptyState }) => ( ), useSectionData: (searchQuery) => { @@ -186,8 +198,12 @@ export const SECTIONS_CONFIG: Record = { ), Skeleton: () => , - Section: ({ refreshTrigger }) => ( - + Section: ({ refreshTrigger, toggleSectionEmptyState }) => ( + ), useSectionData: (searchQuery) => { const { sites, isLoading, refetch } = useSitesData(searchQuery, 100); diff --git a/locales/languages/en.json b/locales/languages/en.json index 23188c0446f..b9af5a48765 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -7238,6 +7238,11 @@ "search_sites": "Search sites", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", - "basic_functionality_disabled_description": "We can't fetch the required metadata when basic functionality is disabled." + "basic_functionality_disabled_description": "We can't fetch the required metadata when basic functionality is disabled.", + "empty_error_trending_state": { + "title": "Trending tokens is not available", + "description": "We can't fetch this page right now", + "try_again": "Try again" + } } } From 08fdc9c90a1c4af6ae2891ead59dddbd70448615 Mon Sep 17 00:00:00 2001 From: George Gkasdrogkas Date: Wed, 3 Dec 2025 20:15:55 +0200 Subject: [PATCH 6/8] fix: select correct swap source asset when navigating from browser (#23534) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR fixes an issue where when users selected the swap button for an asset presented in browser URL bar, that asset was not selected as source when navigating to swap page. ## **Changelog** CHANGELOG entry: fixes an issue where when users selected the swap button for an asset presented in browser URL bar, that asset was not selected as source when navigating to swap page. ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/SWAPS-3487 ## **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** https://github.com/user-attachments/assets/2b9a5836-77da-41b7-899d-86fe73469021 ### **After** https://github.com/user-attachments/assets/1c1ad351-da97-49c7-a64c-47f63fd0f345 ## **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] > Pass the selected token from URL Autocomplete to Swaps navigation and update the navigation hook to accept a token override with proper chain handling and fallbacks. > > - **Swaps/Bridge Navigation**: > - Update `useSwapBridgeNavigation` to accept an optional `tokenOverride` in `goToNativeBridge` and `goToSwaps`. > - Use the override (or provided `sourceToken`) to determine the effective chain ID, format chain IDs, and select the candidate source token. > - Add fallback to mainnet native token when the source chain isn’t bridge-enabled. > - Expand tests to cover token override, mainnet fallback, home-page filter network, Solana CAIP handling, and analytics events. > - **URL Autocomplete**: > - On swap button press, construct a `BridgeToken` from `TokenSearchResult` and invoke `goToSwaps` with it. > - Adjust tests to assert `goToSwaps` is called with the correct token, plus additional tests for loading indicator, result de-duplication, recents limiting, and reset on hide. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 37e79674765c07197854ffce57287cfd031aa68f. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../hooks/useSwapBridgeNavigation/index.ts | 22 +- .../useSwapBridgeNavigation.test.ts | 95 ++++++++- .../UI/UrlAutocomplete/index.test.tsx | 191 +++++++++++++++++- app/components/UI/UrlAutocomplete/index.tsx | 27 ++- 4 files changed, 317 insertions(+), 18 deletions(-) diff --git a/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts b/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts index e0518b269fd..c4bdd372046 100644 --- a/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts +++ b/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts @@ -57,12 +57,15 @@ export const useSwapBridgeNavigation = ({ // Unified swaps/bridge UI const goToNativeBridge = useCallback( - (bridgeViewMode: BridgeViewMode) => { + (bridgeViewMode: BridgeViewMode, tokenOverride?: BridgeToken) => { + // Use tokenOverride if provided, otherwise fall back to tokenBase + const effectiveTokenBase = tokenOverride ?? tokenBase; + // Determine effective chain ID - use home page filter network when no sourceToken provided const getEffectiveChainId = (): CaipChainId | Hex => { - if (tokenBase) { + if (effectiveTokenBase) { // If specific token provided, use its chainId - return tokenBase.chainId; + return effectiveTokenBase.chainId; } // No token provided - check home page filter network @@ -82,7 +85,7 @@ export const useSwapBridgeNavigation = ({ let bridgeSourceNativeAsset; try { - if (!tokenBase) { + if (!effectiveTokenBase) { bridgeSourceNativeAsset = getNativeAssetForChainId(effectiveChainId); } } catch (error) { @@ -104,7 +107,7 @@ export const useSwapBridgeNavigation = ({ : undefined; const candidateSourceToken = - tokenBase ?? bridgeNativeSourceTokenFormatted; + effectiveTokenBase ?? bridgeNativeSourceTokenFormatted; const isBridgeEnabledSource = getIsBridgeEnabledSource(effectiveChainId); let sourceToken = isBridgeEnabledSource ? candidateSourceToken @@ -167,9 +170,12 @@ export const useSwapBridgeNavigation = ({ ); const { networkModal } = useAddNetwork(); - const goToSwaps = useCallback(() => { - goToNativeBridge(BridgeViewMode.Unified); - }, [goToNativeBridge]); + const goToSwaps = useCallback( + (tokenOverride?: BridgeToken) => { + goToNativeBridge(BridgeViewMode.Unified, tokenOverride); + }, + [goToNativeBridge], + ); return { goToSwaps, diff --git a/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/useSwapBridgeNavigation.test.ts b/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/useSwapBridgeNavigation.test.ts index 74474991b51..b809cba2d90 100644 --- a/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/useSwapBridgeNavigation.test.ts +++ b/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/useSwapBridgeNavigation.test.ts @@ -37,9 +37,12 @@ jest.mock('../../../../hooks/useMetrics', () => { }; }); +const mockGetIsBridgeEnabledSource = jest.fn(() => true); jest.mock('../../../../../core/redux/slices/bridge', () => ({ ...jest.requireActual('../../../../../core/redux/slices/bridge'), - selectIsBridgeEnabledSourceFactory: jest.fn(() => () => true), + selectIsBridgeEnabledSourceFactory: jest.fn( + () => mockGetIsBridgeEnabledSource, + ), })); const mockGoToPortfolioBridge = jest.fn(); @@ -140,6 +143,9 @@ describe('useSwapBridgeNavigation', () => { // Reset selectChainId mock to default (selectChainId as unknown as jest.Mock).mockReturnValue(mockChainId); + + // Reset bridge enabled mock to default (enabled) + mockGetIsBridgeEnabledSource.mockReturnValue(true); }); it('uses native token when no token is provided', () => { @@ -202,6 +208,93 @@ describe('useSwapBridgeNavigation', () => { }); }); + it('uses tokenOverride when passed to goToSwaps', () => { + const configuredToken: BridgeToken = { + address: '0x0000000000000000000000000000000000000001', + symbol: 'TOKEN', + name: 'Test Token', + decimals: 18, + chainId: mockChainId, + }; + + const overrideToken: BridgeToken = { + address: '0x0000000000000000000000000000000000000002', + symbol: 'OVERRIDE', + name: 'Override Token', + decimals: 18, + chainId: '0x89' as Hex, + }; + + const { result } = renderHookWithProvider( + () => + useSwapBridgeNavigation({ + location: mockLocation, + sourcePage: mockSourcePage, + sourceToken: configuredToken, + }), + { state: initialState }, + ); + + result.current.goToSwaps(overrideToken); + + expect(mockNavigate).toHaveBeenCalledWith('Bridge', { + screen: 'BridgeView', + params: { + sourceToken: overrideToken, + sourcePage: mockSourcePage, + bridgeViewMode: BridgeViewMode.Unified, + }, + }); + }); + + it('falls back to ETH on mainnet when bridge is not enabled for source chain', () => { + mockGetIsBridgeEnabledSource.mockReturnValue(false); + + // Mock that getNativeAssetForChainId returns ETH for mainnet fallback + (getNativeAssetForChainId as jest.Mock).mockReturnValue({ + address: '0x0000000000000000000000000000000000000000', + name: 'Ether', + symbol: 'ETH', + decimals: 18, + }); + + const unsupportedToken: BridgeToken = { + address: '0x0000000000000000000000000000000000000001', + symbol: 'UNSUPPORTED', + name: 'Unsupported Token', + decimals: 18, + chainId: '0x999' as Hex, + }; + + const { result } = renderHookWithProvider( + () => + useSwapBridgeNavigation({ + location: mockLocation, + sourcePage: mockSourcePage, + sourceToken: unsupportedToken, + }), + { state: initialState }, + ); + + result.current.goToSwaps(); + + expect(mockNavigate).toHaveBeenCalledWith('Bridge', { + screen: 'BridgeView', + params: { + sourceToken: { + address: '0x0000000000000000000000000000000000000000', + name: 'Ether', + symbol: 'ETH', + image: '', + decimals: 18, + chainId: '0x1', + }, + sourcePage: mockSourcePage, + bridgeViewMode: BridgeViewMode.Unified, + }, + }); + }); + it('navigates to Bridge when goToSwaps is called and bridge UI is enabled', () => { const { result } = renderHookWithProvider( () => diff --git a/app/components/UI/UrlAutocomplete/index.test.tsx b/app/components/UI/UrlAutocomplete/index.test.tsx index 394cdef353b..e79d08c650c 100644 --- a/app/components/UI/UrlAutocomplete/index.test.tsx +++ b/app/components/UI/UrlAutocomplete/index.test.tsx @@ -147,11 +147,35 @@ jest.mock('../../../selectors/tokenSearchDiscoveryDataController', () => { }; }); +const mockGoToSwaps = jest.fn(); +jest.mock('../Bridge/hooks/useSwapBridgeNavigation', () => ({ + ...jest.requireActual('../Bridge/hooks/useSwapBridgeNavigation'), + useSwapBridgeNavigation: jest.fn(() => ({ + goToSwaps: mockGoToSwaps, + networkModal: null, + })), +})); + +// Mock useFavicon to prevent async state updates warning +jest.mock('../../hooks/useFavicon/useFavicon', () => ({ + __esModule: true, + default: jest.fn(() => ({ + isLoading: false, + isLoaded: true, + error: null, + favicon: null, + })), +})); + describe('UrlAutocomplete', () => { beforeAll(() => { jest.useFakeTimers(); }); + beforeEach(() => { + jest.clearAllMocks(); + }); + afterAll(() => { jest.useFakeTimers({ legacyFakeTimers: true }); }); @@ -331,7 +355,7 @@ describe('UrlAutocomplete', () => { ).toBeDefined(); }); - it('should swap a token when the swap button is pressed', async () => { + it('calls goToSwaps when the swap button is pressed', async () => { mockUseTSDReturnValue({ results: [ { @@ -364,7 +388,7 @@ describe('UrlAutocomplete', () => { { includeHiddenElements: true }, ); fireEvent.press(swapButton); - expect(mockNavigate).toHaveBeenCalled(); + expect(mockGoToSwaps).toHaveBeenCalled(); }); it('should call onSelect when a bookmark is selected', async () => { @@ -416,4 +440,167 @@ describe('UrlAutocomplete', () => { fireEvent.press(result); expect(onSelect).toHaveBeenCalled(); }); + + it('calls goToSwaps with correct BridgeToken when swap button is pressed', async () => { + mockUseTSDReturnValue({ + results: [ + { + tokenAddress: '0x123', + chainId: '0x1', + name: 'Dogecoin', + symbol: 'DOGE', + usdPrice: 1, + usdPricePercentChange: { + oneDay: 1, + }, + logoUrl: 'https://example.com/doge.png', + }, + ], + isLoading: false, + reset: jest.fn(), + searchTokens: jest.fn(), + }); + const ref = React.createRef(); + render(, { + state: defaultState, + }); + + act(() => { + ref.current?.search('dog'); + jest.runAllTimers(); + }); + + const swapButton = await screen.findByTestId( + 'autocomplete-result-swap-button', + { includeHiddenElements: true }, + ); + fireEvent.press(swapButton); + + expect(mockGoToSwaps).toHaveBeenCalledWith({ + address: '0x123', + name: 'Dogecoin', + symbol: 'DOGE', + chainId: '0x1', + image: 'https://example.com/doge.png', + decimals: 18, + }); + }); + + it('resets token search when hide method is called via ref', async () => { + const resetMock = jest.fn(); + mockUseTSDReturnValue({ + results: [ + { + tokenAddress: '0x123', + chainId: '0x1', + name: 'Dogecoin', + symbol: 'DOGE', + usdPrice: 1, + usdPricePercentChange: { + oneDay: 1, + }, + }, + ], + isLoading: false, + reset: resetMock, + searchTokens: jest.fn(), + }); + const ref = React.createRef(); + render(, { + state: defaultState, + }); + + act(() => { + ref.current?.search('dog'); + jest.runAllTimers(); + }); + + expect( + await screen.findByText('Dogecoin', { includeHiddenElements: true }), + ).toBeDefined(); + + act(() => { + ref.current?.hide(); + }); + + expect(resetMock).toHaveBeenCalled(); + }); + + it('displays token section header with loading indicator when loading', async () => { + mockUseTSDReturnValue({ + results: [], + isLoading: true, + reset: jest.fn(), + searchTokens: jest.fn(), + }); + const ref = React.createRef(); + render(, { + state: defaultState, + }); + + act(() => { + ref.current?.search('token'); + jest.runAllTimers(); + }); + + expect( + await screen.findByText('Tokens', { includeHiddenElements: true }), + ).toBeDefined(); + expect( + await screen.findByTestId('loading-indicator', { + includeHiddenElements: true, + }), + ).toBeDefined(); + }); + + it('removes duplicate results with same url and category', async () => { + const ref = React.createRef(); + render(, { + state: { + ...defaultState, + browser: { + history: [ + { url: 'https://www.google.com', name: 'Google' }, + { url: 'https://www.google.com', name: 'Google Duplicate' }, + ], + }, + }, + }); + + act(() => { + ref.current?.search('google'); + jest.runAllTimers(); + }); + + const googleResults = await screen.findAllByText(/Google/, { + includeHiddenElements: true, + }); + expect(googleResults.length).toBe(1); + }); + + it('limits recent results to MAX_RECENTS', async () => { + const historyItems = Array.from({ length: 10 }, (_, i) => ({ + url: `https://www.site${i}.com`, + name: `Site${i}`, + })); + const ref = React.createRef(); + render(, { + state: { + ...defaultState, + browser: { history: historyItems }, + bookmarks: [], + }, + }); + + act(() => { + ref.current?.search('Site'); + jest.runAllTimers(); + }); + + // MAX_RECENTS is 5, so with 10 items, only 5 should show + const recentsHeader = await screen.findByText('Recents', { + includeHiddenElements: true, + }); + expect(recentsHeader).toBeDefined(); + }); }); diff --git a/app/components/UI/UrlAutocomplete/index.tsx b/app/components/UI/UrlAutocomplete/index.tsx index d2c41963eee..727d619df54 100644 --- a/app/components/UI/UrlAutocomplete/index.tsx +++ b/app/components/UI/UrlAutocomplete/index.tsx @@ -49,6 +49,7 @@ import { SwapBridgeNavigationLocation, useSwapBridgeNavigation, } from '../Bridge/hooks/useSwapBridgeNavigation'; +import { BridgeToken } from '../Bridge/types'; export * from './types'; @@ -254,13 +255,25 @@ const UrlAutocomplete = forwardRef< sourcePage: 'MainView', }); - const goToSwaps = useCallback(async () => { - try { - await goToSwapsHook(); - } catch (error) { - return; - } - }, [goToSwapsHook]); + const goToSwaps = useCallback( + async (tokenResult: TokenSearchResult) => { + try { + const bridgeToken = { + address: tokenResult.address, + name: tokenResult.name, + symbol: tokenResult.symbol, + image: tokenResult.logoUrl, + decimals: tokenResult.decimals, + chainId: tokenResult.chainId, + } satisfies BridgeToken; + + goToSwapsHook(bridgeToken); + } catch (error) { + return; + } + }, + [goToSwapsHook], + ); const renderSectionHeader = useCallback( ({ section: { category } }: { section: ResultsWithCategory }) => ( From aeb8a381c0741e1818280b1e0c9307b42a19d59f Mon Sep 17 00:00:00 2001 From: Pavel Dvorkin Date: Wed, 3 Dec 2025 14:54:40 -0500 Subject: [PATCH 7/8] chore: INFRA-3180:Fix create pr workflow commit.csv issue (#23615) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Ticket: https://consensyssoftware.atlassian.net/browse/INFRA-3180 Create-release-pr workflow currently uses teams.json file which was deprecated instead of new topology.json to generate commits.csv. Causes issues with commits.csv. Fixed in this Pr Discussed here: https://consensys.slack.com/archives/C09B64PEHAQ/p1764576026224679 ## **Changelog** CHANGELOG entry: null ## **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** - [ ] 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] > Update workflow to use MetaMask/github-tools create-release-pr action v1.1.2 instead of v1.1.0. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 4e4e03181191399ca7977f8680d7f24f35dc6b97. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .github/workflows/create-release-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/create-release-pr.yml b/.github/workflows/create-release-pr.yml index 2e5a25c3b41..bb993c51b94 100644 --- a/.github/workflows/create-release-pr.yml +++ b/.github/workflows/create-release-pr.yml @@ -85,7 +85,7 @@ jobs: pull-requests: write steps: - name: Create Release PR - uses: MetaMask/github-tools/.github/actions/create-release-pr@v1.1.0 + uses: MetaMask/github-tools/.github/actions/create-release-pr@v1.1.2 with: platform: mobile checkout-base-branch: ${{ needs.resolve-bases.outputs.checkout_base }} From b377066db4a8f3fdbc2098b12570684d54bb2822 Mon Sep 17 00:00:00 2001 From: Matthew Grainger <46547583+Matt561@users.noreply.github.com> Date: Wed, 3 Dec 2025 16:54:14 -0500 Subject: [PATCH 8/8] feat: MUSD-128 conditionally render bridge-time-row based on transaction type (#23552) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Add conditional rendering of `bridge-time-row` based on transaction type. For mUSD conversion we want the time estimate hidden. ## **Changelog** CHANGELOG entry: add conditional rendering of bridge-time-row by tx type ## **Related issues** Fixes: [MUSD-128: Hide "estimated time" from quote screen](https://consensyssoftware.atlassian.net/browse/MUSD-128) ## **Manual testing steps** ```gherkin Feature: Bridge Time Row Visibility Scenario: user views confirmation for mUSD conversion transaction Given the user is on the confirmation screen for a mUSD conversion transaction When the user views the transaction details Then the bridge estimated time row is not displayed Scenario: user views loading state for mUSD conversion transaction Given the user is on the confirmation screen for a mUSD conversion transaction And the bridge quotes are loading When the user views the transaction details Then the bridge time skeleton loader is not displayed ``` ## **Screenshots/Recordings** ### **Before** Time estimate is displayed https://github.com/user-attachments/assets/f910fd34-4f1f-4ca0-b831-159d3c8fa04a ### **After** Time estimate is hidden https://github.com/user-attachments/assets/3a6d593e-98e2-467f-996c-91421e39996c ## **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] > Conditionally hides the bridge estimated time row and skeleton for `musdConversion` transactions. > > - **Confirmations UI**: > - Add conditional rendering in `bridge-time-row.tsx` to hide time estimate and skeleton when transaction type matches `TransactionType.musdConversion` via `hasTransactionType` and `HIDE_TYPES`. > - Preserve existing behavior (show skeleton when loading, show estimate when quotes exist) for other transaction types; keep same-chain display as `< 10 sec`. > - **Tests**: > - Update `bridge-time-row.test.tsx` to support injecting transaction `type` and add cases asserting no skeleton/estimate for `musdConversion`. > - Retain and verify existing duration formatting and same-chain logic. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ca077f9cfe922162488c88380ea40272836a1412. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../bridge-time-row/bridge-time-row.test.tsx | 31 +++++++++++++++++-- .../rows/bridge-time-row/bridge-time-row.tsx | 11 +++++-- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/app/components/Views/confirmations/components/rows/bridge-time-row/bridge-time-row.test.tsx b/app/components/Views/confirmations/components/rows/bridge-time-row/bridge-time-row.test.tsx index 971ca72b97a..3c804835fdd 100644 --- a/app/components/Views/confirmations/components/rows/bridge-time-row/bridge-time-row.test.tsx +++ b/app/components/Views/confirmations/components/rows/bridge-time-row/bridge-time-row.test.tsx @@ -13,17 +13,27 @@ import { TransactionPayQuote, TransactionPayTotals, } from '@metamask/transaction-pay-controller'; +import { TransactionType } from '@metamask/transaction-controller'; import { Hex, Json } from '@metamask/utils'; import { useTransactionPayToken } from '../../../hooks/pay/useTransactionPayToken'; jest.mock('../../../hooks/pay/useTransactionPayData'); jest.mock('../../../hooks/pay/useTransactionPayToken'); -function render() { +function render(options: { type?: TransactionType } = {}) { const state = merge( {}, simpleSendTransactionControllerMock, transactionApprovalControllerMock, + options.type && { + engine: { + backgroundState: { + TransactionController: { + transactions: [{ type: options.type }], + }, + }, + }, + }, ); return renderWithProvider(, { state }); @@ -76,7 +86,6 @@ describe('BridgeTimeRow', () => { useTransactionPayTotalsMock.mockReturnValue({ estimatedDuration: 120, } as TransactionPayTotals); - useTransactionPayTokenMock.mockReturnValue({ payToken: { chainId: '0x1' as Hex }, } as ReturnType); @@ -93,4 +102,22 @@ describe('BridgeTimeRow', () => { expect(getByTestId(`bridge-time-row-skeleton`)).toBeDefined(); }); + + it('does not render skeleton when transaction type is in HIDE_TYPES', () => { + useIsTransactionPayLoadingMock.mockReturnValue(true); + + const { queryByTestId } = render({ type: TransactionType.musdConversion }); + + expect(queryByTestId('bridge-time-row-skeleton')).toBeNull(); + }); + + it('does not render when transaction type is in HIDE_TYPES', () => { + useTransactionPayTotalsMock.mockReturnValue({ + estimatedDuration: 60, + } as TransactionPayTotals); + + const { queryByText } = render({ type: TransactionType.musdConversion }); + + expect(queryByText('1 min')).toBeNull(); + }); }); diff --git a/app/components/Views/confirmations/components/rows/bridge-time-row/bridge-time-row.tsx b/app/components/Views/confirmations/components/rows/bridge-time-row/bridge-time-row.tsx index 650c85b5070..e215deaec32 100644 --- a/app/components/Views/confirmations/components/rows/bridge-time-row/bridge-time-row.tsx +++ b/app/components/Views/confirmations/components/rows/bridge-time-row/bridge-time-row.tsx @@ -13,17 +13,24 @@ import { import { useTransactionPayToken } from '../../../hooks/pay/useTransactionPayToken'; import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; import { InfoRowSkeleton, InfoRowVariant } from '../../UI/info-row/info-row'; +import { TransactionType } from '@metamask/transaction-controller'; +import { hasTransactionType } from '../../../utils/transaction'; const SAME_CHAIN_DURATION_SECONDS = '< 10'; +const HIDE_TYPES = [TransactionType.musdConversion]; + export function BridgeTimeRow() { const isLoading = useIsTransactionPayLoading(); const { estimatedDuration } = useTransactionPayTotals() ?? {}; const quotes = useTransactionPayQuotes(); const { payToken } = useTransactionPayToken(); - const { chainId } = useTransactionMetadataRequest() ?? {}; + const transactionMetadata = useTransactionMetadataRequest(); + const { chainId } = transactionMetadata ?? {}; - const showEstimate = isLoading || Boolean(quotes?.length); + const showEstimate = + !hasTransactionType(transactionMetadata, HIDE_TYPES) && + (isLoading || Boolean(quotes?.length)); if (!showEstimate) { return null;