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"
+ }
}
}