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 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 }} 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/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/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/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/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/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 }) => ( 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/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/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; 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/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/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/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, 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]; diff --git a/locales/languages/en.json b/locales/languages/en.json index 5fb0267c72a..b9af5a48765 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": { @@ -7229,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" + } } }